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 uneSetError 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’estFolderVisit : 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 :
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 queEmail/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.
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
headersexplicitement et vous les obtenez. partIdetsubPartssurvivent à une projection explicite dans l’arbre. Un arbre sans ses branches n’est plus un arbre : omettresubPartsdebodyPropertiesse lit comme une erreur et non comme un souhait d’aplatir, et le serveur les conserve.
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.
headersliste les champs dans l’ordre du message, avec leurs noms tels qu’écrits et des valeursRaw;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 estRaw:header:Dateest 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 sousurn:ietf:params:jmap:mail; le modèle courrier v2 n’en a pas, donc une requête v2 seule qui le demande reçoitinvalidArguments. Une propriété d’en-tête absente,parentIdourolevautnull, jamais absente. Thread/changesdit les naissances et les disparitions. Une conversation née dans une écriture est signaléecreated, celle dont le dernier message est partidestroyed, 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, unEXPUNGEIMAP, la console, l’effacement d’un expéditeur) ; le reste estupdated. 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"dansmailboxIds(références de création). Un identifiant de dossier qui n’est pas dans le compte estinvalidPropertiessurmailboxIds, à la création comme à la mise à jour, jamais unserverFail. Email/setcompose depuis des blobs comme depuis des valeurs. Une partietextBodyouhtmlBodypeut être unblobId(décodé avec son jeu de caractères) ; une pièce jointe, ou une feuille pièce jointe debodyStructure, peut être unpartIddebodyValues, enregistré comme blob du compte. Une seconde partie de corps, une entréebodyValuesmanquante, ou une partie avec à la fois ou nipartIdniblobIdest refusée au lieu de donner un corps vide.fromn’est pas exigé : un brouillon peut être enregistré avant que son auteur soit choisi, et n’est refusé qu’à l’envoi.Email/importprend ce que dit le client.userFlagss’applique (read,starredetansweredpar les mots-clés, si bien que les compteurs de non-lus restent justes ;importantetmutedaprès l’import) ;snoozedetfollowUpDuesont refusés comme surEmail/set, et un champ inconnu refuse cette seule entrée eninvalidPropertiesqui le nomme, pendant que les autres entrées s’importent.ifInStateest respecté (stateMismatch), un dossier inconnu estinvalidPropertiessurmailboxIds, unblobIdqui ne désigne aucun blobinvalidPropertiessurblobId. 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.snoozedvaut vrai pour un message reporté, commefollowUpDuedé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 lesaccountCapabilitiesde chaque compte, l’objet de session valant{};submissionportemaxDelayedSend: 0(l’envoi différé est lescheduledSendAtpropre à OxiMail, pas FUTURERELEASE) etsubmissionExtensionsavecSIZEetDSN. Sansproperties, 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/changesporte toujoursupdatedProperties, ànull, puisque le journal des changements n’enregistre pas quelles propriétés ont bougé. notFoundn’est jamais vide quand il ne devrait pas l’être. Tout identifiant inanalysable ou inconnu dans un/getatterrit explicitement dansnotFound.- Les valeurs de
updatedsontnull. Selon la RFC 8620 §5.3, testez l’appartenance (id in updated), pas la véracité de la valeur. hasAttachmentveut 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.
attachmentsest tiré de la structure de ce message (RFC 8621 §4.1.7), donc une partiemessage/rfc822est la pièce jointe et ses propres parties ne sont pas annoncées à côté. Transférer un courrier qui portait lui-mêmecontrat.pdfliste le.eml, pascontrat.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.emlavecEmail/parseet 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 avecfetchNestedBodyValues.Email/parserépond aussi surshared:<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 vautnotFoundsinon, 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. previewest 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 queoximail email reindex-bump --allle recalcule depuis le message brut.- Les
participantsd’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 commeEmail.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 ;nullveut dire indéterminée, et un client ne propose rien. C’est une projection de lecture, jamais listée.Email/queryfiltre dessus avecdetectedLanguage, égalité stricte sur le code stocké :nullne correspond à rien, pas même à une chaîne vide, puisque ne pas savoir n’est pas une langue. Thread/getetThread/changesn’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:mailen primaire etv2:threadsen complément, et un client qui ne déclare quecore+mailest 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 avecproperties.- La pagination par ancre est exacte.
anchor+anchorOffset+positiondansEmail/querysuivent la RFC 8620 §5.5 à la lettre, positions négatives comprises. - Un
/queryChangesfiltré 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
$draftpasse par le cycle standard destroy+create ; le serveur ne rejette pas les modifications de brouillon. - S’envoyer un message fonctionne.
onSuccessUpdateEmails’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/importsansreceivedAt,oximail migrateet un import Takeout datent un message par son en-têteDate:(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’APPENDIMAP sans date et la réception SMTP sont inchangés (la RFC 9051 date unAPPENDà l’APPEND). Un import mené par un binaire plus ancien a pu stocker l’en-têteDate:tel quel (forme RFC 2822) ; commereceivedAtse 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-atles réécrit par le normaliseur même de l’ingestion (essai à blanc par défaut,--apply), en enregistrant un changementEmailpar message et un changementThreadpar fil touché, etoximail doctorcompte ce qui reste sousreceived_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/setnomme 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 etinvalidPatchà la modification (le vocabulaire d’écriture) ; le contrôle porte sur la racine exacte, si bien quekeywordsXn’est plus pris pourkeywords. Les pseudo-propriétésheader:<Nom>[:<forme>]de la RFC 8621 §4.1.3 restent acceptées à la création.replied(synonyme du drapeau « répondu ») etarchived, deux booléens, sont acceptés en modification.Thread/settient 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) vautinvalidPropertiesnommant le chemin et le point de code, par exempleto/0/email: control character U+000D (CR) is not allowed; rien n’est rogné. C’était unserverFail, 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/seten création est refusé avecinvalidPropertiesnommant le chemin, là où il répondaitserverFail, 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 estinvalidArgumentsqui la nomme. - La rigueur est une fonctionnalité.
accountIdinconnu →accountNotFound, etaccountIdest requis en lecture comme en écriture ; une méthode dont la capability est absente deusing→unknownMethod, une entrée deusingque 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 bureaucherchait 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.
materieltrouvematérielet 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éfixechamp: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 deFrom,ToetCc, et non plus leur JSON stocké : là,:,"ou la cléemailcorrespondaient à 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 :
Activatne rendait rien là oùActivaterendait 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 deFrom, 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 :totalest 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 donneserverFail, jamais le plafond.NOTetORrendent les bons messages. Un mot plafonné sousNOTouORfaussait l’ensemble, pas seulement le total : un message au-delà des 10 000 n’était ni exclu par leNOT, ni ajouté par leOR. 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/queryChangesreconstruit la même liste. collapseThreadscompte toutes les conversations. Conversations repliées,totalcompte celles qui contiennent au moins un message trouvé, et non plus celles des 10 000 meilleurs par score.
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 conditiontext 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 renvoielimit). Tout autre argument donneinvalidArguments. - 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 duFrom. 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 surEmail/unsubscribe),oneClick, et leshttpsUrietmailtoUride 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 porteunsubscribedAt,unsubscribeModeetreceivedSinceUnsubscribe, 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,lastReceivedAtet 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/unsubscribel’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) oumode: "mailto"(il a envoyé le message) ; le serveur vérifie que le message offre cette voie, n’envoie rien et l’enregistre. Sansmode, l’appel est le POST one-click, comme avant. Envoyerhttpsquand l’utilisateur ouvre la page,mailtoseulement une fois le message composé réellement soumis.Email/unsubscriberefuse un argument qu’il ne connaît pas (invalidArguments) au lieu de l’ignorer. - Retirer une demande :
Email/unsubscribe {key, withdraw: true}, avec lakeyde 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 ditwithdrawn: true, oufalsequand il n’y en avait pas. - Marque : une entrée porte
brandLogoBlobIdetbrandMarkTypeexactement quandEmail/getles servirait sur sonemailId; 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 :
suspicionvautnullquand 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çonnagesuspectedouspam),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) etdmarcFail(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 parEmail/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 lefrom 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 parEmail/setet le soumet parEmailSubmission/setdans 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 enserverFailen renvoyant versEmail/send, une méthode qu’aucun client standard n’appelle. Un email sansFromest refusé à ce moment,invalidEmailsurfrom. - Les destinataires sont jugés avant l’envoi. Une enveloppe, fournie ou produite depuis
To,CcetBcc, sans destinataire estnoRecipients; 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) esttooManyRecipientsavecmaxRecipients; une adresse que la grammaire de chemin SMTP refuse estinvalidRecipients, qui les liste. Une adresse mal formée passait et rebondissait plus tard. Les autres refus sont typés aussi (invalidProperties,forbiddenMailFrompour l’expéditeur d’enveloppe), etEmailSubmission/queryprend le filtre du §7.3 de la RFC 8621 (identityIds,emailIds,threadIds,undoStatus,before,after) et trie suremailId,threadIdetsentAt. - L’envoi différé est de premier rang.
sendAtsur uncreatev2 d’Email/setou surEmail/send, etscheduledSendAtsurEmailSubmission/set, ne prennent que la chaîne RFC 3339UTCDate: 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.
overQuotaporte 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
mailpeut appelerEmail/send, comme il peut soumettre en SMTP : une portée nommée accorde les capabilities OxiMail de la surface qu’elle couvre (mailaccordev2:mail,v2:labelsetv2:threads), jamaisv2:rulesni 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é avecurn: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-Typede la racine (multipart/signedavec un protocole PKCS#7, ouapplication/pkcs7-mime; smime-type=signed-data) et stockesigned; 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 desmimeStatus,smimeStatusAtDeliveryousmimeErrorsenregistre un changementEmailet pousseStateChanged(RFC 9219 §4.3), si bien qu’un client à deltas voit une expiration ou une révocation ;smimeVerifiedAtseul, non. - L’instant « à la réception » est
receivedAtpour un message que le serveur a livré ou accepté en soumission, jamais lesigningTimedu signataire. Un message importé, copié ou ajouté par IMAP, dont le client a choisi lereceivedAt, est jugé à l’heure de la vérification. Le courrier stocké avant que le serveur enregistre la provenance d’un message est jugé à sonreceivedAtquand 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 restesignedet le travailleur réessaie ;signed/failedavec 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 alorssigned/verifiedavecrevocationChecked: false, exposé, jamais tu. - Qui a signé.
smimeSigner, servi sousurn: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 duFrom),profile(mailbox,organization,sponsor,individual,unknown),organization(seulement pour un profilorganizationousponsor, dont l’autorité a validé l’organisation) etrevocationChecked. Il vautnullquand il n’y a pas de signataire à décrire. Une signature ne couvre ni l’objet, ni le nom affiché, ni leReply-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.
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 suropenpgpkey.<domaine>, puis la directe), puis le serveur de clés configuré, et répondfound(par adresse :armoredKey,sourcevalantwkdoukeyserver,host),notFoundetunreachable(par adresse, une raison parmitimeout,connect,serverError,rateLimited,invalidResponse,policy).notFoundetunreachablesont 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é parRecipientKey/set, avec lasourcequ’on lui a rendue. Une adresse illisible vautinvalidArguments, jamaisnotFound.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 dansnotRequestedavec la raison. Il répondfingerprint,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) etnotRequested. Une clé privée est refusée (invalidArguments) avant que quoi que ce soit ne quitte le serveur.
{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 ;
protectionrend ce que le serveur a lu,meetsCurrentFloorcompris.fingerprintest calculé par le serveur depuispublicPart; au plus 32 enveloppes par compte, de 64 Kio chacune. - L’enveloppe n’est pas servie par défaut.
PrivateKeyEnvelope/getsanspropertiesrend tout saufenvelope; 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/changeset par le push. - Une retenue légale tient. Détruire ou remplacer une enveloppe est
forbiddentant que le compte est sous retenue légale. Une seconde enveloppe pour la même clé estalreadyExistsavecexistingId; la partie publique se remplace à empreinte égale (un certificat renouvelé)./setne rend dansupdatedque ce que le serveur a changé sans qu’on le lui demande (updatedAt, etprotectionquand 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 avecurn: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 patchonSuccessUpdateEmaildoit poserkeywords/$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$mdnsentne réarme pas l’envoi.