Skip to main content
Une instance OxiMail stocke tout à trois endroits, tous sous votre contrôle : Plus /etc/oximail/ : la configuration, les clés DKIM, et le matériel de clé racine, qui ne garde plus le contenu mais enveloppe toujours des secrets valables ailleurs (hachages d’app passwords, clés d’API de fournisseurs, identité de nœud de fédération). Les trois emplacements de données plus /etc/oximail forment la surface de sauvegarde complète (exploitation).

Comment le courrier est rangé

Un message est stocké une fois, sous deux formes (ADR-009) : les octets bruts RFC 5322 comme blob, et les métadonnées analysées (en-têtes, structure, hasAttachment, aperçu, fil) comme lignes. Rien n’est ré-analysé à la lecture. Les blobs sont adressés par le SHA-256 de leur clair : une pièce jointe envoyée à dix collègues est stockée une fois par organisation. La déduplication opère sous le niveau des boîtes, et deux copies d’un message dans deux boîtes sont toujours deux lignes de métadonnées pointant vers un blob (jamais de déduplication au niveau du message : cette classe de bug est exclue par construction). Le répertoire de blobs est cloisonné par organisation et par compte ({tenant}/{compte}/..., avec une zone réservée _tenant/ pour le contenu d’organisation) : c’est ce qui rend l’isolation par organisation et la sauvegarde par organisation physiques plutôt que logiques.

Les dossiers

Les dossiers sont des Mailboxes JMAP avec les rôles RFC 8621 (inbox, sent, drafts, trash, junk, archive), provisionnés par compte dans la langue du compte. La hiérarchie, les compteurs par dossier et le partage (shareWith) sont des données. La passerelle IMAP montre le même arbre. Les dossiers en double laissés par d’anciens imports se réparent avec oximail account dedupe-folders (migration).

Corbeille, restauration et rétention

La destruction est en deux temps avec une corbeille visible (ADR-035) : un objet détruit atterrit dans deleted_items et peut être restauré. C’est ce qui soutient les corbeilles de tous les clients et la récupération des ressources DAV détruites. Indépendamment de la corbeille utilisateur, deux fenêtres de rétention comptent en exploitation :
  • change_log : 90 jours. L’historique de synchronisation incrémentale (/changes, sync-collection) est élagué au-delà de 90 jours ; un client absent plus longtemps fait une resynchronisation complète au lieu d’une incrémentale. C’est voulu : le journal de changements est un mécanisme de synchronisation, pas une piste d’audit. L’élagage garde la ligne la plus récente de chaque collection : une collection restée 90 jours sans changement garde son état, et ses clients restent en synchronisation incrémentale. Avant, elle était vidée, son état retombait à 0 et sa numérotation repartait sous le plancher qu’elle venait d’enregistrer. Chaque /changes sur elle était alors refusé pour de bon avec cannotCalculateChanges, et un client resté entre les deux ne recevait rien. Une migration du schéma a réparé les collections déjà touchées : un client à jour n’a rien perdu, et tout curseur plus ancien se resynchronise une fois au lieu de manquer un changement.
  • audit_log : des années. La piste d’audit administrative a son propre horizon long, avec un worker de rétention qui l’applique (conformité).
La rétention qu’un locataire règle s’applique telle quelle : retention.trash_days et retention.chat_days (des surcharges de configuration par locataire) prennent effet dès 1 jour, là où une valeur sous 7 était remontée en silence à 7 par les travailleurs et refusée par l’API d’administration pour la corbeille, si bien que le serveur gardait la donnée plus longtemps que le responsable ne le demandait ; pas de valeur ou 0 veut toujours dire pas de purge. Les corbeilles des tâches, des contacts, des événements et des notes passent par la sortie du Drive, avec le même respect des séquestres et la même annonce aux clients synchronisés, au lieu de 30 jours en dur détruits en silence. Attention à la différence : la corbeille du courrier n’est pas purgée tant qu’un locataire ne pose pas retention.trash_days, alors que les six autres corbeilles (fichiers, événements, contacts, tâches, notes, salons de chat) sont purgées à la valeur de l’instance (30 jours par défaut) tant qu’un locataire ne la surcharge pas (horizons par locataire). Le ramasse-miettes des blobs supprime les blobs que plus aucune ligne ne référence, avec un suivi de références qui inclut les détenteurs non évidents comme l’historique d’édition : le GC n’orpheline jamais un contenu que quelque chose peut encore afficher.

Salons de chat

Quand le propriétaire d’un groupe ou d’un canal le supprime pour tout le monde, le salon part dans une corbeille au lieu d’être effacé. La copie du salon de chaque membre, ses messages, accusés de lecture, messages enregistrés et sondages passent dans des tables de corbeille, et les messages quittent l’index de recherche. Les messages programmés et jamais envoyés sont détruits. Les membres ne voient plus le salon ; ses propriétaires peuvent encore le retrouver et le restaurer.
  • Purge. Le balayage des corbeilles détruit un salon mis à la corbeille depuis plus de trash_retention_days jours. Un locataire peut fixer son propre horizon par la surcharge chat.trash_retention_days, 0 pour ne rien purger (horizons par locataire). Un client ne peut pas purger : supprimer à nouveau un salon à la corbeille répond « introuvable », donc un double clic n’efface jamais un salon pour de bon. Un salon dont un compte membre est sous conservation légale est gardé. La politique de rétention du chat d’un locataire (retention.chat_days) atteint aussi les messages de la corbeille.
  • Purge à la main. oximail chat purge-trash indique ce que le balayage détruirait, avec l’horizon de chaque locataire ; --apply le détruit, --days fixe un seul horizon pour toute la portée et --days 0 vide la corbeille, --tenant <id> se limite à un locataire, --json sert aux scripts. Code de sortie 1 si certains salons n’ont pas pu être détruits. La commande fonctionne serveur arrêté.
  • Restauration. oximail chat restore-room --tenant <id> --room <id> indique ce qui reviendrait ; --apply restaure. La copie d’un membre dont le compte a été supprimé ou désactivé ne revient pas, et le rapport nomme ce membre. La restauration est refusée, sans rien écrire, si un salon vivant porte déjà le même identifiant.
  • Restauration depuis une sauvegarde. Un salon supprimé avant l’existence de la corbeille, ou déjà purgé, revient avec --from-backup <data.db de l'instantané>, plus --blobs <répertoire de blobs de l'instantané> pour les pièces jointes que l’instance n’a plus. L’instantané est ouvert en lecture seule, et chaque pièce jointe récupérée est vérifiée par son hash avant d’être enregistrée. Une pièce jointe introuvable est nommée dans le rapport, et la commande sort en 1.
  • Propriétaires. Par JMAP, un propriétaire liste les salons à la corbeille avec ChatRoom/query et filter.isTrashed: true, et en restaure un avec le patch ChatRoom/set {"isTrashed": false}. La capacité chat annonce roomTrash: true.
  • Audit. chat/room.trashed (le détail dit si le propriétaire a tapé le nom du salon pour confirmer), chat/room.restored, et chat/room.destroyed pour la purge.

Les quotas

Chaque compte individuel a deux quotas de stockage : le courrier (50 Gio par défaut) et le Drive (1 Tio par défaut, fichiers, corbeille et versions conservées compris). N’ont aucun quota :
  • un compte partagé (boîte ou Drive d’équipe), quelle que soit sa taille ;
  • une boîte système : les entrées d’annuaire de type other (postmaster, abuse, hostmaster, dmarc-reports…), et toute boîte que vous reclassez par oximail admin principal set-type --account <adresse> --type other (une archive de journaling, une boîte de rapports ; elle sort aussi des sélecteurs de personnes) ;
  • un compte que l’administrateur a déclaré illimité.
L’organisation (locataire) ne porte pas de quota de stockage.
--mb 0 est refusé : dites --unlimited ou --default. La surface d’administration pose les mêmes valeurs par Principal/setAdmin (maxStorageBytes pour le courrier, maxDriveBytes pour le Drive ; null = défaut, 0 = illimité). Ce qu’un quota plein refuse.
  • Le courrier entrant est différé au RCPT en 452 4.2.2. L’expéditeur réessaie, et le message arrive si de la place se libère avant que sa file n’abandonne. Un message que le serveur a déjà accepté est toujours livré.
  • Les groupes de distribution : un membre plein est sauté (journalisé et compté) ; les autres membres reçoivent le message.
  • Les clients : Email/set, Email/import, Email/copy, FileNode/set et FileVersion/restore répondent overQuota ; IMAP APPEND répond NO [OVERQUOTA].
  • Les brouillons et Envoyés gardent une marge de 1 Gio au-delà d’un quota courrier plein : on peut encore envoyer depuis une boîte pleine.
  • Le Drive : un enregistrement qui rend un fichier plus petit n’est jamais refusé.
Les défauts du déploiement vivent dans [quota] :
Les deux routes de téléversement passent par la même admission : /jmap/upload-tenant, qu’utilisent les pièces jointes du chat et les avatars de salon, applique la garde disque, les refus de quota plein et la fenêtre de 24 h exactement comme /jmap/upload ; elle ne vérifiait que la taille unitaire et la concurrence, si bien qu’un compte pouvait remplir le disque sans envoyer un message. L’usage est un compteur par compte et par ressource, tenu dans la transaction de chaque écriture. oximail account recount compare chaque compteur aux lignes qu’il compte (simulation par défaut, --apply répare), et oximail doctor rapporte la même comparaison sous quota_usage, plus quota_full pour les comptes au seuil souple ou au-delà. Les refus sont journalisés avec le compte et comptés dans oximail_quota_refused_total{resource,door}. oximail admin account-storage --json ajoute les champs mail* et drive* par compte.

La garde d’espace disque

Indépendamment des comptes, le serveur mesure toutes les 30 secondes l’espace libre des volumes où il écrit (blobs, base, index). Sous [storage] min_free_percent (5 par défaut, 0 la désactive), les ajouts des utilisateurs sont refusés temporairement : le courrier entrant et la soumission sont différés en 452 4.3.1, les livraisons locales en file attendent, les clients reçoivent overQuota avec une description qui nomme le serveur (pas leur boîte), et les téléversements répondent 507. La garde se lève un point au-dessus du seuil. oximail doctor rapporte storage_free (avertissement sous deux fois le seuil), et oximail_storage_free_ratio{path} exporte la mesure. Autres bornes :
  • Par collection : des plafonds de volume (ADR-072) bornent la croissance de chaque collection.
  • Les plafonds de capacité du chat sont à part (multi-tenant).

L’index de recherche est jetable

L’index Tantivy ne porte aucune donnée primaire : il contient le texte cherchable du courrier, des notes et des messages de chat, entièrement recomposable depuis la base et les blobs. Il se trouve à [storage] search_path, ou à côté du répertoire des blobs quand cette clé n’est pas renseignée. Le serveur, oximail reindex, oximail migrate, la garde d’espace disque et doctor résolvent tous ce chemin par la même règle. À noter : l’index est le seul stockage dont le contenu n’est pas chiffré par compte (il doit être découpé en jetons côté serveur pour être interrogeable), et c’est pourquoi l’effacement de compte le balaie explicitement. Le texte d’un document retiré reste dans les fichiers de segments jusqu’au compactage suivant, lancé à la fin de chaque série de retraits. doctor search_index indique combien de documents retirés restent sur le disque et la date du dernier compactage. Chaque suppression laisse un ordre, même index fermé. Un message, une copie de chat ou une note détruits écrivent un ordre de purge de l’index dans la file des destructions, dans la transaction même de la suppression. Le worker l’exécute dès qu’un index est ouvert, et rien de ce qui attend n’est consommé entre-temps. Les notes n’étaient retirées que par un index vivant dans le même processus : une page détruite pendant que l’index était fermé, ou purgée de la corbeille, restait trouvable au retour de l’index et échappait à un effacement.

L’index se reconstruit seul après une mise à jour

L’index porte un numéro de génération, relevé quand une version change ce qu’il contient (la génération 3 range le sujet, From, To, Cc et le corps en cinq champs, là où les précédentes tenaient un seul texte concaténé). Au premier démarrage d’un binaire qui écrit une génération plus récente :
  1. chaque ligne est marquée à indexer, l’ancien index est mis de côté sous <path>.previous-g<N> et un index vide est ouvert dans l’état en reconstruction ; les écoutes démarrent sans attendre ;
  2. le nouvel index est amorcé depuis l’ancien, si bien que la recherche est servie complète dès les premières minutes, sans rien faire avancer : le texte de l’ancienne génération a été composé selon les anciennes règles ;
  3. chaque message (depuis son fichier brut), chaque note et chaque copie de chat est recomposé depuis les données stockées, par lots de 256 lignes avec un budget de 2 secondes par tranche, pour qu’aucune écriture en direct n’attende longtemps derrière un commit ; une ligne impossible à projeter est mise à l’écart après trois tentatives ;
  4. le répertoire mis de côté est supprimé une fois l’amorçage terminé. Un arrêt brutal à n’importe quelle étape reprend au début de l’amorçage au démarrage suivant, en reconnaissant les documents déjà écrits.
Un message dont le fichier brut a été perdu n’a plus d’autre source que l’ancien index : son texte est recopié dans la base avant le départ de l’ancien index, et doctor search_text_evidence compte ces messages. La perte de données est nommée, jamais silencieuse. Un binaire plus ancien refuse un index de génération plus récente d’après son numéro, et la migration de base de la même version refuse de toute façon ce binaire (mises à jour). La suppression de l’index mis de côté n’écrit plus de fausse ligne ERROR de la bibliothèque d’index à ce premier démarrage : la reconstruction ferme sa vue en lecture seule de l’ancien index avant de le supprimer.

Un index que le serveur ne peut pas ouvrir

Un index impossible à ouvrir n’est jamais un repli silencieux. Son état est publié par collection dans oximail_search_index_state{collection} (0 ouvert, 1 en reconstruction, 2 impossible à ouvrir ; alertez sur > 0 au-delà de la durée d’une reconstruction). Le journal de démarrage écrit une ligne error qui dit ce que le serveur fait à la place et l’unique remède, et oximail doctor met search_index en échec avec le même texte. Pendant ce temps, le courrier est toujours distribué et laissé à indexer pour un serveur qui ouvrira son index, les ordres de purge attendent, et rien de ce qui attend l’index n’est consommé. Email/query répond à text et body depuis le sujet, les adresses et l’aperçu, et le signale aux clients de la capability mail propre à OxiMail (fullTextIndexUnavailable) ; ChatMessage/query et Note/query refusent. Le serveur n’ouvre son index qu’au démarrage, d’où le remède : arrêter le serveur, reconstruire, le redémarrer.
oximail reindex travaille hors ligne : il est refusé (code 2) tant qu’un serveur tient l’index. Il règle d’abord tout index mis de côté par une reconstruction (il recopie le texte des messages dont le fichier brut a disparu, puis le supprime), marque chaque ligne à indexer, supprime le répertoire de l’index (le met de côté s’il est illisible) et déroule le rattrapage jusqu’à ce que plus rien ne soit dû. Il procède par lots de 2 048 lignes, puisqu’aucune écriture en direct n’attend derrière ses commits : environ 3 300 documents par seconde ont été mesurés à cette taille, contre environ 400 avec les 256 du serveur en service. Il reconstruit le courrier, les notes et le chat ; les contacts ne sont pas dans l’index. --tenant <id> garde l’index et réécrit par-dessus les documents de ce tenant. Code 0 terminé, 1 si des lignes restent dues ou si un index mis de côté illisible a été gardé, 2 refusé ou en échec ; --json donne {verb, state, detail, marked_due, seeded, indexed, left_due, evidence_recorded, set_aside{settled, kept}}. À côté d’un serveur en marche, oximail email reindex-bump --unindexed marque à indexer les messages absents de l’index validé, pour le rattrapage du serveur lui-même, sans rien arrêter. doctor lit l’index en lecture seule et n’en crée jamais :

Maintenance de la base

Aucune planifiée, et c’est voulu. Le checkpoint du WAL est automatique (borné vers 64 Mio, checkpoint à l’arrêt), les statistiques du planificateur de requêtes sont rafraîchies après les migrations, et le pool de connexions n’a pas besoin de réglage en dessous de quelques centaines d’utilisateurs simultanés ([storage] pool_size, 32 par défaut, à augmenter sur une machine chargée en lecture). L’histoire opérationnelle de la base tient en trois points : garder de l’espace disque, garder des sauvegardes, et laisser le serveur tourner. oximail sql existe comme échappatoire gardée pour les scénarios de support ; l’inspection au quotidien passe par les verbes CLI (oximail mailbox, oximail email) et par les vues de données par compte de l’API d’administration.

Ce que promet un accusé de réception

La base tourne en mode WAL avec synchronous = NORMAL : un commit survit à un plantage du processus, mais une coupure de courant ou un plantage de l’hôte peut annuler les dernières secondes de commits. La plupart des écritures s’en accommodent. Trois réponses ne le peuvent pas, parce qu’elles disent à l’autre côté qu’il peut oublier les données : le 250 qui suit les données d’un message (la RFC 5321 §6.1 interdit de perdre un message après lui, même si l’hôte plante ensuite), sur le port entrant et sur la soumission, et la réponse à un FLUSH sur le partage de fichiers (qui promet que les données ont atteint le stockage sous-jacent). Avant chacune de ces réponses, le serveur passe une barrière de durabilité : un fdatasync du fichier WAL, hors du verrou d’écriture de SQLite, qui rend durable chaque commit écrit avant elle, quel qu’en soit l’auteur. Seul le client qui a demandé attend (2,5 ms par synchronisation, mesurées sur un disque de production). Une barrière qui échoue en SMTP répond 451 au lieu de 250 : l’expéditeur réessaie, un doublon plutôt qu’une perte. Quand une porte SMTP est durable, le spool du relais synchronise aussi le fichier du message et son répertoire.
La liste sert à retirer une porte ; un nom inconnu fait refuser la configuration. oximail_durability_barrier_seconds{door,result} mesure chaque barrière (métriques).