Skip to main content
Contacts implement RFC 9610 (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-supplied uid on 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 UID is 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.
  • addressBookIds is optional on create and files the card nowhere. When present it must be a map of id to true (the column is kept for rollback); when absent it stays empty. The card’s source says where it belongs.
  • The source filter compares the card’s source, the value ContactCard/get serves, on ContactCard/query and /queryChanges alike: source: "organization" finds the directory projections (it used to answer 0 by looking for the value among addressBookIds keys), the v1 alias addressBookId keeps the same meaning, and a value outside personal, organization, derived, imported is refused unsupportedFilter instead 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, organizations or titles answers invalidProperties naming 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, so Contact/import, Contact/copy, CardDAV (403 with CARDDAV:valid-address-data) and the migration tool go through it too. notes and postal addresses are multi-line by nature and not judged. Email/set applies 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 55 by 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. A Contact/set that changed a number returns phones. The phone filter finds a number by its digits whatever the spelling: 079 222, +41 79 222 and 0041 79 222 find 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 by oximail admin contact normalize-phones, and doctor contact_phone_form warns while some remain.
  • Table properties hold objects. An entry of emails, phones, organizations, media or anniversaries that is not an object (a bare string such as ["ada@example.com"]) is refused invalidProperties: RFC 9553 maps each id to an object. An array of objects is accepted.
  • Version history is first-class. ContactCard/getHistory lists prior versions of a card, restoreVersion brings 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 PUT that would duplicate an existing UID from another source is refused, never silently forked.
  • Deprecation is advertised honestly. The account capability object announces maxAddressBooksPerCard: 1 and mayCreateAddressBook: false, so a client reads the limits instead of discovering them by trial. The four AddressBook methods are deprecated stubs that answer forbidden naming 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_days override or else [contacts] trash_retention_days (30 by default, floor 1; an override of 0 purges nothing), through the Drive’s exit, legal holds respected and the Destroyed change 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-interactions seeds 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 with source: "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:
  • mayEdit is 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 from editorIds is 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. card holds the JSContact card and nothing else: projection keys such as source or directoryId inside it are refused, not quietly overwritten.
Directory content is organization data, not personal data held on behalf of a member: it is purged by deleting entries, and it does not travel with an individual account’s erasure. Strictness rules are identical to the mail surface: unknown ids in notFound, invalidProperties over coercion, accountNotFound, stateMismatch.