urn:ietf:params:jmap:calendars), with events as JSCalendar objects (RFC 8984, current form RFC 9553). The same data is served over CalDAV — one store, two protocols, and since September one rule for what a non-owner may see on both.
Objects and methods
The v2 calendar types (
urn:oximail:params:jmap:v2:calendar) make two things first-class that RFC 8984 keeps nested: RecurrenceRule (get, set, query, changes, canonical-form deduplicated) and Attendee (get, set, query, changes). Event/getOccurrences expands a rule into concrete instants within [after, before), the rule given as rule, the RFC 8984 object exactly as it is written in recurrenceRules (a rule outside the profile answers invalidArguments naming what is at fault), or by recurrenceRuleId; the older inline entity form is still accepted. It expands under the caps the capability advertises, maxOccurrencesPerExpansion (1000), maxExpansionWindowYears (50), maxRecurrenceOverridesPerEvent (1000); a secondly or minutely rule broader than one day or a thousand instances answers expansionTooBroad so the caller paginates. The RFC 8984 CalendarEvent/* methods stay registered for continuity; v2 clients should prefer the dedicated types.
Confidentiality on a shared calendar
An event’sprivacy is public (the default), private or secret, a closed vocabulary at write: any other value (Private with a capital, confidential) is refused invalidProperties on privacy, on create and on update, and null stands for the default. The store carries the same guard, so CalDAV, iTIP and imports cannot slip past it, and an iCalendar CLASS other than PUBLIC and PRIVATE (CONFIDENTIAL, an x-name, an iana-token) imports as secret: a level the server cannot read fails closed. On the owner’s own reads it changes nothing. For a grantee of the calendar it is served by one declaration with two renderings (ADR-152), because a reduction applied to a materialised object cannot protect a surface that returns ids:
- Surfaces that return objects (
CalendarEvent/get, the CalDAV rendering sites,REPORT,GET .icsandsync-collectionamong them, and the feed export) apply a reduction. Aprivateevent is reduced to its time:titlebecomes a placeholder in the reader’s language (Private,Privé,Privat), anddescription,locations,virtualLocations,links,participants,replyTo,alerts,keywords,categories,color(a colour is a legend in most practices) and the verbatim iCalendar extras are stripped.recurrenceOverridessurvive with their time-bearing keys only,recurrenceId,start,duration,timeZone,showWithoutTime,excluded,status,freeBusyStatus: dropping the map would show the owner busy at an hour they are free, a reduction that invents availability instead of hiding detail.timeZonestays because it is the coordinate system of the timeprivatechooses to disclose, not an attribute of the event. Asecretevent is not served at all. - Surfaces that return ids (
CalendarEvent/query,/changes,/queryChanges) carry a SQL predicate in the query itself, never a post-filter on the ids returned: asecretevent is in no result set, nototaland no page (a count is a complete oracle even with no id served, and a post-filter would shiftpositionandanchor). Aprivateevent stays in the set for structural filters,calendarId,before,after,uid,isTrashed,status,isRecurring,isAllDay,updatedSince(a grid keeps the busy slot, which is the point ofprivateagainstsecret), and leaves the set as soon as a content filter is set,text,summary,description,location,attendeeEmail. That exclusion discloses nothing new: the reader already sees the placeholder and the slot, and “it disappears when I search the text” only tells them what they have in front of them. Before this, a title filter answered yes/no on a private event and a few dozen queries reconstructed a patient’s name.
mayWrite would turn “may edit” into “may read the private appointments”. Readability (which calendar is shared with whom) and confidentiality (what the author wrote on the object) remain two axes composed in that order: an unreadable object is nothing whatever its level. A test in the server refuses the two renderings diverging. Where the tenant keeps the data access log, each read carries two counts, reducedToTime and withheld, absent rather than zero when nothing had to be judged, so “did anyone see Dr X’s private appointments” has an answer.
Recurrence
- The property is
recurrenceRules(plural, RFC 8984) — always — and it holds one rule. A second entry is refusedinvalidPropertiesnamingrecurrenceRules[1], on create and on update, for events and tasks alike; the guard sits in the store, so a CalDAVPUTcarrying twoRRULElines answers403withCALDAV:valid-calendar-datainstead of being stored with only its first rule honoured.excludedRecurrenceRulescarries the same bound. - One form, a closed profile. A rule is the RFC 8984 object with
frequency,interval,countoruntil,byDay(NDayentries,dayandnthOfPeriod),byMonthDay,byMonth,bySetPositionandfirstDayOfWeek. Its bounds are refused by name at write: day codesmotosu,nthOfPeriodin ±1..53 and only on a monthly or yearly rule,byMonth1..12,byMonthDay±1..31,bySetPosition±1..366 and never alone. What is written is stored normalised, and served as stored:@typeset on the rule and itsNDayentries, day codes lower-cased,byDaysorted by weekday then ordinal, lists sorted and deduplicated,byMonthas strings. A rule carrying a property outside the profile (byHour,rscale) is stored as received, logged and counted inoximail_calendar_recurrence_out_of_profile_total{face,reason};oximail doctorcounts such rows asrecurrence_profile, and rows past the one-rule bound asrecurrence_rules. - Monthly and yearly rules expand as RFC 5545 §3.3.10 says.
byMonthDayand abyDaywithout ordinal produce one candidate per listed day of the period (they used to filter the anchor’s own day, sobyMonthDay: [15]anchored on the 10th produced nothing), the limits apply in the RFC’s order, andbySetPositionpicks last, by position in the period’s set: Outlook’s “last working day of the month” is in the profile.firstDayOfWeekcounts where it changes the set, a weekly rule withintervalabove 1 and several days. The CalDAV bridge reads and writesBYSETPOSandWKST. - Exclusion rules apply. Each
excludedRecurrenceRulesentry is expanded from the same anchor by the same authority and its occurrences leave the set before the single exclusions do. It is validated likerecurrenceRules, and travels asEXRULEboth ways over CalDAV, in inbound and outbound iTIP and in the ICS feeds. - Recurring events expand through one occurrence-enumeration authority, shared by JMAP queries, CalDAV
time-rangefilters, free-busy and alarms: the core used to carry two engines, one time-zone aware and one not, and a weekly 9:00 series crossed a DST change one hour off. There is one now, anchored to the event’s local zone. - Per-occurrence overrides are keyed by civil-local time; overrides created in one protocol are honoured (and protected by content-hash ETags) in the other. An instruction such as
meetLinkburied in an override is refused in full, naming the override. CalendarEvent/getdoes not expand;expandRecurrenceson the query surfaces is opt-in. A client that expands itself needs the event’stimeZone, which is one more reason the reduction keeps it.- An imported
RRULEthe model cannot represent is kept verbatim and flaggedhasUnmodeledRecurrenceon read — never a half-converted rule. The verbatim rule leaves with the event on every face, CalDAV, the ICS feeds and the outbound invitation, so a subscriber or an invitee no longer receives the series as a single instance.
Time zones and the wall clock
start is a LocalDateTime; the zone lives in timeZone (a UTC instant is stored as local time + Etc/UTC, never a Z-suffixed start, and a DTSTART:…Z arriving over CalDAV keeps its UTC anchor instead of becoming floating). All-day events use showWithoutTime. A floating event resolves through the fallback chain event zone → person’s zone → organization default → UTC (groupware); the person’s zone can be set, replaced or removed (timeZone: null on the principal, an absence being a state of its own), and the organization default is writable from a client through the admin Tenant/set and refused when it is not an IANA zone.
An event’s length is its duration; an iCalendar DTEND becomes one by a single rule, on every door that reads iCalendar (CalDAV PUT, file import, subscribed ICS feed, inbound iTIP): the exact span to the end in its own zone, days for a DATE, a DATE event without an end lasting one day and a timed one without an end being an instant (RFC 5545 §3.6.1). An end before the start, or of a different value type or floating state, refuses the resource over CalDAV (CALDAV:valid-calendar-data), counts the event as an error in a file import, drops it from a feed, and leaves an invitation unapplied (the mail stays). The DTEND a CalDAV client sent is also kept, and a GET returns it as sent while it still names the model’s end, DURATION otherwise, never both; an override that moved only its end keeps that change in its own duration. The stored end, which window queries and availability read, is the RFC 8984 §1.4.6 addition, days on the wall clock and then hours in absolute time in the event’s zone, so an event that spans a change of clocks ends at the right hour, and an update that changes timeZone recomputes it.
Twice a year a wall clock is not a function of an instant. The server projects a local civil time onto UTC with one convention, the offset in effect before the transition: in the autumn fold the hour that happens twice takes its earlier instant, in the spring gap the hour that never happens lands on the real instant after the transition. That is what a naive VTIMEZONE resolution yields, hence what libical, Apple and Google produce, and a calendar that resolves a wall clock differently from its clients does not merely differ. Every consumer walks through that one authority. doctor (dst_horizon) computes the next switch for every zone in use and counts what falls in the hour that does not exist, so the change of clocks is no longer discovered on Monday morning.
Working hours and availability
A schedule is made of intervals, not a table of days (ADR-139). A principal’sworkingHours is a map of weekday to windows, and a window carries an explicit end-day offset:
endDayOffset is 0 (absent means 0) or 1; start and end are RFC 3339 hours 00:00 to 23:59, never the extended 30:00 notation; end plus the offset must be strictly after start. The semantics are wall clock, never elapsed duration: “22:00 to 06:00 at my watch” lasts nine real hours on the autumn night and seven on the spring one (the DTEND branch of RFC 7953, the right one for a person’s availability). The two degenerate hours follow RFC 5545 §3.3.5, with one declared exception: the end of an interval takes the later reading, because an interval must cover. A day’s unavailable periods are the complement, on that day’s local span, of the union of the windows declared on D−1 and D, each projected from its own starting day, so a Monday night shift makes Tuesday morning worked and “a day off” is only the degenerate case with no window. A window the civil calendar deleted that day (02:30–03:00 on the spring transition) contributes nothing, and no write-time validation could catch it. Validation is at the boundary: a malformed schedule answers invalidProperties once the per-tenant runtime override calendar.working_hours_enforce is set, and is logged with a stable violation field before that (the same two-step as the IANA check on timeZone, principal.timezone_enforce). The server advertises that it understands the offset with the capability urn:oximail:params:jmap:working-hours-day-offset; a client offers “next day” only when the server in front of it says so.
Principal/getAvailability takes ids: Id[], utcStart, utcEnd and showDetails, and answers list[{id, availability: BusyPeriod[], complete}] plus per-id notFound and forbidden arrays. complete is never absent: false says the server cannot vouch for having seen every event overlapping the window, and incompleteReason says why, spanBound (an event without a stored end that began before the look-back, a row from before the end was stored or a database restored from an older snapshot, which oximail calendar backfill-end-dt --apply closes) or openRecurrence (a series whose occurrences are longer than the look-back cap and whose until does not prove it over). A scheduling client must not read an incomplete answer as free. busyStatus is confirmed, tentative or unavailable (there is no busy value); free time is not returned, the client subtracts. An event contributes when its calendar’s includeInAvailability is all or attending (isSubscribed is not part of it: RFC 8984 makes that flag about calendars received from others, and gating on it once emptied a practitioner’s availability because her default calendar carried isSubscribed: false from a migration), the caller holds mayReadFreeBusy on it, the occurrence overlaps the window, privacy is not secret, freeBusyStatus is not free, and it is neither cancelled nor trashed; for attending, the subject must participate as accepted or tentative. Working-hours gaps are synthesised as unavailable. A same-tenant principal none of whose calendars grants the caller free-busy is forbidden, envelope included. BusyPeriod.event is populated only under showDetails and real read access, null for a private event. Availability is computed from the subject’s own calendars only; an extended binary may register one external contributor that merges opaque windows after that gate, and a failing contributor is serverFail, never “free” (ADR-138).
Two things that used to be false and are measured true now: an event longer than 62 days that started before the lookback (three months of leave, consulted in the middle) was not read at all and the person was announced free; the event’s end is now stored and indexed (oximail calendar backfill-end-dt --apply gives pre-existing rows theirs), and a long event is found by its end. And CalDAV VFREEBUSY used to carry a hand-written copy of the JMAP computation; there is one function now.
When a cabinet calls on Monday to say “Friday’s slot disappeared”, oximail calendar availability --tenant … <principal-id> --from … --to … --json replays the answer, server stopped if need be, through the same functions the method uses: the working-hours envelope and the event-derived periods, with a note per window a DST switch adjusted or erased, in owner scope (a CLI operator holds no grantee identity, and the output says so).
Appointment links
AppointmentLink/set creates a one-shot booking link: a title, a duration, a window, an expiry, and a 32-byte token that leaves the server only through this surface, to its owner. The public page /public/book/{token} offers the owner’s free slots in the window (working hours included, through Event/proposeTimes), the invitee picks one, and the event is created through CalendarEvent/set with the invitee as an external participant, so the iTIP REQUEST leaves by the ordinary machinery; the link is then consumed and names the event it produced. While the owner’s availability is incomplete on the window (incompleteFor non-empty), the page offers no slot and says the availability cannot be established, and a booking submitted meanwhile is refused on the same rule: an outsider never books into a gap the server could not see. An unknown token is a silent 404. There is no update: a link is a frozen commitment, and changing it after sharing would deceive the invitee, so one revokes and recreates. AppointmentLink/get lists the owner’s links with their state (open, consumed, expired); the type is never served under shared:, since every URL it returns is a capability.
Scheduling (iTIP)
Creating an event with participants triggers invitation mail — under two hard rules: only when the organizer resolves to a local account (RFC 5546: only the organizer’s server sends), and only after the transaction commits (astateMismatch emits nothing). REPLY, COUNTER and CANCEL follow the same discipline; inbound invitations and replies are processed at ingestion and produce CalendarEventNotification objects. Inbound processing keys on the iCalendar UID (and SEQUENCE for a REQUEST): a REQUEST on an unknown UID creates the attendee’s copy, a REQUEST or CANCEL on a known one updates or cancels it, a REPLY updates the named attendee’s participationStatus. The UID is not authority on its own (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, never from the ORGANIZER the incoming message writes; a REQUEST for a new event only when its ORGANIZER is the sender; a REPLY only from the attendee it modifies, and never from the organizer’s own address. A refused message stays in the mailbox, only the calendar does not move, and the refusal is logged with its reason. A domain without DMARC can still be impersonated by an exact From:, which then takes the address and the UID together. The event, its notification and their change-log entries are written in one transaction: the calendar and what synchronised clients are told move together, or neither does. A grantee’s edit on a shared calendar notifies the owner the same way.
The invitation and the cancellation are written in the recipient’s language (French, German, Italian or English, per the recipient account) and at their clock: the cancellation has the form of the invitation, organizer, title, the day spelled out, start and end with the zone. A REQUEST, a REPLY and a COUNTER describe the event as it is: its end is the same RFC 8984 addition as the stored one, an event without a duration leaves without DTEND (an instant, as the organizer has it, where a one-hour meeting used to be invented), and an all-day event travels in dates (VALUE=DATE, no TZID, a DTEND in DATE), where a reply or a counter-proposal used to carry a timed start at midnight. An event created “with video” carries its join link as CONFERENCE in the outbound invitation and as virtualLocations on the attendee’s copy; that instruction, meetLink, and the meeting it opens are on the Meet page.
Alerts
alerts supports both offset triggers (relativeTo: "start"/"end") and absolute ones (when). They fire server-side (push / notification), once per occurrence, from a scheduler that reads every event carrying an alert (it used to read them under an unordered LIMIT 1000, and skipped 380 of 1 380 on one production box). The account’s “calendar reminders” preference is honoured on the way out. VALARMs round-trip with CalDAV clients; public ICS feed exports deliberately carry none.
“Later” is a server verb. Event/snoozeAlert {accountId, id, alertId, recurrenceId, until} writes the RFC 8984 §4.5.2 snooze itself, through CalendarEvent/set, so the rights, the per-user alerts, the change log and the push are the ones every write goes through: the alert that fired is acknowledged, and a child absolute alert (relatedTo the original) is added at until, a future UTCDateTime at most 7 days ahead. It rings once. A snooze of a snooze hangs from the original and replaces it, and the spent ones are swept on the way, so an event carries at most its pending snoozes. For one occurrence of a series (recurrenceId, refused on an event that does not recur), the snooze is stored on the master under the vendor property oximail.ch:recurrenceId and no other occurrence moves; the second notification names that occurrence. The alert notifications (the Web Push entity and the WebSocket alert frames) carry the alertId the call names. Errors: notFound (the event or the alert), invalidArguments (a bad until, a stray recurrenceId), forbidden (the write refused, a read-only shared calendar for instance). The same verb exists for tasks (Task/snoozeAlert).
A reminder speaks its reader’s language. The push texts (event reminders, tasks due, calls, the generic labels) follow the account’s language (the account’s, then the organization’s, then English), in French, English, German or Italian. Dates follow the organization’s country of establishment (dd.mm.yyyy in Switzerland, Liechtenstein, Germany and Austria), otherwise the language (dd/mm/yyyy in French and Italian, ISO in English). A task’s due time is formatted, no longer sent raw.
Sharing, seen from the calendar
A calendar shared read-only announces exactly that to CalDAV clients (Thunderbird and DAVx5 used to show it editable and fail on every write), and a calendar shared to another account of the same server is reachable over CalDAV, with the owner’s behaviour unchanged. A grant is re-checked per collection, so a calendar share no longer opens the rest of the account. A frozen account’s grants are suspended while the operator’s stay. The grant model itself is on the sharing page. Strictness rules (unknown ids innotFound, invalidProperties over coercion, stateMismatch on ifInState) are identical to the mail surface.