45 KiB
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 in580e010- What WEB does:
client.createContactreads the server-assigned id fromcreatedand then re-fetches the full card withContactCard/getbefore returning it (lib/jmap/client.ts:5128-5133), so the store appends a complete card. - What RN does:
createContactreturnsres.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.handleSavejustgoBack()s (src/screens/ContactFormScreen.tsx:631-633) so the list shows an "Unnamed" row with no email until a push/pull refresh;doDuplicateeven navigates to that stub (src/screens/ContactDetailScreen.tsx:231-232). - Fix hint: in
api/contacts.tsmerge{ ...contact, addressBookIds, ...created }or issue a follow-upContactCard/getfor the created id (mirror WEB) before returning.
- What WEB does:
-
ContactCard/seterrors are swallowed or crash with a TypeError —P2—bug— fixed in580e010- What WEB does: every set call checks
notCreated/notUpdated/notDestroyedand throws the serverdescription(lib/jmap/client.ts:5122-5126, 5155-5160, 5180-5184); the method-levelerrorresponse is also handled. - What RN does:
createContactindexes.created['new-contact']without checkingcreated/notCreated(src/api/contacts.ts:73) so a rejected card surfaces as "Cannot read property 'new-contact' of undefined";updateContact(:76-85) anddeleteContacts(:87-93) never look atnotUpdated/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 atsrc/api/contacts.ts:7-15) and throw onnotCreated/notUpdated/notDestroyed[id].description;bulkDeleteshould only drop the ids that were actually destroyed.
- What WEB does: every set call checks
-
No UID assigned on contact creation (#644) —
P2—bugfix-parity— fixed in580e010- 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:
createContactforwards 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`).
- What WEB does: sends
-
Contact list silently truncated at the query limit (no pagination) —
P2—bug— fixed in580e010- What WEB does:
fetchPaginatedContactswalksContactCard/querywithposition/limit = maxObjectsInGetuntiltotalis reached, then batchesContactCard/get(lib/jmap/client.ts:~4985-5022). - What RN does: a single
ContactCard/querywithlimit: 1000(src/api/contacts.ts:26-40); the server may clamp the limit lower and anything beyond is never fetched.getContactsbatches the get correctly (:42-57). - Fix hint: loop on
positionuntilids.length < limit || total reachedinqueryContacts, mirroring WEB.
- What WEB does:
-
No first-touch gate: concurrent AddressBook/get + ContactCard/query on cold start (#907) —
P2—bugfix-parity— fixed in26a34d5- Store-side:
fetchAddressBooks/fetchContactsare single-flight and contacts wait for an in-flight AddressBook/get; the request-levelFirstTouchGateinsrc/api/jmap-client.tswas added by the client agent. - What WEB does:
FirstTouchGateserialises 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 atlib/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:
ContactsScreenfiresfetchAddressBooks()andfetchContacts()in the same effect (src/screens/ContactsScreen.tsx:120-123) andrestoreSessionfans outfetchContacts+fetchCalendars(src/stores/auth-store.ts:91-93) with no gate insrc/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.tsintosrc/api/and wrapjmapClient.request(key onAddressBook/,ContactCard/,Calendar/,CalendarEvent/+ accountId); reset it on logout/account switch.
- Store-side:
-
Contacts account id: RN uses the mail primary account, not
primaryAccounts[contacts]—P3—partial— fixed in580e010- What WEB does:
getContactsAccountId()prefersprimaryAccounts["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 inapi/contacts.ts.
- What WEB does:
-
Shared / group-account address books not listed or writable —
P2—missing— fixed in26a34d5- Drawer "Shared from X" sections in 358bdb9; writes are routed to the owning account via
originalId/accountId. - What WEB does:
getAllAddressBooks/getAllContactsiterate every contacts-capable or non-personal account, namespace ids asaccountId:bookId, tagisShared/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.tsthroughout);AddressBooktype has noaccountId/isShared(src/api/types.ts:369-377). - Fix hint: iterate
session.accountswhereaccountCapabilities[contacts]or!isPersonal(as WEBgetContactCapableAccountIds), namespace ids, and render a "Shared from" section inContactsSidebarDrawer; route writes to the owning account. ThemyRights.mayWritechecks already present inAddressBookPickerSheet:96andContactsSettings:204,209will then become meaningful.
- Drawer "Shared from X" sections in 358bdb9; writes are routed to the owning account via
-
Deleting a contact does not clean group member references locally —
P3—partial— fixed in26a34d5- Drawer member counts resolve through
selectGroupMembersin358bdb9. - What WEB does:
cleanGroupMembersstrips the deleted card's id/uid/urn:uuid:variants from every group'smembersin state on delete/bulk delete (stores/contact-store.ts:339-366, 571-578, 911-918). - What RN does:
deleteContact/bulkDeletejust filter the list (src/stores/contacts-store.ts:185-195); group counts in the drawer use rawObject.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 throughselectGroupMemberslike WEBmemberCountByGroup(contacts-sidebar.tsx:250-270).
- Drawer member counts resolve through
-
Deleting an address book leaves its contacts in the cache —
P3—partial— fixed in26a34d5- What WEB does:
removeAddressBookdrops 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.
- What WEB does:
-
Persisting every contact card (incl. base64 photos) to AsyncStorage —
P3—rn-only-bug— fixed in26a34d5mediais stripped from the persisted snapshot; picked photos are downscaled to 512px JPEG withexpo-image-manipulatorin9800ad0.- What RN does:
partializepersistscontactsandaddressBooks(src/stores/contacts-store.ts:355-366); cards carry inlinedata:photos (src/screens/ContactFormScreen.tsx:690-691stores 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
mediafrom 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 inbc4c57esetDefaultAddressBook(onSuccessSetIsDefault) and theisDefault-free update patch landed in 580e010; new contacts/imports prefer the default book viagetDefaultAddressBookId.- 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;
updateAddressBookeven forwardsisDefaultas 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 toaddressBooks[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) toapi/contacts.ts+ a row action inContactsSettings; dropisDefaultfrom the update patch; preferbooks.find(b => b.isDefault)as the default target.
-
Default address book can be offered for deletion —
P3—partial— fixed inbc4c57e- 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.isDefaultto the guard.
- What WEB does: hides delete for
-
Address book share / visibility (subscription) management missing —
P3—missing- deferred:
shareWith/myRightsare now requested explicitly (580e010) and typed onAddressBook; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists). - What WEB does:
shareAddressBookviashareWith/<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;
getAddressBooksrequests nopropertiessoshareWithis whatever the server defaults to (src/api/contacts.ts:17-24; WEB requestsADDRESS_BOOK_PROPERTIESincl.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
propertiesexplicitly.
- deferred:
Contact card / form
-
Clearing a whole field group on edit never reaches the server —
P2—rn-only-bug— fixed in9800ad0- What RN does:
formToPatchonly includes keys that are non-empty (src/screens/ContactFormScreen.tsx:378-395), andupdateContactsends the patch as a JMAPupdate(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
nullfor 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 formedia: null(components/contacts/contact-form.tsx:555-558).
- What RN does:
-
Saving the form drops non-photo media (logo, sound) —
P3—rn-only-bug— fixed in9800ad0- What WEB does: copies every non-photo
mediaentry through and keeps the original photo key (components/contacts/contact-form.tsx:544-553). - What RN does: rebuilds
mediawith onlymedia.photo(src/screens/ContactFormScreen.tsx:359-366,395), so an imported LOGO/SOUND is deleted on the first edit. - Fix hint: carry
existing.mediaentries withkind !== 'photo'into the patch.
- What WEB does: copies every non-photo
-
Anniversary input accepts free text that the server rejects; year-only dates get a fake Jan 1 —
P2—rn-only-bug— fixed in9800ad0stringToPartialDate/ losslesspartialDateToStringlive insrc/lib/contact-utils.ts(580e010).- What WEB does:
<input type="date">plusstringToPartialDatealways 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:
stringToAnniversaryDatereturns the raw string for anything other thanYYYY-MM-DD/--MM-DD(src/screens/ContactFormScreen.tsx:116-128), so "1990-05", "May 5" or aYYYY-MM-DDT…value is sent as a string → Stalwart rejects the set (and, via the previous finding, the failure is invisible).partialDateToStringpads a year-only or year-month PartialDate with01(: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,---DDlike WEBparseVcardDate,lib/vcard.ts:360-394), and makepartialDateToStringlossless (1990,1990-05).
-
Organization-kind cards (#701) —
P3—partial— fixed in9800ad0- Detail subtitle no longer repeats the org name (
1cef53a). - What WEB does: person/organization toggle, org name validates as the identity,
name.fullfilled 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.
- Detail subtitle no longer repeats the org name (
-
Photo data URIs from Stalwart not normalised (#307) —
P2—bugfix-parity— fixed in580e010- What WEB does:
normalizeContactPhotoUrirewritesdata:base64,…/data:;base64,…todata:image/jpeg;base64,…ingetContactPhotoUriand in the form (stores/contact-store.ts:157-180;contact-form.tsx:376). - What RN does:
getContactPhotoUrireturnsmedia.uriraw (src/lib/contact-utils.ts:71-77), used byContactListRow:38,ContactDetailScreen:114and the form (ContactFormScreen.tsx:233-237); RNImagecannot decode a media-type-less data URI so such photos render blank, and re-saving writes the malformed URI back. - Fix hint: port
normalizeContactPhotoUriintocontact-utils.tsand apply ingetContactPhotoUri+contactToForm.
- What WEB does:
-
Picked photos are stored full-size —
P3—partial— fixed in9800ad0- What WEB does: downscales to 512px JPEG before embedding (
components/contacts/contact-form.tsx:96-126) and rejects files >10 MB. - What RN does:
ImagePickerwithquality: 0.8, no dimension cap, base64 straight into the card (src/screens/ContactFormScreen.tsx:680-695). - Fix hint: use
expo-image-manipulatorto resize to ≤512px before reading base64.
- What WEB does: downscales to 512px JPEG before embedding (
-
Duplicate contact keeps the source UID —
P2—rn-only-bug— fixed in1cef53a- What WEB does: strips
id,uid,created,updatedbefore creating the copy (components/contacts/contacts-app.tsx:448-451). - What RN does:
doDuplicateonly stripsid/addressBookIds/created/updated(src/screens/ContactDetailScreen.tsx:216-222), so the copy is created with the sameuid; group membership by uid then matches both cards and CardDAV/vCard consumers see two cards with one UID. - Fix hint: also drop
uid(and letcreateContactmint a fresh one once the #644 fix lands).
- What WEB does: strips
-
Gender vocabulary differs from WEB and from the vCard exporter —
P3—partial— fixed in9800ad0- What WEB does:
masculine | feminine | other | none | unknown(vCard SEX mapping,components/contacts/contact-form.tsx:1062-1069), whichgrammaticalGenderToVcardSexunderstands (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.
- What WEB does:
-
Online service
labeland multiple nicknames not editable —P3—partial— fixed in9800ad0- 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:
OnlineDrafthas onlyuri/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.
- Detail shows every nickname (
-
Display name includes the middle name (differs from WEB sorting/grouping) —
P3—partial— fixed in580e010- What WEB does:
given + surnameonly, thenname.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
middlefrom the join or make it a display-only concern.
- What WEB does:
List, search, sort
-
Alphabetical index buckets every non-ASCII initial under "#" —
P3—bug— fixed in358bdb9- 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}/uandlocaleCompare(Hermes supports Unicode property escapes).
- What WEB does:
-
"Uncategorized" means "no address book" in RN but "no category" in WEB, and its count is hard-coded 0 —
P3—bug— fixed in358bdb9selectUncategorized(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 passescount={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 in358bdb9- 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 fromContactsSettings:56-78).
- What WEB does: select-all toggle, range select, bulk export, bulk add-to-group (
-
Rename category (keyword) missing —
P3—missing— fixed in358bdb9renameKeywordin the store (26a34d5); long-press a tag in the drawer to rename.- What WEB does: keyword context menu → rename, rewrites
keywordson 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 → loopupdateContactlike WEBrenameKeyword.
-
Add-to-group from the contact itself missing —
P3—partial— fixed in1cef53a- 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 samememberspatch asGroupDetailScreen.addMembers.
- What WEB does: detail "More" menu and list context menu offer "Add to group" (creating a group if none) (
Groups
-
No way to create a contact group —
P2—missing— fixed in9800ad0- "New group" row in the drawer and long-press on the header "+" (
358bdb9);createGroup/memberIdsroute 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:
ContactFormsupportsasGroup(src/screens/ContactFormScreen.tsx:554,627) but the only caller passes an existingcontactIdfor editing (src/screens/GroupDetailScreen.tsx:138; grepasGroupinsrc/: 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 }.
- "New group" row in the drawer and long-press on the header "+" (
-
Group edit reuses the full person form (name split into first/last, photo, emails…) —
P3—partial— fixed in9800ad0- What WEB does: dedicated group form with name + searchable member checklist (
components/contacts/contact-group-form.tsx:80-196). - What RN does:
GroupDetailScreen→ContactForm asGroupshows 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 togivenand hide the person-only sections.
- What WEB does: dedicated group form with name + searchable member checklist (
-
Group compose: RN expands to individual recipients; no To/Cc/Bcc choice —
P3—partial— fixed in1cef53a- 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.
- Dedupe via
Composer autocomplete
- Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals —
P2—missing- store-side done in
26a34d5(getAutocompletewith group entries + recent recipients,loadRecentRecipients/searchRecipientsfed bysrc/api/recent-recipients.ts,getGroupRecipients, #672 normalisation); the composer usesgetAutocompleteand the recent Sent recipients since5b72c4d. Still open: the on-demand server search (searchRecipientshas no caller), directory principals, and the calendar'sParticipantInput, which still offers contacts only. - What WEB does:
getAutocompletemerges contacts, group entries (as one chip withmemberCount), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (stores/contact-store.ts:622-707, 23-49;loadRecentRecipients:1118-1142fed frommail-app.tsx:423;searchRecipients:1144-1154used bycomponents/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
individualsmatched withmatchesContactSearch(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 Sentto/cc, 300 msgs) to the contacts store, include groups as a pickable entry that expands to member emails on pick, and reusesanitizeDisplayName/splitMailbox-style cleanup for names that contain a mailbox (#672).
- store-side done in
Viewer integration
-
"Add sender to contacts" from the message view missing —
P2—missing— done ind2ed27f(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),findContactByEmailin the store (26a34d5); the sender sheet inEmailThreadScreenbelongs 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: onlySenderAvatarat:794). OnlyContactForm'sroute.paramssupportcontactId/addressBookId/asGroup(src/navigation/types.ts:24) — no email/name prefill. - Fix hint: add
prefill?: { email; name }to theContactFormroute, and a sender-tap sheet inEmailThreadScreenwith "Add to contacts" / "Open contact" (lookup by email inuseContactsStore).
- contact-side done:
-
Sender-name tap → contact popup missing —
P3—missing— done ind2ed27f(tapping a sender or recipient opensAddressActionSheet, 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.
- deferred: viewer agent (same sheet as above;
-
Contact deep links (
/contacts/<id>[/edit],/contacts/new) —P3—missing- partly:
/contacts/<id>opens the contact since 5802cae;/contacts/newand/contacts/<id>/editonly 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>toContactDetail { contactId }and every other contacts path to the tab; it never opensContactForm.
- partly:
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 ine650cb2- What WEB does:
parseVcardDateconverts BDAY/ANNIVERSARY/DEATHDATE/X-ABDATE (basic + extended forms, reduced accuracy,VALUE=textskipped) into{year, month, day}and drops undecodable dates (lib/vcard.ts:360-394,addAnniversaryinbuildContact~: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.tsBDAY case ~:687, ANNIVERSARY ~:701, DEATHDATE ~:709), so every imported birthday is rejected by Stalwart (the wholeContactCard/setcreate 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+addAnniversaryfrom WEB into RNvcard.ts; add a test withBDAY:19850412,BDAY:--0412,BDAY;VALUE=text:circa 1800.
- What WEB does:
-
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 ine650cb2- What WEB does:
X_ONLINE_SERVICESmap → 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 writesX-MAIDENNAMEandX-ABDATEforotheranniversaries (~1173-1178, ~1280-1283). - What RN does:
default: break(src/lib/vcard.ts:~858); none of the maps exist (function list: noX_ONLINE_SERVICES,RELATION_LABELS,ANDROID_*). Export skipssurname2andotheranniversaries (~:1025-1045). - Fix hint: port the maps and the extension cases verbatim; both files otherwise share structure so a near-mechanical diff apply works.
- What WEB does:
-
Apple grouped labels (
item1.TEL+item1.X-ABLABEL) are lost —P2—bugfix-parity— fixed ine650cb2- What WEB does:
splitGroupPrefixkeeps the group,collapsePropertiesfolds a siblingX-ABLABELinto anX-ABLABEL=param,decodeAppleLabelstrips_$!<…>!$_(lib/vcard.ts:116-140, 413-434); labels then land on emails/phones/addresses/online services. - What RN does:
stripGroupPrefixdiscards the group (src/lib/vcard.ts:120-131) and properties are keyed bykeyPartonly (:334-339), so iOS/macOS exports lose every custom label and theX-ABLABELlines are silently dropped (:~854). - Fix hint: port
splitGroupPrefix+collapseProperties+RawPropertylist handling inparseVCard.
- What WEB does:
-
Org-only vCards are dropped on import and exported without FN (#701) —
P2—bugfix-parity— fixed ine650cb2- What WEB does:
hasOrgcounts as identity inbuildContact(lib/vcard.ts:~1123-1127);generateSingleVCardfalls 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 noFN/Nwhen 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.
- What WEB does:
-
Only one NICKNAME survives import —
P3—bugfix-parity— fixed ine650cb2- What WEB does:
card.nicknames[\n${idx}`](lib/vcard.ts` NICKNAME case). - What RN does:
card.nicknames.n0 = { name: val }overwrites (src/lib/vcard.tsNICKNAME case ~:548). - Fix hint: index like WEB.
- What WEB does:
-
Import target defaults to the first book and cannot be changed from Settings —
P3—partial— fixed inbc4c57e- What WEB does:
client.createContactwithoutaddressBookIdsfiles into theisDefaultbook (lib/jmap/client.ts:5095-5101). - What RN does:
ContactsSettingspassesbooks[0]with noonChangeTarget(src/components/settings/ContactsSettings.tsx:54, 269-275);ContactsScreenallows changing the target but also starts ataddressBooks[0]unless a book category is active (src/screens/ContactsScreen.tsx:201-204, 476, 480-486). - Fix hint: prefer
isDefault; passonChangeTargetin settings too.
- What WEB does:
-
Import keeps the source UID (fine) but nothing mints one when absent — covered by the #644 finding above; also
importContactsswallows per-card failures with only aconsole.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 in26a34d5- 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 in78114b6- 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:
ContentSendersSettingsreads/writes onlysettings-store.trustedSenders(src/components/settings/ContentSendersSettings.tsx:19-21, 100-144);removeFromTrustedSendersBookexists 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 callremoveFromTrustedSendersBook(+ local remove) on delete; show the union count.
- What WEB does: modal shows the address-book entries when the feature is on, adds/removes through
-
trustedSendersAddressBooksetting is dead (false, no setter, no toggle) — book is consulted unconditionally —P3—partial— fixed in78114b6- Setting is
boolean | nullwith a "Sync trusted senders to address book" toggle. Untild89e331it 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:
nulluntil 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);EmailBodyViewloads the book regardless and ORstrustedSenderEmailsinto the trust check (:480-485, 513-516); theaddressBookEmailsbranch (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
addressBookEmailsbranch, or expose a "Sync trusted senders to address book" toggle that gates the passive load and theaddToTrustedSendersBookwrite.
- Setting is
-
Race: trusting a sender while the passive book load is in flight silently fails —
P2—rn-only-bug— fixed in26a34d5- What WEB does: single-flight promise shared by all callers (
stores/contact-store.ts:184-185, 1156-1199), soaddToTrustedSendersBookawaits the load and then has abookId. - What RN does:
loadTrustedSendersBookreturns immediately whentrustedSendersLoadingis true (src/stores/contacts-store.ts:279);addToTrustedSendersBookthen readsbookId === nulland 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
createIfMissingis true, create it after the await.
- What WEB does: single-flight promise shared by all callers (
-
Duplicate "Trusted Senders" books are not consolidated and the pick is non-deterministic (#730) —
P3—bugfix-parity— fixed in26a34d5- 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) andfetchAddressBooksthrows 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 in26a34d5- What WEB does: parses
Name <email>and storesname.full(stores/contact-store.ts:1201-1225). - What RN does: lower-cases the whole string and stores
emailsonly (src/stores/contacts-store.ts:308-321); aName <email>string (e.g. typed in settings) becomes an invalid address. - Fix hint: reuse the WEB regex.
- What WEB does: parses
-
Auto-trust reply recipients missing —
P3—missing— done in5b72c4d(area 04)- deferred: composer agent;
addToTrustedSendersBooknow acceptsName <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:
ComposeScreenhas no trusted-sender code (greptrusted: none). - Fix hint: after a successful send in reply mode call
addToTrustedSendersBook(+addTrustedSender) for each outgoing To/Cc.
- deferred: composer agent;
Activity / misc
- Contact activity: upcoming events come only from the already-loaded calendar cache —
P3—partial— fixed in0e51465- 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.
- What WEB does: queries the server for the next 365 days (
Verified at parity (brief list, so the fixer knows what NOT to redo)
- Address book list/create/rename/delete with
myRightschecks (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
addressBookIdspatch (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 since1827748(#963); the sort no longer re-runs on every search keystroke sincee6be892. - 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 WEBmedia: null). name.fulldisplay fallback (#179),getContactPrimaryEmailwithpref.name.full(the vCard FN) is written on every form, "add sender" and import save sincea734d0a(#430).- Group detail: member list resolved via id/uid/
urn:uuid:(contacts-store.ts:401-415), add members viaContactPickerSheet, 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 (
detectDuplicatesidentical), 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-safedecodeBytes), RFC 6868 params, KIND/MEMBER, PHOTO/LOGO/SOUND, RFC 6474/6715/8605/9554 properties. - Contact activity: recent emails via
from/toOR filter (limit 5), opens the thread. - Trusted senders: same book name constant, passive load, add on "Trust sender" in the viewer,
isTrustedAddressBookSendercheck. - 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
StateChangeforAddressBook/ContactCardtriggers 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-cryptokeysplugin slot. mailto:/tel:handoff differences (RN usesLinking).- Native issue #34 (device contacts / calendar sync adapters) is not a parity item; it is done on Android (Settings → Contacts → "Sync to this device", design) and builds on the UID (#644) and PartialDate (#224) fixes above.