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

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:
jcoffey-dev committed 2026-10-05 11:55:51 -07:00
1 parent 3434a5ed39
commit c2502d36bd
16 files changed
+289 -1

No files matched your search

+6
View File
@@ -651,6 +651,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} weitere",
"Contacts": "Kontakte",
"Collected": "Gesammelt",
"Save people you write to as contacts": "Personen, denen Sie schreiben, als Kontakte speichern",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Alle, an die Sie senden und die noch keine Kontakte sind, werden dem Adressbuch „Gesammelt“ hinzugefügt und auf jedem Gerät vorgeschlagen. Ihre eigenen Adressen werden nie hinzugefügt.",
"Contacts are not available": "Kontakte sind nicht verfügbar",
"New contact": "Neuer Kontakt",
"Edit contact": "Kontakt bearbeiten",
+6
View File
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} más",
"Contacts": "Contactos",
"Collected": "Recopilados",
"Save people you write to as contacts": "Guardar como contactos a las personas a las que escribes",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todas las personas a las que envías y que aún no son contactos se añaden a la libreta Recopilados, para que se sugieran en todos tus dispositivos. Tus propias direcciones nunca se añaden.",
"Contacts are not available": "Los contactos no están disponibles",
"New contact": "Contacto nuevo",
"Edit contact": "Editar el contacto",
+6
View File
@@ -648,6 +648,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} autres",
"Contacts": "Contacts",
"Collected": "Collectés",
"Save people you write to as contacts": "Enregistrer comme contacts les personnes à qui vous écrivez",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Toute personne à qui vous écrivez et qui n’est pas encore un contact est ajoutée au carnet Collectés, pour vous être proposée sur tous vos appareils. Vos propres adresses ne sont jamais ajoutées.",
"Contacts are not available": "Les contacts ne sont pas disponibles",
"New contact": "Nouveau contact",
"Edit contact": "Modifier le contact",
+6
View File
@@ -642,6 +642,12 @@ export const catalog: Catalog = {
"+{n} more": "他 {n} 件",
"Contacts": "連絡先",
"Collected": "収集したアドレス",
"Save people you write to as contacts": "メールを送った相手を連絡先に保存する",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "まだ連絡先にない送信先は「収集したアドレス」アドレス帳に追加され、どのデバイスでも候補に表示されます。自分のアドレスは追加されません。",
"Contacts are not available": "連絡先は利用できません",
"New contact": "新しい連絡先",
"Edit contact": "連絡先を編集",
+6
View File
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} meer",
"Contacts": "Contacten",
"Collected": "Verzameld",
"Save people you write to as contacts": "Mensen aan wie je schrijft als contact opslaan",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Iedereen aan wie je mailt en die nog geen contact is, wordt toegevoegd aan het adresboek Verzameld, zodat die op elk apparaat wordt voorgesteld. Je eigen adressen worden nooit toegevoegd.",
"Contacts are not available": "Contacten zijn niet beschikbaar",
"New contact": "Nieuw contact",
"Edit contact": "Contact bewerken",
+6
View File
@@ -646,6 +646,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} outros",
"Contacts": "Contatos",
"Collected": "Coletados",
"Save people you write to as contacts": "Salvar como contatos as pessoas para quem você escreve",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todos para quem você envia e que ainda não são contatos são adicionados à agenda Coletados, para serem sugeridos em todos os dispositivos. Seus próprios endereços nunca são adicionados.",
"Contacts are not available": "Os contatos não estão disponíveis",
"New contact": "Novo contato",
"Edit contact": "Editar o contato",
+6
View File
@@ -645,6 +645,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n}",
"Contacts": "Контакты",
"Collected": "Собранные",
"Save people you write to as contacts": "Сохранять адресатов как контакты",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Все, кому вы пишете и кого ещё нет в контактах, добавляются в адресную книгу «Собранные» и предлагаются на всех устройствах. Ваши собственные адреса не добавляются.",
"Contacts are not available": "Контакты недоступны",
"New contact": "Новый контакт",
"Edit contact": "Изменить контакт",
+3
View File
@@ -271,6 +271,9 @@ export const catalog: Catalog = {
"Contact options": "Kişi seçenekleri",
"Contact saved": "Kişi kaydedildi",
"Contacts": "Kişiler",
"Collected": "Toplananlar",
"Save people you write to as contacts": "Yazdığınız kişileri kişi olarak kaydet",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Gönderdiğiniz ve henüz kişileriniz arasında olmayan herkes Toplananlar adres defterine eklenir, böylece her cihazda önerilir. Kendi adresleriniz asla eklenmez.",
"Contacts are not available": "Kişiler kullanılamıyor",
"Continue": "Devam et",
"Conversation moved to {folder}": "Yazışma {folder} klasörüne taşındı",
+6
View File
@@ -639,6 +639,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n}",
"Contacts": "Контакти",
"Collected": "Зібрані",
"Save people you write to as contacts": "Зберігати адресатів як контакти",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Усі, кому ви пишете і кого ще немає в контактах, додаються до адресної книги «Зібрані» й пропонуються на всіх пристроях. Ваші власні адреси не додаються.",
"Contacts are not available": "Контакти недоступні",
"New contact": "Новий контакт",
"Edit contact": "Змінити контакт",
+6
View File
@@ -641,6 +641,12 @@ export const catalog: Catalog = {
"+{n} more": "还有 {n} 项",
"Contacts": "联系人",
"Collected": "已收集",
"Save people you write to as contacts": "将您发信的联系人保存为联系人",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "您发信的对象如果还不是联系人,会被添加到“已收集”通讯录,在所有设备上都会作为建议显示。您自己的地址不会被添加。",
"Contacts are not available": "联系人不可用",
"New contact": "新建联系人",
"Edit contact": "编辑联系人",
@@ -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);
});
});
+5
View File
@@ -755,6 +755,11 @@ export const useCompose = create<ComposeState>((set, get) => ({
});
try {
const sent = await sendInternal(d, get);
// Everyone written to who is not a contact yet, so they are suggested on every device.
if (settings().collectRecipients) {
const own = useMail.getState().identities.map((i) => i.email);
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
}
toast.success(
sent.held
? translate("Held for review: it's sent once a reviewer releases it")
+27 -1
View File
@@ -2,10 +2,12 @@ import { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { contactDisplayName, contactEmails, contactFromAddress, sortKey } from "@/lib/contacts";
import { uniqueAddresses } from "@/lib/address";
import { parseLdif, uidFromDn } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb";
import { useSettings } from "./settings";
import { t as translate } from "@/lib/i18n";
import { useSession } from "./session";
import { useMail } from "./mail";
@@ -225,6 +227,8 @@ interface ContactsState {
*/
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
createBook(name: string): Promise<Id>;
/** Save the addresses that are not on any contact yet, in the Collected address book. */
collectRecipients(addrs: EmailAddress[], own: string[]): Promise<number>;
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
destroyBook(id: Id): Promise<void>;
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
@@ -616,6 +620,28 @@ export const useContacts = create<ContactsState>((set, get) => ({
return { destroyed: gone.length, unfiled, refused };
},
async collectRecipients(addrs, own) {
if (!get().available || !get().accountId) return 0;
// Every card has to be known, or someone already a contact gets a second card.
if (!get().loaded) await get().loadAll();
const ownSet = new Set(own.map((e) => e.toLowerCase()));
const fresh = uniqueAddresses(addrs).filter((a) => a.email && !ownSet.has(a.email.toLowerCase()) && !get().lookupByEmail(a.email));
if (!fresh.length) return 0;
const accountId = get().accountId!;
const { settings, update } = useSettings.getState();
let bookId = settings.collectedBookId && get().books[settings.collectedBookId] ? settings.collectedBookId : null;
if (!bookId) {
bookId = await get().createBook(translate("Collected"));
update({ collectedBookId: bookId });
}
const create = Object.fromEntries(
fresh.map((a, i) => [`c${i}`, { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...contactFromAddress(a), addressBookIds: { [bookId!]: true } }]),
);
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
await get().syncCards();
return Object.keys(res.created ?? {}).length;
},
async createBook(name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
+11
View File
@@ -206,6 +206,15 @@ export interface Settings {
* dates nobody put there is a surprise rather than a feature.
*/
birthdayCalendar: boolean;
/**
* Save everyone written to who is not a contact yet, in an address book of
* its own, so they are suggested on every device rather than only in the
* browser that sent the message. On by default, as mail clients do; the
* address book can be emptied or deleted like any other.
*/
collectRecipients: boolean;
/** The address book collected recipients go to, once there is one. */
collectedBookId: string | null;
/**
* Calendars subscribed to by URL. The subscription is the setting; the
* events themselves are fetched on demand and never stored, so this follows
@@ -361,6 +370,8 @@ export const DEFAULT_SETTINGS: Settings = {
timeZone: null,
labelsSidebar: true,
birthdayCalendar: false,
collectRecipients: true,
collectedBookId: null,
icalSubscriptions: [],
listSortPreset: "newest",
listSortLevels: [],
@@ -113,6 +113,14 @@ export function CalendarSettings() {
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
/>
<h2>{t("Contacts")}</h2>
<Switch
checked={s.collectRecipients}
onChange={(v) => update({ collectRecipients: v })}
label={t("Save people you write to as contacts")}
hint={t("Everyone you send to who isn\u2019t a contact yet is added to the Collected address book, so they\u2019re suggested on every device. Your own addresses are never added.")}
/>
<h2>{t("Working hours")}</h2>
<div className="field-row">
<div className="field">