Files
native/docs/parity/06-contacts.md
T
Linus Rath c81dc5179f docs: tick the parity items this pass completed
Tick the parity items the fix pass finished (server-side recurrence,
filters and vacation for shared accounts, in-app file preview, the
trusted-senders toggle, UnifiedPush, sender taps in the viewer), and
note on the already ticked ones what this pass changed, such as the tag
view and search reaching shared accounts or contact photos as avatars.
Items that only moved forward say what is left.

Fix hints and "verified" lines that named removed functions
(`setEmailKeywords`, `getEmailDetail`, `fetchEmailsChunked`,
`buildRecurrenceOverridePatch`) or the old whole-map keyword writes now
point at what replaced them.

PARITY_CHECKLIST.md counts done and open items per area: 379 of 415 are
done, 36 open.
2026-09-24 21:02:09 +02:00

45 KiB
Raw Blame History

Contacts & address books

Summary

RN covers the visible surface reasonably well (list with alphabetical index, detail screen with every RFC 9553 field block, a full edit form, groups with member management, address-book CRUD, vCard import/export, trusted-senders book, contact activity). The gaps are mostly below the UI: the RN JMAP layer (src/api/contacts.ts) ignores notCreated/notUpdated/notDestroyed, never assigns a UID (#644), does not re-fetch the created card (so a freshly saved contact shows as a blank row), does not paginate ContactCard/query, and has no first-touch gate (#907). The RN vCard parser is a stale fork of the web one: it predates #224 (dates are imported as raw strings, which Stalwart drops), lacks every X- vendor extension, Apple grouped labels, and org-only cards (#701). Composer autocomplete is contacts-only (no groups, no recent recipients, no directory), there is no "add sender to contacts" entry point from the viewer, no way to create a group, no shared/group-account address books, and the trusted-senders settings screen cannot show or remove entries that live in the synced book.

Findings

JMAP / store layer

  • Newly created contact appears as a blank row until refresh — P2 — bug — fixed in 580e010

    • What WEB does: client.createContact reads the server-assigned id from created and then re-fetches the full card with ContactCard/get before returning it (lib/jmap/client.ts:5128-5133), so the store appends a complete card.
    • What RN does: createContact returns res.methodResponses[0][1].created['new-contact'] verbatim (src/api/contacts.ts:73) which per JMAP only carries server-set properties (id, created, updated…); the store appends that stub (src/stores/contacts-store.ts:170-174). ContactFormScreen.handleSave just goBack()s (src/screens/ContactFormScreen.tsx:631-633) so the list shows an "Unnamed" row with no email until a push/pull refresh; doDuplicate even navigates to that stub (src/screens/ContactDetailScreen.tsx:231-232).
    • Fix hint: in api/contacts.ts merge { ...contact, addressBookIds, ...created } or issue a follow-up ContactCard/get for the created id (mirror WEB) before returning.
  • ContactCard/set errors are swallowed or crash with a TypeError — P2 — bug — fixed in 580e010

    • What WEB does: every set call checks notCreated / notUpdated / notDestroyed and throws the server description (lib/jmap/client.ts:5122-5126, 5155-5160, 5180-5184); the method-level error response is also handled.
    • What RN does: createContact indexes .created['new-contact'] without checking created/notCreated (src/api/contacts.ts:73) so a rejected card surfaces as "Cannot read property 'new-contact' of undefined"; updateContact (:76-85) and deleteContacts (:87-93) never look at notUpdated/notDestroyed, so the store applies the optimistic change (src/stores/contacts-store.ts:176-195) although the server refused it (e.g. read-only shared book, invalid PartialDate).
    • Fix hint: route all three through methodResult() (already defined at src/api/contacts.ts:7-15) and throw on notCreated/notUpdated/notDestroyed[id].description; bulkDelete should only drop the ids that were actually destroyed.
  • No UID assigned on contact creation (#644) — P2 — bugfix-parity — fixed in 580e010

    • What WEB does: sends uid: contactData.uid || 'urn:uuid:<uuid>' on every create because Stalwart stores the card without one otherwise (lib/jmap/client.ts:5113-5116, changelog 1.6.x "Assign a UID to contact cards on creation").
    • What RN does: createContact forwards the card as-is (src/api/contacts.ts:65-70); form, import, duplicate and trusted-sender creation all go through it. Group membership then falls back to the raw id (src/screens/GroupDetailScreen.tsx:90), and exported vCards/CardDAV clients get UID-less cards.
    • Fix hint: add uid: contact.uid || \urn:uuid:${generateUUID()}`inapi/contacts.ts createContact(RN already hassrc/lib/uuid.ts`).
  • Contact list silently truncated at the query limit (no pagination) — P2 — bug — fixed in 580e010

    • What WEB does: fetchPaginatedContacts walks ContactCard/query with position/limit = maxObjectsInGet until total is reached, then batches ContactCard/get (lib/jmap/client.ts:~4985-5022).
    • What RN does: a single ContactCard/query with limit: 1000 (src/api/contacts.ts:26-40); the server may clamp the limit lower and anything beyond is never fetched. getContacts batches the get correctly (:42-57).
    • Fix hint: loop on position until ids.length < limit || total reached in queryContacts, mirroring WEB.
  • No first-touch gate: concurrent AddressBook/get + ContactCard/query on cold start (#907) — P2 — bugfix-parity — fixed in 26a34d5

    • Store-side: fetchAddressBooks/fetchContacts are single-flight and contacts wait for an in-flight AddressBook/get; the request-level FirstTouchGate in src/api/jmap-client.ts was added by the client agent.
    • What WEB does: FirstTouchGate serialises the first request per (collection, account) so Stalwart's lazy default-collection creation cannot race across cluster nodes (lib/jmap/first-touch-gate.ts:1-101, wired at lib/jmap/client.ts:678,1129; changelog 1.9.x "Gate first-touch calendar and contacts requests to stop duplicate default calendars (#907)").
    • What RN does: ContactsScreen fires fetchAddressBooks() and fetchContacts() in the same effect (src/screens/ContactsScreen.tsx:120-123) and restoreSession fans out fetchContacts + fetchCalendars (src/stores/auth-store.ts:91-93) with no gate in src/api/jmap-client.ts (grep for gate/firstTouch: none). Against a multi-node Stalwart this can mint a duplicate default address book / calendar.
    • Fix hint: port first-touch-gate.ts into src/api/ and wrap jmapClient.request (key on AddressBook/, ContactCard/, Calendar/, CalendarEvent/ + accountId); reset it on logout/account switch.
  • Contacts account id: RN uses the mail primary account, not primaryAccounts[contacts] — P3 — partial — fixed in 580e010

    • What WEB does: getContactsAccountId() prefers primaryAccounts["urn:ietf:params:jmap:contacts"] (lib/jmap/client.ts:4677-4680).
    • What RN does: every call uses jmapClient.accountId, resolved from the mail/core primary (src/api/contacts.ts:18,32,44,63; src/api/jmap-client.ts:388-399).
    • Fix hint: add getContactsAccountId() to the RN client and use it in api/contacts.ts.
  • Shared / group-account address books not listed or writable — P2 — missing — fixed in 26a34d5

    • Drawer "Shared from X" sections in 358bdb9; writes are routed to the owning account via originalId/accountId.
    • What WEB does: getAllAddressBooks/getAllContacts iterate every contacts-capable or non-personal account, namespace ids as accountId:bookId, tag isShared/accountName (lib/jmap/client.ts:4718-4733, 4756-4793, 5036-5070); sidebar renders "Shared from X" sections (components/contacts/contacts-sidebar.tsx:160-176, 562-603); create/update/move de-namespace ids and pick the right account (stores/contact-store.ts:473-527, 529-561, 926-978); changelog 1.8.x "Require an explicit shared-account fallback for contacts and calendars".
    • What RN does: single-account only (src/api/contacts.ts throughout); AddressBook type has no accountId/isShared (src/api/types.ts:369-377).
    • Fix hint: iterate session.accounts where accountCapabilities[contacts] or !isPersonal (as WEB getContactCapableAccountIds), namespace ids, and render a "Shared from" section in ContactsSidebarDrawer; route writes to the owning account. The myRights.mayWrite checks already present in AddressBookPickerSheet:96 and ContactsSettings:204,209 will then become meaningful.
  • Deleting a contact does not clean group member references locally — P3 — partial — fixed in 26a34d5

    • Drawer member counts resolve through selectGroupMembers in 358bdb9.
    • What WEB does: cleanGroupMembers strips the deleted card's id/uid/urn:uuid: variants from every group's members in state on delete/bulk delete (stores/contact-store.ts:339-366, 571-578, 911-918).
    • What RN does: deleteContact/bulkDelete just filter the list (src/stores/contacts-store.ts:185-195); group counts in the drawer use raw Object.keys(g.members).length (src/components/contacts/ContactsSidebarDrawer.tsx:150) so they keep counting deleted members until the next full refresh.
    • Fix hint: port cleanGroupMembers; resolve drawer counts through selectGroupMembers like WEB memberCountByGroup (contacts-sidebar.tsx:250-270).
  • Deleting an address book leaves its contacts in the cache — P3 — partial — fixed in 26a34d5

    • What WEB does: removeAddressBook drops contacts filed in that book from state (stores/contact-store.ts:1053-1056).
    • What RN does: strips the book id but keeps the cards (src/stores/contacts-store.ts:260-276), so they show under "All"/"Uncategorized" until a refresh; the confirm text ("will be removed from this book") also implies they survive (src/components/settings/ContactsSettings.tsx:282).
    • Fix hint: filter out cards whose only book was the deleted one, or call fetchContacts() after the destroy.
  • Persisting every contact card (incl. base64 photos) to AsyncStorage — P3 — rn-only-bug — fixed in 26a34d5

    • media is stripped from the persisted snapshot; picked photos are downscaled to 512px JPEG with expo-image-manipulator in 9800ad0.
    • What RN does: partialize persists contacts and addressBooks (src/stores/contacts-store.ts:355-366); cards carry inline data: photos (src/screens/ContactFormScreen.tsx:690-691 stores the picked image as base64 with no downscale). Android AsyncStorage's default 6 MB cap will make the write fail for a few hundred photo contacts and the cache silently stops updating.
    • Fix hint: strip media from the persisted snapshot (re-hydrate photos from the server) and/or downscale photos on pick (WEB caps at 512px JPEG q0.85, components/contacts/contact-form.tsx:96-126).

Address books

  • Set default address book missing — P3 — missing — fixed in bc4c57e

    • setDefaultAddressBook (onSuccessSetIsDefault) and the isDefault-free update patch landed in 580e010; new contacts/imports prefer the default book via getDefaultAddressBookId.
    • What WEB does: context-menu "Set as default" uses AddressBook/set onSuccessSetIsDefault (components/contacts/contacts-sidebar.tsx:612-663, lib/jmap/client.ts:4851-4869, stores/contact-store.ts:1017-1044), and the create form preselects the default book (components/contacts/contact-form.tsx:362-367).
    • What RN does: no UI; updateAddressBook even forwards isDefault as a patch property (src/api/contacts.ts:117,122) which WEB notes is read-only and fails the whole update (lib/jmap/client.ts:4819-4821). New contacts/imports default to addressBooks[0] (src/screens/ContactFormScreen.tsx:581,591-593; src/screens/ContactsScreen.tsx:201-204; src/components/settings/ContactsSettings.tsx:54).
    • Fix hint: add setDefaultAddressBook (onSuccessSetIsDefault) to api/contacts.ts + a row action in ContactsSettings; drop isDefault from the update patch; prefer books.find(b => b.isDefault) as the default target.
  • Default address book can be offered for deletion — P3 — partial — fixed in bc4c57e

    • What WEB does: hides delete for isDefault/shared books (components/contacts/contacts-sidebar.tsx:615).
    • What RN does: shows the trash icon whenever books.length > 1 && mayDelete !== false (src/components/settings/ContactsSettings.tsx:209); the server then rejects and the user sees a raw error.
    • Fix hint: add !book.isDefault to the guard.
  • Address book share / visibility (subscription) management missing — P3 — missing

    • deferred: shareWith/myRights are now requested explicitly (580e010) and typed on AddressBook; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists).
    • What WEB does: shareAddressBook via shareWith/<principal> patch + ShareCollectionDialog (stores/contact-store.ts:1064-1085, components/contacts/contacts-app.tsx:1142-1158); sidebar shows a shared indicator (contacts-sidebar.tsx:879-881).
    • What RN does: nothing; getAddressBooks requests no properties so shareWith is whatever the server defaults to (src/api/contacts.ts:17-24; WEB requests ADDRESS_BOOK_PROPERTIES incl. shareWith, lib/jmap/client.ts:252-261, #257).
    • Fix hint: low priority on mobile; if added, reuse whatever calendar-sharing sheet the calendar agent proposes and request properties explicitly.

Contact card / form

  • Clearing a whole field group on edit never reaches the server — P2 — rn-only-bug — fixed in 9800ad0

    • What RN does: formToPatch only includes keys that are non-empty (src/screens/ContactFormScreen.tsx:378-395), and updateContact sends the patch as a JMAP update (src/api/contacts.ts:82). Removing the last phone/address/note/keyword/anniversary/online link of an existing contact therefore omits the key, which JMAP treats as "unchanged"; the store merge (contacts-store.ts:178-182) also keeps the old value, so the UI shows the removed items again.
    • Fix hint: in edit mode emit null for every collection the form owns that ended up empty (emails, phones, addresses, organizations, titles, anniversaries, onlineServices, personalInfo, notes, keywords, nicknames, speakToAs, calendarUri…) — the same approach WEB uses for media: null (components/contacts/contact-form.tsx:555-558).
  • Saving the form drops non-photo media (logo, sound) — P3 — rn-only-bug — fixed in 9800ad0

    • What WEB does: copies every non-photo media entry through and keeps the original photo key (components/contacts/contact-form.tsx:544-553).
    • What RN does: rebuilds media with only media.photo (src/screens/ContactFormScreen.tsx:359-366,395), so an imported LOGO/SOUND is deleted on the first edit.
    • Fix hint: carry existing.media entries with kind !== 'photo' into the patch.
  • Anniversary input accepts free text that the server rejects; year-only dates get a fake Jan 1 — P2 — rn-only-bug — fixed in 9800ad0

    • stringToPartialDate / lossless partialDateToString live in src/lib/contact-utils.ts (580e010).
    • What WEB does: <input type="date"> plus stringToPartialDate always yields a structured PartialDate (components/contacts/contact-form.tsx:192-206, 525-528); changelog 1.9.x #224 stresses that Stalwart drops non-PartialDate anniversaries.
    • What RN does: stringToAnniversaryDate returns the raw string for anything other than YYYY-MM-DD / --MM-DD (src/screens/ContactFormScreen.tsx:116-128), so "1990-05", "May 5" or a YYYY-MM-DDT… value is sent as a string → Stalwart rejects the set (and, via the previous finding, the failure is invisible). partialDateToString pads a year-only or year-month PartialDate with 01 (:110), so merely opening and saving a contact with {year:1990} rewrites it to 1990-01-01 — this feeds the birthday calendar with a wrong day.
    • Fix hint: reject/normalise input to PartialDate (support YYYY, YYYY-MM, --MM-DD, ---DD like WEB parseVcardDate, lib/vcard.ts:360-394), and make partialDateToString lossless (1990, 1990-05).
  • Organization-kind cards (#701) — P3 — partial — fixed in 9800ad0

    • Detail subtitle no longer repeats the org name (1cef53a).
    • What WEB does: person/organization toggle, org name validates as the identity, name.full filled from org, kind: 'org' written, detail hides the duplicated org line (components/contacts/contact-form.tsx:269-277, 443-449, 491-495, 560-563, 696-735; components/contacts/contact-detail.tsx:178-180).
    • What RN does: no toggle/kind; validation requires given/surname/full or an email (src/screens/ContactFormScreen.tsx:607-612), so an org card needs its name typed into "Display name" and an org-only card without email cannot be created; detail subtitle repeats the org under the heading when they are equal (src/screens/ContactDetailScreen.tsx:136).
    • Fix hint: add a Person/Organization pill in the Identity section; when org: skip name components, set name.full = org, kind: 'org', accept org name as identity; hide the equal subtitle.
  • Photo data URIs from Stalwart not normalised (#307) — P2 — bugfix-parity — fixed in 580e010

    • What WEB does: normalizeContactPhotoUri rewrites data:base64,… / data:;base64,… to data:image/jpeg;base64,… in getContactPhotoUri and in the form (stores/contact-store.ts:157-180; contact-form.tsx:376).
    • What RN does: getContactPhotoUri returns media.uri raw (src/lib/contact-utils.ts:71-77), used by ContactListRow:38, ContactDetailScreen:114 and the form (ContactFormScreen.tsx:233-237); RN Image cannot decode a media-type-less data URI so such photos render blank, and re-saving writes the malformed URI back.
    • Fix hint: port normalizeContactPhotoUri into contact-utils.ts and apply in getContactPhotoUri + contactToForm.
  • Picked photos are stored full-size — P3 — partial — fixed in 9800ad0

    • What WEB does: downscales to 512px JPEG before embedding (components/contacts/contact-form.tsx:96-126) and rejects files >10 MB.
    • What RN does: ImagePicker with quality: 0.8, no dimension cap, base64 straight into the card (src/screens/ContactFormScreen.tsx:680-695).
    • Fix hint: use expo-image-manipulator to resize to ≤512px before reading base64.
  • Duplicate contact keeps the source UID — P2 — rn-only-bug — fixed in 1cef53a

    • What WEB does: strips id, uid, created, updated before creating the copy (components/contacts/contacts-app.tsx:448-451).
    • What RN does: doDuplicate only strips id/addressBookIds/created/updated (src/screens/ContactDetailScreen.tsx:216-222), so the copy is created with the same uid; group membership by uid then matches both cards and CardDAV/vCard consumers see two cards with one UID.
    • Fix hint: also drop uid (and let createContact mint a fresh one once the #644 fix lands).
  • Gender vocabulary differs from WEB and from the vCard exporter — P3 — partial — fixed in 9800ad0

    • What WEB does: masculine | feminine | other | none | unknown (vCard SEX mapping, components/contacts/contact-form.tsx:1062-1069), which grammaticalGenderToVcardSex understands (lib/vcard.ts:34-48).
    • What RN does: masculine | feminine | common | neuter | animate | inanimate (src/screens/ContactFormScreen.tsx:437-445); values other than masculine/feminine export as an empty GENDER and display differently across clients.
    • Fix hint: align option lists (or accept both on display) so the same card renders the same value in both apps.
  • Online service label and multiple nicknames not editable — P3 — partial — fixed in 9800ad0

    • Detail shows every nickname (1cef53a).
    • What WEB does: online-service entries carry label (contact-form.tsx:24-28, 517-523); detail shows all nicknames (contact-detail.tsx:194-196).
    • What RN does: OnlineDraft has only uri/service (src/screens/ContactFormScreen.tsx:42, 332-338), and the form/detail read only the first nickname (:168-170, 269-270; src/lib/contact-utils.ts:171-174). Saving a card with several nicknames collapses them to one.
    • Fix hint: keep unknown nicknames when rebuilding nicknames, add a label field to the online row.
  • Display name includes the middle name (differs from WEB sorting/grouping) — P3 — partial — fixed in 580e010

    • What WEB does: given + surname only, then name.full, nickname, org, email (stores/contact-store.ts:125-150).
    • What RN does: given + middle + surname (src/lib/contact-utils.ts:13-14). Harmless on its own but sort order, dedupe keys and #672-style name sanitising diverge between clients.
    • Fix hint: drop middle from the join or make it a display-only concern.

List, search, sort

  • Alphabetical index buckets every non-ASCII initial under "#" — P3 — bug — fixed in 358bdb9

    • What WEB does: \p{L} test + Intl.Collator(locale, sensitivity base) so Ä/É/Ł get their own sections (components/contacts/contact-list.tsx:242-262).
    • What RN does: /[A-Z]/.test(letter) (src/screens/ContactsScreen.tsx:53-54), so "Éric" and "Ölçer" land in "#".
    • Fix hint: use /\p{L}/u and localeCompare (Hermes supports Unicode property escapes).
  • "Uncategorized" means "no address book" in RN but "no category" in WEB, and its count is hard-coded 0 — P3 — bug — fixed in 358bdb9

    • selectUncategorized (no keywords) in 26a34d5; the drawer row moved under Tags as "No category" with a live count.
    • What WEB does: "No category" = contacts without keywords, with a live count (components/contacts/contacts-sidebar.tsx:246-248, 526-541; contacts-app.tsx:283-285).
    • What RN does: filter = contacts with no addressBookIds (src/stores/contacts-store.ts:439-440; src/screens/ContactsScreen.tsx:146-149), drawer row passes count={0} (src/components/contacts/ContactsSidebarDrawer.tsx:128-136) so the count never shows; on Stalwart every card has a book so the view is always empty.
    • Fix hint: redefine as "no keywords", move the row under the Tags section, compute the count.
  • Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing — P3 — missing

    • deferred: optional; free-text search only.
    • What WEB does: filter drawer with tri-state chips (components/contacts/contact-list.tsx:18-76, 154-228, 350-456; changelog 1.5.x "Revamp contact detail view with filters").
    • What RN does: free-text search only (src/lib/contact-utils.ts:216-241).
    • Fix hint: optional; a filter sheet reusing the WEB predicate block would do.
  • No select-all / bulk export in multi-select — P3 — partial — fixed in 358bdb9

    • What WEB does: select-all toggle, range select, bulk export, bulk add-to-group (components/contacts/contact-list.tsx:283-305, 458-502).
    • What RN does: long-press multi-select with delete / move / tag only (src/screens/ContactsScreen.tsx:164-226, 282-313).
    • Fix hint: add "Select all visible" and "Export selected" (reuse contactsToVCard + share flow from ContactsSettings:56-78).
  • Rename category (keyword) missing — P3 — missing — fixed in 358bdb9

    • renameKeyword in the store (26a34d5); long-press a tag in the drawer to rename.
    • What WEB does: keyword context menu → rename, rewrites keywords on every affected card (components/contacts/contacts-sidebar.tsx:680-698; stores/contact-store.ts:1087-1116).
    • What RN does: tags can be assigned/removed per contact and in bulk (TagAssignSheet, contacts-store.ts:213-235) but not renamed.
    • Fix hint: long-press on a Tags row in ContactsSidebarDrawer → prompt → loop updateContact like WEB renameKeyword.
  • Add-to-group from the contact itself missing — P3 — partial — fixed in 1cef53a

    • What WEB does: detail "More" menu and list context menu offer "Add to group" (creating a group if none) (components/contacts/contacts-app.tsx:438-446; contact-detail.tsx:150-152).
    • What RN does: membership is only editable from GroupDetailScreen (src/screens/GroupDetailScreen.tsx:86-98); the detail "More" sheet has Duplicate/Move/Export/Delete only (src/screens/ContactDetailScreen.tsx:247-270).
    • Fix hint: add a "Add to group" item that opens a group picker (list of selectGroups) and calls the same members patch as GroupDetailScreen.addMembers.

Groups

  • No way to create a contact group — P2 — missing — fixed in 9800ad0

    • "New group" row in the drawer and long-press on the header "+" (358bdb9); createGroup/memberIds route param.
    • What WEB does: "+" menu → "Create group" with name + member picker (components/contacts/contacts-sidebar.tsx:299-305; contact-group-form.tsx; stores/contact-store.ts:734-760).
    • What RN does: ContactForm supports asGroup (src/screens/ContactFormScreen.tsx:554,627) but the only caller passes an existing contactId for editing (src/screens/GroupDetailScreen.tsx:138; grep asGroup in src/: no other call site). The Contacts header "+" always opens a plain new-contact form (src/screens/ContactsScreen.tsx:336-343).
    • Fix hint: add "New group" to the header/drawer that navigates to ContactForm { asGroup: true } with a members picker (ContactPickerSheet), or a dedicated small screen that creates { kind:'group', name:{components:[{kind:'given', value}]}, members }.
  • Group edit reuses the full person form (name split into first/last, photo, emails…) — P3 — partial — fixed in 9800ad0

    • What WEB does: dedicated group form with name + searchable member checklist (components/contacts/contact-group-form.tsx:80-196).
    • What RN does: GroupDetailScreen → ContactForm asGroup shows Prefix/First/Middle/Last/Nickname, Email, Phone… (src/screens/ContactFormScreen.tsx:747-813); a group named "Sales Team" appears in "First name".
    • Fix hint: when asGroup, render a single "Group name" field bound to given and hide the person-only sections.
  • Group compose: RN expands to individual recipients; no To/Cc/Bcc choice — P3 — partial — fixed in 1cef53a

    • Dedupe via getGroupRecipients (26a34d5); a single group chip is up to the composer agent.
    • What WEB does: hands the composer one expandable group chip (RFC 5322 group syntax) with a To/Cc/Bcc submenu (components/contacts/contacts-app.tsx:561-587; contact-group-detail.tsx:69-87; changelog 1.7.x "Contact groups as single expandable recipient chips").
    • What RN does: "Email all" pushes one recipient per member into prefillTo (src/screens/GroupDetailScreen.tsx:72-84). Functionally fine; dedupe by email is missing (two members sharing an address are both added).
    • Fix hint: dedupe case-insensitively; a group chip is optional unless the composer agent adds group chips.

Composer autocomplete

  • Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals — P2 — missing
    • store-side done in 26a34d5 (getAutocomplete with group entries + recent recipients, loadRecentRecipients/searchRecipients fed by src/api/recent-recipients.ts, getGroupRecipients, #672 normalisation); the composer uses getAutocomplete and the recent Sent recipients since 5b72c4d. Still open: the on-demand server search (searchRecipients has no caller), directory principals, and the calendar's ParticipantInput, which still offers contacts only.
    • What WEB does: getAutocomplete merges contacts, group entries (as one chip with memberCount), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (stores/contact-store.ts:622-707, 23-49; loadRecentRecipients :1118-1142 fed from mail-app.tsx:423; searchRecipients :1144-1154 used by components/email/email-composer.tsx:1175-1212; group chip insertion :1218-1233; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips).
    • What RN does: suggestions come only from individuals matched with matchesContactSearch (src/screens/ComposeScreen.tsx:367-392); groups are explicitly filtered out and nothing else is consulted. ParticipantInput (calendar) likewise flattens contacts only (src/components/calendar/ParticipantInput.tsx:34-51).
    • Fix hint: add recentRecipients (scan Sent to/cc, 300 msgs) to the contacts store, include groups as a pickable entry that expands to member emails on pick, and reuse sanitizeDisplayName/splitMailbox-style cleanup for names that contain a mailbox (#672).

Viewer integration

  • "Add sender to contacts" from the message view missing — P2 — missing — done in d2ed27f (the viewer's address sheet, src/components/email/AddressActionSheet.tsx, offers "Add to contacts" and "Open contact")

    • contact-side done: ContactForm { prefill } seeds email + given/surname (9800ad0), findContactByEmail in the store (26a34d5); the sender sheet in EmailThreadScreen belongs to the viewer agent.
    • What WEB does: contact sidebar/popover on sender click with "Add to contacts" that creates a card with given/surname split from the display name (components/email/email-viewer.tsx:5350-5366, 558-564); also the mobile recipient popover (#306) and /contacts/new?addEmail= deep link (components/contacts/contacts-app.tsx:182-198).
    • What RN does: no contact lookup or "add" action in EmailThreadScreen (grep contact/ContactForm: only SenderAvatar at :794). Only ContactForm's route.params support contactId/addressBookId/asGroup (src/navigation/types.ts:24) — no email/name prefill.
    • Fix hint: add prefill?: { email; name } to the ContactForm route, and a sender-tap sheet in EmailThreadScreen with "Add to contacts" / "Open contact" (lookup by email in useContactsStore).
  • Sender-name tap → contact popup missing — P3 — missing — done in d2ed27f (tapping a sender or recipient opens AddressActionSheet, which shows the matching contact and "Open contact")

    • deferred: viewer agent (same sheet as above; findContactByEmail + ContactDetail { contactId } are ready).
    • What WEB does: clicking the sender name opens the contact popup with details/actions (changelog 1.4.x "Show contact popup when clicking the sender name", email-viewer.tsx:392-455).
    • What RN does: none (see previous item).
    • Fix hint: same sheet as above; show the matching card's phone/org and an "Open" that navigates to ContactDetail.
  • Contact deep links (/contacts/<id>[/edit], /contacts/new) — P3 — missing

    • partly: /contacts/<id> opens the contact since 5802cae; /contacts/new and /contacts/<id>/edit only open the Contacts tab.
    • What WEB does: parseContactsPath/buildContactsPath (lib/deep-links.ts:323-329), applied once on mount (components/contacts/contacts-app.tsx:182-224).
    • What RN does: src/navigation/linking.ts (5802cae) maps /contacts/<id> to ContactDetail { contactId } and every other contacts path to the tab; it never opens ContactForm.

vCard import / export (lib/vcard.ts vs src/lib/vcard.ts)

  • Imported dates are raw strings, not RFC 9553 PartialDate (#224) — birthdays vanish on Stalwart — P1 — bugfix-parity — fixed in e650cb2

    • What WEB does: parseVcardDate converts BDAY/ANNIVERSARY/DEATHDATE/X-ABDATE (basic + extended forms, reduced accuracy, VALUE=text skipped) into {year, month, day} and drops undecodable dates (lib/vcard.ts:360-394, addAnniversary in buildContact ~:480-490; changelog 1.9.x "Import vCard dates as RFC 9553 PartialDate and map common X- extensions (#224)").
    • What RN does: card.anniversaries.a0 = { kind: 'birth', date: val } with the raw string (src/lib/vcard.ts BDAY case ~:687, ANNIVERSARY ~:701, DEATHDATE ~:709), so every imported birthday is rejected by Stalwart (the whole ContactCard/set create fails with invalidProperties, and because of the error-handling finding above the failure is reported only as a generic "import failed for one contact" warning) — i.e. importing a vCard with a BDAY loses the entire contact, not just the date.
    • Fix hint: copy parseVcardDate + addAnniversary from WEB into RN vcard.ts; add a test with BDAY:19850412, BDAY:--0412, BDAY;VALUE=text:circa 1800.
  • Vendor X- extensions not imported (X-ABDATE, X-ABRELATEDNAMES, X-SPOUSE/MANAGER/ASSISTANT, X-MAIDENNAME, X-GENDER, X-ANDROID-CUSTOM, X-AIM/X-SKYPE/… IM handles) — P2 — bugfix-parity — fixed in e650cb2

    • What WEB does: X_ONLINE_SERVICES map → onlineServices, RELATION_LABELS/ANDROID_RELATION_TYPES → relatedTo, ANDROID_EVENT_KINDS → anniversaries, X-MAIDENNAME → surname2, X-GENDER → grammaticalGender (lib/vcard.ts:250-323, ~1009-1100, ~1113-1120); export writes X-MAIDENNAME and X-ABDATE for other anniversaries (~1173-1178, ~1280-1283).
    • What RN does: default: break (src/lib/vcard.ts:~858); none of the maps exist (function list: no X_ONLINE_SERVICES, RELATION_LABELS, ANDROID_*). Export skips surname2 and other anniversaries (~:1025-1045).
    • Fix hint: port the maps and the extension cases verbatim; both files otherwise share structure so a near-mechanical diff apply works.
  • Apple grouped labels (item1.TEL + item1.X-ABLABEL) are lost — P2 — bugfix-parity — fixed in e650cb2

    • What WEB does: splitGroupPrefix keeps the group, collapseProperties folds a sibling X-ABLABEL into an X-ABLABEL= param, decodeAppleLabel strips _$!<…>!$_ (lib/vcard.ts:116-140, 413-434); labels then land on emails/phones/addresses/online services.
    • What RN does: stripGroupPrefix discards the group (src/lib/vcard.ts:120-131) and properties are keyed by keyPart only (:334-339), so iOS/macOS exports lose every custom label and the X-ABLABEL lines are silently dropped (:~854).
    • Fix hint: port splitGroupPrefix + collapseProperties + RawProperty list handling in parseVCard.
  • Org-only vCards are dropped on import and exported without FN (#701) — P2 — bugfix-parity — fixed in e650cb2

    • What WEB does: hasOrg counts as identity in buildContact (lib/vcard.ts:~1123-1127); generateSingleVCard falls back to the org name for the mandatory FN (~:1158-1162).
    • What RN does: if (!hasName && !hasEmail && card.kind !== 'group') return null (src/lib/vcard.ts:~893-895) so a company card with only ORG+TEL is skipped; export emits no FN/N when there is no personal name (~:932-936), producing an invalid vCard (FN is required by RFC 6350) that the RN importer itself would then reject.
    • Fix hint: apply the two WEB lines.
  • Only one NICKNAME survives import — P3 — bugfix-parity — fixed in e650cb2

    • What WEB does: card.nicknames[\n${idx}`] (lib/vcard.ts` NICKNAME case).
    • What RN does: card.nicknames.n0 = { name: val } overwrites (src/lib/vcard.ts NICKNAME case ~:548).
    • Fix hint: index like WEB.
  • Import target defaults to the first book and cannot be changed from Settings — P3 — partial — fixed in bc4c57e

    • What WEB does: client.createContact without addressBookIds files into the isDefault book (lib/jmap/client.ts:5095-5101).
    • What RN does: ContactsSettings passes books[0] with no onChangeTarget (src/components/settings/ContactsSettings.tsx:54, 269-275); ContactsScreen allows changing the target but also starts at addressBooks[0] unless a book category is active (src/screens/ContactsScreen.tsx:201-204, 476, 480-486).
    • Fix hint: prefer isDefault; pass onChangeTarget in settings too.
  • Import keeps the source UID (fine) but nothing mints one when absent — covered by the #644 finding above; also importContacts swallows per-card failures with only a console.warn (src/stores/contacts-store.ts:206-208) so "Imported 0 contacts" is the only feedback. P3 — add a failure count to the result sheet. — fixed in 26a34d5

    • The result sheet now shows how many cards failed to import.

Trusted senders

  • Trusted-senders settings show only the local list; synced book entries are invisible and cannot be removed — P2 — partial — fixed in 78114b6

    • What WEB does: modal shows the address-book entries when the feature is on, adds/removes through addToTrustedSendersBook/removeFromTrustedSendersBook, and settings show the synced count (components/trusted-senders-modal.tsx:22-48; components/settings/content-senders-settings.tsx:25-40, 77-82).
    • What RN does: ContentSendersSettings reads/writes only settings-store.trustedSenders (src/components/settings/ContentSendersSettings.tsx:19-21, 100-144); removeFromTrustedSendersBook exists in the store (src/stores/contacts-store.ts:323-333) but has no caller. A sender trusted from the viewer is written to both places (src/components/EmailBodyView.tsx:644-651), so deleting it in settings only removes the local copy and the book keeps trusting it (EmailBodyView.tsx:513-516).
    • Fix hint: in the modal, list trustedSenders ∪ trustedSenderEmails, mark book entries, and call removeFromTrustedSendersBook (+ local remove) on delete; show the union count.
  • trustedSendersAddressBook setting is dead (false, no setter, no toggle) — book is consulted unconditionally — P3 — partial — fixed in 78114b6

    • Setting is boolean | null with a "Sync trusted senders to address book" toggle. Until d89e331 it switched itself on for a contacts-capable account, and the viewer then trusted every contact in every book; it now stays off until the user turns it on, and only the Trusted Senders book counts (the webmail still auto-enables it).
    • What WEB does: null until the account proves contacts support, then auto-true (stores/auth-store.ts:380-385, stores/settings-store.ts:361,593); toggle in settings (content-senders-settings.tsx:77-82); the book is only loaded/used when enabled (mail-app.tsx:199-203, email-viewer.tsx:1705).
    • What RN does: default false, no setter (src/stores/settings-store.ts:106,239); EmailBodyView loads the book regardless and ORs trustedSenderEmails into the trust check (:480-485, 513-516); the addressBookEmails branch (treats every contact as trusted) is gated on the dead flag (:486-498) so it never runs.
    • Fix hint: either drop the flag and the dead addressBookEmails branch, or expose a "Sync trusted senders to address book" toggle that gates the passive load and the addToTrustedSendersBook write.
  • Race: trusting a sender while the passive book load is in flight silently fails — P2 — rn-only-bug — fixed in 26a34d5

    • What WEB does: single-flight promise shared by all callers (stores/contact-store.ts:184-185, 1156-1199), so addToTrustedSendersBook awaits the load and then has a bookId.
    • What RN does: loadTrustedSendersBook returns immediately when trustedSendersLoading is true (src/stores/contacts-store.ts:279); addToTrustedSendersBook then reads bookId === null and throws (:312-317), which the viewer swallows (EmailBodyView.tsx:650). The passive load is kicked off by the same screen a moment earlier (:483-485), so the very first "Trust sender" after app start is likely to hit this.
    • Fix hint: keep the in-flight promise in a module variable and return it; if the passive load found no book and createIfMissing is true, create it after the await.
  • Duplicate "Trusted Senders" books are not consolidated and the pick is non-deterministic (#730) — P3 — bugfix-parity — fixed in 26a34d5

    • Deterministic pick (sorted by id); consolidation not ported.
    • What WEB does: sorts matches by id, uses the first, merges the rest and deletes them (stores/contact-store.ts:1169-1187, 208-245); getAddressBooks({throwOnError}) prevents minting on a failed fetch.
    • What RN does: books.find(b => b.name === …) (src/stores/contacts-store.ts:283) — order-dependent; if the web already created a duplicate pair, RN may trust a different subset than web. RN only creates on explicit add (:284-290) and fetchAddressBooks throws on error, so RN itself does not mint duplicates.
    • Fix hint: sort by id before picking; optional consolidation.
  • Trusted-sender card stores no display name; "Name " input not parsed — P3 — partial — fixed in 26a34d5

    • What WEB does: parses Name <email> and stores name.full (stores/contact-store.ts:1201-1225).
    • What RN does: lower-cases the whole string and stores emails only (src/stores/contacts-store.ts:308-321); a Name <email> string (e.g. typed in settings) becomes an invalid address.
    • Fix hint: reuse the WEB regex.
  • Auto-trust reply recipients missing — P3 — missing — done in 5b72c4d (area 04)

    • deferred: composer agent; addToTrustedSendersBook now accepts Name <email> and never races the passive load (26a34d5).
    • What WEB does: on reply/replyAll every To/Cc recipient is added to the trusted book (components/email/email-composer.tsx:2284-2292; changelog 1.4.x "Auto-add recipients to trusted senders when replying").
    • What RN does: ComposeScreen has no trusted-sender code (grep trusted: none).
    • Fix hint: after a successful send in reply mode call addToTrustedSendersBook (+ addTrustedSender) for each outgoing To/Cc.

Activity / misc

  • Contact activity: upcoming events come only from the already-loaded calendar cache — P3 — partial — fixed in 0e51465
    • What WEB does: queries the server for the next 365 days (components/contacts/contact-activity.tsx:133-169).
    • What RN does: filters useCalendarStore().events (src/components/contacts/ContactActivity.tsx:90-91, 127-142), i.e. only the range the calendar tab happened to load; on a fresh start it is empty.
    • Fix hint: call the RN calendar API for [now, now+365d] with a participant filter, falling back to the cache.

Verified at parity (brief list, so the fixer knows what NOT to redo)

  • Address book list/create/rename/delete with myRights checks (src/components/settings/ContactsSettings.tsx, AddressBookPickerSheet); inline "New address book…" in the move sheet (WEB #415).
  • Move contacts between books (single from detail, bulk from list) via addressBookIds patch (contacts-store.ts:237-241) — RN equivalent of WEB drag-and-drop.
  • Group by first letter with sticky headers + settings toggle (ContactsScreen.tsx:158-162, ContactsSettings.tsx:132-137); sort by display name, or by last name since 1827748 (#963); the sort no longer re-runs on every search keystroke since e6be892.
  • Search over name/emails/phones/orgs (RN also matches keywords).
  • Detail screen renders every WEB field block: emails/phones with contexts+features+labels, addresses (components, flat, timezone), org/units/titles/roles, anniversaries with age, gender/pronouns, languages, personalInfo, online services, calendar URIs, crypto keys, categories, relatedTo, notes, address book names, created/updated; quick actions call/SMS/mail/share; copy via long-press.
  • Contact form: prefix/given/middle/surname/suffix, nickname, display name (name.full), multiple emails/phones with context + phone feature pills, addresses as RFC 9553 components, multiple orgs (org/department/title/role), anniversaries with native date picker, online services, personalInfo w/ level, gender+pronouns, calendar URIs, categories (chips + suggestions from existing keywords), multiple notes, photo pick/remove, address book pick, dirty-state discard confirm, email regex validation, media: {} when photo removed (equivalent of WEB media: null).
  • name.full display fallback (#179), getContactPrimaryEmail with pref. name.full (the vCard FN) is written on every form, "add sender" and import save since a734d0a (#430).
  • Group detail: member list resolved via id/uid/urn:uuid: (contacts-store.ts:401-415), add members via ContactPickerSheet, remove member, delete group, email all.
  • Tags (keywords) = WEB categories: drawer section with counts, filter by tag, bulk assign via TagAssignSheet, per-contact edit in form.
  • vCard import sheet: file picker, 5 MB cap, parse, duplicate detection by email (detectDuplicates identical), select all/none, target book, result count; export all (settings) and single (detail) with share sheet; multi-vCard files; QUOTED-PRINTABLE + charset decode (RN has a Hermes-safe decodeBytes), RFC 6868 params, KIND/MEMBER, PHOTO/LOGO/SOUND, RFC 6474/6715/8605/9554 properties.
  • Contact activity: recent emails via from/to OR filter (limit 5), opens the thread.
  • Trusted senders: same book name constant, passive load, add on "Trust sender" in the viewer, isTrustedAddressBookSender check.
  • Contacts tab hidden when the session lacks urn:ietf:params:jmap:contacts (src/lib/capabilities.ts:29-31, App.tsx:170-179) — WEB 1.8.x "Hide Contacts and Calendars when the account lacks the JMAP capability".
  • Push StateChange for AddressBook/ContactCard triggers refetch (contacts-store.ts:152-168, App.tsx:390-396); contacts cache persisted for instant render; selected category persisted.
  • Contact store reset on logout/account switch (src/stores/auth-store.ts:58,76,113,179,379) — the RN counterpart of the 583e20d9 namespacing fix (single-account store, so no id-form flip).

N/A on mobile

  • Pro multi-account aggregation (hooks/use-pro-multi-account-contacts.ts, fetchAllAccountsContacts, :: id namespacing) — Pro shell only; RN keeps one account active and resets the store on switch.
  • Local-storage (non-JMAP) contact fallback (supportsSync === false, local-<uuid> ids) — RN hides the tab when contacts are unsupported.
  • Drag-and-drop to address books / categories, right-click context menus, Ctrl/Shift range selection, resizable panes, keyboard shortcuts — replaced by long-press multi-select + sheets.
  • Print contact (components/contacts/contact-print.ts).
  • Plugin contact APIs and the contact-cryptokeys plugin slot.
  • mailto:/tel: handoff differences (RN uses Linking).
  • Native issue #34 (device contacts / calendar sync adapters) is a feature request, not a parity item; note that a device sync would want the UID (#644) and PartialDate (#224) fixes above first.