Skip to main content
OxiMail est d’abord un serveur JMAP, mais les déploiements réels comptent des Thunderbird, Apple Mail, eM Client et des clients mobiles qui parlent les protocoles classiques. La fonctionnalité 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 jamais ENABLE 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 simple EXISTS dé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. CONDSTORE et QRESYNC sont pris en charge, y compris les réponses VANISHED : 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 EXPUNGE respecte son ensemble d’UID.
  • Une identification honnête. La commande ID annonce 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 lignes AUTH de 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 ligne DAV sign-in est 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 de FETCH ou de STATUS inconnu, 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 dans STATUS), répond BAD à une commande hors de son état (SEARCH avant SELECT, STATUS avant LOGIN), et tient les quatre capacités qu’il annonçait sans les servir : THREAD=REFERENCES et THREAD=ORDEREDSUBJECT construisent un vrai arbre RFC 5256 depuis Message-ID, References et In-Reply-To au lieu d’une séquence plate, SEARCH MODSEQ, STATUS (SIZE) et QUOTA/GETQUOTAROOT (RES-STORAGE, adossé au quota de stockage) font ce qu’ils disent. EXAMINE honore QRESYNC comme SELECT (une réouverture en lecture seule avec le dernier MODSEQ reçoit VANISHED (EARLIER) et les drapeaux modifiés), le OK tagué de SELECT dit READ-WRITE, BODYSTRUCTURE nomme les pièces jointes et dit la vérité sur leurs paramètres, et un littéral synchronisant ou LITERAL+ est accepté dans toute commande (SEARCH SUBJECT {6} recevait un BAD nu 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 ; CAPABILITY ajoute alors ACL et RIGHTS=texk, MYRIGHTS dérive du droit accordé, et une boîte personnelle dont le chemin coïncide avec un préfixe gagne pour tout verbe. CREATE, DELETE et RENAME sous un préfixe sont refusés, une boîte non partagée ou inexistante est NonExistent dans 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" (canary la restreint aux adresses de imap_shared_namespaces_accounts), surchargeable par tenant à chaud avec oximail config set-override legacy.imap_shared_namespaces … et lue au LOGIN ; [rights] enforce = false l’emporte sur toute valeur. Face éteinte, les verbes ACL sont refusés plutôt qu’acquittés à vide (un DELETEACL répondait OK sans rien retirer), et \Deleted sur 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. \Recent est réel pour une session IMAP4rev1 : son SELECT prend 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. EXAMINE les montre sans les prendre, et une session IMAP4rev2 ne prend rien. RECENT apparaît dans SELECT, NOOP, IDLE, APPEND et STATUS, \Recent dans les réponses de FETCH et de STORE, SEARCH RECENT, NEW et OLD fonctionnent, et STORE \Recent est BAD ; SELECT envoie OK [UNSEEN n]. Les boîtes existantes sont parties de leur UIDNEXT. Une partie message/global (RFC 6532) est écrite dans la grammaire d’IMAP4rev1, qui ne connaît que message/rfc822 comme 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.
  • \Draft est un drapeau permanent. STORE le pose et le retire (il répondait NO, et les autres drapeaux de la commande étaient perdus avec lui), et PERMANENTFLAGS le liste. C’est le même mot-clé $draft stocké que lit JMAP, si bien que SEARCH DRAFT, Email/get et Email/query sont 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. Un APPEND \Draft dans 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.
  • LIST et LSUB suivent LIST-EXTENDED (RFC 5258). LIST accepte une liste de motifs, une option RETURN inconnue est BAD, RECURSIVEMATCH rend le parent avec CHILDINFO, et LSUB avec % rend le niveau d’un enfant abonné en \Noselect. Une boîte supprimée par DELETE garde son abonnement (RFC 3501 §6.3.6) : LIST (SUBSCRIBED) la montre \NonExistent, UNSUBSCRIBE la retire, et une nouvelle boîte du même nom le reprend. CREATE n’abonne plus la boîte qu’il crée (les abonnements existants sont gardés). Sur les espaces de noms partagés, LIST (SUBSCRIBED) et LSUB ne listent que les abonnements de la personne qui consulte.
  • SEARCH BODY et SEARCH TEXT passent par l’index plein texte, comme la recherche du webmail, avec les mêmes conditions : BODY dans le corps seul, TEXT dans le sujet, les adresses From, To et Cc et le corps. Un mot situé au-delà des 256 premiers caractères du corps échappait à Thunderbird, Apple Mail ou Outlook. SORT et THREAD sélectionnent leurs messages avec le même analyseur et le même évaluateur que SEARCH. 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), et SEARCH FROM, TO, CC et BCC trouvent aussi le nom d’un groupe ; BCC ne trouvait rien.
  • SORT et THREAD suivent les RFC 5256 et 5957. SORT confiait ses clés au tri JMAP : sujet entier, adresse entière, aucune normalisation de casse, CC non triable, DATE comparé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é pour DISPLAYFROM et DISPLAYTO), 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 est BAD. THREAD=REFERENCES laisse les References d’un message remplacer le parent qu’un autre message lui avait donné, et THREAD=ORDEREDSUBJECT regroupe les sujets de base quelle que soit leur casse. Un APPEND garde la date qu’il porte quand il n’a pas de liste de drapeaux, et chaque message d’un MULTIAPPEND garde 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. NOOP et CHECK annoncent les drapeaux qu’une autre session a changés, et EXPUNGE ce qu’une session sœur a effacé ; FLAGS liste les mots-clés utilisés dans la boîte, et un nouveau mot-clé est annoncé par * FLAGS avant le FETCH qui le montre. MOVE vers la boîte sélectionnée donne au message un nouvel UID (RFC 6851). La première commande qui active CONDSTORE après un SELECT simple reçoit * OK [HIGHESTMODSEQ n], et ESEARCH RETURN (MIN) ou (MAX) donne le MODSEQ du message qu’il rend (RFC 7162 §3.1.5). SELECT, EXAMINE ou STATUS sur un nom ASCII qui n’est pas de l’UTF-7 modifié valide répond NO [NONEXISTENT], là où un BAD faisait 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 ; smtp et esmtp) et refusent toute autre offre avec no_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 (BYE en IMAP, 421 en 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 simple BAD. Sur le port 143, IMAP n’annonce plus AUTH=PLAIN SASL-IR avant STARTTLS, où LOGIN et AUTHENTICATE sont 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 / PROPFIND sur /. La racine annonce les classes de conformité DAV combinées et répond à un PROPFIND authentifié avec current-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-Match périmé échoue en 412 et 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 évalue time-range (conscient des récurrences, sémantique de chevauchement), prop-filter, param-filter et text-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-query renvoie un vrai VFREEBUSY.
  • Des fuseaux qui font l’aller-retour. DTSTART;TZID= est émis avec le composant VTIMEZONE correspondant (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 VALARM des 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 REPORT dont le corps ne s’analyse pas, qui nomme un rapport inconnu ou qui est vide recevait un 207 contenant 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é est 400 (RFC 4918 §8.2) et un rapport non implémenté 403 avec la précondition supported-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épondait 207 avec une collection racine nommée d’après l’URL), et l’en-tête DAV: annonce 1, 3 avec calendar-access, addressbook et sync-collection, plus la classe 2, qui promettait un LOCK et un UNLOCK que 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 .ics et sync-collection compris, avec la même réduction que CalendarEvent/get. Le VFREEBUSY est calculé par la même fonction que Principal/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ègle displayname, calendar-description, calendar-color, calendar-order et, sur un agenda, son fuseau (calendar-timezone, calendar-timezone-id). La modification est atomique, avec un statut par propriété : 403 pour une propriété protégée, 409 pour une valeur invalide, 424 pour les autres propriétés d’une modification qui a échoué. Elle passe par la même écriture que Calendar/set et TaskList/set : le changement est journalisé et poussé aux autres appareils. Un carnet d’adresses répond 403 à chaque propriété ; l’en-tête Allow nomme PROPPATCH partout où il est servi.
  • Une requête répond à ce qu’elle demande. Le composant d’une calendar-query décide : un filtre VTODO sur un agenda d’événements ne répond rien, et un filtre VALARM imbriqué ne s’applique plus à l’événement lui-même. Une collation est respectée, ou refusée avec supported-collation ; un filtre ou une borne de temps malformés donnent valid-filter. sync-collection respecte DAV:limit (une réponse incrémentale est coupée puis continuée avec 507, une réponse initiale au-delà de la limite est refusée avec number-of-matches-within-limits), refuse un jeton venu du futur et répond 500 à une panne du stockage au lieu d’une resynchronisation complète. If-Match et If-None-Match acceptent des listes, en comparaison forte ou faible, un GET peut répondre 304, et Depth: infinity est refusé avec propfind-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és X-) est resservie telle qu’enregistrée, et LOCATION, CATEGORIES et COLOR sont modélisées ; les lignes vCard que la conversion ne modélise pas sont conservées, dans leur ordre. Un PUT répond l’ETag qu’un GET servira. Un agenda absent répond 404 à PROPFIND et REPORT, et 409 à un PUT dedans ; un UID déjà utilisé dans un autre agenda donne no-uid-conflict, un VTODO dans un agenda d’événements supported-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-collection sous 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épond valid-sync-token (une resynchronisation complète) plutôt qu’un faux href.
  • Une série sans fin dure. Une règle sans COUNT cessait 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, un EXDATE) n’est plus occupée, ni alarmée, ni retenue par un time-range.
  • N’importe quel préfixe XML. Les corps DAV sont lus par espace de noms : un sync-token ou un MKCALENDAR sous un autre préfixe que D: est compris. Un DOCTYPE est refusé, et un corps de synchronisation ou de MKCALENDAR malformé donne 400.

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, SMTP AUTH, 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.