urn:ietf:params:jmap:calendars), avec des événements en objets JSCalendar (RFC 8984, forme courante RFC 9553). Les mêmes données sont servies en CalDAV : un seul magasin, deux protocoles, et depuis septembre une seule règle pour ce qu’un non-propriétaire peut voir sur les deux.
Objets et méthodes
Les types v2 de l’agenda (
urn:oximail:params:jmap:v2:calendar) rendent de premier rang deux choses que la RFC 8984 garde imbriquées : RecurrenceRule (get, set, query, changes, dédoublonnée en forme canonique) et Attendee (get, set, query, changes). Event/getOccurrences déploie une règle en instants concrets dans [after, before), la règle donnée en rule, l’objet RFC 8984 exactement tel qu’il s’écrit dans recurrenceRules (une règle hors du profil répond invalidArguments en nommant ce qui est en faute), ou par recurrenceRuleId ; l’ancienne forme d’entité en ligne est encore acceptée. Le déploiement se fait sous les plafonds que la capability annonce, maxOccurrencesPerExpansion (1000), maxExpansionWindowYears (50), maxRecurrenceOverridesPerEvent (1000) ; une règle secondly ou minutely plus large qu’un jour ou mille instances répond expansionTooBroad pour que l’appelant pagine. Les méthodes CalendarEvent/* de la RFC 8984 restent enregistrées pour la continuité ; les clients v2 devraient préférer les types dédiés.
La confidentialité sur un agenda partagé
Leprivacy d’un événement vaut public (le défaut), private ou secret, un vocabulaire fermé à l’écriture : toute autre valeur (Private avec une majuscule, confidential) est refusée invalidProperties sur privacy, à la création comme à la mise à jour, et null vaut le défaut. Le magasin porte la même garde, si bien que CalDAV, iTIP et les imports ne la contournent pas, et une CLASS iCalendar autre que PUBLIC et PRIVATE (CONFIDENTIAL, un x-name, un iana-token) s’importe en secret : un niveau que le serveur ne sait pas lire échoue fermé. Sur les lectures du propriétaire lui-même, il ne change rien. Pour un bénéficiaire de l’agenda, il est servi par une seule déclaration et deux rendus (ADR-152), parce qu’une réduction appliquée à un objet matérialisé ne peut pas protéger une surface qui rend des identifiants :
- Les surfaces qui rendent des objets (
CalendarEvent/get, les sites de rendu CalDAV,REPORT,GET .icsetsync-collectionentre autres, et l’export de flux) appliquent une réduction. Un événementprivateest réduit à son heure :titledevient un substitut dans la langue du lecteur (Private,Privé,Privat), etdescription,locations,virtualLocations,links,participants,replyTo,alerts,keywords,categories,color(une couleur est une légende dans la plupart des cabinets) et les propriétés iCalendar conservées telles quelles sont retirées.recurrenceOverridessurvit avec ses seules clés de temps,recurrenceId,start,duration,timeZone,showWithoutTime,excluded,status,freeBusyStatus: jeter la carte montrerait le propriétaire occupé à une heure où il est libre, une réduction qui invente de la disponibilité au lieu de cacher le détail.timeZonereste parce que c’est le système de coordonnées de l’heure queprivatechoisit de divulguer, pas un attribut de l’événement. Un événementsecretn’est pas servi du tout. - Les surfaces qui rendent des identifiants (
CalendarEvent/query,/changes,/queryChanges) portent un prédicat SQL dans la requête elle-même, jamais un filtrage après coup sur les identifiants rendus : unsecretn’est dans aucun jeu de résultats, aucuntotal, aucune page (un compte est un oracle complet même sans un seul identifiant servi, et un filtrage après coup décaleraitpositionetanchor). Unprivatereste dans le jeu pour les filtres structurels,calendarId,before,after,uid,isTrashed,status,isRecurring,isAllDay,updatedSince(une grille garde le créneau occupé, c’est la raison d’être deprivateface àsecret), et sort du jeu dès qu’un filtre de contenu est posé,text,summary,description,location,attendeeEmail. Cette exclusion ne divulgue rien de neuf : le lecteur voit déjà le substitut et le créneau, et « il disparaît quand je cherche dans le texte » ne lui apprend que ce qu’il a sous les yeux. Avant cela, un filtre sur le titre répondait oui ou non sur un événement privé, et quelques dizaines de requêtes reconstruisaient un nom de patient.
mayWrite transformerait « peut modifier » en « peut lire les rendez-vous privés ». La lisibilité (quel agenda est partagé avec qui) et la confidentialité (ce que l’auteur a écrit sur l’objet) restent deux axes composés dans cet ordre : un objet non lisible n’est rien, quel que soit son niveau. Un test du serveur refuse que les deux rendus divergent. Là où le locataire tient le journal des accès aux données, chaque lecture porte deux nombres, reducedToTime et withheld, absents plutôt qu’à zéro quand rien n’a eu à être jugé, si bien que « quelqu’un a-t-il vu les rendez-vous privés du Dr X » a une réponse.
Récurrence
- La propriété est
recurrenceRules(au pluriel, RFC 8984), toujours, et elle porte une seule règle. Une deuxième entrée est refuséeinvalidPropertiesen nommantrecurrenceRules[1], à la création comme à la mise à jour, pour les événements comme pour les tâches ; la garde vit dans le magasin, si bien qu’unPUTCalDAV portant deux lignesRRULErépond403avecCALDAV:valid-calendar-dataau lieu d’être stocké avec sa seule première règle honorée.excludedRecurrenceRulesporte la même borne. - Une seule forme, un profil fermé. Une règle est l’objet RFC 8984 avec
frequency,interval,countouuntil,byDay(entréesNDay,dayetnthOfPeriod),byMonthDay,byMonth,bySetPositionetfirstDayOfWeek. Ses bornes sont refusées nommément à l’écriture : codes de jourmoàsu,nthOfPerioddans ±1..53 et seulement sur une règle mensuelle ou annuelle,byMonth1..12,byMonthDay±1..31,bySetPosition±1..366 et jamais seul. Ce qui s’écrit est stocké normalisé, et servi tel que stocké :@typeposé sur la règle et ses entréesNDay, codes de jour en minuscules,byDaytrié par jour de semaine puis par ordinal, listes triées et dédoublonnées,byMonthen chaînes. Une règle qui porte une propriété hors du profil (byHour,rscale) est stockée telle que reçue, journalisée et comptée dansoximail_calendar_recurrence_out_of_profile_total{face,reason};oximail doctorcompte ces lignes enrecurrence_profile, et celles qui dépassent la borne d’une règle enrecurrence_rules. - Les règles mensuelles et annuelles se déploient comme le dit la RFC 5545 §3.3.10.
byMonthDayet unbyDaysans ordinal produisent un candidat par jour listé dans la période (ils filtraient le seul jour de l’ancre, si bien quebyMonthDay: [15]ancré au 10 ne produisait rien), les limites s’appliquent dans l’ordre de la RFC, etbySetPositionchoisit en dernier, par position dans le jeu de la période : le « dernier jour ouvré du mois » d’Outlook entre dans le profil.firstDayOfWeekcompte là où il change le jeu, une règle hebdomadaire d’intervalsupérieur à 1 sur plusieurs jours. Le pont CalDAV lit et écritBYSETPOSetWKST. - Les règles d’exclusion s’appliquent. Chaque entrée de
excludedRecurrenceRulesest déployée depuis la même ancre par la même autorité, et ses occurrences quittent le jeu avant les exclusions ponctuelles. Elle est validée commerecurrenceRules, et voyage enEXRULEdans les deux sens en CalDAV, dans l’iTIP entrant et sortant et dans les flux ICS. - Les événements récurrents se déploient par une seule autorité d’énumération des occurrences, partagée par les requêtes JMAP, les filtres
time-rangeCalDAV, le libre/occupé et les alarmes : le cœur portait deux moteurs, l’un sensible au fuseau et l’autre non, et une série hebdomadaire à 9 h traversait un changement d’heure décalée d’une heure. Il n’y en a plus qu’un, ancré sur le fuseau local de l’événement. - Les surcharges par occurrence sont indexées par l’heure civile locale ; une surcharge créée dans un protocole est honorée (et protégée par des ETag de contenu) dans l’autre. Une instruction comme
meetLinkenfouie dans une surcharge est refusée en entier, en nommant la surcharge. CalendarEvent/getne déploie pas ;expandRecurrencessur les surfaces de requête est explicite. Un client qui déploie lui-même a besoin dutimeZonede l’événement, une raison de plus pour que la réduction le garde.- Une
RRULEimportée que le modèle ne sait pas représenter est conservée telle quelle et signaléehasUnmodeledRecurrenceà la lecture, jamais une règle à moitié convertie. La règle conservée part avec l’événement sur toutes les faces, CalDAV, les flux ICS et l’invitation sortante, si bien qu’un abonné ou un invité ne reçoit plus la série comme une instance unique.
Les fuseaux et l’heure murale
start est un LocalDateTime ; le fuseau vit dans timeZone (un instant UTC est stocké comme heure locale + Etc/UTC, jamais un start suffixé Z, et un DTSTART:…Z arrivant par CalDAV garde son ancre UTC au lieu de devenir flottant). Les événements journée entière utilisent showWithoutTime. Un événement flottant se résout par la chaîne de repli fuseau de l’événement → fuseau de la personne → défaut de l’organisation → UTC (travail collaboratif) ; le fuseau de la personne peut être posé, remplacé ou retiré (timeZone: null sur le principal, l’absence étant un état à part entière), et le défaut de l’organisation s’écrit depuis un client par le Tenant/set d’administration, refusé quand ce n’est pas une zone IANA.
La longueur d’un événement est sa duration ; un DTEND iCalendar en devient une par une seule règle, à chaque porte qui lit de l’iCalendar (PUT CalDAV, import de fichier, flux ICS abonné, iTIP entrant) : l’écart exact jusqu’à la fin dans son propre fuseau, des jours pour une DATE, un événement en DATE sans fin durant un jour et un événement horaire sans fin étant un instant (RFC 5545 §3.6.1). Une fin antérieure au début, ou d’un autre type de valeur ou d’un autre état flottant, fait refuser la ressource en CalDAV (CALDAV:valid-calendar-data), compte l’événement en erreur dans un import de fichier, l’écarte d’un flux et laisse une invitation non appliquée (le courrier reste). Le DTEND envoyé par un client CalDAV est aussi conservé, et un GET le rend tel quel tant qu’il désigne la fin du modèle, DURATION sinon, jamais les deux ; une exception qui ne déplaçait que sa fin garde ce changement dans sa propre duration. La fin stockée, que lisent les requêtes par fenêtre et la disponibilité, est l’addition de la RFC 8984 §1.4.6, les jours sur l’heure murale puis les heures en temps absolu dans le fuseau de l’événement, si bien qu’un événement qui traverse un changement d’heure finit à la bonne heure, et une mise à jour qui change timeZone la recalcule.
Deux fois par an, une heure murale n’est pas une fonction d’un instant. Le serveur projette une heure civile locale sur l’UTC avec une seule convention, le décalage en vigueur avant la transition : dans le repli d’automne, l’heure qui a lieu deux fois prend son premier instant ; dans le trou du printemps, l’heure qui n’a pas lieu tombe sur l’instant réel qui suit la transition. C’est ce que produit une résolution VTIMEZONE naïve, donc libical, Apple et Google, et un agenda qui résout une heure murale autrement que ses clients ne fait pas que différer. Chaque consommateur passe par cette seule autorité. doctor (dst_horizon) calcule la prochaine bascule pour chaque fuseau utilisé et compte ce qui tombe dans l’heure qui n’existe pas, si bien que le changement d’heure ne se découvre plus le lundi matin.
Les heures de travail et la disponibilité
Un horaire est fait d’intervalles, pas d’une table de jours (ADR-139). LeworkingHours d’un principal est une carte jour de semaine → fenêtres, et une fenêtre porte un décalage de jour de fin explicite :
endDayOffset vaut 0 (absent = 0) ou 1 ; start et end sont des heures RFC 3339 de 00:00 à 23:59, jamais la notation étendue 30:00 ; end plus le décalage doit être strictement après start. La sémantique est l’heure murale, jamais la durée écoulée : « de 22 h à 6 h à ma montre » dure neuf heures réelles la nuit d’automne et sept celle du printemps (la branche DTEND de la RFC 7953, la bonne pour la disponibilité d’une personne). Les deux heures dégénérées suivent la RFC 5545 §3.3.5, avec une dérogation déclarée : la fin d’un intervalle prend la lecture la plus tardive, parce qu’un intervalle doit couvrir. Les périodes indisponibles d’un jour sont le complément, sur le span local de ce jour, de l’union des fenêtres déclarées sur J−1 et J, chacune projetée depuis son propre jour de départ, si bien qu’un poste de nuit du lundi rend le mardi matin travaillé, et qu’un « jour de repos » n’est que le cas dégénéré sans fenêtre. Une fenêtre que le calendrier civil a supprimée ce jour-là (02:30–03:00 à la transition de printemps) ne contribue rien, et aucune validation à l’écriture ne pourrait l’attraper. La validation est à la frontière : un horaire malformé répond invalidProperties une fois posée la surcharge d’exécution par tenant calendar.working_hours_enforce, et est journalisé avec un champ violation stable avant cela (le même passage en deux temps que le contrôle IANA sur timeZone, principal.timezone_enforce). Le serveur annonce qu’il comprend le décalage par la capability urn:oximail:params:jmap:working-hours-day-offset ; un client ne propose « le lendemain » que si le serveur en face le dit.
Principal/getAvailability prend ids: Id[], utcStart, utcEnd et showDetails, et répond list[{id, availability: BusyPeriod[], complete}] plus les tableaux par identifiant notFound et forbidden. complete n’est jamais absent : false dit que le serveur ne peut pas garantir avoir vu tout événement qui recouvre la fenêtre, et incompleteReason dit pourquoi, spanBound (un événement sans fin stockée commencé avant la portée de lecture, une ligne d’avant le stockage de la fin ou une base restaurée d’un instantané antérieur, ce que oximail calendar backfill-end-dt --apply referme) ou openRecurrence (une série dont les occurrences sont plus longues que le plafond de la portée de lecture et dont l’until ne prouve pas la fin). Un client de planification ne doit pas lire une réponse incomplète comme « libre ». busyStatus vaut confirmed, tentative ou unavailable (il n’y a pas de valeur busy) ; le temps libre n’est pas rendu, le client soustrait. Un événement contribue quand l’includeInAvailability de son agenda vaut all ou attending (isSubscribed n’en fait pas partie : la RFC 8984 en fait un drapeau sur les agendas reçus d’autrui, et le tester a un jour vidé la disponibilité d’une praticienne parce que son agenda par défaut portait isSubscribed: false hérité d’une migration), que l’appelant détient mayReadFreeBusy dessus, que l’occurrence recouvre la fenêtre, que privacy n’est pas secret, que freeBusyStatus n’est pas free, et qu’il n’est ni annulé ni à la corbeille ; pour attending, le sujet doit participer en accepted ou tentative. Les trous d’heures de travail sont synthétisés en unavailable. Un principal du même tenant dont aucun agenda n’accorde le libre/occupé à l’appelant est forbidden, enveloppe comprise. BusyPeriod.event n’est peuplé que sous showDetails et un vrai droit de lecture, null pour un événement private. La disponibilité se calcule sur les agendas du sujet seulement ; un binaire étendu peut enregistrer un contributeur externe qui fusionne des fenêtres opaques après cette porte, et un contributeur en échec est serverFail, jamais « libre » (ADR-138).
Deux choses fausses hier, mesurées vraies aujourd’hui : un événement de plus de 62 jours commencé avant la portée de lecture (trois mois de congé, consultés au milieu) n’était pas lu du tout et la personne était annoncée libre ; la fin de l’événement est désormais stockée et indexée (oximail calendar backfill-end-dt --apply la donne aux lignes antérieures), et un long événement est trouvé par sa fin. Et le VFREEBUSY CalDAV portait une copie écrite à la main du calcul JMAP ; il n’y a plus qu’une fonction.
Quand un cabinet appelle le lundi pour dire « le créneau de vendredi a disparu », oximail calendar availability --tenant … <principal-id> --from … --to … --json rejoue la réponse, serveur arrêté au besoin, par les mêmes fonctions que la méthode : l’enveloppe des heures de travail et les périodes issues des événements, avec une note par fenêtre qu’une bascule d’heure a ajustée ou effacée, en portée propriétaire (un opérateur en ligne de commande n’a pas d’identité de bénéficiaire, et la sortie le dit).
Les liens de rendez-vous
AppointmentLink/set crée un lien de rendez-vous ponctuel : un titre, une durée, une fenêtre, une expiration, et un jeton de 32 octets qui ne quitte le serveur que par cette surface, vers son propriétaire. La page publique /public/book/{token} propose les créneaux libres du propriétaire dans la fenêtre (heures de travail comprises, via Event/proposeTimes), l’invité en choisit un, et l’événement est créé par CalendarEvent/set avec l’invité en participant externe, si bien que le REQUEST iTIP part par la mécanique ordinaire ; le lien passe alors consumed et nomme l’événement produit. Tant que la disponibilité du propriétaire est incomplète sur la fenêtre (incompleteFor non vide), la page ne propose aucun créneau et dit que la disponibilité ne peut pas être établie, et une réservation soumise entre-temps est refusée sur la même règle : un extérieur ne réserve jamais dans un trou que le serveur n’a pas pu voir. Un jeton inconnu est un 404 muet. Il n’y a pas de mise à jour : un lien est un engagement figé, et le modifier après partage tromperait l’invité, donc on révoque et on recrée. AppointmentLink/get liste les liens du propriétaire avec leur état (ouvert, consommé, périmé) ; le type n’est jamais servi sous shared:, puisque chaque URL rendue est une capacité.
Planification (iTIP)
Créer un événement avec des participants déclenche le courrier d’invitation, sous deux règles dures : seulement quand l’organisateur résout vers un compte local (RFC 5546 : seul le serveur de l’organisateur envoie), et seulement après la validation de la transaction (unstateMismatch n’émet rien). REPLY, COUNTER et CANCEL suivent la même discipline ; les invitations et réponses entrantes sont traitées à l’ingestion et produisent des objets CalendarEventNotification. Le traitement entrant est indexé sur l’UID iCalendar (et SEQUENCE pour un REQUEST) : un REQUEST sur un UID inconnu crée l’exemplaire du participant, un REQUEST ou un CANCEL sur un UID connu le met à jour ou l’annule, un REPLY met à jour le participationStatus du participant nommé. L’UID ne fait pas autorité à lui seul (RFC 5546 §3.2) : un message classé pourriel ou refusé par DMARC ne bouge rien ; un REQUEST sur un événement connu et un CANCEL ne s’appliquent que depuis l’organisateur que l’événement stocké nomme, jamais depuis l’ORGANIZER qu’écrit le message entrant ; un REQUEST pour un nouvel événement seulement quand son ORGANIZER est l’expéditeur ; un REPLY seulement depuis le participant qu’il modifie, et jamais depuis l’adresse de l’organisateur. Un message refusé reste dans la boîte, seul l’agenda ne bouge pas, et le refus est journalisé avec sa raison. Un domaine sans DMARC reste usurpable par un From: exact, qui exige alors l’adresse et l’UID ensemble. L’événement, sa notification et leurs entrées de journal de changements s’écrivent dans une seule transaction : l’agenda et ce qu’on annonce aux clients synchronisés bougent ensemble, ou rien ne bouge. La modification d’un bénéficiaire sur un agenda partagé notifie le propriétaire de la même façon.
L’invitation et l’annulation sont écrites dans la langue du destinataire (français, allemand, italien ou anglais, selon le compte destinataire) et à sa pendule : l’annulation a la forme de l’invitation, organisateur, titre, le jour en toutes lettres, l’heure de début et de fin avec le fuseau. Un REQUEST, un REPLY et un COUNTER décrivent l’événement tel qu’il est : sa fin est la même addition RFC 8984 que la fin stockée, un événement sans duration part sans DTEND (un instant, comme chez l’organisateur, là où une réunion d’une heure était inventée), et un événement sur la journée entière voyage en dates (VALUE=DATE, sans TZID, un DTEND en DATE), là où une réponse ou une contre-proposition portait un début horaire à minuit. Un événement créé « avec visio » porte son lien en CONFERENCE dans l’invitation sortante et en virtualLocations sur la copie du participant ; cette instruction, meetLink, et la réunion qu’elle ouvre sont sur la page Meet.
Alertes
alerts accepte les déclencheurs relatifs (relativeTo: "start"/"end") et absolus (when). Elles se déclenchent côté serveur (push / notification), une fois par occurrence, depuis un planificateur qui lit tous les événements porteurs d’alerte (il les lisait sous un LIMIT 1000 sans ordre, et en sautait 380 sur 1 380 sur une box de production). La préférence « rappels d’agenda » du compte est honorée à la sortie. Les VALARM font l’aller-retour avec les clients CalDAV ; les exports de flux ICS publics n’en portent délibérément aucun.
« Plus tard » est un verbe du serveur. Event/snoozeAlert {accountId, id, alertId, recurrenceId, until} écrit lui-même le report de la RFC 8984 §4.5.2, par CalendarEvent/set : les droits, les alertes par utilisateur, le journal de changements et le push sont ceux de toute écriture. L’alerte qui a sonné est acquittée, et une alerte absolue enfant (relatedTo l’originale) est ajoutée à until, un UTCDateTime futur, au plus 7 jours plus tard. Elle sonne une fois. Le report d’un report s’accroche à l’originale et le remplace, et les reports échus sont balayés au passage : un événement ne porte au plus que ses reports en attente. Pour une occurrence d’une série (recurrenceId, refusé sur un événement non récurrent), le report est rangé sur le maître sous la propriété propriétaire oximail.ch:recurrenceId, et aucune autre occurrence ne bouge ; la deuxième notification nomme cette occurrence. Les notifications d’alerte (l’entité Web Push et les trames d’alerte WebSocket) portent l’alertId que l’appel nomme. Erreurs : notFound (l’événement ou l’alerte), invalidArguments (un until invalide, un recurrenceId de trop), forbidden (l’écriture refusée, par exemple sur un agenda partagé en lecture seule). Le même verbe existe pour les tâches (Task/snoozeAlert).
Un rappel parle la langue de son lecteur. Les textes push (rappels d’événement, tâches échues, appels, libellés génériques) suivent la langue du compte (celle du compte, puis celle de l’organisation, puis l’anglais), en français, anglais, allemand ou italien. Les dates suivent le pays d’établissement de l’organisation (jj.mm.aaaa en Suisse, au Liechtenstein, en Allemagne et en Autriche), sinon la langue (jj/mm/aaaa en français et en italien, ISO en anglais). L’échéance d’une tâche est mise en forme, et non plus envoyée brute.
Le partage, vu depuis l’agenda
Un agenda partagé en lecture seule l’annonce exactement aux clients CalDAV (Thunderbird et DAVx5 le montraient modifiable et échouaient à chaque écriture), et un agenda partagé à un autre compte du même serveur est atteignable en CalDAV, le comportement du propriétaire restant inchangé. Un droit est revérifié par collection, si bien qu’un partage d’agenda n’ouvre plus le reste du compte. Les mandats d’un compte gelé sont suspendus, ceux de l’opérateur restent. Le modèle de droits lui-même est sur la page du partage. Les règles de rigueur (identifiants inconnus dansnotFound, invalidProperties plutôt qu’une coercition, stateMismatch sur ifInState) sont identiques à celles de la surface courrier.