urn:ietf:params:jmap:contacts), with cards as JSContact objects (RFC 9553). The same data is served over CardDAV.
Objects and methods
Behaviours worth knowing
- The server owns
uid. A client-supplieduidon create is rejected (invalidProperties) — the server mints it, which is what makes the CardDAV mapping unambiguous. - An unknown property is refused by name, on create and on update. A card sent with a split first and last name, as software from elsewhere naturally does, used to answer success and exist without a name: only the recognised properties had been kept, the rest never looked at. The properties the server manages itself (the interaction statistics) stay refused by their own check, which says why; they do not become “unknown”.
- A vCard without
UIDis deduplicated by content on import. Many old exports and converted CSVs carry no identifier, and each card used to get a fresh one, so re-importing the same file duplicated the whole book. For those cards only, the same primary address and the same full name name the existing card in the answer instead of creating one, trash included; a card that carries an identifier is never deduplicated by content (two distinct cards legitimately share an address), and a card without an address imports as before. The same rule applies to an import into a shared directory. addressBookIdsis optional on create and files the card nowhere. When present it must be a map of id totrue(the column is kept for rollback); when absent it stays empty. The card’ssourcesays where it belongs.- The
sourcefilter compares the card’ssource, the valueContactCard/getserves, onContactCard/queryand/queryChangesalike:source: "organization"finds the directory projections (it used to answer 0 by looking for the value amongaddressBookIdskeys), the v1 aliasaddressBookIdkeeps the same meaning, and a value outsidepersonal,organization,derived,importedis refusedunsupportedFilterinstead of answering an empty list. - A control character is refused at write, by its path. A C0 character or DEL in
name,nicknames,emails,phones,organizationsortitlesanswersinvalidPropertiesnaming the JSON path and the code point (emails/0/address: control character U+000D (CR) is not allowed); nothing is trimmed, what is stored is what was sent. The guard sits in the store, soContact/import,Contact/copy, CardDAV (403withCARDDAV:valid-address-data) and the migration tool go through it too.notesand postal addresses are multi-line by nature and not judged.Email/setapplies the same rule to addresses, display names and subjects, so the two surfaces judge the same bytes the same way. - Phone numbers are stored in their international form. A number the numbering plans recognize is written
+41 79 222 44 55by every writer (Contact/set,Contact/import,Contact/copy, CardDAV, the migration tool). A national number is read in the country the organization declared for itself, never in the instance’s; a number that is not valid, that may hold more than one number, or that is laid out as another country’s numbers are, is kept as written. AContact/setthat changed a number returnsphones. Thephonefilter finds a number by its digits whatever the spelling:079 222,+41 79 222and0041 79 222find the same card, and the national spelling of a pattern written with its country code is matched at the start of a number only. Numbers stored before are converted byoximail admin contact normalize-phones, anddoctor contact_phone_formwarns while some remain. - Table properties hold objects. An entry of
emails,phones,organizations,mediaoranniversariesthat is not an object (a bare string such as["ada@example.com"]) is refusedinvalidProperties: RFC 9553 maps each id to an object. An array of objects is accepted. - Version history is first-class.
ContactCard/getHistorylists prior versions of a card,restoreVersionbrings one back — edits from any protocol (JMAP or CardDAV) are versioned the same way. - Round-trip is lossless. Name and address components map completely between vCard (3.0/4.0) and JSContact in both directions; vCard properties without a JSContact model are preserved rather than dropped. A CardDAV
PUTthat would duplicate an existing UID from another source is refused, never silently forked. - Deprecation is advertised honestly. The account capability object announces
maxAddressBooksPerCard: 1andmayCreateAddressBook: false, so a client reads the limits instead of discovering them by trial. The fourAddressBookmethods are deprecated stubs that answerforbiddennaming the deprecation; there is no successor, a client drops the call. - The trash is two-stage (ADR-035), and the trash sweep destroys what has been trashed longer than the tenant’s horizon, its
contacts.trash_retention_daysoverride or else[contacts] trash_retention_days(30 by default, floor 1; an override of0purges nothing), through the Drive’s exit, legal holds respected and theDestroyedchange announced (per-tenant horizons). - “Last interaction” looks back. The interaction counters (
interactionCount,lastInteractionAt) were fed forward only, so long-standing correspondents showed “never”;oximail admin contact backfill-interactionsseeds them from existing mail at the same grain as the live subscriber (recipients of sent mail, sender of received mail, never drafts), monotonic (never lowers a count or a date, so replayable), touches only existing cards, dry-run by default and bounded in accounts with--limit.
Tenant contact directories
An organization can publish shared contact sets — “Clients”, “Staff” — maintained on the admin plane. The design choice that shapes the whole surface: a directory is not a container members file cards into. Members never see a container at all. What a member sees is their own address book, where the entries arrive as read-only cards withsource: "organization" and directorySource: "tenant:<directoryId>" — the same plane an external LDAP or SCIM connector feeds. A client that already renders organization-sourced contacts needs no new screen to show them.
Who may write what. Directory objects themselves are gated on the tenant-admin role. Entries are gated on that directory’s editorIds, which are principal ids: naming a group is valid and normal, and membership is resolved through the grantee expansion at the access check rather than stored expanded, so adding someone to a group grants them editing without touching the directory.
Two behaviours worth knowing before you build on it:
mayEditis computed for you, and you cannot compute it yourself. It follows the caller’s tenant role plus the server-side group expansion, so two accounts legitimately read different values for the same directory — and deriving it fromeditorIdsis impossible, since that list is blind to group membership by design. This is exactly why it is declared in the published canon rather than left for a client to infer from the TypeScript type.- The server’s bookkeeping is not yours to write.
cardholds the JSContact card and nothing else: projection keys such assourceordirectoryIdinside it are refused, not quietly overwritten.
notFound, invalidProperties over coercion, accountNotFound, stateMismatch.