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.
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 a feature request, not a parity item; note that a device sync would want the UID (#644) and PartialDate (#224) fixes above first.