Save the people you write to as contacts
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped
The addresses written to were remembered only in the browser that sent
the message, as a list of recent recipients, so a new device or a cleared
browser suggested nobody. After each confirmed send, the recipients who
are not contacts yet are now saved on the server, in an address book of
their own called Collected, so they are suggested everywhere.
- Only addresses on no card in any address book, own or shared, are
added, de-duplicated, and never the sender's own identities.
- The book is created on first use and remembered by id in the synced
settings, so its name can be anything; a deleted one is replaced.
- Names are split as the contact editor does ("Smith, Jane").
- Settings > Calendar & contacts > Contacts has a switch, on by default,
as mail clients do; the book can be emptied or deleted like any other.
New strings: 3, in all ten catalogs (1699 -> 1702).
This commit is contained in:
1 parent
3434a5ed39
commit
c2502d36bd
16 files changed
+289
-1
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/* A confirmed send hands its recipients to the contacts store, unless the reader turned that off. */
|
||||
|
||||
function okServer() {
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && args.create) return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: "e1" } } }, id];
|
||||
if (name === "EmailSubmission/set") return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: "s1" } } }, id];
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
}
|
||||
|
||||
let collect: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "Me", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: { mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" } } as never,
|
||||
});
|
||||
collect = vi.fn(async () => 1);
|
||||
useContacts.setState({ collectRecipients: collect as never });
|
||||
okServer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }], subject: "Hi" });
|
||||
await useCompose.getState().send(key);
|
||||
};
|
||||
|
||||
describe("collecting recipients when a message is sent", () => {
|
||||
it("passes every recipient, and the sender's own addresses to leave out", async () => {
|
||||
await send();
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
const [addrs, own] = collect.mock.calls[0]!;
|
||||
expect((addrs as { email: string }[]).map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(own).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not when the setting is off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } }));
|
||||
await send();
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { ContactCard, JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* People written to used to be remembered only in this browser, as a list of
|
||||
* recent addresses, so a new device suggested nobody. Each confirmed send now
|
||||
* saves the recipients who are not contacts yet, in an address book of their
|
||||
* own, on the server.
|
||||
*/
|
||||
|
||||
interface Calls {
|
||||
books: Record<string, unknown>[];
|
||||
cards: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function server(): Calls {
|
||||
const calls: Calls = { books: [], cards: [] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "AddressBook/set" && args.create) {
|
||||
calls.books.push(args.create as Record<string, unknown>);
|
||||
useContacts.setState((s) => ({ books: { ...s.books, bNew: { id: "bNew", name: "Collected" } as never } }));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { b: { id: "bNew" } } }, id];
|
||||
}
|
||||
if (name === "ContactCard/set" && args.create) {
|
||||
const create = args.create as Record<string, unknown>;
|
||||
calls.cards.push(...Object.values(create) as Record<string, unknown>[]);
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: Object.fromEntries(Object.keys(create).map((k, i) => [k, { id: `n${i}` }])) }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false, changed: [], created: [], updated: [], destroyed: [], hasMoreChanges: false, oldState: "1", newState: "1" }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
const card = (id: string, email: string): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full: id }, emails: { e: { address: email } } }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.contacts]: {} } } },
|
||||
primaryAccounts: { [CAP.contacts]: "a1" },
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: true,
|
||||
loaded: true,
|
||||
books: { b1: { id: "b1", name: "Personal" } as never },
|
||||
cards: { c1: card("c1", "[email protected]") },
|
||||
sharedCards: {},
|
||||
syncCards: (async () => undefined) as never,
|
||||
loadBooks: (async () => undefined) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("saving the people written to", () => {
|
||||
it("adds only addresses that are not contacts yet, and never your own", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients(
|
||||
[
|
||||
{ name: "Known Person", email: "[email protected]" },
|
||||
{ name: "Smith, Jane", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Jane again", email: "[email protected]" },
|
||||
],
|
||||
["[email protected]"],
|
||||
);
|
||||
expect(n).toBe(1);
|
||||
expect(calls.cards).toHaveLength(1);
|
||||
const added = calls.cards[0]!;
|
||||
expect(Object.values(added.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
|
||||
// Filed under the Collected book, with the name split the way the editor does.
|
||||
expect(added.addressBookIds).toEqual({ bNew: true });
|
||||
expect((added.name as { components: { kind: string; value: string }[] }).components.map((c) => c.value)).toEqual(expect.arrayContaining(["Jane", "Smith"]));
|
||||
});
|
||||
|
||||
it("creates the Collected book once and remembers it", async () => {
|
||||
const calls = server();
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(useSettings.getState().settings.collectedBookId).toBe("bNew");
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(calls.cards.at(-1)!.addressBookIds).toEqual({ bNew: true });
|
||||
});
|
||||
|
||||
it("makes a new book when the remembered one has been deleted", async () => {
|
||||
const calls = server();
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectedBookId: "gone" } }));
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does nothing, and makes no book, when everyone is already a contact", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(n).toBe(0);
|
||||
expect(calls.books).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user