Le chat est une extension JMAP d’OxiMail (urn:oximail:params:jmap:chat, avec polls, custom-emoji et mentions comme objets de capacité voisins) : le même modèle de requête, les mêmes chaînes d’état et le même push que tous les autres domaines. Un client JMAP obtient le chat avec la mécanique qu’il a déjà. Le temps réel passe par le WebSocket JMAP.
Objets et méthodes
Le corps d’un message et ses pièces jointes
Le corps d’un message ou d’un salon est content : l’enveloppe JSON du client lui-même (text, attachments, toute clé propre au client), envoyée comme tableau d’octets ou chaîne base64 et servie comme tableau d’octets, stockée telle quelle et restituée intacte, avec les clés gérées par le serveur (reactions, transcript) fusionnées dedans. C’est du texte en clair : le serveur lit le text d’un message pour l’aperçu du salon, la recherche, les mentions et les aperçus de liens. Son ancien nom, encryptedContent, décrivait un chiffrement qui n’a jamais été construit ; il n’est plus servi, et un ChatMessage/set ou un ChatRoom/set qui le porte est refusé par invalidProperties en nommant le renommage.
Les pièces jointes sont des blobs JMAP standard. Ce qu’un salon partage (les images et fichiers d’un message, l’avatar du salon) est téléversé à la portée de l’organisation (/jmap/upload-tenant), mais il est servi à qui le détient, plus à toute l’organisation : son téléverseur, ou un membre dont l’une des propres copies le référence (un salon, ou un salon à la corbeille, qui le porte en avatar ; un message marqué porteur d’une pièce jointe et dont le corps le nomme sous blobId). Tous les autres reçoivent le 404 d’un blob absent : un ancien membre, ou le membre d’un autre salon qui a gardé l’identifiant, ne lit rien. Les émojis personnalisés, le logo de l’organisation et les photos d’annuaire restent publiés à tous les membres.
- Une écriture ne peut citer qu’un fichier que son auteur peut lire.
ChatMessage/set (création et modification), ChatRoom/set avatarBlobId (création et modification) et la création CustomEmoji/set vérifient chaque blob qu’ils nomment selon la règle de téléchargement ci-dessus ; citer la pièce jointe de quelqu’un d’autre par son identifiant est refusé par invalidProperties, dans les mêmes termes que le blob soit illisible ou inexistant, pour que la réponse ne dise rien des fichiers que l’organisation détient. Une pièce jointe que le message portait déjà passe une édition ; sous un compte partagé, la portée propre du délégué compte aussi.
- Un fichier glissé dans le composeur et jamais envoyé ne reste pas. Le balayage quotidien des téléversements jamais rattachés couvre les blobs à la portée de l’organisation : celui qui n’est référencé nulle part dans l’organisation au bout de 24 heures est libéré, quel que soit son téléverseur.
Un salon détruit par son propriétaire part à la corbeille
Jusqu’en septembre, un groupe ou un canal détruit par son propriétaire effaçait d’un geste toutes les copies de membre, les messages, les accusés et les clés, sans retour possible autre qu’une greffe manuelle depuis une sauvegarde. ChatRoom/set destroy met désormais le salon à la corbeille : toutes les copies de membre quittent les tables vivantes d’un coup et rien n’est supprimé. Sur le fil, le propriétaire continue de lire le salon (Updated, isTrashed: true), tout autre membre le perd (Destroyed), et tout le monde perd les messages, que personne ne lit dans la corbeille. Le propriétaire le restaure par ChatRoom/set update {isTrashed: false} et rien d’autre ; isTrashed: true est refusé (la corbeille s’atteint par destroy), tout autre patch d’un salon à la corbeille est refusé, et un membre qui n’en est pas propriétaire reçoit notFound, comme pour tout salon qu’il ne peut pas voir. La capability annonce roomTrash: true pour qu’un client distingue un serveur qui garde le salon d’un serveur qui l’effaçait.
Détruire demande une confirmation tapée (onDestroyConfirm, une carte identifiant de salon → ce que l’utilisateur a saisi) : le nom du salon tel qu’affiché, ou son nombre de membres s’il n’a pas de nom, comparés après normalisation NFC, bords rognés et espaces intérieurs réduits. Une confirmation fausse est refusée ; une confirmation absente est consignée dans la ligne d’audit et laissée passer, ce contrôle étant une garde contre un geste malheureux, pas une autorisation. Quitter ou masquer un salon ne demande jamais rien. Un salon à la corbeille est restaurable pendant l’horizon de purge que l’exploitant règle, et le balayage automatique ne détruit rien après une restauration non acquittée ni pendant une sauvegarde (la corbeille des salons, côté exploitant).
Qui a fait quoi est au journal d’audit. Détruire un salon, retirer un membre, changer un rôle et modérer un message sont des actes qui retirent quelque chose à d’autres ; chacun écrit une ligne du journal d’administration avec son auteur, l’adresse d’origine et la session, là où change_log ne porte pas d’auteur et où le journal ignorait le chat (conformité).
L’annuaire des salons
Un groupe ou un canal dont le propriétaire pose allowKnocking: true accepte une demande d’entrée : une personne extérieure au salon écrit sa propre entrée membershipStates/<son identifiant de principal>: "knocking" avec ChatRoom/set, dans une modification à part, et un propriétaire l’accepte (invited ou joined) ou la refuse en retirant l’entrée. Demander à entrer exigeait un identifiant de salon appris ailleurs : une porte que personne ne voyait. ChatRoomDirectory est l’endroit où trouver un tel salon, et le seul où l’existence d’un salon parvient à quelqu’un qui n’en fait pas partie.
- Quels salons. Les groupes et canaux
allowKnocking de l’organisation de l’appelant ; jamais un salon direct, un salon à la corbeille, un salon dont l’appelant est déjà membre ou dont il est banni.
- Ce qu’il montre, et rien d’autre.
id (celui que nomme la demande), roomType (group ou channel), name (null s’il n’y en a pas), description (vide s’il n’y en a pas), memberCount (combien, jamais qui) et knocking (l’appelant a déjà demandé). Le dernier message, les participants, les rôles et la carte des appartenances sont des données de membres et n’y figurent jamais ; properties est un vocabulaire fermé, un nom inconnu donnant invalidArguments. C’est un type à part entière, pas un élargissement de ChatRoom/query, dont le résultat ne contient jamais que les salons de l’appelant.
- Aucun oracle.
ChatRoomDirectory/get avec ids: null rend tout l’annuaire de l’appelant ; un identifiant qui n’y figure pas est dans notFound, quelle qu’en soit la raison (salon fermé aux demandes, bannissement, identifiant inconnu, ou salon déjà à l’appelant).
- La requête.
ChatRoomDirectory/query filtre sur text (une sous-chaîne du nom ou de la description, sans tenir compte de la casse) et sur roomType, et ne trie que par name (puis id, un salon sans nom en dernier) ; tout autre comparateur donne unsupportedSort.
- Pas de
/changes. L’annuaire couvre toute l’organisation et n’a pas de journal de changements à lui : son state et son queryState sont une empreinte de ce qu’il montre, et canCalculateChanges vaut false. Un client le relit quand il l’affiche.
La capacité chat annonce roomDirectory: true : un client ne propose « parcourir les salons » que sur un serveur qui l’a, au lieu de sonder ChatRoomDirectory/get à chaque démarrage et de recevoir unknownMethod d’un serveur plus ancien.
La transcription des messages vocaux
Un message vocal est un message ordinaire dont l’enveloppe porte exactement une pièce jointe audio/*. Sa transcription est portée par le serveur, pas par chaque client : ChatMessage/transcribe {accountId, id} fait calculer au serveur la transcription une fois par message, et elle est écrite sous la clé transcript, gérée par le serveur, de content sur la copie de chaque membre, comme reactions. L’aperçu du salon (quand le message n’a pas de text), le corps de notification et la recherche plein texte la lisent, ce qu’une transcription faite sur un seul appareil ne pourrait jamais alimenter.
- La réponse est
{accountId, id, transcript, submitted}. Une transcription done est rendue telle quelle (submitted: false) ; sinon le serveur écrit pending sur chaque copie (chaque membre voit un changement d’état ChatMessage), confie l’audio une fois à son moteur et répond submitted: true, et l’état final arrive par un autre changement d’état. Seul done porte text ; failed porte l’error du moteur et peut être redemandé.
- Sans moteur, l’instance le dit. Un serveur sans moteur de reconnaissance vocale écrit
failed avec "no transcription engine", jamais un silence ; les champs de provenance (provider, endpointHost, residencyTier) disent où l’audio est allé quand un moteur a tourné.
requestedBy nomme le demandeur par identifiant de principal, l’identité par laquelle tous les autres champs du chat nomment les personnes, pour que chaque membre puisse le résoudre.
- Erreurs :
invalidArguments quand l’appelant ne détient pas ce message ou qu’une transcription est déjà pending ; invalidProperties (id) quand le message n’est pas un message vocal ; forbidden sur un compte partagé, ou quand la politique de l’instance est never.
- La politique de l’instance est le réglage d’exploitation
[chat] transcript_policy, annoncé nulle part : on-demand (le défaut, le premier membre qui demande), always (transcrit à l’ingestion, par un moteur que seule une édition étendue porte) ou never.
Les fils et les notifications
- Un fil se suit et se quitte explicitement.
ChatMessage/get sert threadFollowed sur une racine de fil (false sur une réponse, qui n’a pas de fil à elle). ChatMessage/set update {threadFollowed: true | false} sur sa propre copie de la racine, dans une modification à part, suit ou quitte le fil ; sur une réponse, c’est refusé par invalidProperties. Quitter oublie l’état non lu du fil et le sort de la vue myThreads ; répondre, être mentionné ou marquer le fil non lu le fait suivre à nouveau.
- Une réponse ne notifie que les suiveurs du fil, par push comme par trame WebSocket ; un message racine notifie tout le salon. Un message programmé suit la même règle quand il part.
- La préférence de notification d’un salon est appliquée par le serveur.
ChatRoom.notificationPref (all, mentions ou nothing, par membre) filtre la notification avant son émission, donc elle vaut pour le Web Push d’une appli fermée comme pour la trame dans l’appli : nothing ne notifie jamais, mentions seulement quand le message est mis en évidence pour ce membre (une mention, une mention de tout le salon, une règle de mot-clé, jamais d’un expéditeur mis en sourdine), et une valeur que le serveur ne connaît pas notifie plutôt que de réduire quelqu’un au silence.
- La notification d’un canal ou d’un groupe dit qui écrit. Le titre est l’expéditeur, avec le salon entre parenthèses (
Alice (#general), Alice (Projet X), Alice dans un salon direct ou sans nom) : un client qui masque l’aperçu garde le corps privé et nomme quand même l’expéditeur ; entity.authorPrincipalId porte l’identifiant de principal de l’auteur.
- Une requête de fil filtre comme toute autre.
ChatMessage/query {inThread} honore sendStatuses (par défaut sent seul, donc une réponse programmée n’apparaît pas dans le fil avant son heure d’envoi), text, isRoot et hasThread ; la racine ne reste dans le résultat que si elle correspond.
Les appels dans le salon
Un appel ancré sur un salon (Meet) y laisse sa trace : le serveur écrit un message d’appel par session, un ChatMessage dont le champ call porte le résumé. Les compteurs de non-lus, la liste des salons, la lecture hors ligne, la recherche et chaque appareil passent ainsi par la mécanique des messages qu’un client possède déjà.
- Quand il est écrit. Quand la session est terminée ; dans un salon direct, dès que la sonnerie du seul appelé s’arrête sans réponse alors que l’appelant attend encore, car un appel manqué ne doit pas attendre que l’appelant raccroche (une arrivée plus tardive ne le réécrit pas). Une réunion dont la conversation n’a jamais été ouverte n’a pas de salon : elle ne reçoit aucun message plutôt qu’une conversation inventée.
- Ce qu’il dit.
media vaut audio pour une session audio seule et video sinon ; startedAt est l’instant où un deuxième participant est arrivé (null si personne n’est venu), et durationSeconds vaut alors 0 ; answeredPrincipalIds contient qui était dans l’appel, l’initiateur compris dès qu’un autre l’a rejoint ; missedPrincipalIds qui a sonné sans jamais rejoindre avant la fin de la sonnerie ; declinedPrincipalIds qui a refusé sans rejoindre ensuite. Chaque lecteur y apprend s’il a pris l’appel : un client écrit « appel manqué » pour un principal de missedPrincipalIds, l’appel et sa durée pour les autres.
- Non lu seulement pour qui l’a manqué. Le message n’augmente les compteurs de non-lus que des comptes de
missedPrincipalIds ; pour tous les autres, il est lu dès son écriture.
- Il appartient au serveur, pas au client. Son auteur est l’initiateur de l’appel et son
content vaut null ; son contenu, ses mentions et son sondage ne se modifient pas (forbidden), il s’épingle ou se caviarde comme tout message, et un client qui envoie call reçoit invalidProperties.
- La liste des salons le sait.
ChatRoom.lastMessageCall porte le même résumé tant que le message d’appel est le dernier du salon, et repasse à null au message suivant. Un message d’appel laisse lastMessagePreview à null, et une liste de salons ne charge aucun message : c’est donc ce champ qui permet à un client d’écrire « appel manqué » sur la ligne du salon, et de distinguer un appel d’un dernier message dont le serveur ne sait pas lire le corps, au lieu d’annoncer un nouveau message. Il est posé par le serveur (refusé par ChatRoom/set), toujours présent sur le fil, null compris, pour qu’un patch poussé puisse l’effacer, et projetable comme toute propriété de ChatRoom. Les salons dont le dernier message était déjà un message d’appel ont reçu leur résumé à la migration.
- Un onglet ouvert apprend que sa sonnerie s’est arrêtée. Le Web Push qui le dit est omis tant qu’un onglet est ouvert ; le WebSocket porte à la place son équivalent, une
Notification de category call dont l’entity vaut {sessionId, ringing: false, outcome}, outcome valant answered, declined ou missed. L’appelant lit dans MeetSession.missed qui n’a pas répondu, au lieu de sonner sans fin.
Les requêtes de messages
ChatMessage/query pagine comme le dit la RFC 8620 §5.5, quel que soit le périmètre (un salon, inThread, une recherche de texte, isSaved, myThreads) :
- Une ancre doit faire partie du résultat. Un
anchor hors du résultat du filtre, ou inconnu, donne anchorNotFound, là où il était lu comme un curseur ou finissait en serverFail. anchorOffset s’applique en entier et s’arrête à 0, position dans la réponse est l’indice du premier identifiant rendu, et chaque page garde l’ordre du tri.
- Un fil lu depuis une ancre ne liste que ce fil. Avec
inThread et anchor, la requête parcourait tout le salon autour de l’ancre alors que son total comptait le fil ; et une recherche de texte lue depuis une ancre échouait. Chaque page et le total passent désormais par un seul et même prédicat.
calculateTotal est honoré sur tous les périmètres. Une recherche par salon ou sur tous les salons (roomId, accessibleByMe) rend le total demandé, compté sur toutes les correspondances et pas seulement sur la page affichée ; il manquait en silence hors de isSaved et myThreads. Il n’est compté que sur demande.
Transport et vivacité
- WebSocket (
/jmap/ws) : appels de méthode et push sur une seule socket. Les indicateurs de saisie sont des trames WS hors du modèle de requête JMAP, limitées aux seuls membres du salon et bridées à une trame par 3 s par (salon, compte) ; les arrêts passent toujours pour que les indicateurs s’effacent.
- La présence porte une note. À côté du statut à quatre états, une note libre avec un emoji optionnel (« 🌴 de retour le 12 ») se pose par une trame
ChatCustomStatus à part (text, emoji ; un texte vide sans emoji l’efface), parce que la plier dans la trame de présence ferait qu’un champ absent voudrait dire « inchangé » ou « efface-moi ». Elle est éphémère comme le reste de la présence, perdue au redémarrage, servie en customStatus (null quand il n’y en a pas) dans les diffusions ChatPresence comme dans ChatPresenceResponse. La capability chat l’annonce, customStatus: true avec maxCustomStatusChars (100) et maxCustomStatusEmojiChars (16), référencés depuis les constantes que le validateur applique, pour qu’un client négocie au lieu d’émettre et d’encaisser un refus ; une note trop longue est refusée, jamais tronquée.
- La vivacité est revérifiée. L’ancre d’authentification de la socket (la session d’appareil) est revalidée périodiquement ; une session révoquée ou expirée ferme en
4401 et le client se reconnecte proprement, au lieu de traîner à moitié mort (le chat coule, les requêtes HTTP échouent). La rotation de routine des tokens ne déconnecte jamais un client sain.
Plafonds de capacité
L’objet de capacité du chat annonce les quotas appliqués comme données : {maxRoomsPerAccount: 100, maxParticipantsPerRoom: 200, maxMessageSizeOctets: 65536, maxPinnedPerRoom: 50, maxReadReceiptLiveMembers: 20}, pour que les clients se bornent avant de rencontrer overQuota/tooLarge (multi-tenant). maxPinnedPerRoom plafonne les messages épinglés par salon ; la taille de message borne l’objet de création sérialisé, enveloppe content comprise ; maxReadReceiptLiveMembers est expliqué sous les accusés de lecture.
Les accusés de lecture
ChatReadReceipt est un curseur de lecture par membre, à savoir l’identifiant du dernier message que ce membre a lu. C’est ce qu’un client affiche sous forme d’une pile d’avatars de lecteurs sous un message.
- Un curseur n’avance jamais que vers l’avant.
ChatReadReceipt/set refuse un lastReadMessageId explicite plus ancien que celui déjà stocké, avec invalidProperties, avant toute écriture : une entrée refusée laisse donc intacts le curseur comme le badge de non-lus. Pour remettre un salon en non-lu, utilisez destroy : c’est le sens prévu. Quand le curseur a été déduit par défaut au lieu d’être envoyé par l’appelant, ce qui arrive tout seul lorsque le message le plus récent est détruit, ce n’est jamais une erreur : le curseur stocké l’emporte, et la réponse rapporte le curseur que le serveur détient, pas le plus ancien qu’on lui a passé.
- Une lecture prévient les autres membres, en direct. Marquer un salon comme lu met en file un changement d’état
ChatReadReceipt pour chacun des autres membres, afin que leur surface « vu par » suive la lecture, au lieu d’être juste au chargement puis figée. Deux bornes s’appliquent : un canal qui garde ses curseurs cachés ne prévient personne, et un salon plus grand que maxReadReceiptLiveMembers (20 par défaut) ne prévient personne non plus, car une lecture prévient tous les autres membres et le volume croît donc comme le carré de la taille du salon. Au-delà de ce plafond, les curseurs restent exacts et ChatReadReceipt/get les renvoie toujours tous : ils se rafraîchissent simplement à la prochaine récupération plutôt que dans la seconde.
- Les salons directs et de groupe renvoient l’accusé de chaque membre. Un canal non, sauf s’il le choisit. Le raisonnement derrière la fermeture des curseurs d’un canal, c’est qu’un grand salon de diffusion laisserait fuiter des habitudes de lecture : vrai là, et faux pour le petit canal d’équipe où « qui a vu ce message » est justement la raison de poster.
ChatRoom porte donc readReceiptsVisible : false par défaut, pour que la confidentialité reste le défaut et que les salons existants gardent leur comportement, réglable par un propriétaire uniquement, et propriété partagée du salon plutôt que préférence par membre, puisqu’elle décide de ce que le salon divulgue de ses membres et qu’un membre ne peut pas y répondre pour les autres. La poser sur un salon direct ou de groupe est refusé au lieu d’être stocké : ces salons renvoient déjà tous les accusés, et une valeur stockée là serait une promesse que rien ne tient.
- Un membre peut lire sans le signaler, en salon direct et de groupe, par réciprocité. Quand la préférence de compte
chat.sendReadReceipts (Preferences/set, true par défaut) est éteinte, le curseur du membre avance toujours (c’est son état de non-lu) mais il n’est servi à personne et ne prévient personne ; en retour, le ChatReadReceipt/get de ce membre renvoie ses propres curseurs et ceux de personne d’autre. Les canaux gardent readReceiptsVisible.
Envoi programmé et aperçus de liens
Deux capacités voisines annoncent des surfaces d’extension de ChatMessage :
urn:oximail:params:jmap:scheduled-send : une création ChatMessage/set peut porter scheduledAt ; le message attend avec un sendStatus jusqu’à ce que le planificateur côté serveur le délivre (le même planificateur que les alertes d’agenda).
urn:oximail:params:jmap:link-unfurl : le serveur remplit les aperçus de liens sur les messages (récupérés côté serveur derrière une garde anti-SSRF ; les clients ne récupèrent jamais eux-mêmes des URL tierces).
ChatMessage.unfurls est un tableau d’objets typés, complété au fur et à mesure que chaque URL du corps se résout, et absent tant qu’un message n’a pas d’URL ou qu’aucun aperçu n’a encore abouti :
Tous les champs sauf url sont facultatifs et omis quand la page source ne les fournissait pas. Le stockage reste un blob JSON dans une colonne, mais c’est désormais un détail d’implémentation que le fil ne laisse plus filtrer : cette propriété était auparavant projetée telle que la colonne, soit un tableau d’octets bruts ([91, 123, 34, …]), ce qui coûtait environ quatre caractères sur le fil par octet utile et obligeait chaque client à réimplémenter octets vers UTF-8 puis analyse JSON, pour une donnée que le serveur produit et comprend entièrement. Le décodage a maintenant lieu une seule fois, côté serveur, là où un blob impossible à analyser échoue bruyamment au lieu d’être silencieusement remplacé par une liste vide.
content reste un tableau d’octets sur le fil pour une autre raison : c’est l’enveloppe du client lui-même, stockée telle quelle pour que les clés que le serveur ne connaît pas fassent l’aller-retour intactes. unfurls est une donnée que le serveur produit, il est donc servi comme donnée.
Comportements à connaître
-
La diffusion est matérialisée : un message de salon écrit une ligne par membre, ce que les plafonds bornent, et c’est pourquoi les écritures d’appartenance, de message et de journal de changements sont transactionnelles (
/changes ne peut jamais manquer un message validé).
-
Les mentions sont structurelles (
@principal comme donnée, pas un balayage de chaîne) : un renommage ne les casse pas et les clients les rendent fiablement.
-
Les éditions de messages gardent l’historique ; les références de l’historique d’édition sont suivies par le GC de blobs, une pièce jointe éditée ne devient jamais une référence pendante.
-
Les messages différés et les rappels passent par le même ordonnanceur côté serveur que les alertes d’agenda.
-
Une réaction est bornée à 64 caractères, emoji composés intacts ; chaque saisie libre de la surface a une borne, et celle-ci était l’exception.
-
Créer un salon refuse ce que le modifier refusait. Les deux bras de
ChatRoom/set rejettent une propriété inconnue en la nommant ; create lisait les clés qu’il connaissait et annonçait un succès pour le reste. ChatMessage et ChatRoom déclarent chacun un vocabulaire d’écriture unique, vérifié avant que le gestionnaire ne s’exécute : une propriété inconnue est refusée en la nommant, et les sendStatus, sentAt et authorPrincipalId d’un message, posés par le serveur, sont refusés avec leur motif.
-
Aucun chiffrement de bout en bout n’est servi. La surface
keySlots, qui laissait un client déposer la clé d’un salon enveloppée pour chaque membre, est retirée (zéro ligne mesurée sur six instances), et un ChatRoom ou un ChatMessage créé avec un encryptionScheme autre que none est refusé par invalidProperties, au lieu d’être stocké et indexé en clair sous une étiquette qui disait le contraire.
Les règles de rigueur sont identiques à la surface courrier.