Boîte mail de l'association : trois transports, une file d'attente

Le site savait composer de beaux messages et ne savait pas les envoyer :
`email-client.ts` ne proposait que la copie ou le téléchargement d'un
brouillon Outlook, complété à la main. `/backend/boite-mail` branche
désormais la boîte de l'association, sur le modèle exact de l'écran
Réseaux sociaux — secrets chiffrés, jamais renvoyés au navigateur, champ
laissé vide qui conserve la valeur enregistrée, contrôle de santé qui ne
lève jamais.

Trois transports, un seul actif à la fois, désigné par l'administrateur —
pas de cascade automatique : si Google se bloque, la bascule se voit.

- **Google** passe par l'API Gmail plutôt que par SMTP avec XOAUTH2 :
  celui-ci exigerait `https://mail.google.com/`, portée *restreinte* et
  donc audit de sécurité, là où `gmail.send` est simplement *sensible*.
- **Microsoft** passe par Graph : l'authentification basique SMTP est
  désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
- **SMTP** couvre le reste via `nodemailer`, seule dépendance ajoutée.

`from_address` est **lu chez le fournisseur** et non saisi : Gmail expédie
comme l'utilisateur authentifié, Graph comme la boîte. Une adresse d'un
autre domaine ferait tomber SPF et DKIM.

La file `mail_messages` porte un destinataire unique par ligne — la
confidentialité d'une diffusion est structurelle, aucune copie partagée
n'est possible. Elle est vidée par la boucle de fond, avec réclamation en
`FOR UPDATE SKIP LOCKED`, réessais espacés et reprise des verrous laissés
par un conteneur arrêté en plein envoi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
Claude committed 2026-09-14 16:00:11 +00:00
1 parent d57e544a18
commit 8d4443a1a1
19 files changed
+3997 -16

No files matched your search

+27 -7
View File
@@ -1,13 +1,19 @@
/**
* Boucle de libération des publications programmées. Fichier séparé de
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans
* le bundle edge du middleware.
* Boucles de fond : libération des publications programmées et vidage de la
* file d'envoi. Fichier séparé de `instrumentation.ts` : il touche la base et
* `nodemailer`, et ne doit donc jamais entrer dans le bundle edge du
* middleware — c'est cet import-là que l'`IgnorePlugin` de `next.config.mjs`
* coupe.
*/
import { releaseDuePromotions } from "@/lib/promo-publish";
import { processOutbox } from "@/lib/mail-outbox";
const INTERVAL_MS = 60_000;
const PROMO_INTERVAL_MS = 60_000;
// Plus court que la minute des promotions : un mot de passe ou une invitation
// mis en file ne doit pas attendre une minute pleine.
const MAIL_INTERVAL_MS = 20_000;
async function tick() {
async function promoTick() {
try {
const n = await releaseDuePromotions();
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
@@ -18,7 +24,21 @@ async function tick() {
}
}
async function mailTick() {
try {
const { sent, failed } = await processOutbox();
if (sent || failed) console.log(`[mail] ${sent} envoyé(s), ${failed} en échec`);
} catch (error) {
console.error("[mail] file d'attente :", error);
}
}
// Un premier passage au démarrage rattrape les échéances tombées pendant un
// redéploiement ou une coupure.
void tick();
setInterval(tick, INTERVAL_MS).unref();
void promoTick();
setInterval(promoTick, PROMO_INTERVAL_MS).unref();
if (process.env.MAIL_WORKER !== "off") {
void mailTick();
setInterval(mailTick, MAIL_INTERVAL_MS).unref();
}