Files
ihasmail/web/src/store/contacts.ts
T
jcoffey-dev 76cc2dee56
ci / node (pull_request) Successful in 4m42s
ci / publish (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m13s
Point links at the new git host
GitHub went dark with the account suspension on 2026-09-20 and GitLab was
retired on 2026-09-22, so links to either no longer resolve. Repository,
file, release and download links now point at git.coffeylabs.org, images
at registry.coffeylabs.org, and old GitHub issue and pull request links at
coffey-labs/ihasmail-github-archive, whose numbers match GitHub's.
2026-09-22 09:02:57 -07:00

886 lines
39 KiB
TypeScript

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 { parseLdif, uidFromDn } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb";
import { useSettings } from "./settings";
import { useSession } from "./session";
import { useMail } from "./mail";
/**
* Create cards in batches the server will take.
*
* `ContactCard/set` is refused whole over `maxObjectsInSet` -- the server does
* not take the first 500 and drop the rest, it creates nothing and answers
* `requestTooLarge` -- so an address book big enough to cross the ceiling
* imported nothing at all. The same bug the calendar import had, found on a
* real 800 KB export ([#173]).
*
* `maxObjectsInSet` is what the session advertises and 500 where a server does
* not say; splitting by it rather than by a constant follows a deployment that
* has tuned the limit.
*
* Both imports come through here, which is what the LDIF import's "from
* `ContactCard/set` down they are the same" was always claiming and is now
* true of.
*
* [#173]: https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/173
*/
/**
* The UIDs an address book already holds.
*
* Read once per import rather than once per card, and narrowed to the target
* book from `addressBookIds` here rather than through a filter -- the same
* arrangement, and for the same reasons, as the calendar's scan in #222.
*
* Asked of the server rather than read from the cards already in the store.
* The store's copy is complete once the view has loaded, and importing is not
* something that waits for a view: a scan that is right whatever the client
* happens to be holding costs one pass over a list nobody imports into twice a
* day.
*/
async function scanBook(accountId: Id, addressBookId: Id): Promise<{ byUid: Map<string, Id>; likeness: Set<string> }> {
/* The id as well as the UID, because a card that is already here is now
updated rather than skipped, and updating needs something to address. */
const byUid = new Map<string, Id>();
const likeness = new Set<string>();
const page = client.maxObjectsInGet;
for (let position = 0; ; ) {
const q = await client.call<QueryResponse>("ContactCard/query", { accountId, position, limit: page, calculateTotal: true });
const ids = q.ids ?? [];
if (!ids.length) break;
for (const part of chunk(ids, page)) {
const g = await client.call<GetResponse<ContactCard>>("ContactCard/get", { accountId, ids: part, properties: ["uid", "addressBookIds", "name", "emails"] });
for (const c of g.list) {
if (!c.addressBookIds?.[addressBookId]) continue;
if (c.uid && !byUid.has(c.uid)) byUid.set(c.uid, c.id);
for (const key of likenessKeys(c)) likeness.add(key);
}
}
position += ids.length;
// `total` is optional, so the empty page above is what actually ends this.
if (q.total != null && position >= q.total) break;
}
return { byUid, likeness };
}
/**
* What makes two cards *look* like the same person -- name and one address.
*
* Deliberately not used to skip or merge anything. It is a guess, and it is
* wrong in both directions: two colleagues who share a name and a shared alias
* collapse into one, and somebody whose address changed since the last export
* looks like a stranger. Either mistake is silent and one of them is
* unrecoverable, so it counts and never acts.
*
* What is left for it to count, now that an LDIF re-import matches on the
* entry's `dn`, is the entries that matching could not catch: one whose `dn`
* moved between exports, and anything imported before there was a `dn` to match
* on. Those arrive as new cards, and saying "40 of these look like contacts you
* already had" is the honest half of the answer -- the reported harm was
* confusion rather than duplication, and being told costs nothing.
*
* One key per address, so a person whose second address matches is still
* recognized.
*/
function likenessKeys(c: Partial<ContactCard>): string[] {
const name = contactDisplayName(c as ContactCard).trim().toLowerCase();
if (!name) return [];
const addresses = Object.values(c.emails ?? {}).map((e) => e.address?.trim().toLowerCase()).filter(Boolean);
return addresses.map((a) => `${name}\u0000${a}`);
}
async function writeCards(
accountId: Id,
create: Record<string, unknown>,
update: Record<Id, unknown> = {},
): Promise<{ created: number; updated: number; refused?: SetError }> {
/*
* Creates and updates share one budget. Stalwart counts every object in a
* `/set` against `maxObjectsInSet` -- creates, updates and destroys together
* -- so batching them separately would let a file of 300 new and 300 changed
* cards through as two calls of 300 and be refused for a limit of 500 that
* neither half exceeds.
*/
const keys = [
...Object.keys(create).map((k) => ["create", k] as const),
...Object.keys(update).map((k) => ["update", k] as const),
];
let created = 0;
let updated = 0;
let refused: SetError | undefined;
for (const part of chunk(keys, client.maxObjectsInSet)) {
const subCreate: Record<string, unknown> = {};
const subUpdate: Record<string, unknown> = {};
for (const [kind, k] of part) {
if (kind === "create") subCreate[k] = create[k];
else subUpdate[k] = update[k];
}
let res: SetResponse<ContactCard>;
try {
res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create: subCreate, update: subUpdate });
} catch (err) {
// A batch that failed with earlier ones already filed: those contacts are
// in the address book, and an error saying only that the import failed
// sends someone looking for contacts that are already there.
if (!created && !updated) throw err;
throw new Error(`${created + updated} of ${keys.length} contacts were imported before this happened: ${(err as Error).message}`);
}
created += Object.keys(res.created ?? {}).length;
updated += Object.keys(res.updated ?? {}).length;
refused ??= Object.values(res.notCreated ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
}
return { created, updated, refused };
}
export interface Suggestion {
name: string | null;
email: string;
source: "contact" | "gal" | "recent";
contactId?: Id;
photo?: string | null;
}
/*
* Asked for by name: `shareWith` is not returned by default.
*
* An `AddressBook/get` with no `properties` omits it entirely -- confirmed
* against 0.16.19 on 2026-08-27 on a book that really was shared. See the note
* on CALENDAR_PROPS; both had the same hole and Files did not.
*/
export const ADDRESS_BOOK_PROPS = ["id", "name", "description", "sortOrder", "isDefault", "isSubscribed", "shareWith", "myRights"];
/** A book somebody else shared, and the account it lives in. */
export interface SharedBook {
accountId: Id;
accountName: string;
book: AddressBook;
}
/** Which book the contact list is showing. `accountId` null means the reader's. */
export interface BookSelection {
accountId: Id | null;
bookId: Id | "all";
}
/** Cards from shared accounts are keyed by account too: ids collide across them. */
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
interface ContactsState {
accountId: Id | null;
available: boolean;
books: Record<Id, AddressBook>;
cards: Record<Id, ContactCard>;
/** The server's ContactCard state `cards` was read at, for asking what changed since. */
cardState: string | null;
loaded: boolean;
loading: boolean;
error: string | null;
principals: Principal[];
principalsLoaded: boolean;
recent: EmailAddress[];
/** Address books shared with the reader, from every non-personal account. */
sharedBooks: SharedBook[];
/** Their cards, keyed by account and id. See `sharedKey`. */
sharedCards: Record<string, ContactCard>;
sharedLoaded: boolean;
selection: BookSelection;
init(): Promise<void>;
loadBooks(): Promise<void>;
loadAll(): Promise<void>;
/**
* Bring `cards` up to date with what changed on the server, or load them all
* when that cannot be worked out.
*/
syncCards(): Promise<void>;
/** Books and cards from accounts that shared with the reader. */
loadShared(): Promise<void>;
select(selection: BookSelection): void;
/** Add a shared address book to, or remove it from, the reader's own view. */
setBookSubscribed(accountId: Id, bookId: Id, subscribed: boolean): Promise<void>;
/** The account a card belongs to, null for the reader's own. */
accountOfCard(id: Id): Id | null;
getCard(id: Id): Promise<ContactCard | null>;
search(text: string): ContactCard[];
/** The search filter itself, so a shared book can be filtered the same way. */
filterCards(cards: ContactCard[], text: string): ContactCard[];
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
/**
* Delete cards outright, reporting what the server actually destroyed rather
* than what was asked for. Nothing is thrown for a refusal -- a partial one
* has a count worth telling somebody about, and `refused` says why the rest
* did not go.
*/
destroyCards(ids: Id[]): Promise<{ destroyed: number; refused?: SetError }>;
/**
* Empty an address book: everything filed in it, gone.
*
* `unfiled` is the part that is not a deletion. A card filed in two books is
* only *this* book's to remove, so it is taken out of this one and left
* alone in the other -- destroying it would empty a book nobody asked about.
*/
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
createBook(name: string): Promise<Id>;
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. */
importVCard(text: string, addressBookId: Id): Promise<{ created: number; updated: number; alike: number }>;
/**
* Import an address book in LDIF, read against Mozilla's schema.
*
* Mozilla's schema has no UID, so a re-import is recognized by the entry's
* `dn` instead -- the same update-rather-than-duplicate rule the vCard import
* follows, on the only identity the file carries. `alike` is what is left
* over: entries that were created and still look like somebody already here,
* which is what a changed `dn` produces. Answered in the same shape as the
* vCard import so the caller need not know which it called.
*/
importLdif(text: string, addressBookId: Id): Promise<{ created: number; updated: number; alike: number }>;
loadPrincipals(): Promise<void>;
suggest(query: string, limit?: number): Promise<Suggestion[]>;
addRecent(addrs: EmailAddress[]): void;
lookupByEmail(email: string): ContactCard | undefined;
applyChanges(types: Set<string>): void;
}
export const CARD_PROPS = undefined; // all properties
export const useContacts = create<ContactsState>((set, get) => ({
accountId: null,
available: false,
books: {},
cards: {},
cardState: null,
loaded: false,
loading: false,
error: null,
principals: [],
principalsLoaded: false,
recent: [],
sharedBooks: [],
sharedCards: {},
sharedLoaded: false,
selection: { accountId: null, bookId: "all" },
async init() {
// The reader's own, not whichever account is selected: a shared address
// book is shown beside theirs rather than instead of it, so nothing here
// should move when the switcher does.
const accountId = useSession.getState().ownAccountFor(CAP.contacts);
const available = Boolean(accountId && client.hasCapability(CAP.contacts));
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } });
set({ available });
if (!available) return;
await get().loadBooks();
void get().loadShared();
/*
* The cards too, in the background. The avatars in the mail list come from
* them, and nothing else loaded them until Contacts was opened or an
* address was typed -- so a photo appeared once somebody did either, and
* was gone again after the next reload (#376).
*/
if (!get().loaded) void get().loadAll();
},
/*
* Books and cards from accounts that shared with the reader.
*
* These are held apart from the reader's own rather than merged into them,
* because ids are only unique within an account: two accounts each having a
* book "ab1" is ordinary, and a flat map keyed on the bare id would have one
* quietly replace the other. `sharedKey` keeps them apart.
*
* Loaded eagerly, unlike the shared folders in Files, because these are not
* only browsed -- they have to answer when someone types a name into a To
* field, which cannot wait for a folder to be opened first.
*/
async loadShared() {
const session = useSession.getState();
const own = session.ownAccountFor(CAP.contacts);
const s = session.session;
const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
if (!accounts.length) {
set({ sharedBooks: [], sharedCards: {}, sharedLoaded: true });
return;
}
const books: SharedBook[] = [];
const cards: Record<string, ContactCard> = {};
// Every account at once: calls made in one tick share a request, where a
// loop sent one after another for each account shared with the reader.
await Promise.all(accounts.map(async ([accountId, account]) => {
try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
/*
* Cards come only from books the reader has added.
*
* Stalwart hands back every book in a reachable account with full
* rights on each, shared or not -- an account linked for its files
* offered its address book too -- so `isSubscribed` is the only thing
* separating "shared with me" from "reachable". Loading the rest would
* put a stranger's contacts in the To field, which is the one place
* this must not guess.
*/
const added = new Set(useSettings.getState().settings.addedShares);
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
if (!wanted.size) return;
// One page. A shared book is a colleague's contacts, not an archive,
// and the alternative is holding the reader's own list hostage to it.
const cardsRes = await client.chain([
["ContactCard/query", { accountId, limit: 500 }, "q"],
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
]);
const g = cardsRes.get("g")?.[0] as unknown as GetResponse<ContactCard>;
for (const c of g.list) {
if (!Object.keys(c.addressBookIds ?? {}).some((id) => wanted.has(id))) continue;
cards[sharedKey(accountId, c.id)] = c;
}
} catch {
// An account that refuses is one that shared nothing here. Not an
// error to show: the reader did not ask for it and cannot act on it.
}
}));
// Answers arrive in any order; list the books in the session's.
const order = new Map(accounts.map(([id], i) => [id, i]));
books.sort((a, b) => (order.get(a.accountId) ?? 0) - (order.get(b.accountId) ?? 0));
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
},
async setBookSubscribed(accountId, bookId, subscribed) {
/*
* `notUpdated` matters more here than anywhere else this pattern is used.
* Subscribing is a write to somebody *else's* account, so it is the one
* call in the app that a perfectly healthy server is entitled to refuse --
* and a refusal arrives as a successful response carrying a per-object
* failure, not as a thrown error. Ignoring it made a refused subscribe look
* exactly like a button that does nothing.
*/
/*
* Ask the server to remember it, and remember it here when it will not.
*
* Subscribing writes to the owner's account, and Stalwart 0.16.19 refuses
* that for a book shared read-only -- "You are not allowed to modify this
* address book" -- while accepting the same write on a shared calendar. The
* server's own flag is still preferred when it takes it, because then every
* client agrees; a refusal is an ordinary answer here rather than a
* failure, and the preference goes in the reader's own synced settings.
*/
const key = sharedKey(accountId, bookId);
let stored = false;
try {
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } });
const err = res.notUpdated?.[bookId];
if (err) throw new Error(setErrorMessage(err));
stored = true;
} catch {
stored = false;
}
if (!stored) {
const { settings, update } = useSettings.getState();
const added = new Set(settings.addedShares);
if (subscribed) added.add(key);
else added.delete(key);
update({ addedShares: [...added] });
}
if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {
set({ selection: { accountId: null, bookId: "all" } });
}
await get().loadShared();
},
select(selection) {
set({ selection });
},
accountOfCard(id) {
if (get().cards[id]) return null;
const hit = Object.entries(get().sharedCards).find(([key]) => key.endsWith(`:${id}`));
return hit ? hit[0].slice(0, hit[0].length - id.length - 1) : null;
},
async loadBooks() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
const books: Record<Id, AddressBook> = {};
for (const b of res.list) books[b.id] = b;
set({ books, error: null });
} catch (err) {
set({ error: (err as Error).message });
}
},
async loadAll() {
const accountId = get().accountId;
if (!accountId || get().loading) return;
set({ loading: true });
try {
const cards: Record<Id, ContactCard> = {};
let position = 0;
const limit = 500;
let cardState: string | null = null;
for (let guard = 0; guard < 50; guard++) {
const res = await client.chain([
["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"],
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
for (const c of g.list) cards[c.id] = c;
// The first page's: a change made while the rest were being read is
// then reported again by the next sync, rather than missed.
cardState ??= g.state;
position += q.ids.length;
if (q.ids.length < limit || (q.total != null && position >= q.total)) break;
}
set({ cards, cardState, loaded: true, loading: false, error: null });
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
},
/*
* What changed, rather than everything again.
*
* Every push that touched a card, and every edit made here, used to reload
* the whole address book -- up to fifty pages of five hundred cards with all
* their properties -- to pick up one change. ContactCard/changes names what
* changed since the state the cards were read at, and only those are fetched.
* A server that cannot say (`cannotCalculateChanges`), or any other failure,
* falls back to the full load, which is what happened before.
*/
async syncCards() {
const { accountId, cardState, loaded } = get();
if (!accountId || !loaded || !cardState) return get().loadAll();
try {
const changed = new Set<Id>();
const destroyed = new Set<Id>();
let since = cardState;
for (let guard = 0; guard < 50; guard++) {
const ch = await client.call<ChangesResponse>("ContactCard/changes", { accountId, sinceState: since, maxChanges: 500 });
for (const id of [...ch.created, ...ch.updated]) { changed.add(id); destroyed.delete(id); }
for (const id of ch.destroyed) { destroyed.add(id); changed.delete(id); }
since = ch.newState;
if (!ch.hasMoreChanges) break;
}
const fetched = await Promise.all(
chunk([...changed], client.maxObjectsInGet).map((part) => client.call<GetResponse<ContactCard>>("ContactCard/get", { accountId, ids: part })),
);
if (get().accountId !== accountId) return;
set((s) => {
const cards = { ...s.cards };
for (const id of destroyed) delete cards[id];
for (const r of fetched) {
for (const c of r.list) cards[c.id] = c;
// An id listed as changed but gone by the time it was asked for.
for (const id of r.notFound ?? []) delete cards[id];
}
return { cards, cardState: since, error: null };
});
} catch (err) {
if (!(err instanceof JmapMethodError) || err.type !== "cannotCalculateChanges") console.warn("[ihasmail] contact sync failed, reloading:", err);
set({ cardState: null });
await get().loadAll();
}
},
async getCard(id) {
const accountId = get().accountId;
if (!accountId) return null;
const res = await client.call<GetResponse<ContactCard>>("ContactCard/get", { accountId, ids: [id] });
const c = res.list[0];
if (c) set((s) => ({ cards: { ...s.cards, [c.id]: c } }));
return c ?? null;
},
filterCards(cards, text) {
const q = text.trim().toLowerCase();
const filtered = q
? cards.filter((c) => {
const hay = [contactDisplayName(c), ...Object.values(c.emails ?? {}).map((e) => e.address), ...Object.values(c.phones ?? {}).map((p) => p.number), ...Object.values(c.organizations ?? {}).map((o) => o.name ?? ""), ...Object.values(c.nicknames ?? {}).map((n) => n.name)]
.join(" ")
.toLowerCase();
return hay.includes(q);
})
: cards;
return filtered.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
},
search(text) {
return get().filterCards(Object.values(get().cards), text);
},
async createCard(card, addressBookId) {
const accountId = get().accountId!;
const obj = { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...card, addressBookIds: { [addressBookId]: true } };
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create: { c: obj } });
const err = res.notCreated?.c;
if (err) throw new Error(setErrorMessage(err));
const id = res.created!.c!.id;
await get().getCard(id);
return id;
},
async updateCard(id, patch) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("ContactCard/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().getCard(id);
},
/*
* Batched for the same reason the imports are: a selection larger than
* `maxObjectsInSet` is refused whole, so "select all" over a big address book
* deleted nothing and said why in JMAP's words.
*
* The ids that actually went are what leaves the list, rather than everything
* that was asked for. A batch that fails after earlier ones succeeded must
* not leave deleted contacts on screen, and must not take live ones off it.
*/
async destroyCards(ids) {
const accountId = get().accountId!;
const gone: Id[] = [];
let refused: SetError | undefined;
try {
for (const part of chunk(ids, client.maxObjectsInSet)) {
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: part });
gone.push(...(res.destroyed ?? []));
refused ??= Object.values(res.notDestroyed ?? {})[0];
}
} finally {
if (gone.length) {
set((s) => {
const cards = { ...s.cards };
for (const id of gone) delete cards[id];
return { cards };
});
}
}
/* Answered rather than thrown. A refusal that took half the selection with
it still deleted the other half, and an error that says only "it failed"
sends somebody looking for contacts that are already gone. */
return { destroyed: gone.length, refused };
},
async emptyBook(bookId) {
const accountId = get().accountId!;
const inBook = Object.values(get().cards).filter((c) => c.addressBookIds?.[bookId]);
/*
* Two different acts, decided per card.
*
* A card filed only here is deleted. A card filed here *and* somewhere else
* is removed from this book and left where it also lives -- emptying one
* book must not empty another, and `ContactCard/set destroy` does not know
* the difference: it takes the card away from every book at once.
*/
const destroy: Id[] = [];
const update: Record<Id, unknown> = {};
for (const c of inBook) {
if (Object.keys(c.addressBookIds ?? {}).length > 1) update[c.id] = { [`addressBookIds/${bookId}`]: null };
else destroy.push(c.id);
}
const gone: Id[] = [];
let unfiled = 0;
let refused: SetError | undefined;
/* One budget for both, the way `writeCards` shares one: Stalwart counts
every object in a `/set` against `maxObjectsInSet` together. */
const work = [
...destroy.map((id) => ["destroy", id] as const),
...Object.keys(update).map((id) => ["update", id] as const),
];
try {
for (const part of chunk(work, client.maxObjectsInSet)) {
const partDestroy = part.filter(([kind]) => kind === "destroy").map(([, id]) => id);
const partUpdate: Record<Id, unknown> = {};
for (const [kind, id] of part) if (kind === "update") partUpdate[id] = update[id];
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", {
accountId,
...(partDestroy.length ? { destroy: partDestroy } : {}),
...(Object.keys(partUpdate).length ? { update: partUpdate } : {}),
});
gone.push(...(res.destroyed ?? []));
unfiled += Object.keys(res.updated ?? {}).length;
refused ??= Object.values(res.notDestroyed ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
}
} finally {
await get().syncCards();
}
return { destroyed: gone.length, unfiled, refused };
},
async createBook(name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
const err = res.notCreated?.b;
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
return res.created!.b!.id;
},
async updateBook(id, patch) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
},
async destroyBook(id) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, destroy: [id], onDestroyRemoveContents: true });
const err = res.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
await get().syncCards();
},
async importVCard(text, addressBookId) {
const accountId = get().accountId!;
const up = await client.upload(accountId, new Blob([text], { type: "text/vcard" }), { type: "text/vcard" });
const parsed = await client.call<{ parsed?: Record<string, ContactCard[] | ContactCard>; notParsable?: Id[] }>("ContactCard/parse", { accountId, blobIds: [up.blobId] });
const entry = parsed.parsed?.[up.blobId];
const cards: ContactCard[] = entry ? (Array.isArray(entry) ? entry : [entry]) : [];
if (!cards.length) throw new Error("No contacts found in file");
const { byUid } = await scanBook(accountId, addressBookId);
const create: Record<string, unknown> = {};
const update: Record<Id, unknown> = {};
cards.forEach((c, i) => {
const { id: _id, addressBookIds: _ab, ...rest } = c as ContactCard & { id?: Id };
/*
* A vCard UID is an identity its author meant, so a card whose UID this
* book already holds is that card -- and the newer version of it wins.
*
* It used to be skipped. The reporter asked for the opposite on #174 and
* he is right: the reason to import a file a second time is usually that
* the first one was not right, and skipping means a corrected export
* corrects nothing.
*
* A merge, not a replacement. Properties the file carries overwrite what
* is here; properties it does not mention are left alone, so a phone
* number somebody added in ihasmail after the first import survives a
* re-import of the original file. The cost is that a field genuinely
* deleted at the source stays here -- worth it, because the other way
* round loses work nobody asked to lose.
*/
const existing = rest.uid ? byUid.get(rest.uid) : undefined;
if (existing) {
update[existing] = { ...rest, addressBookIds: undefined };
delete (update[existing] as Record<string, unknown>).addressBookIds;
return;
}
create[`c${i}`] = { ...rest, uid: rest.uid || crypto.randomUUID(), addressBookIds: { [addressBookId]: true } };
});
try {
const { created, updated, refused } = await writeCards(accountId, create, update);
// Nothing at all got in: say why rather than report importing none as
// though the file had been empty. The LDIF import said this already; a
// vCard import that quietly returned 0 was the odd one out.
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts");
/* No likeness count: a vCard carries a UID, so anything already here was
matched on it rather than guessed at. */
return { created, updated, alike: 0 };
} finally {
await get().syncCards();
}
},
/*
* LDIF, which nothing on the server reads.
*
* vCard has `ContactCard/parse` and so never needed a parser here; LDIF has
* no equivalent, so the file is read in the browser -- `parseLdif` for the
* syntax, `cardFromLdif` for what Mozilla's schema means by it -- and what
* goes to the server is finished cards. That is the whole difference between
* the two imports; from `ContactCard/set` down they are the same.
*/
async importLdif(text, addressBookId) {
const accountId = get().accountId!;
/* The record and not just the card: the `dn` is the entry's identity and
`cardFromLdif` deliberately does not carry it into the card. */
const entries = parseLdif(text)
.map((rec) => ({ uid: uidFromDn(rec.dn), card: cardFromLdif(rec) }))
.filter((e): e is { uid: string | null; card: Partial<ContactCard> } => e.card !== null);
if (!entries.length) throw new Error("it has no contacts in it");
/*
* Read before anything is written, so "already had" means before this
* import rather than including it.
*/
const before = await scanBook(accountId, addressBookId);
let alike = 0;
const create: Record<string, unknown> = {};
const update: Record<Id, unknown> = {};
/* Where in `create` an entry from this same file already landed. A
directory cannot hold two entries under one `dn`, so a file that does is
malformed -- but it must not become two cards sharing a uid, which is a
duplicate of exactly the kind being fixed here. The later one wins, as it
would in the directory. */
const pending = new Map<string, string>();
entries.forEach(({ uid, card }, i) => {
/*
* An entry whose `dn` this book already holds is that entry, and the
* newer version of it wins -- a merge, as the vCard import does it:
* properties the file carries overwrite what is here, properties it does
* not mention are left alone. The reason to import a file twice is
* usually that the first attempt was not right, so skipping would mean a
* corrected export corrects nothing (#174).
*/
const existing = uid ? before.byUid.get(uid) : undefined;
if (existing) {
update[existing] = card;
return;
}
const seen = uid ? pending.get(uid) : undefined;
const key = seen ?? `c${i}`;
if (uid) pending.set(uid, key);
/*
* Only what is actually being created can look like a duplicate: what
* matched above is not a look-alike but the same entry. So this counts
* what `dn` matching could not catch -- an entry whose `dn` moved, or one
* imported before there was anything to match on -- and still only
* counts, because name-plus-email is a guess wrong in both directions and
* a merge made on a guess cannot be undone.
*/
if (!seen && likenessKeys(card).some((k) => before.likeness.has(k))) alike++;
create[key] = { "@type": "Card", version: "1.0", ...card, uid: uid ?? crypto.randomUUID(), addressBookIds: { [addressBookId]: true } };
});
try {
const { created, updated, refused } = await writeCards(accountId, create, update);
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts");
return { created, updated, alike };
} finally {
await get().syncCards();
}
},
async loadPrincipals() {
if (get().principalsLoaded) return;
const accountId = useSession.getState().accountFor(CAP.principals);
if (!accountId || !client.hasCapability(CAP.principals)) {
set({ principalsLoaded: true });
return;
}
try {
const res = await client.chain([
["Principal/query", { accountId, limit: 1000 }, "q"],
["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties: ["id", "type", "name", "description", "email", "timeZone"] }, "g"],
]);
const g = res.get("g")?.[0] as unknown as GetResponse<Principal>;
set({ principals: g.list, principalsLoaded: true });
} catch {
set({ principalsLoaded: true });
}
},
async suggest(query, limit = 8) {
const q = query.trim().toLowerCase();
if (!q) return [];
const st = get();
if (!st.loaded && st.available && !st.loading) void st.loadAll();
if (!st.principalsLoaded) void st.loadPrincipals();
const out: Suggestion[] = [];
const seen = new Set<string>();
const add = (s: Suggestion) => {
const k = s.email.toLowerCase();
if (!k || seen.has(k)) return;
seen.add(k);
out.push(s);
};
const score = (name: string | null, email: string): number => {
const n = (name ?? "").toLowerCase();
const e = email.toLowerCase();
if (e.startsWith(q) || n.startsWith(q)) return 0;
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
if (e.includes(q) || n.includes(q)) return 2;
return 99;
};
const candidates: Array<Suggestion & { score: number }> = [];
// A shared address book is only useful if it answers when you are writing
// to someone in it, so its cards are offered alongside the reader's own.
// They rank a shade lower, so a name in both wins from your own book.
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
for (const { c, penalty } of [...own, ...shared]) {
for (const a of contactEmails(c)) {
const sc = score(a.name, a.email);
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
}
}
for (const p of st.principals) {
if (!p.email) continue;
const sc = score(p.name, p.email);
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
}
for (const r of st.recent) {
const sc = score(r.name, r.email);
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
}
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
for (const c of candidates) {
add(c);
if (out.length >= limit) break;
}
return out;
},
addRecent(addrs) {
const cur = get().recent;
const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200);
set({ recent: next });
try {
saveJson(accountKey(get().accountId, "recent"), next);
} catch {
/* ignore */
}
},
lookupByEmail(email) {
const e = email.toLowerCase();
const match = (c: ContactCard) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e);
// The reader's own books first: a card they wrote themselves should win
// over a colleague's version of the same person.
return Object.values(get().cards).find(match) ?? Object.values(get().sharedCards).find(match);
},
applyChanges(types) {
if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); }
if (types.has("ContactCard") && get().loaded) void get().syncCards();
},
}));
useSession.subscribe((s) => {
if (s.status === "authenticated") {
const accountId = s.accountFor(CAP.contacts);
let recent: EmailAddress[] = [];
try {
recent = loadRaw<EmailAddress[]>(accountKey(accountId, "recent"), []);
} catch {
/* ignore */
}
useContacts.setState({ recent });
} else {
useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false });
}
});
// Harvest recent recipients from Sent when the mail store learns about them.
useMail.subscribe((s, prev) => {
if (s.emails === prev.emails) return;
const sentId = s.roleId("sent");
if (!sentId) return;
// cheap: only look at newly-added emails in Sent
const addrs: EmailAddress[] = [];
for (const id of Object.keys(s.emails)) {
if (prev.emails[id]) continue;
const e = s.emails[id]!;
if (e.mailboxIds[sentId]) addrs.push(...(e.to ?? []), ...(e.cc ?? []));
}
if (addrs.length) useContacts.getState().addRecent(addrs.slice(0, 50));
});