Skip to main content
JMAP (JSON Meta Application Protocol) est l’API que parle OxiMail pour le courrier, les agendas, les contacts, les tâches, les fichiers et le chat. Cette page couvre la couche Core de la RFC 8620 : comment un client découvre le serveur, s’authentifie, groupe plusieurs appels de méthode dans une seule requête HTTP, enchaîne ces appels avec des références arrière, déclare les capacités qu’il utilise, et reçoit les notifications push. Tout ce qui suit décrit ce que OxiMail expose réellement en v0.30.0. Les jeux de méthodes par domaine s’appuient sur cette couche : voir JMAP Mail, Agendas, Contacts, Tâches, Notes, Fichiers, Partage et Chat.

La ressource session

Un client JMAP commence par récupérer la ressource session. C’est le document de découverte unique qui indique au client tout ce dont il a besoin : les capacités prises en charge par le serveur, les comptes accessibles à l’utilisateur, et les URL de toutes les autres opérations.
Le point d’accès de la session est authentifié : il faut présenter un token valide pour le récupérer. La réponse est un objet JSON (RFC 8620 §2) qui contient les champs suivants.

Ne jamais coder les chemins en dur

Les trois champs *Url sont des modèles d’URL (RFC 6570) : un client, en particulier une intégration sans état comme un worker serverless ou un script cron, doit les lire depuis la session et substituer les variables ({accountId}, {blobId}, …), jamais reconstruire les chemins de mémoire d’après la disposition d’un autre serveur. Ce n’est pas théorique. Le point d’envoi d’OxiMail est /jmap/upload/{accountId} sans barre oblique finale (routage strict) ; d’autres serveurs JMAP utilisent /jmap/upload/{accountId}/. Un client qui code la forme avec barre oblique reçoit un 404 à chaque envoi, et le bug ressemble à une panne serveur. Lire uploadUrl depuis la session rend le même code client compatible avec n’importe quel serveur RFC 8620. Un client sans état n’a pas besoin de récupérer la session à chaque invocation :
  1. GET /.well-known/jmap une fois, mettre en cache l’objet de session avec sa chaîne state.
  2. À chaque exécution, utiliser directement les modèles en cache.
  3. Chaque réponse de méthode JMAP porte un sessionState : quand il ne correspond plus au state en cache (ou qu’une URL substituée se met à répondre 404), récupérer la session à nouveau et remplacer le cache.
L’objet de la capacité Core annonce aussi les limites strictes du serveur, qu’un client doit respecter avant d’envoyer une requête.
Un utilisateur peut voir plus d’un compte dans la session. Outre son compte personnel, tout dossier, agenda ou carnet d’adresses partagé avec lui via le partage JMAP apparaît comme une entrée de compte supplémentaire (indexée shared:{ownerId}), qui ne porte que les capacités partagées et parfois isReadOnly: true.

Authentification

OxiMail utilise des tokens Bearer (RFC 6750). On obtient un token en envoyant les identifiants au point d’accès de connexion.
La réponse renvoie le token et l’identifiant du compte.
Envoyez ce token sur chaque requête suivante.

Durée de vie et renouvellement du token

Les tokens expirent 24 heures après leur émission. Pour rester connecté sans ressaisir de mot de passe, renouvelez le token.
Si le token est encore valide, ou expiré depuis moins de 7 jours, le serveur supprime l’ancien token et en renvoie un nouveau avec une nouvelle expiration à 24 heures. Le format de la réponse est le même que pour la connexion : { "accessToken": "...", "accountId": "..." }. Passé le délai de grâce de 7 jours, le renouvellement échoue avec un 401 et l’utilisateur doit se reconnecter.

Tokens liés à l’appareil DPoP (RFC 9449)

Un client peut activer la preuve de possession à la connexion en envoyant une preuve DPoP (un JWT signé par une clé ES256 éphémère) avec ses identifiants. La session est alors liée à cette clé : POST /auth/refresh exige une preuve fraîche signée par la même clé. Un bearer token volé seul ne peut donc plus être renouvelé. Deux couches de durcissement côté serveur prolongent ce mécanisme. Les deux sont configurables et désactivées par défaut :
  • Nonce serveur (§8). Chaque réponse d’authentification porte un en-tête DPoP-Nonce. Une preuve qui contient une revendication nonce est toujours vérifiée contre lui. Un nonce périmé répond 401 avec l’erreur use_dpop_nonce et un nonce frais, et le client réessaie (la séquence standard de la RFC 9449). Avec [auth] dpop_nonce_required = true, le serveur rejette en plus les preuves sans nonce : la fraîcheur contrôlée par le serveur remplace la fenêtre d’horloge.
  • Preuve de possession par requête (§7). Avec [auth] dpop_resource = "require", une session liée par DPoP doit envoyer une preuve sur chaque requête qui présente son token dans l’en-tête Authorization, liée à ce token par la revendication ath. Un bearer volé ne peut alors plus appeler JMAP du tout, et pas seulement plus se renouveler. "monitor" journalise ce que require rejetterait, pour un déploiement sans risque. Les sessions qui n’ont jamais lié de clé et les mots de passe d’application ne sont pas concernés. La comparaison htu normalise les ports par défaut selon la RFC 3986 §6.2 (un :443 explicite d’un côté ou de l’autre correspond quand même).
Comme les éléments médias (<video>, <audio>) ne peuvent pas porter d’en-tête DPoP, une session liée sous require frappe à la place un jeton délégué de courte durée : POST /jmap/media-token (lui-même protégé par DPoP) renvoie un token de 5 minutes limité à exactement un blob, passé en ?media_token= dans le src de l’élément. Il est refusé sur toute autre route, pour tout autre blob, et après expiration.

La preuve récente

Un token bearer volé ou un mot de passe d’application fuité ne doit pas suffire à prendre un compte pour de bon. Quelques actes demandent donc plus qu’un token valide :
  • Les identifiants changent depuis une session interactive seulement. Enrôler ou retirer une passkey, créer un mot de passe d’application et changer le mot de passe sont acceptés depuis une session d’appareil ouverte par une connexion, jamais depuis un token qu’un mot de passe d’application a ouvert. La première passkey d’un compte s’enrôle avec le mot de passe saisi de nouveau (currentPassword) ; une suivante demande une preuve récente avec une passkey existante. Chaque enrôlement et chaque retrait laissent une ligne d’audit et un avis au titulaire du compte.
  • Les actes sensibles demandent une preuve datée. Un compte qui a une passkey doit l’avoir prouvée sur la session en cours depuis moins de dix minutes pour changer un rôle, poser le mot de passe de quelqu’un d’autre (Principal/setPasswordAdmin, où un administrateur sans passkey est refusé d’emblée) ou poser une règle de transfert. Le coffre de clés privées demande une authentification récente de la session, par mot de passe ou par passkey. Renouveler un token ne date rien.
  • Le refus se soigne, et le dit. Une preuve manquante répond l’erreur de méthode stepUpRequired, et non plus forbidden, avec maxAge, l’âge maximal en secondes d’une authentification que le serveur accepte pour cet acte (600) ; la description garde son préfixe stepUpRequired: pour les anciens clients. Un compte sans rien d’enrôlé, là où une passkey est exigée, reste en forbidden : aucune cérémonie n’y remédie.
Le client prouve alors de nouveau la session ouverte, sans nouvelle connexion et sans changer de token en cours d’usage :
/auth/password/reauth sert au compte sans passkey. Il répond {"status": "ok", "validForSeconds": 600} sur la même session, derrière le même token ; un mauvais mot de passe répond 401 et ne date rien, un token de mot de passe d’application 403 (il n’ouvre aucune session à prouver), une clé de corps inconnue 400. Il compte dans le même ralentissement par compte et le même fail2ban qu’une connexion. Un mot de passe ne vaut jamais preuve par passkey : une session ouverte par passkey, puis prouvée de nouveau par mot de passe, devient une session par mot de passe, et les actes qui exigent une passkey se ferment jusqu’à la prochaine cérémonie passkey.

Le ticket éphémère de streaming

L’API EventSource du navigateur ne peut pas définir d’en-têtes de requête personnalisés, donc elle ne peut pas envoyer Authorization: Bearer. La RFC 6750 §2.3 autorise à la place le token dans la chaîne de requête, et OxiMail accepte toujours ?access_token= sur ses deux routes de streaming. Mais un token dans une URL atterrit dans les journaux d’accès des reverse proxies et dans tout ce qui enregistre des URL. La façon prise en charge d’ouvrir un flux est donc un ticket éphémère dédié, frappé par un appel authentifié normalement, par en-tête :
Sans corps. La réponse n’est jamais mise en cache (Cache-Control: no-store) :
Passez-le en ?stream_ticket= sur l’URL du flux, à la place du token de session. Ce qu’est ce ticket :
  • De courte durée : 60 secondes. Il autorise l’ouverture d’un flux, pas la durée de vie du flux : un flux déjà connecté n’est pas fermé quand son ticket expire. La fenêtre n’a qu’à couvrir la frappe puis la connexion, plus la reconnexion automatique du navigateur à l’intérieur de ce laps de temps. Une coupure plus tardive demande une nouvelle frappe au niveau applicatif, que le client doit implémenter de toute façon.
  • Limité à (organisation, compte, session d'appareil), et accepté sur /jmap/eventsource et /jmap/push-patches uniquement : présenté ailleurs, il est refusé. Transporter la session d’appareil n’est pas un détail : /jmap/push-patches enregistre sa connexion sous cet identifiant de session, et c’est ce lien qui fait que les bases de comparaison du flux sont celles-là même que les réponses /get de la session ont semées. Un flux qui l’aurait perdu fonctionnerait, en envoyant silencieusement des valeurs complètes pour toujours au lieu de patchs.
  • Possession déléguée, donc il ne porte aucune preuve propre. Il a été frappé par un appel passé dans l’extracteur normal, token dans un en-tête, et sous dpop_resource = "require" avec une preuve de ressource : c’est pourquoi le ticket lui-même est exempté de cette porte. Il ne porte pas non plus d’identité au-delà de son périmètre : les deux gestionnaires de flux lisent l’organisation, le compte et la session, rien d’autre.
Un ticket qui fuite dans un journal est inerte dans la minute ; le token de session de 24 heures n’entre jamais dans une URL. Le jeton ?media_token= limité à un blob, décrit plus haut, est un token différent et volontairement plus étroit : un blob, cinq minutes, /jmap/download seulement.
?access_token= reste accepté sur les deux routes de streaming pour compatibilité, y compris pour les sessions liées par DPoP sous dpop_resource = "require" (l’API du navigateur ne peut pas envoyer d’en-têtes : c’est le seul résidu documenté de la porte de preuve par ressource). Les nouveaux clients doivent frapper un ticket.

Émettre des requêtes

Tous les appels de méthode vont vers un seul point d’accès, dans une unique requête POST groupée. Le corps de la requête (RFC 8620 §3.3) comporte trois parties.
  • using déclare les capacités sur lesquelles la requête s’appuie. Une méthode dont la capacité n’est pas listée dans using est inconnue de cette requête et répond unknownMethod, la description nommant la capacité qu’il lui faut : la méthode n’est jamais exécutée malgré tout. unknownCapability est l’erreur de requête pour une entrée de using que le serveur ne prend pas en charge. Une capacité d’éditeur se nomme par son URN ou par son URL (plus bas).
  • methodCalls est un tableau ordonné. Chaque appel est un tableau de trois éléments : [nomMéthode, arguments, callId]. Le callId est votre propre étiquette, renvoyée telle quelle pour que vous puissiez associer chaque réponse à son appel.
La réponse reprend cette structure avec un tableau methodResponses, dans le même ordre, chaque élément étant étiqueté avec le callId correspondant.
Un identifiant invalide ne disparaît jamais en silence. Tout ce qu’un appel /get ne trouve pas revient dans notFound. OxiMail n’abandonne jamais discrètement un identifiant illisible ou inconnu.
Deux règles sur accountId valent pour tout appel, lectures comprises :
  • accountId est requis (RFC 8620 §5.1) sur toute méthode qui agit sur un compte, c’est-à-dire toutes sauf celles que la RFC définit sans compte (Core/echo, PushSubscription/* et le rattrapage de flux Push/catchup). Une session sert plusieurs comptes et le serveur n’en choisit jamais un : une écriture sans lui était déjà refusée, mais une lecture était encore servie depuis le compte primaire de l’appelant, avec un accountId d’apparence valide sur la réponse, indiagnosticable côté client. Les deux sont refusées désormais, invalidArguments nommant la règle.
  • Un compte effectif par requête. Une requête dont les appels résolvent vers un compte partagé (shared:<propriétaire>) ne peut pas porter aussi un appel sur le compte propre de l’appelant : l’appel étranger est refusé avec un message qui nomme la règle, et part dans sa propre requête. Avant cette règle, un tel appel s’exécutait dans le compte du propriétaire : un objet bien réel répondait « introuvable », et une création aurait pu atterrir dans la boîte de quelqu’un d’autre.

Références arrière (result references)

L’intérêt du groupage, c’est qu’un appel peut alimenter le suivant dans la même requête, ce qui évite un aller-retour. C’est une référence arrière, aussi appelée result reference (RFC 8620 §3.7). À la place d’une valeur d’argument littérale, vous passez un objet avec un préfixe # sur le nom de l’argument.
Le second appel signifie : « pour mon argument ids, prends le résultat de l’appel c0 (qui doit être un Email/query) et récupère la valeur au pointeur JSON /ids ». Le serveur résout la référence à partir du résultat du premier appel avant d’exécuter le second. Une référence arrière doit indiquer le bon callId précédent, le bon name de méthode et un path valide. Si l’un de ces éléments est faux, OxiMail renvoie invalidResultReference au lieu de substituer null ou une liste vide. Un argument donné à la fois directement et par référence (ids et #ids dans le même appel) est invalidArguments (RFC 8620 §3.7) : l’un des deux l’emportait en silence, selon l’ordre de lecture de la table.

Les références de création

Un enregistrement créé plus tôt dans la même requête se nomme par son identifiant de création précédé de #, et le serveur substitue le vrai identifiant (RFC 8620 §5.3). OxiMail la résout comme valeur (destroy: ["#e"], emailId: "#e"), comme clé d’un update de /set (update: {"#e": {…}}, qui répondait notFound), comme clé de chaque ensemble indexé par identifiant (mailboxIds: {"#m": true}, qui arrivait non résolu au stockage et répondait serverFail) et comme segment d’identifiant d’un chemin de patch qui y mène (mailboxIds/#m). Nulle part ailleurs : un mot-clé peut légitimement commencer par # (RFC 8621 §4.1.1). Une référence qui ne désigne rien reste une erreur du client : notFound sur une mise à jour, invalidProperties qui nomme mailboxIds sur un message. Une création et un patch du même enregistrement dans un seul appel /set restent un refus nommé du patch, puisque les créations ne passent d’abord que pour les références entre enregistrements d’un même type.

Écrire avec /set

Ce que répond un /set, c’est ce que le serveur a enregistré : un client n’a pas besoin d’un /get derrière chaque écriture.
  • Un objet créé revient avec ce que le serveur a posé. L’entrée created porte l’identifiant plus chaque propriété que le client n’a pas envoyée telle quelle : valeurs par défaut, valeurs calculées, adresses normalisées, dates posées par la base (le receivedAt et le messageId généré d’un email, l’échéance d’un an par défaut d’un octroi de fédération, l’expires d’un abonnement push). Elle ne portait guère que l’identifiant, si bien qu’un client affichait une valeur qu’il ne connaissait pas avant son /get suivant.
  • Un objet entier lu par Foo/get est un patch valide. Un client qui renvoie un objet qu’il a lu, propriétés posées par le serveur comprises, n’est pas refusé : le serveur relit ces propriétés par le Foo/get du type, après chaque contrôle d’autorité, et accepte le patch sans elles quand chacune vaut sa valeur actuelle. Une propriété posée par le serveur envoyée avec une autre valeur reste refusée nommément.
  • alreadyExists nomme l’enregistrement. Chaque refus de ce genre porte existingId (RFC 8620 §5.4) : une clé de destinataire, un lien de tâche, une fiche ou une entrée d’annuaire importées deux fois.
  • Les états appartiennent à leur type. Un ifInState qui n’est pas un état du type répond stateMismatch ; maxChanges: 0 sur un /changes ou un /queryChanges est invalidArguments (il répondait hasMoreChanges: true sans avancer, une boucle pour un client qui le suit).

Capacités

Le serveur annonce ce qu’il sait faire via des URN de capacité dans la table capabilities de la session. Le client déclare ensuite celles qu’il compte utiliser en les listant dans le tableau using de la requête. OxiMail annonce deux familles.

JMAP standard (v1)

Les capacités normalisées par l’IETF, dans l’espace de noms urn:ietf:params:jmap:* : Core, Mail (RFC 8621), Submission, réponse d’absence, Quota (RFC 9425), Principals et partage (RFC 9670), Contacts (RFC 9610), Agendas, Fichiers et WebSocket (RFC 8887).

JMAP modernisé OxiMail (v2)

OxiMail expose aussi un ensemble de capacités modernisées dans l’espace de noms urn:oximail:params:jmap:v2:*. Ce sont les extensions JMAP modernisées d’OxiMail (Internet-Drafts en cours), qui couvrent notamment : Voir JMAP v2 modernisé pour la surface complète.

Deux noms pour chaque capacité d’éditeur

La RFC 8620 §1.8 demande qu’une extension d’éditeur soit nommée par une URL sur un domaine que l’éditeur possède. Chaque capacité OxiMail est donc servie sous ses deux formes, son URN et son URL, https://oximail.ch/jmap/<nom>, le : du nom devenant / : urn:oximail:params:jmap:v2:mail est aussi https://oximail.ch/jmap/v2/mail, urn:oximail:params:jmap:privatekeys est aussi https://oximail.ch/jmap/privatekeys. Les deux figurent dans les capabilities de la session, dans les accountCapabilities de chaque compte et dans les capabilities d’un Principal, et un client peut nommer l’une ou l’autre dans using, dans un patch de Principal ou dans le scope personnalisé d’un mot de passe d’application ; nommée deux fois, une fois sous chaque forme, elle compte une fois. Les deux formes sont une seule capacité par une règle, jamais par une liste : une nouvelle extension a son URL le jour où elle est déclarée. Une capacité urn:ietf: n’a qu’une forme.

Mélanger v1 et v2 dans une même requête

En v0.30.0, les surfaces mail, contacts et agenda v1 et v2 sont servies par les mêmes gestionnaires, grâce à un mécanisme de double capacité : un gestionnaire qui annonce une capacité v1 comme capacité principale accepte aussi la capacité v2 correspondante comme capacité additionnelle. Une requête peut donc lister à la fois une capacité v1 et son équivalent v2 dans using en même temps. La structure renvoyée sur le réseau est l’union des propriétés v1 et v2, et le client lit celle qu’il veut via l’argument properties de /get.
Le protocole conserve une vérification d’exclusion mutuelle pour un usage futur, mais en v0.30.0 l’ensemble des paires v1/v2 mutuellement exclusives est vide. Rien n’est rejeté lorsqu’on associe une capacité v1 à son équivalent v2. Si une future surface v3 réintroduisait une séparation stricte, une requête qui associerait les deux URN exclus serait rejetée avec unknownCapability.

Le téléchargement de blob, et ce que signifie un échec

Les blobs sont récupérés depuis le modèle downloadUrl, soit /jmap/download/{accountId}/{blobId}/{name}?type={type}, avec l’en-tête Authorization, ou avec un ?media_token= pour un élément média (voir plus haut). Une propriété du modèle de stockage est visible ici. Pour un message, le blob brut RFC 5322 est la source unique de vérité ; les blobs par partie qu’un client télécharge en sont un cache dérivé. Les parties qui sont des pièces jointes sont matérialisées à l’ingestion ; les parties de corps ne le sont pas du tout. Un téléchargement portant sur une partie absente du disque est donc normal, et le serveur y répond en re-dérivant : il localise le message propriétaire, charge et déchiffre le brut côté serveur, le re-analyse, extrait la partie correspondante, vérifie que l’empreinte en clair de la partie égale le blobId demandé, puis sert les octets avec un 200. Des octets non vérifiés ne sont jamais servis. Quand cela ne peut pas aboutir, la réponse dit de quel cas il s’agit, au lieu d’un 404 nu : La distinction est tout l’enjeu : un client, ou un opérateur qui lit les journaux, peut désormais différencier « ce blob n’a jamais existé ici » de « ce blob devrait exister et le message derrière lui a disparu », ce qu’un 404 unique confondait.
La re-dérivation s’applique aux requêtes portant sur les blobs de votre propre compte, et à un blob de courrier d’un compte partagé que vous pouvez lire : une pièce jointe, une image en ligne ou le message brut d’un e-mail atteint par une boîte partagée ou un label partagé se télécharge selon la règle même d’Email/get (vous lisez au moins un e-mail du propriétaire qui le porte), déchiffré dans la seule projection courrier, et re-dérivé comme le vôtre si la partie a quitté le cache. Tout le reste, sur les portes des comptes partagés et des avatars de pairs, conserve volontairement une sémantique de 404 indiscernable, pour ne pas devenir un oracle d’existence.
Un blob téléversé est à l’abri avant que vous le référenciez. Entre votre téléversement et le /set qui le nomme, un blob est non référencé par protocole (RFC 8620 §6.1). Le ramasse-miettes de blobs ne récupère jamais un blob non référencé plus jeune qu’une fenêtre de grâce de 24 heures, lue sur l’horodatage du fichier lui-même ; un horodatage illisible ou situé dans le futur conserve le blob au lieu de le collecter. Téléversez, prenez le temps qu’il vous faut, référencez ensuite.

Push : savoir quand l’état change

Chaque type JMAP suit une chaîne d’état opaque. Quand vous appelez Foo/get, la réponse inclut l’état courant (state). Plus tard, vous pouvez appeler Foo/changes avec le dernier état que vous avez vu, et le serveur vous indique exactement quels objets ont été créés, mis à jour ou détruits depuis. Vous ne sondez jamais des listes complètes : vous synchronisez des deltas. Chaque écriture enregistre son entrée de journal de changements dans sa propre transaction, quelle que soit la porte par laquelle elle arrive (JMAP, changements de drapeaux, copies, déplacements et expunges IMAP, Email/import, Email/wake, les workers de mise en veille, de relance et d’expiration, une invitation reçue, les règles Sieve, Rule/apply, TaskTemplate/instantiate) : un échec annule les deux, si bien qu’aucun changement n’existe que /changes et sync-collection ne signaleraient jamais. Pour savoir quand appeler Foo/changes, OxiMail envoie une petite notification StateChange dès que l’état d’un type avance. Il existe deux transports, tous deux annoncés dans la session.

EventSource (Server-Sent Events)

Un flux SSE standard à l’adresse eventSourceUrl.
Le serveur diffuse des événements StateChange. Chaque événement nomme le compte et la collection dont l’état a bougé, ainsi que la nouvelle chaîne d’état. Utilisez types=Email,Mailbox pour filtrer sur certaines collections, ping= pour l’intervalle de keep-alive, et stream_ticket= pour l’authentification dans le navigateur (voir plus haut). Le flux est limité à l’organisation authentifiée, et porte deux bras : votre propre compte, et chaque compte qui partage avec vous, sous la clé shared:<propriétaire>, gardé par la classe de collection partagée (une modification de tâches n’est jamais révélée à qui ne partage que l’agenda) et rafraîchi à chaud sur une ShareNotification ou par un plancher TTL, la même dérivation que le WebSocket. Un événement partagé porte l’identifiant shared:<propriétaire>:<collection>:<état>. Une reconnexion avec Last-Event-ID est rattrapée sur les deux bras en un seul événement : les états de votre propre compte et ceux de chaque shared:<propriétaire>, sous la même porte de classe que le bras direct, si bien qu’un client qui a perdu son flux ne dépense plus un /changes par partage pour savoir ce qui a bougé. Un ping=N non nul demande un vrai événement ping chaque fois que N secondes passent sans événement, ses données {"interval": N} donnant l’intervalle réellement retenu (borné entre 10 et 900 secondes) ; ping=0 n’en demande aucun, et sans le paramètre le serveur en envoie toutes les 30 secondes. Les pings étaient des commentaires SSE, qu’aucun écouteur EventSource ne peut observer (RFC 8620 §7.3).

WebSocket

OxiMail parle aussi JMAP par-dessus WebSocket (RFC 8887). La session annonce une URL wss://.../jmap/ws avec supportsPush: true. Un client qui détient déjà une connexion WebSocket peut donc à la fois envoyer des appels de méthode et recevoir le push sur le même canal. Le serveur envoie un ping à chaque WebSocket toutes les 30 secondes et ferme celui qui n’a rien répondu depuis 90. Un appareil dont le WebSocket est actif ne reçoit pas de Web Push, puisque son onglet ouvert affiche la trame transmise sur la connexion ; une application de téléphone mise en veille avec sa connexion TCP encore ouverte passait pour un onglet ouvert, et ne recevait ni la trame ni le push. Un navigateur répond seul à un ping, si bien qu’un onglet ralenti en arrière-plan reste connecté. Les push ainsi retenus sont comptés (oximail_push_suppressed_total). Un abonnement Web Push vit aussi longtemps que son appareil. Quand le client ne donne pas d’échéance (le client web n’en donne jamais), l’échéance de l’abonnement est celle de la session d’appareil et glisse avec elle à chaque connexion et à chaque renouvellement ; il expirait sept jours après sa création alors que la session continuait, si bien qu’un téléphone laissé de côté une semaine restait connecté et ne recevait plus rien. Une échéance que le client fixe est gardée telle quelle : le serveur peut la raccourcir, jamais l’allonger (RFC 8620 §7.2). Un abonné qui prend du retard apprend l’état actuel. Un abonné EventSource ou WebSocket qui prend du retard sur le bus d’événements du serveur reçoit un StateChange qui porte l’état actuel de chaque collection qu’il suit, celles de son propre compte et de chaque compte partagé sous la même garde, au lieu d’un trou qui n’était que journalisé. Une trame que le serveur ne peut pas servir répond avec la forme de la RFC 8887, {"@type": "RequestError", "type": "urn:ietf:params:jmap:error:…"} : notJSON pour une trame qui ne s’analyse pas, notRequest pour une trame sans @type ou d’un type que le serveur ne connaît pas, unknownCapability et limit comme en HTTP. Un client qui aiguille sur @type, comme la RFC le lui dit, les voit ; les anciennes trames {"type": "Error"} lui étaient invisibles. Dans les deux cas, le principe est le même : une notification push est un indice qu’une collection a changé d’état. Le client enchaîne avec Foo/changes pour récupérer le delta réel.

La fenêtre de rejeu et les deltas refusés

Le journal des changements est conservé 90 jours, et ce chiffre est publié, pas interne : la capacité urn:oximail:params:jmap:v2:streaming l’annonce en replayWindowSeconds, dérivé de la constante même par laquelle le worker de rétention purge. L’annonce ne peut donc pas dériver du mécanisme. Un client dont le sinceState pointe dans l’intervalle purgé est refusé, avec l’erreur de la RFC 8620 §5.2 :
Ce refus signifie : le delta n’est pas calculable, faites une resynchronisation complète. Ce n’est pas une panne passagère et réessayer n’y changera rien. Les objets créés puis détruits à l’intérieur de l’intervalle purgé sont irrécupérables depuis le journal, et un objet détruit là resterait sinon indéfiniment dans le cache du client, avec un Foo/get qui répond notFound. Traitez cannotCalculateChanges en jetant l’état mis en cache pour cette collection et en la re-interrogeant intégralement. Le plancher qui produit ce refus est enregistré par la purge elle-même, dans la même transaction que la suppression, par (organisation, compte, collection). C’est donc une propriété du stockage, et tous les consommateurs en héritent : toutes les méthodes Foo/changes, Push/catchup, le rejeu des push-patches, et le sync-collection de CalDAV et CardDAV, qui répond au même cas en exigeant une synchronisation complète. Un curseur au niveau du plancher reste servable : seul un curseur en dessous est refusé. Un compte qui n’a jamais subi de purge répond à n’importe quel curseur. Une collection calme garde son état. La purge garde le changement le plus récent de chaque collection : un dossier ou une liste de tâches que personne n’a touché depuis 90 jours répond normalement à ses /changes. Elle était vidée : son état retombait à 0 et sa numérotation repartait sous le plancher que la purge venait d’enregistrer, si bien que chaque /changes de cette collection était refusé pour de bon, et qu’un client posé entre le nouvel état et le plancher ne recevait rien. Les collections déjà dans ce cas ont été réparées à la mise à jour ; un curseur antérieur à la réparation se resynchronise une fois.
Être hors fenêtre est un cas attendu : un téléphone réinstallé, un portable de retour après une longue absence, un client DAV en pause. C’était être hors fenêtre en silence qui constituait le défaut : avant l’existence de ce plancher, un tel client recevait un delta partiel accompagné d’un newState frais, et en concluait qu’il était parfaitement synchronisé.

Un identifiant, une issue

Dans une même réponse Foo/changes, un identifiant apparaît dans au plus un des ensembles created, updated, destroyed, et une seule fois (RFC 8620 §5.2). Le stockage réduit les lignes brutes du journal à l’issue terminale sur la fenêtre demandée : créé puis mis à jour donne created ; créé puis détruit est omis entièrement, puisque le client n’a jamais vu l’objet ; mis à jour puis détruit donne destroyed ; détruit puis recréé donne updated. Un client n’a donc jamais à réconcilier un identifiant qui se prétend à la fois mis à jour et détruit, ni besoin de l’heuristique « updated intersecté avec notFound » pour retirer un objet.

Pour aller plus loin