Update a contact on re-import rather than skipping it

#228 skipped a vCard whose UID the book already held. 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, so 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 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, which is
the better way to be wrong -- the other way round loses work nobody asked to
lose. Worth confirming with him rather than assuming.

`addressBookIds` is left off the patch. The card is already in this book, so
saying it again says nothing, and saying it on a card that is also in another
book would move it.

Creates and updates now share one batch budget. Stalwart counts every object
in a /set together, so batching the halves separately would send 300 new and
300 changed as two calls of 300 and be refused for a limit of 500 that neither
half exceeds.

LDIF is untouched and still reports look-alikes without acting on them, since
what it should match on is the question still open on #223. Both imports keep
one answer shape so a caller need not know which it called; LDIF's `updated`
is always 0, which is the honest number rather than a missing field.

The message a vCard attached to a message shows changes with it: the newer
copy now wins instead of being dropped, so it says the contact was brought up
to date rather than that nothing was added.

Refs #223.
This commit is contained in:
2026-09-02 15:06:04 -07:00
parent af0e8b2be2
commit 9f4bd65fd5
16 changed files with 210 additions and 99 deletions
+6 -5
View File
@@ -17,14 +17,15 @@ export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId:
const text = await client.fetchBlobText(accountId, part.blobId!, "text/vcard");
const book = Object.values(contacts.books).find((b) => b.isDefault) ?? Object.values(contacts.books)[0];
if (!book) throw new Error("No address book available");
const { created, skipped } = await contacts.importVCard(text, book.id);
const { created, updated } = await contacts.importVCard(text, book.id);
setDone(true);
/*
* A card attached to a message is usually one you have already been sent
* once. Saying "Added 0 contacts" for that would read as a failure; it is
* the opposite -- there was nothing to do.
* A card attached to a message is usually one you already have, and now
* the newer copy wins rather than being dropped -- so the message says it
* was brought up to date. "Added 0 contacts" would read as a failure when
* the opposite happened.
*/
if (!created && skipped) toast.success(plural(skipped, { one: "Already in your contacts", other: "All {n} are already in your contacts" }));
if (!created && updated) toast.success(plural(updated, { one: "Updated the contact you already had", other: "Updated {n} contacts you already had" }));
else toast.success(plural(created, { one: "Added {n} contact", other: "Added {n} contacts" }));
} catch (err) {
toast.error((err as Error).message);