Skip to main content
Beyond mail, OxiMail serves calendars, contacts, tasks, files, and chat over JMAP, with CalDAV/CardDAV bridges for classic clients (legacy protocols). This page covers the behaviours an operator needs to understand and the knobs and repair tools that go with them.

Calendar scheduling (iTIP)

When users invite each other, the server exchanges the scheduling messages (REQUEST / REPLY / CANCEL) by email, per RFC 5546. Two rules keep this from misfiring:
  • Organizer-side only. OxiMail sends scheduling mail only for events whose ORGANIZER resolves to a local account (RFC 5546 §3.2: only the organizer’s server sends invitations). An event that arrived as an inbound invitation never re-emits a REQUEST when the attendee’s client writes it back, and deleting a received copy never broadcasts an unauthorized CANCEL — on both the JMAP and CalDAV paths, fail-closed.
  • Emitted after the commit. iTIP messages and calendar events are emitted only once the transaction that changed the event has actually committed. A concurrent-edit conflict (stateMismatch) or any commit failure emits nothing — invitations can no longer escape for a change that was rolled back, and an update REQUEST always carries the post-change content with the bumped SEQUENCE.
Inbound invitations (text/calendar parts) are processed on ingestion, including Outlook’s parameterised property style, and update the recipient’s calendar, under a third rule:
  • Applied only by who has authority (RFC 5546 §3.2). A message classified as junk or failed by DMARC moves nothing; a REQUEST on a known event and a CANCEL are applied only from the organizer the stored event names, a new REQUEST only when its ORGANIZER is the sender, a REPLY only from the attendee it modifies. Holding the UID, which every forwarded .ics and every full feed export carries, no longer lets anyone cancel or rewrite someone else’s meeting. A refused message stays in the mailbox and the refusal is logged with a closed reason (junk, dmarc_fail, no_sender, not_the_organizer, organizer_mismatch, no_organizer, not_the_attendee, organizer_replies); an external domain without DMARC remains spoofable by an exact From: that also knows the UID.
The event, its notification and the change-log entries are written in one transaction, so a calendar never changes without synchronised clients hearing about it.

Free-busy and availability

Three read surfaces answer “when is this person free”, all through one occurrence-enumeration authority (a recurring event expands identically everywhere, DST included):
  • Principal/getAvailability (JMAP) composes availability from the subject’s calendars whose includeInAvailability is all or attending (whether a calendar is subscribed plays no part); a privacy: "secret" event hides its busy time entirely; event details are only populated for callers who hold real read access — free-busy access never implies detail access. A caller with free-busy rights on none of the subject’s calendars gets an explicit forbidden, not a misleading empty answer. Each answer says whether it is complete: a row without a stored end from before the end was indexed (oximail calendar backfill-end-dt --apply closes that case), or a series longer than the look-back cap, makes it false, and find-a-time (Event/proposeTimes, which names such people in incompleteFor) and the public booking page never read it as free; the booking page then offers no slot (availability).
  • CalDAV free-busy-query returns a VFREEBUSY of merged busy periods (tentative counts as busy; free/transparent, trashed, and cancelled events are excluded).
  • The scheduling UI in a client builds on the same data.

What CalDAV shows of a shared calendar

A calendar a colleague shares with you is readable from a CalDAV client too, read-only: the owner keeps exactly their behaviour, another account of the same server gets the read, calendar by calendar, under the same rights rule as the rest of the product (one rule, so the two surfaces cannot diverge). Discovery lists only what is shared, an unshared calendar stays absent rather than revealed by an error, and a write answers a refusal that says where to write (“through the workspace, or as the calendar’s owner”). The event’s own privacy is honoured on that face by the same function as over JMAP (confidentiality, ADR-152): private is reduced to its time, the title replaced by a placeholder in the reader’s language; secret is not served at all, busy time included. A text-match filter applies to the reduced text, so successive yes/no queries cannot guess a hidden title; the ETag is computed on what is served, so two readers of one address never share a validator and the owner’s edits to a withheld description move nothing the reader may know about; an event that becomes secret reaches sync-collection as the same tombstone as a deleted one, so the copy already held is removed. CLASS: on the wire is metadata, never the control. The DAV surface announces compliance classes 1, 3 with its access class (calendar-access, addressbook), never class 2: no LOCK/UNLOCK exists, and an audit requires every announced class to be held. A REPORT the collection does not implement is refused 403 with <DAV:supported-report>, an empty or malformed body 400, never the whole collection; a URL segment that is not an account is 404, not a principal; and the ADR-072 ceilings are applied at the doors from one registry (maxIcalBodySizeBytes and maxVcardBodySizeBytes, 1 MiB, answer 413 on the CalDAV and CardDAV PUT) rather than as literals in the routes (legacy protocols). A PUT the store refuses answers 403 with the RFC’s precondition and the reason in DAV:responsedescription, never the 500 any store failure used to produce: CALDAV:valid-calendar-data for an event or a task with a second RRULE, a recurrence bound outside the profile, a DTEND before its DTSTART, CARDDAV:valid-address-data for a card with a control character in an address, a name or a phone number. The rules are the JMAP ones, enforced where the rows are written, so no door is a side entrance (recurrence). oximail doctor counts the stored events and tasks past the one-rule bound (recurrence_rules) and the rules outside the closed profile, which are still stored as received (recurrence_profile). A CLASS other than PUBLIC and PRIVATE (CONFIDENTIAL, an x-name) imports as secret, a privacy outside public, private, secret is refused on every door, and the rows that already carried another level were set to secret by a schema migration: an unreadable level fails closed.

Time zones

Floating events (no explicit zone) resolve through a fallback chain: event zone → user zone → organization default → UTC. The wizard captures the organization default at install (first boot); change it later with:
Windows registry zone names (from Outlook, Exchange, eM Client) are normalized to IANA on every ingest path, and exported VEVENTs embed the matching VTIMEZONE so strict clients compute correct local times. A Windows name covers several IANA zones; it resolves to the zone CLDR gives for the instance’s declared country of establishment (W. Europe Standard Time is Europe/Zurich on a Swiss box, Europe/Berlin with no country declared, the world default). The country is read at boot and every ten minutes, so a change needs no restart; an entry of [legacy.windows_tz_overrides] still wins. On a host whose own zone is UTC or unknown, the wizard proposes the zone of the country of establishment when that country has a single offset.

Notifications

Calendar changes produce CalendarEventNotification objects for the people concerned: a grantee’s write on a shared calendar notifies the owner, and an inbound scheduling message notifies the affected user (REQUEST/CANCEL → the recipient, REPLY → the organizer; self-authored changes are suppressed). Notifications are change-logged and pushed, so clients see them without polling. Alerts (reminders) support both offset and absolute triggers and fire once per occurrence through a deduplication table. “Later” on a fired alert is a server verb, Event/snoozeAlert or Task/snoozeAlert: the alert rings again once at the chosen time, at most 7 days ahead (alerts). A push text is written in the reader’s language, with the date format of the organization’s country (alerts).

Tasks

Task lists mirror calendars: JMAP methods, CalDAV VTODO bridge, shared lists. Each account has exactly one default task list, enforced by a database invariant — a Task/set create without taskListIds goes there. Alerts on tasks support absolute triggers without requiring a due date. A task carries its timeZone (RFC 8984): a DUE:…Z written by CalDAV anchors in UTC rather than floating, a TZID is normalised to IANA, and the alert scheduler honours the task’s own anchor before the principal → organization chain, so a reminder fires at the task’s instant rather than the principal’s wall clock; a row from before the column stays floating, its historical meaning. Sharing a list goes through the same rights calculation as mailboxes and calendars (TaskList/get filters what a cross-account reader may read and computes myRights, Task/get|query are scoped to readable lists, Task/set consults the rights per list, grants and revocations notify the grantee); a second copy of the rule is a bug, so there is none (tasks).

Contacts

Contacts are JMAP ContactCards (RFC 9610) with a CardDAV bridge. The server owns the uid (a client-supplied one on create is rejected), and a CardDAV PUT that would create a cross-source duplicate of an existing UID is refused rather than silently forked. Name and address components round-trip completely between vCard and the JMAP form. A property the server does not know is refused by name on create and on update, never kept silently: a card created with a split first and last name, as software from elsewhere naturally sends, used to answer success and exist without a name. An addressbook-query no longer counts the collection itself among its results, so an empty search looks empty. A control character in an address, a name or a phone number is refused on every door, named by its path, where a feeding script’s stray carriage return used to be stored and then matched by no address comparison. A merge of duplicates sends the absorbed cards to the trash with the id of their target (mergedInto) instead of destroying them, so a wrong merge is undone by restoring the card within the contacts trash horizon (contacts). Address autocompletion on a contact card goes through the server (GET /geocode, an authenticated route): the browser never calls the geocoding service itself, and the instance speaks to it with one voice, its own User-Agent and, by default, one request per second. The question goes to the organization’s country first (its declared country of establishment, else the instance’s; none declared means the whole world), in the browser’s language (the first tag of Accept-Language, forwarded only when it has the shape of a language tag). A search that finds nothing in that country is asked again of the whole world, so a contact abroad stays findable. A client can name the country itself with country (an assigned ISO 3166-1 alpha-2 code, or any; anything else is 400). The text typed still reaches the geocoding service, but no longer tied to a person. [geocode] enabled = false removes the route (404) and the client keeps free entry.

The trashes

Events, tasks and contacts each have a two-stage trash (ADR-035), and since #1471 the three go through the Drive’s exit rather than a hard-coded 30 days destroyed in silence: one setting per domain, trash_retention_days in [calendar], [contacts] and [tasks] (30 by default, floor 1; the same key exists in [drive] and [notes]), an account with a message under legal hold keeps its objects, each destruction is one cascading transaction and is announced to /changes as a Destroyed on the domain’s collection, so a synchronised client’s trash never keeps what the server removed (storage). Chat rooms have their own trash, with the same key in [chat] (chat rooms).

Per-tenant horizons

The six values in the file apply to the whole instance. Each tenant can override each of them with a runtime override whose key is the TOML path: drive.trash_retention_days, calendar.trash_retention_days, contacts.trash_retention_days, tasks.trash_retention_days, notes.trash_retention_days, chat.trash_retention_days. A value the server cannot use keeps the data: a destruction cannot be undone, a longer stay can. Write the override through the admin API, PUT /admin/v1/tenants/{id}/config (or PUT /admin/v1/config for the default tenant), which refuses anything but an integer from 0 to 36500. Or from the CLI, dry run first, then --apply:
The CLI shows the value’s type in the dry run and applies the admin API’s rules before the dry run and before the write: a value the API refuses (out of range, negative, not an integer) is refused with the same details, exit code 2, nothing written. oximail config clear-override removes the override, and the tenant is back on the instance value. The trash sweep walks the tenants that hold accounts and applies each tenant’s six horizons. It runs at startup and then once an hour, and reads the overrides at every pass, so an override needs no restart and takes effect within the hour; a change to the file needs a restart. oximail drive purge-trash and oximail chat purge-trash apply the same policy when --days is not given, tenant by tenant. With --days, that one horizon applies to the whole scope, including a tenant whose override is 0. In --json output, horizonSource says which (flag or policy), and under a policy tenantHorizons maps each tenant to its horizon in days, null meaning no purge. horizonDays stays the --days value, or the instance value when the policy applied. Mail works the other way round. Its trash policy, retention.trash_days, purges nothing while it is absent: a tenant has to set it (storage). The six trashes above purge at the instance value while their override is absent. For all seven, 0 means no purge.

Sharing

Calendars, task lists, address books, and drive folders share one grant model (shareWith maps keyed by principal, RFC 9670 shapes). Two operator-relevant guarantees:
  • Per-viewer state stays per-viewer. A grantee hiding a shared calendar (isVisible) or starring a shared file affects their own view only, never the owner’s object.
  • Grantees must be human-backed, or a group. A grant to a non-loginnable technical identity is rejected at write time on every domain (a group is the exception: it resolves to its members) — a grant that could never be exercised is a lie in the ACL. Shared accounts’ drive custody has its own surface: oximail account custodian (see multi-tenancy).

Repair one-shots

Migration-era artifacts have dedicated, dry-run-by-default repair commands: Both record JMAP changes so clients resync cleanly. See the CLI reference.