A pushed contact change, and every edit or import made here, reloaded the whole address book. The store now keeps the state its cards were read at and asks ContactCard/changes what changed since, fetching only those cards, split to maxObjectsInGet. A server that cannot say falls back to the full load. The calendar held every week or month the reader had visited, queried each of them again on any event change, and walked them all on every render. It now holds the four most recently shown; a change reloads those in place, without emptying the view first, and a window dropped is loaded again when it is next shown. Shared calendars' events are fetched from every account at once, and instancesIn builds the added-shares set once. The mock keeps a ContactCard change log, answers ContactCard/changes, and announces a ContactCard/set, as Stalwart does.
879 lines
38 KiB
TypeScript
879 lines
38 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://github.com/Coffey-Labs/ihasmail/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();
|
|
},
|
|
|
|
/*
|
|
* 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));
|
|
});
|