Client webmail personnel, exécuté entièrement sur un Worker Cloudflare unique. Chaque installation le configure avec son propre domaine et ses propres identités d'envoi (voir « Mise en service » ci-dessous).
Cloudmail tient dans un seul Worker Cloudflare qui porte trois rôles à la fois :
le handler email() (exporté par src/email.ts) reçoit les messages entrants via
Email Routing et les fait suivre au pipeline d'ingestion (parsing MIME, stockage,
threading) ; une API HTTP construite avec Hono (src/api/routes.ts) expose les
opérations de lecture, réponse, recherche et suppression sous /api/* ; et le même
Worker sert les fichiers statiques du SPA (le binding ASSETS, construit dans
web/dist) pour toute autre route, avec fallback SPA. Les métadonnées
structurées (identités, threads, messages, pièces jointes) vivent dans une base
D1 ; le MIME brut de chaque message reçu et les corps des pièces jointes sont
stockés dans un bucket R2. Cloudflare Access se place devant l'ensemble de
l'application : aucune requête n'atteint l'API ou le SPA sans un jeton Access
valide (sauf en développement local, voir plus bas).
Les redirections sont gérées depuis l'application, pas depuis le tableau de bord
Cloudflare. Une règle de la table D1 forward_rules associe une adresse source
— une partie locale, ou * pour toutes les adresses du domaine — à une
destination vérifiée sur le compte Cloudflare. À la réception, handleEmail
(src/email.ts) applique toutes les règles qui correspondent au
destinataire, catch-all comprise, et dédoublonne les destinations identiques.
Le forward précède l'archivage, parce que message.raw est un ReadableStream
à usage unique ; les deux étapes sont isolées. Ce que cette isolation garantit
exactement : une exception levée par le forward ou par la lecture des règles
est rattrapée et n'empêche pas l'archivage, et un forward qui bloque est
abandonné au bout de dix secondes par destination (FORWARD_TIMEOUT_MS dans
src/email.ts) pour que l'archivage garde du temps d'exécution — sans cette
borne, un forward qui ne rend jamais la main laisserait le message sans objet R2
ni ligne D1, puisque rien n'est encore écrit à ce moment-là. Aucune de ces
situations n'appelle setReject. Cette séparation est ce qui permet
qu'un message arrive à la fois dans Cloudmail et dans une boîte externe :
Cloudflare Email Routing ne sait livrer qu'à un Worker ou à une adresse,
jamais aux deux.
Cloudmail est destiné à être cloné puis déployé par d'autres personnes. Une valeur qui décrit une installation n'a donc pas sa place dans le dépôt, même lorsqu'elle n'est pas secrète. Le critère n'est pas « est-ce confidentiel ? » mais « est-ce que cette valeur change d'une installation à l'autre ? ».
Un exemple qui illustre la nuance : le team domain Cloudflare Access et
l'Application Audience (AUD) de votre application sont publics — Cloudflare
les sert à n'importe quel visiteur anonyme dans la redirection vers la page de
connexion, on les lit dans un simple curl -I. Ils ne doivent pourtant pas être
versionnés, parce qu'ils désignent votre installation et personne d'autre.
Sont concernés : adresses email, domaines et sous-domaines, identifiants de compte, de base D1, de bucket R2 ou de zone DNS, team domain et AUD Access, et bien sûr tout jeton ou clé.
Où les mettre. Toute valeur lue par le Worker via env se pose en secret,
qui ne transite jamais par git :
pnpm wrangler secret put ACCESS_TEAM_DOMAIN
pnpm wrangler secret put ACCESS_AUD
pnpm wrangler secret put ALLOWED_EMAILSEn développement local, copiez .dev.vars.example vers .dev.vars (ignoré par
git) et renseignez-y les mêmes variables. Si l'une manque en production,
requireAccessConfig() (src/auth/access.ts) refuse toute requête avec un
message qui la nomme, au lieu de laisser passer une erreur interne opaque.
Dans les tests et la documentation, n'utilisez que des valeurs d'exemple neutres
(vous@example.com, example.com) — jamais une adresse ou un domaine réel.
Pour ce qui n'est pas lu via env. Une route et un database_id sont de la
configuration de déploiement : Wrangler les lit dans son fichier de config, ils
ne peuvent donc pas être des secrets. Le fichier versionné wrangler.jsonc n'en
porte que des placeholders — mail.example.com, "local", example.com — et vos
valeurs réelles vivent dans un wrangler.overrides.json à la racine, ignoré par
git :
{
"routes": [{ "pattern": "mail.votredomaine.fr", "custom_domain": true }],
"d1_databases": [{ "database_id": "<id affiché par wrangler d1 create>" }],
"vars": { "MAIL_DOMAIN": "votredomaine.fr" }
}scripts/config.mjs fusionne les deux vers .wrangler/generated.jsonc, que les
commandes de déploiement utilisent via -c. pnpm run deploy et
pnpm run migrate:remote s'en occupent seuls ; pnpm run config:check vérifie
sans rien écrire.
Deux conséquences voulues de cette conception :
- un clone neuf fonctionne sans préparation — les placeholders sont
structurellement valides, donc
pnpm testetpnpm devtournent immédiatement, Miniflare simulant D1 et R2 sans authentification ; - il est impossible de déployer par accident avec les placeholders —
scripts/config.mjsrefuse et nomme chaque valeur manquante, plutôt que d'envoyer votre Worker sur un domaine et une base qui ne vous appartiennent pas.
Les overrides ne remplacent que ce qu'ils mentionnent, et la fusion des tableaux
se fait élément par élément : n'indiquer que database_id conserve le binding,
le nom de base et le dossier de migrations décrits par le fichier versionné.
- Un domaine à vous (ex.
example.com) doit être géré sur Cloudflare (zone DNS active). Les instructions ci-dessous utilisentexample.comet le sous-domainemail.example.comcomme exemples : vos valeurs réelles vont danswrangler.overrides.jsonet dans les secrets, jamais dans un fichier versionné (voir la section précédente). - Un plan Workers Paid est nécessaire pour utiliser Email Sending (l'API
d'envoi utilisée par
src/send/client.ts). Email Routing, utilisé pour la réception, est gratuit et ne nécessite pas ce plan. - Cloudflare Access (Zero Trust) doit être disponible sur le compte pour protéger
le sous-domaine choisi (
mail.example.comdans les instructions).
Résumé de tout ce que lit src/env.ts (Env), toutes requises pour un
déploiement complet. Chaque ligne renvoie à l'étape de « Mise en service » qui
détaille comment obtenir la valeur ; ce tableau ne fait que rassembler où
chacune se pose.
| Variable | Nature | Où la poser en déployé | Où la poser en local | À quoi elle sert | Étape |
|---|---|---|---|---|---|
CF_ACCOUNT_ID |
secret | pnpm wrangler secret put CF_ACCOUNT_ID |
.dev.vars |
Identifie le compte Cloudflare dans l'URL appelée par src/send/client.ts (envoi) |
8-9 |
CF_API_TOKEN |
secret | pnpm wrangler secret put CF_API_TOKEN |
.dev.vars |
Authentifie l'envoi ; token dédié, permission Email Sending: Send uniquement | 8-9 |
CF_ROUTING_TOKEN |
secret | pnpm wrangler secret put CF_ROUTING_TOKEN |
.dev.vars |
Authentifie la lecture des destinations vérifiées (src/forwarding/destinations.ts) ; token distinct du précédent, permission Email Routing: Read uniquement |
8-9 |
ACCESS_TEAM_DOMAIN |
var | wrangler.jsonc → vars |
— (non vérifié en local, voir DEV_BYPASS_AUTH) |
Team domain Cloudflare Access, utilisé par src/auth/access.ts pour valider le JWT |
7 |
ACCESS_AUD |
var | wrangler.jsonc → vars |
— | Audience (AUD) de l'application Access, même vérification JWT | 7 |
ALLOWED_EMAILS |
var | wrangler.jsonc → vars |
— | Adresse(s) autorisée(s) à se connecter (doublon applicatif de la politique Access) | 7 |
MAIL_DOMAIN |
var | wrangler.jsonc → vars |
— | Domaine utilisé pour générer le Message-ID des emails envoyés (src/api/routes.ts) |
5 |
DEV_BYPASS_AUTH |
var, local uniquement | jamais posée en déployé | .dev.vars (=1) |
Désactive la vérification Access pour le développement local ; voir l'avertissement dans .dev.vars.example |
Développement local |
Les secrets (CF_*) sont posés une fois par wrangler secret put et ne sont
jamais lisibles a posteriori — pour les changer, il faut reposer la commande.
Les vars (ACCESS_*, ALLOWED_EMAILS, MAIL_DOMAIN) sont en clair dans
wrangler.jsonc et versionnées avec ce dépôt : voir l'avertissement de
« Prérequis Cloudflare » sur les trois valeurs à remplacer avant tout
déploiement. Les bindings DB (D1), MAIL (R2) et ASSETS ne sont pas des
variables mais des ressources déclarées dans wrangler.jsonc (étape 1).
Telles que définies dans package.json :
pnpm dev— lance en parallèlepnpm wrangler dev(le Worker, avec D1 et R2 simulés localement par Miniflare) etpnpm --filter web dev(le serveur de dev Vite du SPA).pnpm test— exécutevitest run(247 tests côté Worker : ingestion, API, auth, envoi, redirections) puispnpm --filter web test(51 tests côté SPA).pnpm build— construit uniquement le SPA (pnpm --filter web build), dont la sortie (web/dist) est servie par le Worker via le bindingASSETS.pnpm run deploy— génère la configuration de déploiement (scripts/config.mjs), reconstruit le SPA, puis déploie le Worker (code + assets) sur Cloudflare avec cette configuration. Lerunn'est pas optionnel ici : dans un workspace pnpm,deployest une commande native de pnpm, etpnpm deployéchoue donc avecERR_PNPM_NOTHING_TO_DEPLOYsans jamais lancer le script.pnpm run config— écrit.wrangler/generated.jsoncen fusionnantwrangler.jsoncetwrangler.overrides.json.pnpm run config:checkfait la même vérification sans rien écrire, utile pour s'assurer qu'une installation est complète avant de déployer.pnpm run migrate:remote— applique les migrations D1 sur la base distante avec la configuration générée.pnpm typecheck—tsc --noEmit, non demandé par le brief mais utile en local.
Les migrations deviennent immuables dès leur première application. Tant que la base distante n'existe pas,
migrations/0001_initial.sqlpeut encore être modifiée en place. Après la première application (étape 2 ci-dessous), toute évolution du schéma passe par un nouveau fichiermigrations/000N_*.sql: D1 enregistre les migrations déjà jouées par leur nom, donc une modification a posteriori ne serait jamais rejouée et la base garderait silencieusement l'ancien schéma. Si une base locale de développement se retrouve dans cet état, la réinitialiser suffit :rm -rf .wrangler/state/v3/d1 .wrangler/state/v3/r2puispnpm wrangler d1 migrations apply cloudmail --local.
Ces étapes touchent le compte Cloudflare payant de l'utilisateur et rendent le service public : elles ne sont pas automatisées et doivent être exécutées à la main, dans l'ordre, par la personne qui opère le compte.
L'ordre compte, et pas seulement pour des raisons de commodité : la base D1 est
migrée et peuplée (étapes 2 et 3) avant tout branchement d'Email Routing
(étape 6). L'ordre inverse perd du courrier — un message arrivé entre
l'activation de la règle catch-all et l'application des migrations rencontre
no such table: messages, l'erreur est avalée par handleEmail (qui n'appelle
jamais setReject, pour ne pas renvoyer de bounce à l'expéditeur), et le message
ne survit que comme objet R2 sans ligne D1 ni index inverse.
pnpm wrangler d1 create cloudmail
pnpm wrangler r2 bucket create cloudmailLa commande d1 create affiche un database_id. Créer alors, à la racine du
dépôt, un wrangler.overrides.json — ignoré par git — portant vos trois valeurs
de déploiement :
{
"routes": [{ "pattern": "mail.example.com", "custom_domain": true }],
"d1_databases": [{ "database_id": "REMPLACER_PAR_VOTRE_ID" }],
"vars": { "MAIL_DOMAIN": "example.com" }
}En remplaçant mail.example.com par votre propre sous-domaine et example.com
par votre domaine (celui de l'étape 5). Rien d'autre n'est nécessaire : les
overrides ne mentionnent que ce qui diffère, la fusion conservant le binding, le
nom de base et le dossier de migrations décrits par wrangler.jsonc.
Si cette étape est oubliée, rien ne part sur votre compte : scripts/config.mjs
refuse de produire une configuration contenant encore les valeurs d'exemple, et
nomme celles qui manquent. C'est délibéré — déployer avec les placeholders
enverrait le Worker sur un domaine et une base qui ne sont pas les vôtres.
Ces valeurs ne servent qu'au distant : en local, wrangler dev et les tests
Vitest lisent wrangler.jsonc avec ses placeholders, Miniflare simulant D1 et R2
sans authentification Cloudflare. C'est ce qui permet à un clone neuf de lancer
pnpm test et pnpm dev sans aucune préparation.
pnpm run migrate:remoteCe script génère la configuration de déploiement puis lance
wrangler d1 migrations apply cloudmail --remote -c .wrangler/generated.jsonc.
Le -c n'est pas décoratif : sans lui, Wrangler lirait le database_id
placeholder "local" du fichier versionné au lieu de votre base réelle.
Cette étape applique toutes les migrations du dossier migrations/ : les
tables (identities, threads, messages, ...) de 0001_initial.sql, puis la
table forward_rules de 0002_forward_rules.sql. Elle ne dépend que de l'étape
1 (base créée, database_id renseigné) : ni du Worker, ni d'Access, ni d'Email
Routing. Si elle est oubliée, toute requête D1 échoue avec « no such table » — y
compris celles du handler email(), dont l'échec est silencieux.
Sur une installation déjà déployée, cette commande est à rejouer avant de
déployer cette version. D1 n'applique que les migrations qu'il n'a pas encore
enregistrées, donc la rejouer sur une base à jour ne coûte rien ; l'omettre, en
revanche, ne casse rien de visible et c'est précisément le problème. Sans
forward_rules, GET /api/forwarding/rules répond 500 et la vue
« Redirections » affiche son message d'erreur au lieu de la liste, tandis que
chaque message entrant fait échouer la lecture des règles : handleEmail log
forward_rules_failed et archive normalement. Aucun courrier n'est donc perdu ni
rejeté, mais aucune redirection n'a lieu — la fonctionnalité est silencieusement
absente, et le reste jusqu'à ce que la migration soit appliquée.
pnpm wrangler d1 execute cloudmail --remote -c .wrangler/generated.jsonc --command \
"INSERT INTO identities (address, display_name, is_default) VALUES ('vous@example.com', 'Votre Nom', 1)"Remplacer vous@example.com et Votre Nom par l'adresse d'envoi et le nom
affiché souhaités, sur votre propre domaine — c'est ce nom qui apparaîtra chez
le destinataire comme expéditeur (« Votre Nom vous@example.com » plutôt que
l'adresse seule).
Cette étape crée l'identité d'envoi par défaut ; elle a besoin des tables de
l'étape 2. Sans ligne dans identities, l'API n'a aucune adresse From à
proposer pour composer ou répondre à un message, et POST /api/messages refuse
tout envoi avec unknown_sender. En SQL uniquement ici parce que le Worker
n'est pas encore déployé (étape 4) ni Access configuré (étape 7) — l'onglet
« Identités » de l'interface fait ensuite l'affaire pour toute identité
supplémentaire, sans repasser par wrangler d1 execute.
pnpm run deployCe premier déploiement n'a qu'un but : faire exister le Worker cloudmail sur
le compte Cloudflare. Il est nécessaire ici, avant même la configuration
d'Access et des secrets d'envoi, parce que l'étape 6 (Email Routing) doit
choisir ce Worker dans une liste déroulante du tableau de bord — et cette
liste ne propose que des Workers déjà déployés. Sans ce premier déploiement,
l'étape 6 est une impasse : la liste est vide et il n'y a rien à sélectionner.
Ce déploiement n'a besoin que des bindings de l'étape 1 (base D1 et bucket R2
existants). À ce stade, le Worker déployé est incomplet (secrets d'envoi
absents, ACCESS_TEAM_DOMAIN/ACCESS_AUD encore vides) : c'est normal, et sans
danger — avec ces variables vides, requireAccess() rejette toute requête
API en 401, donc rien n'est exposé publiquement entre ce déploiement et le
déploiement final de l'étape 10. Sa base, elle, est déjà migrée et peuplée
(étapes 2-3) : le Worker est d'emblée capable d'ingérer du courrier.
Tableau de bord Cloudflare → Email → Email Service → Sending → ajouter votre
domaine (example.com) et publier les enregistrements DNS demandés
(SPF/DKIM). Attendre le statut « verified ».
Reporter ce même domaine dans wrangler.jsonc, section vars →
MAIL_DOMAIN (utilisé pour générer le Message-ID des emails envoyés, voir
src/api/routes.ts).
Cette étape produit l'autorisation d'envoyer des emails depuis votre domaine
via l'API Cloudflare Email Sending. Si elle est oubliée ou incomplète, tout
envoi via src/send/client.ts échoue (l'API Cloudflare rejette les messages
provenant d'un domaine non vérifié).
Tableau de bord Cloudflare → Email → Email Routing → activer, puis créer une
règle catch-all « Send to a Worker » pointant sur le Worker cloudmail (visible
dans la liste grâce au déploiement de l'étape 4).
Cette étape produit le déclenchement du handler email() (src/email.ts) pour
tout message reçu sur *@votre-domaine. Si elle est oubliée, aucun message
entrant n'atteint jamais Cloudmail : Cloudflare les rejette ou les jette selon
la configuration DNS MX en place.
C'est la première étape à partir de laquelle du courrier réel peut arriver : elle exige donc que tout ce dont l'ingestion a besoin existe déjà — le Worker déployé (étape 4), le bucket R2 (étape 1) et surtout les tables D1 (étape 2). C'est la raison de la position de cette étape dans la séquence.
Attention aux règles littérales déjà en place — mais ne les supprimez pas
maintenant. Une règle Email Routing sur une adresse précise passe avant le
catch-all : tant qu'elle existe, le Worker ne voit jamais cette adresse, et
Cloudmail n'en archive rien. Si le domaine porte déjà des règles de forwarding
(par exemple contact@ vers une boîte Gmail), c'est bien au Worker de reprendre
leur forward, en plus de l'archivage — mais il n'en est pas encore capable. À
cette étape, l'interface « Redirections » est injoignable et inutilisable :
l'application Access n'existe pas (étape 7), le secret CF_ROUTING_TOKEN n'est
pas posé (étape 9) et le Worker n'a pas été redéployé avec ces valeurs (étape
10), si bien que le formulaire ne peut lister aucune destination vérifiée et
qu'aucune règle ne peut être créée. Supprimer les règles littérales ici ouvrirait
donc une fenêtre allant jusqu'à l'étape 10 pendant laquelle tout est archivé mais
rien n'est redirigé vers la boîte externe — exactement la régression que les
redirections gérées depuis Cloudmail existent pour éviter. La bascule se fait en
dernier : voir « Reprendre les redirections » à la fin de l'étape 10.
Recréer une redirection catch-all vers une boîte externe plutôt que des règles nominatives forwarde aussi tout le courrier adressé à des adresses inexistantes, que Cloudflare drope aujourd'hui. Le choix est laissé à l'utilisateur ; les règles nominatives sont recommandées.
Zero Trust → Access → Applications → Self-hosted, domaine mail.example.com
(le sous-domaine choisi à l'étape « Prérequis Cloudflare »), politique
« Emails » limitée à votre propre adresse (celle avec laquelle vous vous
connecterez). Copier ensuite l'Application Audience (AUD) et le team domain
dans wrangler.jsonc, section vars :
Cette étape produit la protection d'accès de mail.example.com : sans jeton
Access valide, src/auth/access.ts (requireAccess()) rejette toute requête
API avec 401 unauthenticated. Si ACCESS_TEAM_DOMAIN ou ACCESS_AUD sont
laissés vides (leur valeur par défaut dans wrangler.jsonc), la vérification
JWT échoue systématiquement et personne — pas même l'utilisateur légitime — ne
peut se connecter. (Ces valeurs ne seront effectivement appliquées qu'au
déploiement final, étape 10.)
Deux tokens distincts, chacun avec une seule permission :
- Envoi — permission « Email Sending: Send » uniquement. Utilisé par
src/send/client.tspour appelerPOST /accounts/{account_id}/email/sending/send. - Routage — permission « Email Routing: Read » uniquement. Utilisé par
src/forwarding/destinations.tspour lister les destinations vérifiées que l'interface propose dans le formulaire de redirection.
Les séparer garde le moindre privilège : une fuite du token d'envoi ne donne pas accès à la configuration de routage, et réciproquement. Un token trop permissif serait un risque inutile ; un token absent ou mal scopé fait échouer l'opération correspondante avec une erreur d'autorisation Cloudflare.
pnpm wrangler secret put CF_ACCOUNT_ID
pnpm wrangler secret put CF_API_TOKEN
pnpm wrangler secret put CF_ROUTING_TOKENCes trois commandes produisent les secrets chiffrés lus par le Worker :
CF_ACCOUNT_ID et CF_API_TOKEN par src/send/client.ts (envoi),
CF_ROUTING_TOKEN par src/forwarding/destinations.ts (lecture des
destinations vérifiées). Sans les deux premiers, toute tentative de réponse ou
d'envoi échoue immédiatement ; sans le troisième, le formulaire de redirection
répond « Impossible de lire les destinations vérifiées » et aucune règle ne peut
être créée. Elles s'appliquent au Worker, qui doit donc déjà exister (étape 4).
pnpm run deploySecond et dernier déploiement : cette fois le Worker part avec les secrets
d'envoi posés (étape 9), les variables ACCESS_TEAM_DOMAIN/ACCESS_AUD
renseignées (étape 7) et une base D1 migrée et peuplée (étapes 2-3). C'est
cette exécution qui rend le service effectivement utilisable en production ;
tant qu'elle n'a pas eu lieu après les étapes précédentes, l'authentification
Access et l'envoi d'email restent non fonctionnels malgré un Worker déjà en
ligne depuis l'étape 4.
Reprendre les redirections (en dernier). C'est seulement maintenant que
l'interface « Redirections » est joignable et capable de lister les destinations
vérifiées du compte, donc seulement maintenant que les règles de forwarding
littérales évoquées à l'étape 6 peuvent être retirées d'Email Routing. Dans cet
ordre, et pas l'inverse : créer d'abord dans Cloudmail la redirection équivalente
à chaque règle littérale (contact@ vers la même boîte externe, par exemple),
puis supprimer les règles littérales du tableau de bord. Tant qu'une règle
littérale existe, elle continue de livrer à la boîte externe et le Worker ne voit
pas l'adresse : la redirection Cloudmail créée en doublon reste simplement sans
effet, et prend le relais à la seconde où la règle littérale disparaît. Aucune
fenêtre sans redirection ne s'ouvre. Un message de test envoyé à l'adresse
concernée après la bascule doit arriver à la fois dans Cloudmail et dans la
boîte externe ; s'il n'arrive que dans Cloudmail, la règle correspondante est
absente ou désactivée, et la ligne de l'interface affiche l'erreur de la dernière
tentative.
src/email.ts exporte reparse(env, rawKey, envelopeFrom), qui relit le MIME
brut déjà stocké dans R2 (clé rawKey), le re-parse, supprime la ligne D1
existante correspondante (en décrémentant au passage les compteurs du thread
d'origine) puis rappelle storeIncoming comme si le message venait d'arriver.
C'est la fonction à utiliser pour rejouer un message après un correctif du
parseur, sans avoir à faire renvoyer l'email par l'expéditeur d'origine.
reparse ne rejoue pas les redirections. Elle rejoue l'ingestion d'un
message déjà stocké ; re-forwarder à cette occasion enverrait un doublon aux
destinataires externes, qui ont déjà reçu leur copie lors de la réception
initiale. Un rejeu corrige donc la ligne D1 et le contenu indexé, jamais ce qui
est déjà parti.
Aucun point d'entrée n'est livré aujourd'hui. reparse n'est appelée nulle
part dans le code : ni route API, ni script, ni commande wrangler. Elle
existe et est testée, mais rien dans l'application déployée ne permet de la
déclencher. Pour l'invoquer malgré tout, il faut s'en donner un temporairement :
- Ajouter localement, dans
src/api/routes.ts, une route authentifiée (donc passant parrequireAccess()comme les autres) qui appellereparse(c.env, rawKey, envelopeFrom)avec des paramètres fournis par la requête ou codés en dur pour l'usage ponctuel. - Lancer
pnpm wrangler dev --remotepour que ce Worker de développement local s'exécute contre les bindings D1/R2 distants réels (et non contre les simulations locales de Miniflare) — sans quoi le rejeu ne toucherait que des données locales éphémères. - Déclencher la route pour effectuer le rejeu.
- Retirer la route ajoutée à l'étape 1 avant de committer ou de redéployer.
Cette route ne doit jamais être déployée en production. reparse
supprime puis réinsère une ligne de messages (et ajuste les compteurs du
thread concerné) : c'est une opération destructive exécutée sans confirmation
ni garde-fou particulier au-delà de l'authentification Access générique.
L'exposer durablement sur une application accessible depuis Internet mérite
son propre cycle de conception et de revue, pas un ajout de dernière minute.
Limite connue de reparse elle-même : si le message rejoué était le seul
message d'un thread, storeIncoming recrée un nouveau thread pour lui (le
threading se base sur le sujet normalisé et les en-têtes de référence au
moment du re-parsing, pas sur l'ancien thread_id) ; l'ancien thread,
désormais vide, reste orphelin en base plutôt que d'être supprimé. Ce cas doit
être nettoyé manuellement si besoin.
La clé R2 du MIME brut d'un message est adressée par contenu :
raw/<sha256-du-contenu>.eml. Elle n'est pas générée à partir de métadonnées
(ni horodatage, ni identifiant de message) : deux messages aux octets
identiques partagent la même clé. La ligne D1 correspondante (table
messages) est la seule adresse connue de cet objet R2 — il n'existe pas
d'index inverse ni de listing qui permette de retrouver un message à partir de
sa clé R2 sans passer par D1.
L'invariant le plus fort du projet est « aucun message reçu n'est perdu » : le
MIME brut est écrit dans R2 avant tout parsing et toute écriture D1. Le
corollaire est qu'un échec ultérieur (insertion D1 refusée, base pas encore
migrée, bug d'ingestion) laisse un objet R2 sans ligne messages — et comme il
n'existe aucun index inverse (voir la section précédente), rien ne le signale.
Cette procédure est la seule façon de vérifier l'invariant en production. Elle
est manuelle et hors application : aucune route d'administration n'est livrée,
et c'est délibéré (une route qui liste ou rejoue du contenu de message mérite
son propre cycle de conception, pas un ajout de dernière minute).
1. Extraire les clés connues de D1.
pnpm wrangler d1 execute cloudmail --remote -c .wrangler/generated.jsonc --json \
--command "SELECT raw_key FROM messages ORDER BY raw_key" \
| jq -r '.[0].results[].raw_key' | sort > d1-raw-keys.txt2. Lister le préfixe raw/ dans R2. Attention : wrangler r2 object ne sait
que get, put et delete — il n'existe pas de sous-commande de listing
(vérifié sur wrangler 4.x). Le listing passe donc par l'API S3-compatible de R2,
avec un jeton R2 « Object Read » (Access Key ID / Secret Access Key créés depuis
R2 → Manage API tokens) :
export AWS_ACCESS_KEY_ID=<access key id R2>
export AWS_SECRET_ACCESS_KEY=<secret access key R2>
export AWS_DEFAULT_REGION=auto
aws s3api list-objects-v2 \
--endpoint-url "https://<CF_ACCOUNT_ID>.r2.cloudflarestorage.com" \
--bucket cloudmail --prefix "raw/" \
--query 'Contents[].Key' --output text \
| tr '\t' '\n' | sort > r2-raw-keys.txt(À défaut d'aws, rclone lsf sur un remote S3 pointant le même endpoint
produit la même liste ; l'explorateur d'objets du tableau de bord R2 permet de
parcourir le préfixe à l'œil, ce qui suffit sur un petit volume.)
3. Comparer les deux listes.
# Orphelins : objet R2 présent, aucune ligne D1 — le cas à traiter.
comm -23 r2-raw-keys.txt d1-raw-keys.txt
# Cas inverse : ligne D1 dont l'objet R2 a disparu (purge interrompue, cf.
# purgeMessage) — GET /api/messages/:id/raw répond 404 pour ces messages.
comm -13 r2-raw-keys.txt d1-raw-keys.txt4. Inspecter un orphelin, pour décider s'il vaut la peine d'être réingéré :
pnpm wrangler r2 object get cloudmail/raw/<sha256>.eml --remote --file orphelin.eml
head -40 orphelin.eml # From, To, Subject, Message-ID5. Réingérer. reparse(env, rawKey, envelopeFrom) (section « Rejeu d'un
message » ci-dessus) relit exactement cet objet et rejoue l'ingestion complète ;
elle fonctionne aussi bien sur un orphelin — il n'y a alors simplement aucune
ligne D1 existante à supprimer au préalable. Le déclenchement se fait par la
route temporaire décrite dans cette même section, à retirer ensuite.
Copier .dev.vars.example vers .dev.vars (fichier ignoré par git) pour
lancer pnpm dev avec DEV_BYPASS_AUTH=1, qui désactive la vérification
Cloudflare Access en local. Voir les avertissements dans .dev.vars.example :
cette variable ne doit jamais être définie ailleurs qu'en local.