Skip to main content
Le courrier est la surface de la RFC 8621, demandée avec urn:ietf:params:jmap:mail (plus submission et vacationresponse pour l’envoi et la réponse automatique). Cette page liste ce qu’OxiMail sert et là où il va au-delà de la RFC. La mécanique commune à toutes les méthodes (lots, références arrière, /changes, push) est sur JMAP Core.

Objets et méthodes

Extensions de méthodes propres à OxiMail sur la surface courrier : Email/send (soumission en un appel), Email/cancel (annuler un envoi différé ou dans la fenêtre d’annulation), Email/wake (fin de mise en veille), Email/unsubscribe et Email/getSubscriptions (abonnements). La réindexation relève de l’administration, pas du client. Le modèle courrier v2 (urn:oximail:params:jmap:v2:mail) remplace le mécanisme des rôles de la RFC 8621 par une énumération de premier rang Email.folder (inbox, sent, drafts, trash, junk, archive, outbox, snoozed, setAside), les collections d’organisation de l’utilisateur étant des références Label (JMAP v2) ; Email/query filtre dessus avec inFolder. Les dossiers personnels restent des objets Mailbox, désignés par inMailbox.

Les dossiers et leurs noms

Deux dossiers qui partagent un parent ne peuvent pas partager un nom (RFC 8621 §2). Créer deux homonymes sous des parents différents est légitime ; déplacer l’un sous le parent de l’autre est désormais refusé, comme renommer vers le nom d’un frère, par une SetError qui nomme la propriété que le client a envoyée (name pour un renommage, parentId pour un déplacement) pour qu’il puisse corriger ce qu’il a écrit. L’unicité est une contrainte de base, posée après une réparation unique des doublons existants ; en IMAP, deux chemins identiques étaient ambigus pour LIST et SELECT, et un fileinto de règle de tri en choisissait un au hasard. Un nom ne porte aucun caractère réservé : \ / : * ? " < > | (le jeu de Windows et de SMB, choisi pour être familier plutôt que minimal), aucun caractère de contrôle, ni espace au début ou à la fin, ni point final, puisque deux dossiers qui ne diffèrent que par là sont deux dossiers que personne ne sait distinguer. / est le séparateur de hiérarchie que le serveur déclare à IMAP (NAMESPACE, le délimiteur de LIST), donc un nom qui le contiendrait ne désignerait aucun dossier unique à travers sa propre hiérarchie. Les accents, les emoji et les espaces intérieurs restent permis. Une règle de tri désigne sa cible par le dossier lui-même, pas par son texte : l’action move d’une règle prend un mailboxId, ou un role (inbox, archive, junk, important) résolu au moment où la règle s’exécute, si bien que renommer ou déplacer un dossier ne casse jamais une règle qui y range. Les règles enregistrées avec un nom de dossier sont converties une fois, au démarrage du serveur, vers l’identifiant du dossier (ou son rôle). Voir la capability Rules.

Nouveaux depuis votre dernière visite

Le « +11 » à côté de « 43 non lus » demande, par personne et par dossier, l’instant où le dossier a été réellement affiché pour la dernière fois, le même sur tous les appareils. C’est FolderVisit : un objet par dossier, id le nom de fil du dossier (inbox, setAside, …) ou l’identifiant d’un dossier personnel, lastViewedAt l’instant que le serveur a enregistré (null : jamais visité). FolderVisit/markViewed {ids} estampille maintenant, une méthode d’action plutôt qu’un /set, parce qu’un /set demanderait au client une valeur que le serveur doit ignorer, et émet StateChanged pour que les autres appareils du lecteur relisent leurs compteurs ; une visite inchangée n’émet rien. L’état de l’objet est constant et il n’a pas de /changes : une visite est un réglage personnel, pas un flux. Il vit dans le compte du lecteur, jamais dans celui du propriétaire : une visite qui ferait avancer l’état du propriétaire lui dirait, ainsi qu’à tout autre lecteur d’une boîte partagée, quand quelqu’un l’a ouverte, si bien que le type est refusé sous shared:. Le compteur lui-même est un filtre d’Email/query :
Il compte les non-lus entrés dans le dossier après la visite. Le serveur pose une date d’entrée (une colonne stockée, pas une propriété de fil) quand un message entre dans un dossier, livraison, déplacement, réveil d’une mise en veille, rappel de suivi, archivage d’un fil, passage en envoyé, et jamais à un import, un APPEND IMAP ou un brouillon ; receivedAt n’est jamais touché, et les messages déjà là avant toute visite n’ont pas de date d’entrée. Un non-lu déjà présent au moment de la visite n’est pas nouveau ; un message déplacé, réveillé ou rappelé après l’est ; le lire le retire. newSinceVisit n’accepte que true, exige exactement une ancre dans la même condition, inFolder ou inMailbox (un dossier personnel de ce compte, jamais un dossier à rôle), est unsupportedFilter sur la requête d’un autre compte, et Email/queryChanges répond canCalculateChanges: false pour lui, puisque les visites ne sont pas dans l’état des Email. Deux drapeaux de v2:mail l’annoncent, folderVisit (les méthodes et le filtre) et folderVisitMailboxes (les dossiers personnels aussi), parce qu’un serveur qui sert le premier sans le second existe.

Arbre du corps et valeurs de corps

Tout ce que Email/get dit du corps d’un message vient d’un seul parcours en profondeur du véritable arbre MIME. C’est ce qui fait que partId désigne la même partie dans toutes les propriétés d’une même réponse : l’identifiant lu dans bodyStructure est celui que porte la pièce jointe, et c’est la clé à chercher dans bodyValues.
bodyValues est vide si vous ne le demandez pas. La RFC 8621 §4.2 conditionne le contenu à fetchTextBodyValues, fetchHTMLBodyValues ou fetchAllBodyValues, et OxiMail en tient désormais la lettre : les valeurs étaient auparavant remplies que les arguments soient présents ou non. Un client qui n’a jamais posé d’argument fetch* et lisait quand même bodyValues y lira une table vide. Posez l’argument. Email/get et Email/parse passent par la même sélection : la règle est identique des deux côtés, avec l’orthographe de la RFC. Email/parse attendait fetchHtmlBodyValues et ignorait en silence fetchHTMLBodyValues, si bien qu’un message joint purement HTML s’ouvrait avec un bodyValues vide.
Les en-têtes par partie sont le chemin vers les paramètres de Content-Type. La propriété type est normativement nue, text/calendar et rien de plus, si bien que les paramètres qui portent le sens ne s’atteignent qu’en demandant les en-têtes de la partie : bodyProperties: ["partId", "type", "headers"]. C’est la façon prévue par la RFC de distinguer une véritable invitation (method=REQUEST) d’un simple .ics joint à la main, sans télécharger le blob. Deux écarts sont délibérés et méritent d’être connus :
  • La projection par défaut retire les en-têtes de partie. La forme par défaut reste un sous-ensemble de la liste normative du §4.2 ; demandez headers explicitement et vous les obtenez.
  • partId et subParts survivent à une projection explicite dans l’arbre. Un arbre sans ses branches n’est plus un arbre : omettre subParts de bodyProperties se lit comme une erreur et non comme un souhait d’aplatir, et le serveur les conserve.
Un bodyStructure demandé est toujours servi. Deux formes ne portent aucun arbre MIME analysé, un message que l’analyseur a refusé et relu à plat, et un brouillon natif dont la forme canonique v2 est body/html, et elles omettaient la propriété en silence. Elles répondent désormais une structure synthétisée depuis les feuilles que la ligne possède : une feuille seule servie nue, texte plus HTML en multipart/alternative, les pièces jointes l’enveloppant en multipart/mixed. Un arbre analysé est toujours servi tel quel. Le corps d’un message joint, dans la même réponse. La RFC 8621 §4.1.4 interdit de descendre dans une partie message/* pour calculer textBody et htmlBody (un hameçonnage transféré en pièce jointe deviendrait sinon le corps du signalement), ce qui laissait au cas le plus fréquent, le transfert sans commentaire, un second aller-retour par Email/parse. Email/get et Email/parse prennent fetchNestedBodyValues (false par défaut) : posé, les entrées du message extérieur viennent d’abord, inchangées, suivies des entrées textBody / htmlBody de chaque partie message/rfc822 ou message/global à profondeur un, chacune marquée viaMessagePartId (le partId de la partie jointe) et portant un partId de la forme <viaMessagePartId>.<partId dans le message joint>. Leurs valeurs entrent dans bodyValues sous les mêmes règles fetch* et leurs blobs se téléchargent. viaMessagePartId fait partie du vocabulaire de bodyProperties, et une projection explicite ne le retire jamais d’une entrée marquée, qui se lirait alors comme le texte de l’auteur extérieur. Le transfert nu (une racine message/rfc822) est servi aussi ; un message joint à l’intérieur du message joint s’atteint par Email/parse sur le blob du premier. Un brouillon v2 natif garde la forme RFC 8621. bodyStructure est accepté à la création. Email/set prend la forme du §4.6 de la RFC 8621 à côté de textBody / htmlBody / attachments (jamais avec eux, comme l’exige le §4.6). Le serveur l’aplatit en une feuille text/plain, une feuille text/html et les pièces jointes par blobId (un cid rend la disposition inline), puis construit son propre arbre canonique, ce que le §4.6 permet. Ce que le constructeur ne sait pas porter est refusé en invalidProperties avec le chemin qui le contient, jamais ignoré : champs d’en-tête par partie, language ou location, une feuille partId qui n’est ni text/plain ni text/html, un second corps du même type, un partId absent de bodyValues, charset ou size à côté d’un partId, une feuille qui porte à la fois ou ni l’un ni l’autre de partId et blobId.

Comportements à connaître

  • Les en-têtes se lisent dans le message. headers liste les champs dans l’ordre du message, avec leurs noms tels qu’écrits et des valeurs Raw ; header:<Name> prend les sept formes du §4.1.2 de la RFC 8621 (asRaw, asText, asAddresses, asGroupedAddresses, asMessageIds, asDate, asURLs) et :all, la dernière occurrence par défaut. Sans forme, la valeur est Raw : header:Date est le champ tel qu’écrit, pas une date, et une forme qu’un champ ne prend pas est refusée. Ils venaient d’une table enregistrée à l’ingestion : décodés, triés par nom, en forme texte seulement. sender (RFC 8621 §4.1.3) est servi sous urn:ietf:params:jmap:mail ; le modèle courrier v2 n’en a pas, donc une requête v2 seule qui le demande reçoit invalidArguments. Une propriété d’en-tête absente, parentId ou role vaut null, jamais absente.
  • Thread/changes dit les naissances et les disparitions. Une conversation née dans une écriture est signalée created, celle dont le dernier message est parti destroyed, celle née et disparue dans la même opération pas du tout, sur chaque chemin d’écriture (la remise, Email/set, une copie, un dossier détruit, un réveil, un EXPUNGE IMAP, la console, l’effacement d’un expéditeur) ; le reste est updated. La purge de la corbeille enregistre maintenant le fil et le dossier qu’elle change, pas seulement l’Email.
  • Un message peut être rangé dans un dossier créé dans la même requête, en le nommant "#creationId" dans mailboxIds (références de création). Un identifiant de dossier qui n’est pas dans le compte est invalidProperties sur mailboxIds, à la création comme à la mise à jour, jamais un serverFail.
  • Email/set compose depuis des blobs comme depuis des valeurs. Une partie textBody ou htmlBody peut être un blobId (décodé avec son jeu de caractères) ; une pièce jointe, ou une feuille pièce jointe de bodyStructure, peut être un partId de bodyValues, enregistré comme blob du compte. Une seconde partie de corps, une entrée bodyValues manquante, ou une partie avec à la fois ou ni partId ni blobId est refusée au lieu de donner un corps vide. from n’est pas exigé : un brouillon peut être enregistré avant que son auteur soit choisi, et n’est refusé qu’à l’envoi.
  • Email/import prend ce que dit le client. userFlags s’applique (read, starred et answered par les mots-clés, si bien que les compteurs de non-lus restent justes ; important et muted après l’import) ; snoozed et followUpDue sont refusés comme sur Email/set, et un champ inconnu refuse cette seule entrée en invalidProperties qui le nomme, pendant que les autres entrées s’importent. ifInState est respecté (stateMismatch), un dossier inconnu est invalidProperties sur mailboxIds, un blobId qui ne désigne aucun blob invalidProperties sur blobId. Un import répété après la destruction de l’email l’importe de nouveau au lieu de répondre l’identifiant d’un message qui n’existe plus. userFlags.snoozed vaut vrai pour un message reporté, comme followUpDue déjà.
  • Les valeurs de capability sont sur le compte. Les valeurs du courrier (maxMailboxesPerEmail, mayCreateTopLevelMailbox, faux sur un compte en lecture seule ou partagé, et le reste du §1.3.1 de la RFC 8621) sont dans les accountCapabilities de chaque compte, l’objet de session valant {} ; submission porte maxDelayedSend: 0 (l’envoi différé est le scheduledSendAt propre à OxiMail, pas FUTURERELEASE) et submissionExtensions avec SIZE et DSN. Sans properties, un client de la seule capability courrier de la RFC reçoit la liste de propriétés par défaut de la RFC 8621. Mailbox/changes porte toujours updatedProperties, à null, puisque le journal des changements n’enregistre pas quelles propriétés ont bougé.
  • notFound n’est jamais vide quand il ne devrait pas l’être. Tout identifiant inanalysable ou inconnu dans un /get atterrit explicitement dans notFound.
  • Les valeurs de updated sont null. Selon la RFC 8620 §5.3, testez l’appartenance (id in updated), pas la véracité de la valeur.
  • hasAttachment veut dire pièces jointes. Le drapeau est calculé depuis la structure MIME à l’ingestion, et recalculé quand une modification ajoute ou retire une partie, pas depuis l’extraction de texte.
  • Un message joint est une pièce jointe, pas son contenu. attachments est tiré de la structure de ce message (RFC 8621 §4.1.7), donc une partie message/rfc822 est la pièce jointe et ses propres parties ne sont pas annoncées à côté. Transférer un courrier qui portait lui-même contrat.pdf liste le .eml, pas contrat.pdf : on ne dit jamais au lecteur qu’il a reçu des fichiers que l’expéditeur n’a pas joints. Les parties encapsulées restent atteignables : analysez le blob .eml avec Email/parse et leurs blobs se téléchargent normalement (chaque partie est stockée, imbriquées comprises ; ce que la liste du fil répond et ce qui est téléchargeable sont deux questions différentes), ou demandez-les dans la même réponse avec fetchNestedBodyValues. Email/parse répond aussi sur shared:<propriétaire>, par la règle que la route de téléchargement applique déjà : un blob s’analyse quand l’appelant peut lire au moins un des courriers du propriétaire qui le porte (une boîte partagée en lecture, ou une étiquette partagée), et vaut notFound sinon, la réponse d’un blob inconnu. C’est ainsi qu’un collègue qui reprend la boîte d’une personne partie ouvre le message transféré en pièce jointe.
  • preview est ce que l’expéditeur a écrit. Un message dont les parties propres portent des mots est résumé à partir d’elles ; le texte d’un message qu’il joint ne sert que si l’enveloppe n’en a aucun. Quand la partie texte n’est que le squelette de la partie HTML (l’expéditeur a retiré les balises en gardant le contenu de <title> et de <style>, ce que prouve la feuille de style du frère HTML retrouvée telle quelle dans la partie texte) ou ne porte aucun texte (vide, ou la phrase de service « This is a multi-part message in MIME format »), l’aperçu vient du HTML. Les messages déjà stockés gardent le leur jusqu’à ce que oximail email reindex-bump --all le recalcule depuis le message brut.
  • Les participants d’un fil portent des noms. Le nom d’affichage de chaque correspondant est pris dans n’importe quel message du fil qui en donne un, le plus récent l’emportant quand deux diffèrent : un correspondant externe sans fiche de contact est listé par son nom, pas par son adresse. Ce nom est fourni par l’expéditeur et non vérifié, exactement comme Email.from.name : du texte à afficher, jamais une preuve d’identité.
  • Chaque message connaît sa langue. detectedLanguage (ISO 639-1, "en", "de", …) est détectée à l’ingestion, hors ligne et de façon déterministe, sur le même texte que l’index ; un message ancien la reçoit à l’ouverture sans backfill ; null veut dire indéterminée, et un client ne propose rien. C’est une projection de lecture, jamais listée. Email/query filtre dessus avec detectedLanguage, égalité stricte sur le code stocké : null ne correspond à rien, pas même à une chaîne vide, puisque ne pas savoir n’est pas une langue.
  • Thread/get et Thread/changes n’ont besoin que de la capability mail. La RFC 8621 §3 rend cette paire obligatoire, donc ces deux méthodes portent les deux capabilities, urn:ietf:params:jmap:mail en primaire et v2:threads en complément, et un client qui ne déclare que core + mail est servi. Les verbes de fil plus riches (query, queryChanges, set, archive, merge, split) restent des extensions v2. La forme {id, emailIds} de la RFC est un sous-ensemble strict de ce que l’objet sert : sélectionnez-la avec properties.
  • La pagination par ancre est exacte. anchor + anchorOffset + position dans Email/query suivent la RFC 8620 §5.5 à la lettre, positions négatives comprises.
  • Un /queryChanges filtré ne parle que du filtre. Les messages qui n’ont jamais correspondu au filtre ne sont pas nommés parmi les retirés.
  • Les brouillons se réécrivent. Une modification de corps sur un message $draft passe par le cycle standard destroy+create ; le serveur ne rejette pas les modifications de brouillon.
  • S’envoyer un message fonctionne. onSuccessUpdateEmail s’exécute après la remise, et la déduplication n’a jamais lieu au niveau du message.
  • Un import sans date prend celle du message. Email/import sans receivedAt, oximail migrate et un import Takeout datent un message par son en-tête Date: (normalisé en UTC) quand la source ne fournit aucune date exploitable ; « maintenant » n’est plus que le dernier recours, si bien qu’un lot importé n’arrive plus entièrement à la minute de l’import. L’APPEND IMAP sans date et la réception SMTP sont inchangés (la RFC 9051 date un APPEND à l’APPEND). Un import mené par un binaire plus ancien a pu stocker l’en-tête Date: tel quel (forme RFC 2822) ; comme receivedAt se trie et se filtre comme une chaîne, ces messages passaient devant le courrier du jour dans une liste du plus récent au plus ancien. oximail email normalize-received-at les réécrit par le normaliseur même de l’ingestion (essai à blanc par défaut, --apply), en enregistrant un changement Email par message et un changement Thread par fil touché, et oximail doctor compte ce qui reste sous received_at_format.
  • Un nom d’affichage encodé à virgules est un seul expéditeur. Un From: dont le nom est un mot encodé RFC 2047 contenant des virgules n’explose plus en expéditeurs vides.
  • Email/set nomme ce qu’il ne connaît pas. Une propriété hors du vocabulaire d’écriture du type est refusée en la nommant, invalidProperties à la création et invalidPatch à la modification (le vocabulaire d’écriture) ; le contrôle porte sur la racine exacte, si bien que keywordsX n’est plus pris pour keywords. Les pseudo-propriétés header:<Nom>[:<forme>] de la RFC 8621 §4.1.3 restent acceptées à la création. replied (synonyme du drapeau « répondu ») et archived, deux booléens, sont acceptés en modification. Thread/set tient la même règle. Un caractère de contrôle (U+0000 à U+001F, ou DEL) dans une adresse, un nom affiché ou le sujet (from, to, cc, bcc, replyTo, sender, subject, à la création comme en modification) vaut invalidProperties nommant le chemin et le point de code, par exemple to/0/email: control character U+000D (CR) is not allowed ; rien n’est rogné. C’était un serverFail, qu’un client bien élevé retente.
  • Un caractère de contrôle est l’erreur du client, pas du serveur. Un caractère C0 (U+0000 à U+001F) ou DEL dans une adresse, un nom affiché ou le sujet d’un Email/set en création est refusé avec invalidProperties nommant le chemin, là où il répondait serverFail, qu’un client bien élevé retente. Le même juge refuse les mêmes octets dans une fiche de contact, si bien que le carnet ne peut plus garder une adresse que le courrier refuserait.
  • Deux erreurs pour deux fautes. Un filtre que le serveur ne sait pas évaluer est unsupportedFilter ; une valeur de la mauvaise forme sur un filtre pris en charge est invalidArguments qui la nomme.
  • La rigueur est une fonctionnalité. accountId inconnu → accountNotFound, et accountId est requis en lecture comme en écriture ; une méthode dont la capability est absente de using → unknownMethod, une entrée de using que le serveur ne prend pas en charge → unknownCapability ; une mauvaise référence arrière → invalidResultReference ; une requête qui mélange le compte propre de l’appelant et un compte partagé est refusée sur l’appel étranger, un compte effectif par requête. Le serveur est l’implémentation de référence et n’a pas de mode tolérant.

Filtrage et recherche

Email/query prend en charge l’arbre de filtre de la RFC 8621 (inMailbox, from/to/subject/text, hasKeyword, hasAttachment, dates, tailles) avec les combinateurs AND/OR/NOT, plus les conditions v2 (inFolder, read, newSinceVisit, detectedLanguage, le verdict anti-spam). Les conditions plein texte interrogent l’index Tantivy : un message est cherchable dès que SMTP a répondu 250 OK (accepté veut dire cherchable). Quatre choses que le moteur fait et qui étaient mesurées fausses sur la flotte :
  • Une requête de plusieurs mots exige tous les mots. matériel de bureau cherchait matériel OU de OU bureau : une requête de plusieurs mots était donc plus large que chacun d’eux, et un mot fréquent ramenait la boîte entière. Tous les mots sont exigés désormais, avec un repli sur « l’un des mots » quand aucun message ne les porte tous : une personne qui ne trouve rien reformule, et chaque reformulation est un aller-retour. La racinisation est délibérément absente (une liste de racines fausse fait pire qu’aucune, et exiger tous les mots rend les mots vides gratuits).
  • L’accent ne décide pas. materiel trouve matériel et l’inverse ; l’analyseur replie les diacritiques sur le texte indexé comme sur la requête.
  • Un deux-points est du texte. re: facture, une heure, une URL étaient lus comme un nom de champ par l’analyseur de requête et basculaient en silence sur une méthode moins bonne ; ils sont cherchés comme des mots, tout comme un préfixe champ: tapé dans la requête, qui ne désigne jamais un champ. Une requête qui ne contient rien que l’index sache lire (« : » ou « ” » seuls) n’est pas encore refusée : elle reçoit la réponse de repli décrite plus bas. Ce repli compare les noms et les adresses de From, To et Cc, et non plus leur JSON stocké : là, :, " ou la clé email correspondaient à tous les messages et ramenaient la boîte entière.
  • Un mot se trouve par son début. Un mot ne se trouvait que par son orthographe entière : Activat ne rendait rien là où Activate rendait des centaines de messages, et un client qui cherche au fil de la frappe restait à zéro jusqu’à la dernière lettre (les mots composés allemands le rendaient criant). Le dernier terme d’une suite de mots tapée vaut désormais aussi pour les mots qui commencent par lui, dès trois caractères ; les termes précédents restent exacts. Un * ou un % final demande la même chose explicitement, au lieu de rendre zéro sans erreur comme avant. Une requête écrite dans le langage de requête (une phrase entre guillemets, OR, une exclusion par -) garde son sens exact. C’est mécanique, pas de la racinisation : identique dans toutes les langues, et au pire cela ramène trop, jamais faux.

Ce que cherche chaque condition

L’index garde cinq champs de texte par message : le sujet, les noms et adresses de From, To et Cc, et le corps. Les deux conditions de la RFC 8621 ne les lisent pas de la même façon, et cette différence est désormais tenue : Une condition placée dans un opérateur passe aussi par l’index. Une condition text ou body sous AND, OR ou NOT était comparée à l’aperçu de 256 caractères. C’est le cas d’une recherche limitée à un dossier, combinée à une date, ou faite dans une boîte partagée (le serveur l’enveloppe dans un AND). Un mot situé plus loin dans le corps était donc trouvé par la recherche seule, puis perdu dès qu’on ajoutait un dossier. Chaque condition de l’arbre est maintenant résolue dans l’index, à la racine comme dans un opérateur, et la base ne fait que composer l’arbre autour. SEARCH en IMAP partage la même résolution.

Les grands ensembles de résultats

Pour un mot, l’index remet à la base au plus ses 10 000 meilleurs candidats. Au-delà, les réponses étaient calculées sur ces 10 000 seuls et présentées comme complètes. Elles sont désormais exactes :
  • total est le vrai nombre. Une recherche qui trouve plus de 10 000 messages dit combien elle en trouve, au lieu de s’arrêter à 10 000. Le compte se fait dans l’index, sur les messages que le reste du filtre autorise (périmètre d’un lecteur de boîte partagée compris). Un compte qui échoue donne serverFail, jamais le plafond.
  • NOT et OR rendent les bons messages. Un mot plafonné sous NOT ou OR faussait l’ensemble, pas seulement le total : un message au-delà des 10 000 n’était ni exclu par le NOT, ni ajouté par le OR. Le serveur liste maintenant les messages dont le sort dépend de ce mot, puis demande à l’index, sans limite, lesquels le portent.
  • Un tri par date liste tous les résultats. Avec tout autre tri que relevance, un mot présent dans plus de 10 000 messages les rend tous dans l’ordre demandé, les plus récents d’abord pour une liste antichronologique. Un message récent pouvait manquer avant, sans que rien ne le signale. Email/queryChanges reconstruit la même liste.
  • collapseThreads compte toutes les conversations. Conversations repliées, total compte celles qui contiennent au moins un message trouvé, et non plus celles des 10 000 meilleurs par score.
Seul relevance garde les 10 000 meilleurs par score, puisque son classement se trouve là. Son total est compté quand même.

Classement et surlignage

Email/query sait trier par pertinence : le comparateur relevance rend le classement du moteur, n’est accepté qu’en tête et qu’avec un filtre text ou body (unsupportedSort sinon, puisqu’il n’y a pas de classement sans requête), est refusé avec collapseThreads: true (unsupportedSort, puisqu’une liste repliée à un message par conversation ne rend aucun classement), et doit être demandé explicitement, parce qu’un serveur ne désobéit pas au tri qu’on lui donne (RFC 8621 §4.4.2). sortProperties dans la capability v2:mail le liste. Le moteur notait sujet, adresses et corps comme un seul texte : un mot dans une mention légale de pied de page pesait autant qu’un mot du sujet, et les notifications courtes et répétitives passaient devant. L’index pèse désormais le champ lui-même : un mot du sujet compte trois fois, du nom ou de l’adresse de l’expéditeur deux fois, de To, de Cc ou du corps une fois. L’ordre relevance pondère en plus la note par l’âge du message (la note est divisée par deux à 60 jours et continue de décroître lentement, si bien qu’un message ancien qui correspond bien mieux passe encore devant). Un message qui porte le mot entier passe devant celui qui ne porte qu’un mot plus long commençant pareil. Les autres ordres de tri sont inchangés, tout comme l’ensemble des messages trouvés. Email/queryChanges refuse un tri comme Email/query. Un tri que la base refuse (relevance sans condition de texte, une propriété inconnue) donne unsupportedSort, là où il donnait serverFail. Un tri par spamScore demandé par quelqu’un qui n’administre pas le tenant est refusé avant tout travail : la position des entrées added révélerait l’ordre même que ce comparateur leur ferme. SearchSnippet/get surligne le mot que la recherche a trouvé. Il renvoie les fragments surlignés des listes de résultats, découpés et repliés par l’analyseur de l’index lui-même : reunion surligne Réunion, un début tapé (factu) surligne le mot entier facture, et ni les lettres prises dans un autre mot (art dans départ) ni un opérateur de requête (OR, un -mot exclu) ne sont plus surlignés. Les fragments viennent du sujet et de l’aperçu stocké (preview), jamais d’un extrait du corps.

Le repli sans index

Une condition text ou body à laquelle l’index n’a pas répondu (aucun index ouvert, une requête sur laquelle il a échoué, ou une requête qui ne contient rien qu’il sache lire) reçoit une réponse de repli, calculée sur le sujet, les adresses et l’aperçu. Ce repli manque tout mot situé plus loin dans le corps. La réponse le dit : Email/query et Email/queryChanges ajoutent fullTextIndexUnavailable: true, seulement pour une requête dont le using cite urn:oximail:params:jmap:v2:mail (RFC 8620 §1.8), et rien sinon. Un client peut alors prévenir que la recherche était partielle, au lieu de présenter une liste courte comme complète. ChatMessage/query et Note/query n’ont pas ce repli et répondent serverFail tant que l’index ne s’ouvre pas. Le côté exploitant, de la jauge d’état au remède, se trouve sur boîtes et stockage.

Abonnements

Email/getSubscriptions (capability courrier v2) répond en un appel à « à quoi suis-je abonné ? » : les expéditeurs des lettres d’information que le compte a reçues, une entrée par liste, à partir des messages qui portent un List-Unsubscribe exploitable (un URI https ou mailto, le même jugement que la propriété listUnsubscribe).
  • Arguments : windowDays (90 par défaut, de 1 à 365), position, limit (100 par défaut ; au-delà de 500, le serveur répond 500 et renvoie limit). Tout autre argument donne invalidArguments.
  • Regroupement : par List-Id (RFC 2919) quand le message en a un, puisqu’une plateforme peut varier l’expéditeur d’une même liste ; sinon par l’adresse du From. Jamais par le seul domaine.
  • Messages comptés : ceux d’un dossier autre qu’Envoyés, Brouillons et Corbeille. Une liste apparaît dès qu’elle en compte au moins 2 dans la fenêtre, si bien qu’un reçu isolé qui porte l’en-tête n’est pas un abonnement.
  • Chaque entrée : key, name, email, count, unreadCount, junkedCount (les messages de cette liste en Indésirables : une lettre d’information déplacée là reste un abonnement), lastReceivedAt, emailId (le dernier message, pour enchaîner sur Email/unsubscribe), oneClick, et les httpsUri et mailtoUri de ce message (null quand la liste n’offre pas cette voie), si bien qu’un client ouvre la page depuis le clic sans relire le message.
  • Ordre : le plus de messages d’abord, puis le plus récent.
  • Listes quittées : une fois que le compte a demandé à quitter une liste par Email/unsubscribe, son entrée porte unsubscribedAt, unsubscribeMode et receivedSinceUnsubscribe, les messages arrivés après la demande (une liste qui ne la respecte pas). Elle reste listée tant que la demande date de la fenêtre, même sans aucun message (count à 0, emailId, lastReceivedAt et les deux URI à null, nom et adresse tels qu’enregistrés à la demande), ou tant qu’elle envoie encore ; le seuil de 2 messages ne s’y applique pas.
  • Enregistrer une demande : Email/unsubscribe l’enregistre après que son propre POST one-click a été accepté (2xx). Pour les autres voies, le client fait le travail et le dit : mode: "https" (il a ouvert la page) ou mode: "mailto" (il a envoyé le message) ; le serveur vérifie que le message offre cette voie, n’envoie rien et l’enregistre. Sans mode, l’appel est le POST one-click, comme avant. Envoyer https quand l’utilisateur ouvre la page, mailto seulement une fois le message composé réellement soumis. Email/unsubscribe refuse un argument qu’il ne connaît pas (invalidArguments) au lieu de l’ignorer.
  • Retirer une demande : Email/unsubscribe {key, withdraw: true}, avec la key de l’entrée et rien d’autre, supprime la demande (un envoi mailto annulé, un clic par erreur) et ne contacte personne ; la réponse dit withdrawn: true, ou false quand il n’y en avait pas.
  • Marque : une entrée porte brandLogoBlobId et brandMarkType exactement quand Email/get les servirait sur son emailId ; une entrée sans message n’en a pas. Une nouvelle demande pour la même liste remplace la précédente ; les demandes sont effacées avec le compte.
  • Suspicion : suspicion vaut null quand rien ne cloche chez l’expéditeur, sinon {"reasons": [...]} pris dans une liste fermée : phishing (un message de l’entrée en bande d’hameçonnage suspected ou spam), serverSpam (tous les messages de l’entrée classés spam par le serveur et encore dans Indésirables ; en sortir un lève la raison) et dmarcFail (DMARC en échec sur le dernier message). Se désabonner d’un spam confirme l’adresse à l’expéditeur, et la page peut être piégée : sur une entrée suspecte, proposez d’examiner les messages (laisser en Indésirables, supprimer, ce n’est pas du spam), pas de se désabonner. Seule la bande sert, jamais un score ; une entrée sans message n’a pas de suspicion.
  • Réservé au propriétaire du compte ; sous accès partagé, l’appel répond forbidden. Il n’écrit rien : bloquer un expéditeur ou ranger ses prochains messages est une règle, traiter les anciens passe par Email/set.

Mots-clés et drapeaux

Les mots-clés JMAP ($seen, $flagged, $draft, $answered, $junk, personnalisés) sont le même état que les drapeaux IMAP lisent et écrivent : un seul stockage, deux protocoles. $encrypted marque un message remis dont la racine est chiffrée (multipart/encrypted, OpenPGP/MIME, ou application/pkcs7-mime en enveloped-data ou authEnveloped-data, S/MIME), pour qu’une liste de messages l’affiche sans lire le corps. Aucune norme ne définit un tel marqueur ; le serveur le pose à la remise comme il pose $hasattachment, sous le nom que KMail emploie déjà, sans rien ouvrir ni rien déchiffrer (un OpenPGP en ligne dans un corps texte n’est pas vu). Un import, un APPEND IMAP ou un brouillon garde les mots-clés que son client a écrits. Les messages remis avant sont marqués par oximail email backfill-encrypted (à blanc par défaut, --apply, un --account ou tous, --json), qui enregistre un changement Email par message. Le pipeline anti-spam classe avec $junk, et un client qui déplace un message vers Indésirables ou l’en sort entraîne le filtre dans les deux sens ; les étiquettes utilisateur sont un objet v2 distinct (JMAP v2).

L’envoi

Le pipeline de soumission valide le from contre les identités du compte (enveloppe et en-tête : falsifier l’adresse d’un collègue échoue avec forbiddenFrom), stocke le message et le remet à la file de remise. onSuccessUpdateEmail/onSuccessDestroyEmail s’exécutent après l’acceptation de la remise, dans la réponse de la même requête. Une soumission refusée remonte sa SetError précise (un expéditeur d’enveloppe interdit, un paramètre d’enveloppe non pris en charge) plutôt qu’un serverFail qui l’emballe.
  • Un brouillon créé en JMAP s’envoie par EmailSubmission/set. Un client standard crée le brouillon par Email/set et le soumet par EmailSubmission/set dans la même requête (emailId: "#m"), comme le décrit la RFC 8621 §7 ; le serveur matérialise le brouillon avec le même constructeur qu’Email/send, puis le soumet. Il refusait en serverFail en renvoyant vers Email/send, une méthode qu’aucun client standard n’appelle. Un email sans From est refusé à ce moment, invalidEmail sur from.
  • Les destinataires sont jugés avant l’envoi. Une enveloppe, fournie ou produite depuis To, Cc et Bcc, sans destinataire est noRecipients ; plus de 100 destinataires (la borne d’une transaction SMTP, la même sur les deux portes, comptée avant l’expansion d’un groupe) est tooManyRecipients avec maxRecipients ; une adresse que la grammaire de chemin SMTP refuse est invalidRecipients, qui les liste. Une adresse mal formée passait et rebondissait plus tard. Les autres refus sont typés aussi (invalidProperties, forbiddenMailFrom pour l’expéditeur d’enveloppe), et EmailSubmission/query prend le filtre du §7.3 de la RFC 8621 (identityIds, emailIds, threadIds, undoStatus, before, after) et trie sur emailId, threadId et sentAt.
  • L’envoi différé est de premier rang. sendAt sur un create v2 d’Email/set ou sur Email/send, et scheduledSendAt sur EmailSubmission/set, ne prennent que la chaîne RFC 3339 UTCDate : l’entier Unix accepté pendant une migration de fil en trois temps est retiré, et un entier est refusé avec une erreur qui nomme la forme attendue. Une soumission différée est mise en file et annulable (Email/cancel) jusqu’à son départ ; les envois différés d’un compte désactivé sont annulés à la source. Une demande d’accusé de remise (paramètres DSN de l’envelope) survit sur une soumission différée, si bien que demander un accusé ne force plus un envoi immédiat.
  • Le plafond de téléversement est réel. Le maxConcurrentUpload (4) que la session annonce est appliqué sur les deux routes de téléversement ; le cinquième téléversement simultané répond l’erreur de limite de la RFC 8620 qui le nomme.
  • Un refus de quota dit lequel et de combien. overQuota porte une description qui nomme le quota dépassé et la taille refusée face à sa limite ; la forme de fil sans description est inchangée (les quotas).
  • Envoyer un brouillon ne laisse pas de non-lu derrière lui. Le brouillon est marqué lu tant qu’il est encore dans Brouillons, avant de passer dans Envoyés ; le compteur de non-lus des Brouillons grossissait d’un à chaque message envoyé. Les compteurs qui ont dérivé avant la correction se réparent par oximail mailbox recount --all.
  • Un mot de passe d’application « courrier » envoie en JMAP. Un mot de passe d’application de portée mail peut appeler Email/send, comme il peut soumettre en SMTP : une portée nommée accorde les capabilities OxiMail de la surface qu’elle couvre (mail accorde v2:mail, v2:labels et v2:threads), jamais v2:rules ni Sieve, puisqu’une règle peut rediriger le courrier. Une liste de capabilities personnalisée reste exacte.

Les messages signés (S/MIME)

OxiMail vérifie la signature S/MIME du courrier qu’il reçoit et sert le verdict tel que la RFC 9219 le définit, demandé avec urn:ietf:params:jmap:smimeverify. La vérification est un fait d’authentification daté ; elle ne décide jamais de l’acceptation, du classement ni du score anti-spam. Les quatre propriétés, et les conditions de filtre hasSmime, hasVerifiedSmime et hasVerifiedSmimeAtDelivery, n’existent que pour un client qui a déclaré la capability dans using (RFC 8620 §1.8) : demandée sans elle, une propriété vaut invalidArguments et une condition unsupportedFilter, chacune nommant la capability. Avec elle, une propriété demandée est toujours présente, null pour un message qui n’est pas S/MIME.
  • Vérifié après la livraison, tenu vrai dans le temps. L’ingestion ne lit que le Content-Type de la racine (multipart/signed avec un protocole PKCS#7, ou application/pkcs7-mime; smime-type=signed-data) et stocke signed ; un travailleur vérifie ensuite. Seule la racine du message compte : une partie signée imbriquée dans un message transféré ne dit rien du message qui la porte. Le verdict est ensuite réévalué à la plus proche de trois échéances : l’expiration du certificat, la prochaine mise à jour d’une liste de révocation utilisée, un réessai. Un changement de smimeStatus, smimeStatusAtDelivery ou smimeErrors enregistre un changement Email et pousse StateChanged (RFC 9219 §4.3), si bien qu’un client à deltas voit une expiration ou une révocation ; smimeVerifiedAt seul, non.
  • L’instant « à la réception » est receivedAt pour un message que le serveur a livré ou accepté en soumission, jamais le signingTime du signataire. Un message importé, copié ou ajouté par IMAP, dont le client a choisi le receivedAt, est jugé à l’heure de la vérification. Le courrier stocké avant que le serveur enregistre la provenance d’un message est jugé à son receivedAt quand c’est une date RFC 3339.
  • Révocation par CRL, jamais OCSP. Les listes ne sont récupérées que pour les points de distribution d’un chemin de certificats déjà validé, et gardées en cache jusqu’à leur nextUpdate ; le serveur ne suit jamais une adresse désignée par le certificat de l’expéditeur avant qu’une ancre ait validé le chemin, n’interroge jamais OCSP (qui dirait à l’autorité qui lit quoi, et quand) et ne suit jamais l’AIA. Tant qu’aucune liste n’a jamais été obtenue pour un chemin, le statut reste signed et le travailleur réessaie ; signed/failed avec la raison CRL n’arrive que si aucune liste valide ne couvre l’instant et que la récupération échoue. L’exploitant peut couper le contrôle de révocation (authentification du courrier) : la signature est alors signed/verified avec revocationChecked: false, exposé, jamais tu.
  • Qui a signé. smimeSigner, servi sous urn:oximail:params:jmap:v2:mail (refusé en le nommant sans elle), décrit le signataire d’une signature vérifiée : issuer, notBefore, notAfter, addresses (celles du certificat, à montrer quand elles diffèrent du From), profile (mailbox, organization, sponsor, individual, unknown), organization (seulement pour un profil organization ou sponsor, dont l’autorité a validé l’organisation) et revocationChecked. Il vaut null quand il n’y a pas de signataire à décrire. Une signature ne couvre ni l’objet, ni le nom affiché, ni le Reply-To, et un certificat « boîte validée » s’obtient facilement : un client dit « Signé par <adresse> », pas « Vérifié ».
  • Mises à jour en direct. Un abonnement push-patches peut nommer ces propriétés ; les flux qu’aucune requête ne porte utilisent les capabilities que le compte détient, si bien que l’abonnement n’est plus refusé en bloc.
Le courrier signé stocké avant que la vérification existe est marqué par oximail smime backfill, après quoi le travailleur le vérifie à son rythme ; oximail smime verify revérifie depuis les octets.

Messages signés (OpenPGP)

La signature OpenPGP à la racine d’un message reçu (multipart/signed avec application/pgp-signature, ou un texte signé en clair) reçoit un verdict sur le même modèle, sous urn:oximail:params:jmap:v2:mail puisqu’aucune RFC n’en définit : pgpStatus (null sans signature OpenPGP à la racine, signed quand elle ne peut pas être vérifiée, signed/verified, signed/failed), pgpStatusAtDelivery, pgpErrors (une liste fermée : unknownKey, unsupportedSignature, malformedSignature, badSignature, keyExpired, keyRevoked, fromNotInKey), pgpVerifiedAt et pgpSigner. La signature est vérifiée contre les seules clés du compte, jamais un annuaire, et de nouveau quand elles changent. Email/query filtre dessus avec hasPgp (pgpStatus n’est pas null, quel que soit le verdict), hasVerifiedPgp (pgpStatus vaut signed/verified) et hasVerifiedPgpAtDelivery (la même chose sur pgpStatusAtDelivery) ; false sélectionne tous les autres messages, non signés compris. Sans la capability dans using, chaque condition vaut unsupportedFilter. Comme tout serveur annonce cette capability, le drapeau pgpFilters: true de son objet dit que ces conditions sont servies. Un filtre « signés » qui couvre les deux techniques s’écrit {"operator": "OR", "conditions": [{"hasSmime": true}, {"hasPgp": true}]}, avec les deux capabilities dans using.

Les clés OpenPGP

RecipientKey porte les clés publiques OpenPGP des correspondants que le compte conserve : email, armoredPublicKey, fingerprint, algorithm, userId, expiresAt et source (keyserver, wkd, signed-email, manual-import). Le matériel de clé est immuable : une clé renouvelée a une nouvelle empreinte et voyage en création plus suppression ; seuls userId, expiresAt et source se modifient. La recherche d’une nouvelle clé et la publication de la sienne passent par le serveur, si bien que le serveur de clés n’apprend plus l’adresse IP de l’utilisateur en même temps que l’adresse cherchée :
  • RecipientKey/lookup {emails} (50 adresses au plus) consulte d’abord le Web Key Directory du domaine du destinataire (la méthode avancée sur openpgpkey.<domaine>, puis la directe), puis le serveur de clés configuré, et répond found (par adresse : armoredKey, source valant wkd ou keyserver, host), notFound et unreachable (par adresse, une raison parmi timeout, connect, serverError, rateLimited, invalidResponse, policy). notFound et unreachable sont distincts : un serveur de clés injoignable n’est jamais rapporté comme « pas de clé », ce qui laisserait l’utilisateur écrire en clair. Une clé n’est trouvée que si elle nomme l’adresse et que ses auto-signatures se vérifient. La méthode ne stocke rien ; le client garde la clé par RecipientKey/set, avec la source qu’on lui a rendue. Une adresse illisible vaut invalidArguments, jamais notFound.
  • PgpKey/publish {armoredPublicKey} envoie la clé au serveur de clés et ne demande la vérification que pour les adresses de la clé que le compte possède (son adresse principale et ses alias d’envoi exacts), en listant les autres dans notRequested avec la raison. Il répond fingerprint, host, status (par adresse, tel que le serveur de clés le rend après la demande : unpublished, pending, published, revoked), verificationRequested (seulement les adresses que le serveur de clés dit en attente) et notRequested. Une clé privée est refusée (invalidArguments) avant que quoi que ce soit ne quitte le serveur.
La session annonce la capability avec {lookup, publish, keyserver}. Un geste que l’exploitant a coupé dans [pgp_directory] répond forbidden, si bien qu’un client lit la session et se tait au lieu d’échouer à chaque destinataire ; trop d’appels pour un compte répondent l’erreur limit de la RFC 8620.

Les clés privées gardées sur le serveur

Le titulaire d’un compte personnel peut garder sur le serveur une copie de sa clé privée OpenPGP ou S/MIME, déjà protégée par sa phrase secrète, pour qu’un nouvel appareil la trouve (urn:oximail:params:jmap:privatekeys, aussi https://oximail.ch/jmap/privatekeys ; PrivateKeyEnvelope/get, /set, /changes, sur le compte personnel seulement). Le serveur ne la déchiffre jamais, n’exécute aucune dérivation de clé et ne voit jamais la phrase ; il contrôle la seule structure et refuse une clé enregistrée sans protection.
  • Ce qui est accepté. Une clé secrète OpenPGP transférable dont chaque paquet secret est protégé par AES en mode OCB sous Argon2 (clés v4 et v6), ou une clé privée PKCS#8 chiffrée sous PBES2 (PBKDF2 avec une fonction SHA-2 explicite, ou scrypt ; un sel d’au moins 16 octets ; AES-CBC), avec les certificats correspondants pour S/MIME. La dérivation doit atteindre un plancher (coût Argon2 196 608, 600 000 itérations PBKDF2, scrypt 2^20) et rester sous un plafond ; protection rend ce que le serveur a lu, meetsCurrentFloor compris. fingerprint est calculé par le serveur depuis publicPart ; au plus 32 enveloppes par compte, de 64 Kio chacune.
  • L’enveloppe n’est pas servie par défaut. PrivateKeyEnvelope/get sans properties rend tout sauf envelope ; la nommer, ajouter, remplacer ou retirer une clé exige une authentification récente de la session qui le demande, par mot de passe ou par passkey, depuis moins de dix minutes. Un mot de passe d’application n’a pas de session et ne compte jamais.
  • Chaque récupération est visible. Lire l’enveloppe fait avancer l’état du type (lastRetrievedAt) : les autres appareils du compte l’apprennent par /changes et par le push.
  • Une retenue légale tient. Détruire ou remplacer une enveloppe est forbidden tant que le compte est sous retenue légale. Une seconde enveloppe pour la même clé est alreadyExists avec existingId ; la partie publique se remplace à empreinte égale (un certificat renouvelé). /set ne rend dans updated que ce que le serveur a changé sans qu’on le lui demande (updatedAt, et protection quand une nouvelle enveloppe a été lue).

Les accusés de lecture (MDN)

Les notifications de disposition de message sont la surface de la RFC 9007, demandée avec urn:ietf:params:jmap:mdn. Les deux sens sont servis. Lire les accusés qu’on vous a envoyés. Les MDN entrants sont rattachés, à l’ingestion SMTP, au message envoyé auquel ils répondent, appariés sur Original-Message-ID et exposés en Email.mdnBlobIds. MDN/parse transforme un tel blob en objet MDN structuré, avec disposition, originalMessageId, finalRecipient, reportingUA, originalRecipient, mdnGateway, error et extensionFields. Un client affiche donc « votre message a été affiché le … » sans réimplémenter lui-même la RFC 8098. Les verdicts sont rendus par blob, jamais effondrés au niveau de la méthode : chaque identifiant atterrit dans parsed, dans notParsable (le blob s’analyse comme un courrier mais n’est pas un MDN, ou ne s’analyse pas du tout, la RFC 9007 ne trace pas de ligne plus fine) ou dans notFound. Les groupes vides sont servis comme null, la forme de la RFC elle-même. En envoyer un. MDN/send compose un accusé RFC 8098 pour un message reçu et le remet à la file de sortie, avec disposition et, en option, subject, textBody, reportingUA, finalRecipient (qui doit être une adresse que ce compte a le droit d’utiliser, sinon forbiddenFrom), includeOriginalMessage et extensionFields. Deux propriétés de cette méthode méritent d’être connues avant de bâtir dessus :
  • Rien n’envoie de MDN automatiquement. L’émission est un geste explicite de l’utilisateur, et cette méthode est ce geste. L’accusé part avec une enveloppe d’expéditeur nulle (RFC 8098 §3) et ne génère pas de trafic de suivi s’il échoue.
  • Le « déjà envoyé » n’est pas effaçable par le client. La RFC 9007 ancre le contrôle anti-doublon sur le mot-clé $mdnsent, et OxiMail tient la lettre de la RFC : le patch onSuccessUpdateEmail doit poser keywords/$mdnsent: true, refusé nommément sinon, pour qu’un client tiers littéral fonctionne sans modification. Mais le serveur vérifie en plus un horodatage d’audit côté serveur, écrit atomiquement avec le patch. Effacer $mdnsent ne réarme pas l’envoi.
Pour la vue protocoles classiques des mêmes données, voir protocoles historiques ; pour les extensions courrier v2 (étiquettes, mise en veille, messages enregistrés), voir JMAP v2.