legacy les fournit comme des couches de traduction au-dessus du même modèle de données JMAP : un changement de drapeau IMAP et un changement de mot-clé JMAP sont la même mutation, vue à travers deux protocoles. Il n’y a pas de second stockage de courrier qui pourrait dériver.
IMAP (RFC 9051)
La passerelle IMAP implémente IMAP4rev2 sur le port 993 (TLS implicite), et IMAP4rev1 (RFC 3501) pour les clients qui n’envoient jamaisENABLE IMAP4rev2, c’est-à-dire la plupart : les réponses dont la grammaire diffère entre les deux révisions suivent ce que la session a activé. Les points qui comptent pour un exploitant :
- Les mêmes données, en direct. Un message déplacé dans le webmail apparaît déplacé dans chaque session IMAP ; les changements faits par une connexion sœur sont annoncés correctement : une boîte qui rétrécit est annoncée par des lignes
EXPUNGE(numérotation de l’ancienne vue, en ordre décroissant), jamais par un simpleEXISTSdécroissant, que la RFC 9051 interdit et que les clients multi-connexions comme eM Client punissent d’une resynchronisation complète du dossier. - Resynchronisation incrémentale.
CONDSTOREetQRESYNCsont pris en charge, y compris les réponsesVANISHED: un client qui se reconnecte récupère des deltas au lieu de relister le dossier. - Des UID durables. L’unicité des UID par boîte est imposée par la couche de stockage ;
UID EXPUNGErespecte son ensemble d’UID. - Une identification honnête. La commande
IDannonce le nom du serveur et sa vraie version, et journalise l’identité déclarée du client : quand un client précis se comporte mal, les journaux peuvent le nommer. - Mots de passe d’application. Les clients qui ne peuvent pas suivre le flux de connexion web s’authentifient avec des mots de passe d’application par client ; les échecs alimentent fail2ban comme sur tous les autres écouteurs. Une session ouverte avec un mot de passe d’application dont la portée ouvre IMAP (
full,mail) voit les espaces de noms partagés décrits plus bas, comme avec le mot de passe du compte. Ils ne s’ouvraient qu’avec ce dernier : passer un client à un mot de passe d’application cachait les boîtes de l’équipe. - Quel appareil est lequel, dans le journal. La ligne de connexion IMAP porte la session, l’adresse du client et l’identifiant du mot de passe d’application qui l’a ouverte, et la ligne de fin de session nomme l’utilisateur au lieu de
-; les lignesAUTHde SMTP portent elles aussi l’identifiant du secret et l’adresse du client. Un client CalDAV ou CardDAV envoie ses identifiants à chaque requête : une ligne par requête noierait le journal. Une ligneDAV sign-inest donc écrite la première fois qu’un secret est vu depuis une adresse, puis au plus une fois par heure pour le même compte, le même secret et la même adresse. Elle nomme le protocole, le compte, le secret (l’identifiant d’un mot de passe d’application, ou le mot de passe du compte), l’adresse du client et le client que la requête annonce. - Ce qui est annoncé est servi. Une batterie de conformité passée en lecture seule sur une vraie boîte a montré un serveur qui répondait
OKà tout ; il refuse désormais ce qu’il ne comprend pas (une commande inconnue, un item deFETCHou deSTATUSinconnu, une date de recherche mal formée, un jeu de caractères non pris en charge, un numéro de séquence zéro, une boîte absente dansSTATUS), répondBADà une commande hors de son état (SEARCHavantSELECT,STATUSavantLOGIN), et tient les quatre capacités qu’il annonçait sans les servir :THREAD=REFERENCESetTHREAD=ORDEREDSUBJECTconstruisent un vrai arbre RFC 5256 depuis Message-ID, References et In-Reply-To au lieu d’une séquence plate,SEARCH MODSEQ,STATUS (SIZE)etQUOTA/GETQUOTAROOT(RES-STORAGE, adossé au quota de stockage) font ce qu’ils disent.EXAMINEhonoreQRESYNCcommeSELECT(une réouverture en lecture seule avec le dernier MODSEQ reçoitVANISHED (EARLIER)et les drapeaux modifiés), leOKtagué deSELECTditREAD-WRITE,BODYSTRUCTUREnomme les pièces jointes et dit la vérité sur leurs paramètres, et un littéral synchronisant ouLITERAL+est accepté dans toute commande (SEARCH SUBJECT {6}recevait unBADnu sans demande de continuation), un littéral refusé étant consommé pour que la session reste synchrone. - Les boîtes partagées apparaissent dans les clients IMAP. Une boîte qu’un collègue partage depuis le webmail est servie sous les deux espaces de noms RFC 2342 que le serveur déclare :
Other Users/<adresse du propriétaire>/…pour la boîte d’un compte humain partagée avec la session,Shared/<adresse>/…pour celle d’un compte d’équipe ;CAPABILITYajoute alorsACLetRIGHTS=texk,MYRIGHTSdérive du droit accordé, et une boîte personnelle dont le chemin coïncide avec un préfixe gagne pour tout verbe.CREATE,DELETEetRENAMEsous un préfixe sont refusés, une boîte non partagée ou inexistante estNonExistentdans tous les cas (jamais un oracle sur l’annuaire), et le segment du propriétaire est comparé à l’ensemble des propriétaires de la session, jamais résolu comme une adresse. La face se commute par instance avec[legacy] imap_shared_namespaces = "off" | "canary" | "on"(canaryla restreint aux adresses deimap_shared_namespaces_accounts), surchargeable par tenant à chaud avecoximail config set-override legacy.imap_shared_namespaces …et lue auLOGIN;[rights] enforce = falsel’emporte sur toute valeur. Face éteinte, les verbes ACL sont refusés plutôt qu’acquittés à vide (unDELETEACLrépondaitOKsans rien retirer), et\Deletedsur une boîte partagée exige le droit de supprimer, puisque le propriétaire purgera ce qu’un bénéficiaire a marqué. - Les clients IMAP4rev1 voient ce qui est nouveau.
\Recentest réel pour une session IMAP4rev1 : sonSELECTprend pour elle les messages qu’aucune session n’a encore ouverts, un message n’étant récent que pour une seule session, et le courrier qui arrive pendant que la boîte est ouverte revient à la première session qui le voit.EXAMINEles montre sans les prendre, et une session IMAP4rev2 ne prend rien.RECENTapparaît dansSELECT,NOOP,IDLE,APPENDetSTATUS,\Recentdans les réponses deFETCHet deSTORE,SEARCH RECENT,NEWetOLDfonctionnent, etSTORE \RecentestBAD;SELECTenvoieOK [UNSEEN n]. Les boîtes existantes sont parties de leurUIDNEXT. Une partiemessage/global(RFC 6532) est écrite dans la grammaire d’IMAP4rev1, qui ne connaît quemessage/rfc822comme message encapsulé : une telle session la reçoit comme un type de corps simple. Une session IMAP4rev2 reçoit la forme encapsulée avec son enveloppe interne, les sections y descendent, et la recherche lit son texte. \Draftest un drapeau permanent.STOREle pose et le retire (il répondaitNO, et les autres drapeaux de la commande étaient perdus avec lui), etPERMANENTFLAGSle liste. C’est le même mot-clé$draftstocké que lit JMAP, si bien queSEARCH DRAFT,Email/getetEmail/querysont d’accord. Il ne fait jamais d’un message un brouillon modifiable : seuls le dossier Brouillons et les changements d’état du serveur lui-même le font. UnAPPEND \Draftdans la boîte de réception, ou une règle qui ajoute$draftà la distribution, ne rend plus un message reçu modifiable et envoyable.LISTetLSUBsuivent LIST-EXTENDED (RFC 5258).LISTaccepte une liste de motifs, une optionRETURNinconnue estBAD,RECURSIVEMATCHrend le parent avecCHILDINFO, etLSUBavec%rend le niveau d’un enfant abonné en\Noselect. Une boîte supprimée parDELETEgarde son abonnement (RFC 3501 §6.3.6) :LIST (SUBSCRIBED)la montre\NonExistent,UNSUBSCRIBEla retire, et une nouvelle boîte du même nom le reprend.CREATEn’abonne plus la boîte qu’il crée (les abonnements existants sont gardés). Sur les espaces de noms partagés,LIST (SUBSCRIBED)etLSUBne listent que les abonnements de la personne qui consulte.SEARCH BODYetSEARCH TEXTpassent par l’index plein texte, comme la recherche du webmail, avec les mêmes conditions :BODYdans le corps seul,TEXTdans le sujet, les adressesFrom,ToetCcet le corps. Un mot situé au-delà des 256 premiers caractères du corps échappait à Thunderbird, Apple Mail ou Outlook.SORTetTHREADsélectionnent leurs messages avec le même analyseur et le même évaluateur queSEARCH. Sans réponse de l’index, l’aperçu répond et le journal dit pourquoi.LARGERà sa limite haute ne trouve rien (le calcul débordait et trouvait tous les messages), etSEARCH FROM,TO,CCetBCCtrouvent aussi le nom d’un groupe ;BCCne trouvait rien.SORTetTHREADsuivent les RFC 5256 et 5957.SORTconfiait ses clés au tri JMAP : sujet entier, adresse entière, aucune normalisation de casse,CCnon triable,DATEcomparée comme du texte d’un fuseau à l’autre. Les clés sont désormais le sujet de base, la partie boîte de l’adresse (ou le nom affiché pourDISPLAYFROMetDISPLAYTO), comparées selon les collations des RFC, les ex æquo dans l’ordre de la boîte ; un groupe se trie par son nom, et une clé inconnue estBAD.THREAD=REFERENCESlaisse lesReferencesd’un message remplacer le parent qu’un autre message lui avait donné, etTHREAD=ORDEREDSUBJECTregroupe les sujets de base quelle que soit leur casse. UnAPPENDgarde la date qu’il porte quand il n’a pas de liste de drapeaux, et chaque message d’unMULTIAPPENDgarde la sienne ; les deux arrivaient datés de « maintenant ».- Des adresses telles que l’en-tête les écrit.
ENVELOPEécrit les groupes d’adresses avec leurs marqueurs (RFC 3501 §7.4.2), une adresse sans domaine garde sa partie locale, une adresse écrite avec des commentaires se lit comme l’adresse seule, et une ancienne route source est gardée comme route de l’ENVELOPE. Les messages stockés avant ce changement ne sont relus que lorsqu’ils sont réanalysés depuis leur fichier brut (oximail email reparse-broken,oximail repair-address-headers). - Les changements d’une autre session arrivent.
NOOPetCHECKannoncent les drapeaux qu’une autre session a changés, etEXPUNGEce qu’une session sœur a effacé ;FLAGSliste les mots-clés utilisés dans la boîte, et un nouveau mot-clé est annoncé par* FLAGSavant leFETCHqui le montre.MOVEvers la boîte sélectionnée donne au message un nouvel UID (RFC 6851). La première commande qui active CONDSTORE après unSELECTsimple reçoit* OK [HIGHESTMODSEQ n], etESEARCH RETURN (MIN)ou(MAX)donne le MODSEQ du message qu’il rend (RFC 7162 §3.1.5).SELECT,EXAMINEouSTATUSsur un nom ASCII qui n’est pas de l’UTF-7 modifié valide répondNO [NONEXISTENT], là où unBADfaisait couper la connexion aux clients. - Les ports de courrier ne parlent que de courrier. Les ports 993, 143, 465, 25 et 587 partagent le certificat du serveur web, et acceptaient une négociation TLS qui proposait l’ALPN
http/1.1: on pouvait faire parler un navigateur avec eux (l’attaque ALPACA, RFC 9325 §3.8). Ils n’acceptent plus que leur propre protocole (imap;smtpetesmtp) et refusent toute autre offre avecno_application_protocol(RFC 7301 §3.2). Un client qui ne propose aucun ALPN, c’est-à-dire tous les clients de courrier en usage, est servi comme avant. Avant l’authentification, la quatrième ligne illisible ou commande inconnue met fin à la session (BYEen IMAP,421en SMTP) : les lignes d’en-tête d’une requête détournée ne peuvent plus faire répondre une ligne de son corps. Une commande connue envoyée hors de son état reste un simpleBAD. Sur le port 143, IMAP n’annonce plusAUTH=PLAIN SASL-IRavantSTARTTLS, oùLOGINetAUTHENTICATEsont de toute façon refusés et où un client SASL-IR enverrait ses identifiants en clair dès sa première ligne, et n’envoie plus de second message d’accueil après la négociation. - Un compte suspendu cesse de lire. L’état du compte est relu sur une session IMAP ouverte, si bien qu’une suspension ferme la porte à une connexion qui la précède, au lieu de servir la boîte jusqu’au délai d’inactivité.
- Le journal n’enregistre plus à quelle vitesse un client lit. Les trois lignes par message ouvert (commande, début du transfert, fin du transfert) qui faisaient 95 % du journal sur une box avec un client actif ont disparu ; le résumé de session porte ce dont un exploitant a besoin.
CalDAV (RFC 4791) et CardDAV (RFC 6352)
Les agendas, les listes de tâches et les carnets d’adresses sont servis en HTTPS sur l’écouteur principal, sous les préfixes de chemin configurés. Les passerelles traduisent vers les mêmes objets JMAP que le webmail édite, et chaque mutation est inscrite au journal de changements : JMAP/changes, la sync-collection de la RFC 6578 et le push voient immédiatement une écriture DAV.
Comportements d’interopérabilité vérifiés contre de vrais clients (Apple Calendar, Thunderbird, eM Client 10.4) :
- Amorçage à la racine du serveur. Certains clients ne retiennent que le nom d’hôte de l’autodécouverte et sondent
OPTIONS/PROPFINDsur/. La racine annonce les classes de conformité DAV combinées et répond à unPROPFINDauthentifié aveccurrent-user-principal; chaque principal annonce aussi le home set de l’autre service, si bien qu’une seule passe de découverte trouve agendas, listes de tâches et carnets d’adresses. - Du XML sûr pour les parseurs stricts. Les contenus textuels qui embarquent du HTML ou un
&nu sont enveloppés en CDATA : une seule description d’événement décorée ne peut pas rendre tout un multiget illisible et tuer la synchronisation. - Des ETag en empreinte de contenu. L’ETag d’un VEVENT change exactement quand sa représentation change, y compris quand une exception de récurrence est ajoutée depuis un autre client : un
If-Matchpérimé échoue en412et le client se resynchronise au lieu d’écraser en silence le changement qu’il n’a jamais vu. Idem pour les VTODO. - Un filtrage côté serveur réel.
calendar-queryévaluetime-range(conscient des récurrences, sémantique de chevauchement),prop-filter,param-filterettext-match; une forme de filtre que le serveur ne modélise pas dégrade vers un résultat trop inclusif, jamais vers un résultat vide en silence.free-busy-queryrenvoie un vraiVFREEBUSY. - Des fuseaux qui font l’aller-retour.
DTSTART;TZID=est émis avec le composantVTIMEZONEcorrespondant (généré depuis la base IANA, 597 zones), les noms de fuseaux du registre Windows émis par Outlook/Exchange sont normalisés vers IANA sur chaque chemin d’ingestion, et les propriétés paramétrées à la façon d’Outlook (SUMMARY;LANGUAGE=fr-FR:...) s’analysent correctement. - Des collections créées par le client.
MKCALENDARà une URL choisie par le client fonctionne (Apple Calendar et Thunderbird créent leurs collections à une URL en UUID de leur choix). - Des alarmes modélisées. Les composants
VALARMdes VEVENT et des VTODO s’importent dans le modèle d’alertes et sont réémis au GET, y compris les déclencheurs absolus (VALUE=DATE-TIME). Les flux ICS publics ne portent volontairement aucun VALARM : un abonné ne doit pas hériter des rappels de l’éditeur. - Un rapport que le serveur ne sait pas nommer est refusé. Un
REPORTdont le corps ne s’analyse pas, qui nomme un rapport inconnu ou qui est vide recevait un207contenant toute la collection (896 événements et 451 fiches mesurés, réponses identiques dans les trois cas), si bien qu’un client dont le filtre portait une faute synchronisait tout, en silence. Un corps malformé est400(RFC 4918 §8.2) et un rapport non implémenté403avec la préconditionsupported-report(RFC 3253 §3.6), jamais un multistatus. Un segment d’URL qui n’est pas un compte n’est pas un principal (PROPFIND /caldav/not-a-uuid/répondait207avec une collection racine nommée d’après l’URL), et l’en-têteDAV:annonce1, 3aveccalendar-access,addressbooketsync-collection, plus la classe2, qui promettait unLOCKet unUNLOCKque personne ne servait. - Les agendas partagés, et seulement ce qu’ils montrent. Un agenda partagé à un autre compte du même serveur est atteignable en CalDAV, le comportement du propriétaire restant inchangé, un partage en lecture seule s’annonce en lecture seule, et la confidentialité d’un agenda partagé s’applique sur chaque site de rendu CalDAV,
REPORT,GET .icsetsync-collectioncompris, avec la même réduction queCalendarEvent/get. LeVFREEBUSYest calculé par la même fonction quePrincipal/getAvailability, là où il en portait une copie écrite à la main. - Renommer et recolorer depuis le client. Un
PROPPATCH(RFC 4918 §9.2) sur un agenda ou une liste de tâches règledisplayname,calendar-description,calendar-color,calendar-orderet, sur un agenda, son fuseau (calendar-timezone,calendar-timezone-id). La modification est atomique, avec un statut par propriété :403pour une propriété protégée,409pour une valeur invalide,424pour les autres propriétés d’une modification qui a échoué. Elle passe par la même écriture queCalendar/setetTaskList/set: le changement est journalisé et poussé aux autres appareils. Un carnet d’adresses répond403à chaque propriété ; l’en-têteAllownommePROPPATCHpartout où il est servi. - Une requête répond à ce qu’elle demande. Le composant d’une
calendar-querydécide : un filtreVTODOsur un agenda d’événements ne répond rien, et un filtreVALARMimbriqué ne s’applique plus à l’événement lui-même. Une collation est respectée, ou refusée avecsupported-collation; un filtre ou une borne de temps malformés donnentvalid-filter.sync-collectionrespecteDAV:limit(une réponse incrémentale est coupée puis continuée avec507, une réponse initiale au-delà de la limite est refusée avecnumber-of-matches-within-limits), refuse un jeton venu du futur et répond500à une panne du stockage au lieu d’une resynchronisation complète.If-MatchetIf-None-Matchacceptent des listes, en comparaison forte ou faible, unGETpeut répondre304, etDepth: infinityest refusé avecpropfind-finite-depth. Une requête bornée à une semaine lit cette semaine, et non tout l’agenda (2,1 ms au lieu de 71 ms sur 10 000 événements). - Ce qu’un client a enregistré revient. Chaque propriété hors des champs modélisés (
URL,GEO, les propriétésX-) est resservie telle qu’enregistrée, etLOCATION,CATEGORIESetCOLORsont modélisées ; les lignes vCard que la conversion ne modélise pas sont conservées, dans leur ordre. UnPUTrépond l’ETagqu’unGETservira. Un agenda absent répond404àPROPFINDetREPORT, et409à unPUTdedans ; un UID déjà utilisé dans un autre agenda donneno-uid-conflict, unVTODOdans un agenda d’événementssupported-calendar-component. - Une suppression atteint les autres appareils. Un événement, une tâche ou une fiche supprimés seuls, par CalDAV, CardDAV ou JMAP, sont annoncés à
sync-collectionsous le href que les clients connaissent. Avant, l’annonce portait un nom interne qu’aucun client ne reconnaissait, et l’objet restait sur les autres appareils. Un objet détruit que le serveur ne sait pas nommer répondvalid-sync-token(une resynchronisation complète) plutôt qu’un faux href. - Une série sans fin dure. Une règle sans
COUNTcessait d’être déroulée un an après sa première occurrence : la série disparaissait des requêtes par période, des disponibilités et des alarmes. Elle est désormais parcourue à partir de la fenêtre demandée. Une occurrence retirée (une exclusion, une annulation, unEXDATE) n’est plus occupée, ni alarmée, ni retenue par untime-range. - N’importe quel préfixe XML. Les corps DAV sont lus par espace de noms : un
sync-tokenou unMKCALENDARsous un autre préfixe queD:est compris. UnDOCTYPEest refusé, et un corps de synchronisation ou deMKCALENDARmalformé donne400.
Points d’accès d’autodécouverte
Quatre familles de points d’accès configurent les clients automatiquement (adossées aux enregistrements DNS de la page d’authentification) :
Ces points d’accès sont anonymes et préremplissent l’adresse sur laquelle un client s’interroge (
email= pour le profil, emailaddress pour l’autoconfig, <EMailAddress> dans le corps Autodiscover). Une valeur qui n’est pas une adresse (un espace, un caractère de contrôle, <, > ou ") est refusée en 400, et chaque valeur écrite dans le document, nom de l’organisation compris, est échappée. Sans cela, un lien piégé vers le vrai serveur pouvait servir, depuis l’hôte même de l’organisation, un profil désignant un autre serveur de messagerie. Une adresse avec ' ou & (o'brien@...) est acceptée et échappée.
Derrière un proxy inverse, faites passer tout vers OxiMail sauf le chemin de défi ACME : l’extrait généré par l’assistant fait exactement cela. Une liste blanche de routes, ou une interception des
OPTIONS/CORS au niveau du proxy, casse en silence la découverte et le DAV ; voir Premier démarrage.Essais de mot de passe : ralentis par compte, jamais verrouillés
Cinq portes reçoivent une adresse et un secret en clair : IMAP, SMTPAUTH, ManageSieve, CalDAV/CardDAV en Basic, et POST /auth/login. Elles partagent une seule vérification, qui essaie d’abord les mots de passe d’application du compte puis le mot de passe du compte, et un seul compteur d’échecs par compte, tenu en base.
Les 5 premiers échecs ne coûtent rien. Au-delà, aucune tentative n’est vérifiée avant un délai qui double à chaque échec, de 1 seconde jusqu’à 60 secondes ; un échec de plus de 24 heures ne compte plus, et une réussite remet le compteur à zéro. Un refus pendant le délai ressemble exactement à un mauvais mot de passe, il ne révèle donc rien. Une attaque par devinettes répartie sur de nombreuses adresses est tenue à une tentative par minute et par compte au plus, là où le bannissement par adresse de fail2ban ne la voyait jamais. Le titulaire du compte n’est jamais verrouillé : un verrou serait une arme contre lui. Ce sont des valeurs fixes, pas des réglages.
Ne sont ni ralentis ni comptés : une adresse de [security] trusted_ips, un client dont le secret a été vérifié dans les 5 dernières minutes (il détient le bon secret), et une connexion par passkey. Les bannissements par adresse de fail2ban et la limite de /auth/login restent ce qu’ils étaient. Un changement de mot de passe, la révocation d’un mot de passe d’application ou le gel d’un compte prennent effet à la requête suivante, pas à la fin des 5 minutes.
Ce que le mode legacy ne change pas
Les passerelles sont des chemins d’accès en lecture-écriture, pas un serveur parallèle : l’authentification, le magasin de blobs, l’isolation par organisation, fail2ban et la limitation de débit sont la même mécanique que pour JMAP. Désactiver[legacy] retire les écouteurs et les préfixes de routes ; les données restent intactes.