oximail-mail est le crate de domaine JMAP Mail. Il implémente JMAP Mail (RFC 8621) au-dessus de la machinerie de dispatch, de capacités et de stockage fournie par oximail-core et oximail-store. Il possède le modèle de données mail (Mailbox, Email, Thread, EmailSubmission, Identity, VacationResponse), les handlers de méthode JMAP pour chacun, l’unique chemin d’ingestion vers lequel tout message entrant ou créé localement converge, et le chemin de lecture par projection de colonnes qui ne matérialise que les colonnes lues par un consommateur donné.
Vue d’ensemble
Un client JMAP gère sa boîte mail via les méthodes de ce crate : il liste les dossiers avecMailbox/get, récupère les messages avec Email/get, recherche avec Email/query, regroupe les conversations avec Thread/get, et envoie avec Email/send ou EmailSubmission/set. Chaque handler implémente le trait JmapMethod de oximail-core et est enregistré par le binaire. Le dispatcher route une requête en lot vers lui après avoir résolu les back-references, validé le compte et vérifié les capacités.
Le crate se situe au-dessus de oximail-core et oximail-store, et ne s’adresse jamais directement à un autre crate de domaine. Quand un message entrant contient une invitation calendrier, oximail-mail n’appelle pas le crate calendrier : il émet un DomainEvent sur l’EventBus, et l’abonné calendrier réagit. Cela maintient les crates mail et calendrier découplés (ADR-011).
Tout le stockage passe par les traits Store, BlobStore et SearchStore. Chaque méthode porte donc le tenant_id, et un handler ne peut physiquement pas lire le courrier d’une autre organisation (ADR-015). Les octets RFC 5322 bruts vivent dans le blob store adressé par contenu. Les métadonnées analysées vivent dans le store structuré. Le message est analysé une seule fois, à l’ingestion, et jamais ré-analysé à la lecture (ADR-009).
Fonctions
- Mailbox (RFC 8621 §2) : dossiers avec rôles (
inbox,sent,drafts,trash,junk,archive), hiérarchie, compteurs par dossier, état d’abonnement et droits de partage RFC 9670. - Email (RFC 8621 §4) : l’objet message, dont les métadonnées (
from,to,subject,preview,hasAttachment,threadId, mots-clés) sont précalculées à l’ingestion, le corps étant extrait à la demande depuis le blob brut. - Thread (RFC 8621 §3) : l’agrégat de conversation, une liste ordonnée d’identifiants d’emails regroupés par recherche de chaîne Message-Id (ADR-019).
- EmailSubmission (RFC 8621 §7) : la demande d’envoi, avec envoi programmé et l’effet de bord
onSuccessUpdateEmailappliqué après la livraison. - Identity (RFC 8621 §6) : les adresses d’expéditeur qu’un compte peut utiliser, avec signatures et champs de profil étendus.
- VacationResponse (RFC 8621 §8) : le singleton de réponse automatique par compte.
- Le chemin d’ingestion : un unique point de convergence
ingest_emailpour tout message entrant ou créé localement (SMTP entrant, soumission SMTP,Email/import,Email/set createet migration). - Chemin de lecture par projection de colonnes (ADR-062) : un unique registre déclaratif de colonnes pilote à la fois quelles colonnes SQL une lecture doit faire avec
SELECTet à quels groupes de propriétés la demande d’un client se résout, de sorte que chaque lecture ne matérialise que ce dont elle a besoin. - Le pont v1 vers v2 :
Email/getetEmail/setacceptent la forme v1 RFC 8621, la forme v2 modernisée, ou les deux à la fois.
Comment ça marche
Le modèle de données
Chaque type est une structure serde indexée par unId UUIDv7. Mailbox porte son rôle, son parent, ses compteurs et ses droits de partage. Email porte les métadonnées précalculées ; le corps (textBody, htmlBody, bodyValues, bodyStructure) n’est pas stocké en colonnes mais extrait à la demande depuis le blob brut quand un client le réclame. Thread est l’agrégat léger, juste une liste ordonnée email_ids, car le travail de regroupement a lieu à l’ingestion. VacationResponse est un singleton : son identifiant vaut toujours la chaîne "singleton", puisqu’il n’y en a qu’un par compte.
Ingestion : un seul chemin, entièrement vérifié
ingest_email est l’unique point de convergence pour toutes les façons qu’un message a d’entrer dans le système. Il analyse le MIME une seule fois (avec limites de profondeur et de nombre de parties : un message trop complexe est rejeté, pas tronqué en silence), stocke les octets RFC 5322 bruts comme blob adressé par contenu ainsi que chaque partie MIME comme son propre blob, résout le thread par Message-Id, In-Reply-To et References, écrit les métadonnées structurées, indexe le texte recherchable dans Tantivy, recalcule les compteurs de threads du dossier, enregistre les changements JMAP pour Email, Mailbox et Thread, et émet les événements StateChanged et EmailIngested correspondants. Une partie text/calendar émet en plus ITipReceived pour l’abonné calendrier.
Le chemin est protégé par un test de checklist explicite : chaque étape de ingest_email est vérifiée par test_ingest_full_checklist, donc une étape ajoutée sans assertion correspondante fait échouer la suite. C’est la discipline derrière l’ADR-017 : tout message accepté par le serveur doit finir stocké quelque part.
Le chemin de lecture par projection de colonnes
Une lecture naïve charge chaque colonne de la ligneemails même quand l’appelant n’a besoin que des drapeaux. Le chemin de projection (ADR-062) évite cela. Un unique registre déclaratif, EMAIL_COLUMNS, est la source de vérité : chaque entrée associe une colonne SQL à son type d’accesseur (obligatoire ou optionnel, gestion du NULL), à son groupe de stockage et à son sélecteur de propriété de transport.
Deux consommateurs lisent ce registre. email_select_columns construit la liste SELECT pour une projection donnée, en n’utilisant que des noms de colonnes statiques, de sorte qu’aucune chaîne client n’est jamais concaténée dans une requête. EmailProjection::from_jmap_properties résout l’ensemble de properties demandé par un client vers l’ensemble minimal de groupes qui les portent. MailStore::get_emails_projected réunit les deux : il construit le SELECT étroit et analyse le résultat par nom de colonne, en tolérant tout sous-ensemble ou tout ordre de colonnes.
Un ensemble obligatoire de 16 colonnes (celles qui alimentent le parser, la logique de sécurité et de dérivation, le descripteur de chiffrement et l’état de cycle de vie) est toujours matérialisé, quelle que soit la projection. Tout le reste (le corps inline, le JSON d’en-têtes, les listes d’adresses d’enveloppe, les références, l’aperçu, les jetons de recherche, les résultats d’authentification) n’est chargé que quand un groupe le sélectionne. La sortie de transport est identique octet pour octet à une lecture complète ; la projection ne fait que réduire les colonnes récupérées.
Le chemin de projection est en lecture seule. Son gain est la réduction du chargement de colonnes : une requête qui ne lit que les drapeaux cesse de charger le JSON d’en-têtes, le corps inline, l’enveloppe, les références, l’aperçu, les jetons de recherche et les résultats d’authentification. Chaque valeur émise est identique à une matérialisation complète. L’état de suivi des changements par message (modseq) n’est délibérément pas sur la surface de projection : il est lu par une requête latérale dédiée, de sorte que la projection ne peut jamais affecter la sortie de suivi des changements.
Le pont v1 vers v2
OxiMail annonce deux générations de capacité mail côte à côte : la capacité standardurn:ietf:params:jmap:mail (v1, RFC 8621) et l’extension mail modernisée d’OxiMail, urn:oximail:params:jmap:v2:mail (l’extension JMAP mail modernisée d’OxiMail, un Internet-Draft en cours). La surface v2 ne remplace pas la v1 : le même handler enregistre l’URN v2 comme capacité additionnelle. Email/get et Email/set acceptent donc la forme v1 RFC 8621, la forme v2 modernisée, ou les deux à la fois.
Les capacités mail v1 et v2 ne sont pas mutuellement exclusives. Le dispatcher du core tient une liste de paires v1 et v2 mutuellement exclusives, mais cette liste est vide sur la ligne actuelle : mail a abandonné sa paire. Une requête qui liste à la fois
urn:ietf:params:jmap:mail et urn:oximail:params:jmap:v2:mail dans using est acceptée, et la réponse est l’union des propriétés v1 et v2. Un client v2 qui lit les propriétés modernisées userFlags, folder ou state n’a pas à annoncer la capacité v1.draft vers sending vers sent, avec scheduled, failed, cancelled), des drapeaux utilisateur natifs, une famille d’audit et un vecteur de causalité. Les mots-clés v1 ($seen, $flagged, $answered) et les userFlags v2 convergent vers une seule source de vérité stockée : un client v1 qui lit keywords et un client v2 qui lit userFlags voient les mêmes faits, dérivés de la même colonne.
Envoi : la soumission après la livraison
Email/send et EmailSubmission/set créent la demande d’envoi. Le crate définit le trait SubmissionSender (implémenté par la file de livraison du crate SMTP) et le trait RecipientExpander (implémenté par l’extension de groupes de distribution), tous deux déclarés ici pour éviter une dépendance circulaire : le crate mail possède le contrat, les autres crates l’implémentent.
L’effet de bord onSuccessUpdateEmail, le patch qui déplace un brouillon envoyé vers le dossier Sent et bascule ses mots-clés, est appliqué après la fin de la livraison, pas avant. C’est le correctif du bug send-to-self de Stalwart : le dedup tient compte du contexte de la boîte, et la mise à jour post-livraison ne laisse jamais un message vers soi-même se faire avaler par une correspondance Message-Id prématurée.
Garanties de correction
Voici des garanties que le crate respecte, chacune liée à un échec passé précis qu’il refuse de répéter :- Le message vers soi-même n’est jamais perdu en silence. Le dedup tient compte du contexte de la boîte et
onSuccessUpdateEmails’exécute après la livraison, donc un message adressé à son propre compte est livré et stocké au lieu d’être dédupliqué. - Les identifiants invalides vont dans
notFound. Tout identifiant d’Email/getqui ne s’analyse pas ou n’existe pas est signalé dans le tableaunotFound, jamais filtré en silence. hasAttachmentlit le drapeau stocké. Le filtrehasAttachmentd’Email/queryet la propriété d’Email/getutilisent le drapeau de pièce jointe calculé à partir des vraies parties MIME à l’ingestion, pas un balayage du texte du corps extrait.- Un échec de déchiffrement remonte, il ne se cache pas. Si un corps ne peut pas être déchiffré à la lecture,
Email/getrenvoie unserverFail, pas une réponse réduite aux métadonnées qui ressemblerait à un message légitimement vide.
Invariants
- Ne jamais échouer en silence. Les identifiants non analysables ou inconnus atterrissent dans
notFound; un message accepté est toujours stocké (ADR-017) ; un échec de déchiffrement renvoie une erreur, jamais un corps vide. - Analyser une seule fois. Le message est analysé à l’ingestion et stocké à la fois en octets bruts et en métadonnées structurées ; les lectures ne ré-analysent jamais (ADR-009).
- Un seul mécanisme de lecture.
Email/getet les chemins de lecture IMAP passent tous par le même constructeur de projection, donc la liste de colonnes et le parser de ligne ne dérivent jamais vers deux implémentations. - Aucun import inter-domaines. L’intégration calendrier passe par l’
EventBus, jamais par un import direct.
Configuration
oximail-mail ne lit aucun fichier de configuration directement ; il consomme les stores et les limites que le binaire résout. Les réglages côté opérateur qui influencent la surface mail (la taille maximale de message, les plafonds de volume par collection, les chemins de base des blobs et de la recherche, et la clé de chiffrement au repos) sont documentés dans la référence de configuration. Le modèle de chiffrement au repos derrière les blobs de message (le schéma façon LUKS, pas du bout en bout) est traité dans chiffrement au repos.
Surface protocolaire / JMAP
oximail-mail implémente les méthodes JMAP Mail. Elles sont annoncées sous urn:ietf:params:jmap:mail (v1) et, le cas échéant, urn:oximail:params:jmap:v2:mail (v2).
| Méthode | Rôle |
|---|---|
Mailbox/get, /set, /query, /queryChanges, /changes | RFC 8621 §2 : dossiers, rôles, hiérarchie, compteurs, partage. |
Email/get, /set, /query, /queryChanges, /changes | RFC 8621 §4 : récupérer, muter, rechercher, paginer et comparer les messages. |
Email/import | RFC 8621 §4.8 : importer un message RFC 5322 brut dans une boîte. |
Email/parse | RFC 8621 §4.9 : analyser un blob en structure Email sans le stocker. |
Email/send | Envoi de confort (créer le message et le soumettre en un seul appel). |
Email/cancel, Email/wake, Email/reindex | Cycle de vie v2 : annuler un envoi programmé, réveiller un message en sommeil, reconstruire l’index de recherche d’un message. |
EmailSubmission/get, /set, /query, /changes | RFC 8621 §7 : demandes d’envoi, envoi programmé, onSuccessUpdateEmail. |
Thread/get, /changes, /queryChanges | RFC 8621 §3 : l’agrégat de conversation. |
Identity/get, /set, /changes | RFC 8621 §6 : identités d’expéditeur et signatures. |
VacationResponse/get, /set | RFC 8621 §8 : le singleton de réponse automatique par compte. |
SearchSnippet/get | RFC 8621 §5 : extraits de correspondance surlignés pour une requête. |
Quota/get | Quota de stockage par compte. |
Preferences/get, /set | Singleton de préférences utilisateur. |
Décisions de conception
- ADR-009 (stocker brut plus structuré) : le message est analysé une fois à l’ingestion ; les octets RFC 5322 bruts vont au blob store et les métadonnées analysées au store structuré, donc
hasAttachment,previewetthreadIdsont calculés une fois, jamais à la lecture. - ADR-014 (dedup de blob par hash de contenu) : le message brut et chaque partie MIME sont adressés par
SHA-256du contenu, donc des pièces jointes identiques partagent un seul blob ; deux messages avec le même Message-Id dans des boîtes différentes restent deux objets Email distincts. - ADR-019 (thread par chaîne Message-Id) : les threads sont résolus par recherche exacte de la chaîne Message-Id stockée en texte clair, pas un hash, donc le risque de collision est nul.
- ADR-017 (un email accepté doit être livré) : tout message accepté par le serveur finit stocké ; le test de checklist de
ingest_emailvérifie chaque étape du chemin. - ADR-062 (cohabitation stockage RFC 5322 brut plus structuré, brouillon/projection) : le chemin de lecture par projection de colonnes et la cohabitation du corps de brouillon v2 ; un brouillon v2 garde son corps dans des colonnes natives jusqu’à ce qu’
Email/sendsérialise un blob à la frontière SMTP. - ADR-011 (bus d’événements inter-domaines) : l’intégration calendrier est un événement
ITipReceived, pas un appel direct vers le crate calendrier.
Standards
- RFC 8621 : JMAP for Mail. La référence principale de ce crate : Mailbox (§2), Thread (§3), Email (§4), recherche et extraits (§5), Identity (§6), EmailSubmission (§7), VacationResponse (§8).
- RFC 5322 : Internet Message Format. Le format de transport des octets bruts stockés dans le blob store et analysés à l’ingestion.
- RFC 9670 : JMAP Sharing. Les champs
shareWithetmyRightsde la boîte.
urn:oximail:params:jmap:v2:mail.
Carte des sources et tests
| Fichier (dans le crate) | Contenu |
|---|---|
src/email.rs | La structure de métadonnées Email, EmailFilter, EmailSort. |
src/email_v2.rs | Les extensions Email v2 : la machine à états de cycle de vie EmailState, les groupes de propriétés catégorisés, les drapeaux utilisateur, l’audit, la causalité. |
src/email_projection.rs | Le registre EMAIL_COLUMNS, email_select_columns et EmailProjection::from_jmap_properties : la fondation de la projection de colonnes. |
src/mailbox.rs | La structure Mailbox et l’énumération MailboxRole. |
src/thread.rs | L’agrégat Thread (ADR-019). |
src/submission.rs | EmailSubmission, Envelope et les traits SubmissionSender / RecipientExpander. |
src/identity.rs | La structure Identity. |
src/vacation.rs | Le singleton VacationResponse. |
src/ingest.rs | ingest_email : l’unique point de convergence d’ingestion, protégé par test_ingest_full_checklist. |
src/mail_store.rs | MailStore : les opérations de store structuré, dont get_emails_projected. |
src/methods/ | Les handlers de méthode JMAP (email_get, email_set, email_query, mailbox_*, thread_*, identity_*, email_submission_*, vacation_response_* et les autres). |
src/subscribers/ | Les abonnés aux événements iTIP (envoyeurs de reply / request / counter). |
ingest_email ; modifier le chemin impose de mettre à jour ce test. La forme de transport v2 est figée par un test de format de transport inter-couches.
État
Implémenté et en production en v0.30.0 : le modèle complet Mailbox / Email / Thread / EmailSubmission / Identity / VacationResponse, tous les handlers de méthode JMAP listés ci-dessus, l’unique cheminingest_email avec sa protection par checklist, et le pont de double capacité v1 / v2 sur Email/get et Email/set.
Le chemin de lecture par projection de colonnes est câblé de bout en bout sur les chemins de lecture (réel, testé) : la primitive de store (get_emails_projected / get_email_projected), Email/get (qui résout une demande de properties vers son ensemble minimal de groupes) et les chemins de lecture IMAP pilotent tous leur projection à partir des besoins réels des consommateurs. Le delta net de comportement de transport est nul : la projection ne fait que réduire les colonnes chargées.
Quelques éléments adjacents à la projection sont encore en spécification ou différés et ne sont pas câblés sur la ligne actuelle : le rejet strict d’une propriété
Email/get inconnue, l’élagage de sur-émission pour headerJson, le court-circuit propriété-de-corps-sans-bodyValues, et le delta inline d’Email/changes (en attente de la décision produit sur les threads / conversations). Le refactor du handler v2 (le chemin de mise à jour d’Email/set qui applique la machine à états sur les types v2) est planifié comme suite ; les types v2 et la forme de transport sont implémentés aujourd’hui, et les handlers v1 existants continuent d’opérer sur les métadonnées v1.