L'outbox se taisait pour tout le foyer dès qu'un seul membre avait reçu

Audit de bout en bout du service mail (docs/audit-mail-2026-07-30.md) :
quatorze défauts relevés, dix corrigés ici. Trois changent le dessin de
D-013 plutôt que de réparer un écart, d'où D-039.

Le plus coûteux : la boucle d'envoi marquait « envoyé » dès qu'un membre
avait été servi. Sur un foyer à deux, la première adresse passe, la
seconde rebondit, la porte d'idempotence se referme — et le second membre
ne reçoit jamais ce rappel, ni le lendemain ni jamais. La clé de l'outbox
porte désormais le destinataire, et c'est le user_id qui est stocké,
jamais l'adresse.

Le plus discret : `metadata` de /sign-in/magic-link est un champ public
du corps de la requête. Sans compte ni session, un appel forgé faisait
partir depuis un domaine aligné SPF/DKIM/DMARC un « ton foyer est activé,
le paiement est passé ». Un sceau dérivé de BETTER_AUTH_SECRET distingue
l'appel interne ; tout écart retombe sur « connexion », le seul contexte
qui n'affirme rien.

Et une avance de rappel de 21 jours sur une fenêtre CatNat de 30 jours
consommait 30 % du délai légal tous les jours sans que rien ne le signale.

Aucun gabarit mail n'avait jamais pu être testé : vitest n'a pas le relais
JSX de Next, tout rendu échouait sur « React is not defined ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gautier Stefanini 2026-07-31 12:02:56 +00:00
parent 06883e04df
commit cc54bc7ef3
26 changed files with 4961 additions and 137 deletions

View file

@ -40,6 +40,9 @@ SMTP_USER=
SMTP_PASSWORD=
FROM_EMAIL=noreply@fokan.fr
FROM_NAME=fokan
# Boîte où atterrissent les réponses. Facultatif : à défaut, `sendMail` retombe sur SMTP_USER,
# qui est par construction une vraie boîte — contrairement à l'alias `noreply@` de FROM_EMAIL.
# REPLY_TO_EMAIL=contact@fokan.fr
# Paiement (Stripe, § 10.1) — 19,99 €/an. Sans STRIPE_SECRET_KEY, /api/checkout répond 501.
# Utiliser les clés de TEST (sk_test_/whsec_test) tant que la case de rétractation (§ 10.7)

View file

@ -2,7 +2,7 @@
Projet « Veille du foyer » (marque sœur de Kankwa, éditée par MIAW). La source de vérité complète est [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — en cas de doute sur une décision produit ou technique, la réponse y est probablement déjà, validée. Les divergences postérieures sont tranchées et datées dans [docs/decisions.md](docs/decisions.md), qui prime sur le cadrage.
Documents de référence par pilier : [maison](docs/pilier-maison.md) · [véhicules](docs/pilier-vehicules.md) · [papiers](docs/pilier-papiers.md) · [enfance & scolarité](docs/pilier-scolarite.md) · [animaux](docs/pilier-animaux.md) · [contrats](docs/pilier-contrats.md) (transverse, pas un pilier). État connu des défauts et chantiers restants — les deux audits se complètent : [docs/audit-2026-07-30.md](docs/audit-2026-07-30.md) (connaissance, piliers, documents, ajouts classés par valeur, question du 6e pilier) et [docs/audit-2026-07-28.md](docs/audit-2026-07-28.md) (sécurité et exploitation, seule référence sur ces deux volets).
Documents de référence par pilier : [maison](docs/pilier-maison.md) · [véhicules](docs/pilier-vehicules.md) · [papiers](docs/pilier-papiers.md) · [enfance & scolarité](docs/pilier-scolarite.md) · [animaux](docs/pilier-animaux.md) · [contrats](docs/pilier-contrats.md) (transverse, pas un pilier). État connu des défauts et chantiers restants — les trois audits se complètent : [docs/audit-2026-07-30.md](docs/audit-2026-07-30.md) (connaissance, piliers, documents, ajouts classés par valeur, question du 6e pilier), [docs/audit-2026-07-28.md](docs/audit-2026-07-28.md) (sécurité et exploitation, seule référence sur ces deux volets) et [docs/audit-mail-2026-07-30.md](docs/audit-mail-2026-07-30.md) (service mail de bout en bout : les cinq messages qui peuvent partir, leurs déclencheurs, leurs conditions et leur contenu — seule référence sur ce volet ; son § 6 porte les quatre décisions produit encore ouvertes, avec options chiffrées et recommandation, son § 7 les deux chantiers qui débordent du canal).
## Constitution produit (arbitre de toute feature)

View file

@ -0,0 +1,564 @@
# Audit du service mail — 30/07/2026
Périmètre : tout ce qui peut faire partir un message depuis `noreply@fokan.fr`, du prospect
anonyme à l'abonné payant. Trois questions, dans cet ordre : **quels mails**, **sur quel
déclencheur et sous quelles conditions**, **avec quel contenu**. Les défauts sont en § 4.
Complète les deux audits existants : [audit-2026-07-28](audit-2026-07-28.md) (sécurité et
exploitation — F1 et S2 y traitent déjà une partie du mail) et
[audit-2026-07-30](audit-2026-07-30.md) (connaissance et piliers).
État mesuré au moment de l'audit : SMTP **configuré et actif** (`mail.infomaniak.com:587`,
`contact@fokan.fr` s'authentifie, `noreply@fokan.fr` en expéditeur), base de développement
**vide** (0 foyer, 0 membre, 0 ligne de `notifications_log`, 1 lead). Les chiffres ci-dessous
viennent donc du code et des fiches, pas du trafic.
## État des correctifs — 31/07/2026
Le lot A a été appliqué le 31/07/2026 (645 tests verts, `tsc --noEmit` et `next build` passés,
migration `0016_right_jamie_braddock` jouée) — détail en § 5, et **[D-039](decisions.md) pour les
trois points où il révise le dessin de D-013** plutôt que de le réparer. Les descriptions de § 4 restent au
présent : elles décrivent le défaut tel qu'il se posait, c'est ce qui rend la correction lisible.
Ce qui reste ouvert n'est pas resté sans réponse : **§ 6 porte les quatre décisions produit** (les
options, leur coût, la recommandation) et **§ 7 les deux chantiers hors périmètre mail**. § 8 donne
l'ordre suggéré. Rien de tout cela ne demande de refaire l'analyse.
| § | Défaut | État |
|---|--------|------|
| 4.1 | `metadata` public sur le lien magique | ✅ corrigé — sceau interne + plafond applicatif |
| 4.2 | CatNat notifiée 9 jours trop tard | ✅ corrigé — `AVANCE_PAR_STRATEGIE` |
| 4.3 | 31 fiches muettes | ⏸ ouvert — décision en [§ 6.3](#63--les-31-fiches-muettes--assumer-ou-ouvrir--défaut--43) |
| 4.4 | Activation perdue en 15 min | ✅ corrigé — URL durable dans le mail |
| 4.5 | Invitation irrattrapable 7 jours | ✅ corrigé — renvoi plafonné |
| 4.6 | Gestes promis et absents | ⏸ ouvert — décision en § 6.2, chantier en § 7.1 |
| 4.7 | Aucun désabonnement | ⏸ ouvert — décision en § 6.1 |
| 4.8 | Envoi partiel définitif | ✅ corrigé — outbox par destinataire |
| 4.9 | `message_id` jamais écrit | ✅ corrigé |
| 4.10 | HTML seul | ✅ corrigé — `multipart/alternative` + `Reply-To` |
| 4.11 | Aucun délai de garde SMTP | ✅ corrigé |
| 4.12 | Mot de passe sans chemin | ⏸ ouvert — décision en § 6.4 |
| 4.13 | Objet ≠ titre | ✅ corrigé — objet unique |
| 4.14 | Frontière I/O non testée | ✅ corrigé — 31 tests ajoutés |
---
## 1. Ce qui peut partir : cinq messages, trois gabarits
| # | Message | Gabarit | Destinataire | Authentification requise |
|---|---------|---------|--------------|--------------------------|
| 1 | Lien de connexion | [magic-link.tsx](../src/emails/magic-link.tsx) (`context: "connexion"`) | qui saisit une adresse sur `/connexion` | **aucune** |
| 2 | Invitation au foyer | idem (`context: "invitation"`) | l'adresse invitée | membre du foyer |
| 3 | Activation après paiement | idem (`context: "activation"`) | l'acheteur Stripe | webhook Stripe signé |
| 4 | « Je te renvoie ce lien » | [garder-lien.tsx](../src/emails/garder-lien.tsx) | qui saisit une adresse sur le hub | **aucune** |
| 5 | Rappel d'échéances | [rappel.tsx](../src/emails/rappel.tsx) | chaque membre du foyer | abonnement actif |
Un seul point de sortie : [`sendMail`](../src/adapters/mail.ts) (nodemailer, SMTP). Sans
`SMTP_HOST`/`USER`/`PASSWORD`, dégradation gracieuse — un `console.warn`, jamais de crash, et le
lien magique est imprimé en clair pour le développement.
**Rien d'autre ne sort.** Pas de mail de bienvenue hors paiement, pas de réinitialisation de mot
de passe, pas de vérification d'adresse, pas de relance de panier, pas de fin d'abonnement, pas
d'accusé de résiliation, pas de récapitulatif annuel. Les événements Stripe
`subscription.updated` / `deleted` changent le statut en base **en silence** : un foyer dont
l'abonnement s'éteint cesse simplement de recevoir des rappels le lendemain, sans qu'aucun
message ne le lui dise.
---
## 2. Déclencheurs et conditions d'envoi
### 2.1 — Lien de connexion (prospect ou abonné)
- **Déclencheur** : `POST /api/auth/sign-in/magic-link`, depuis
[connexion/page.tsx:21](../src/app/connexion/page.tsx#L21).
- **Conditions** : une adresse syntaxiquement valide. **C'est tout.** Aucun compte préexistant
n'est exigé — better-auth crée l'utilisateur à la vérification du lien.
- **Débit** : 5 requêtes / 60 s / IP, plafond porté par le plugin magic-link lui-même
(`node_modules/better-auth/dist/plugins/magic-link/index.mjs`). Actif seulement si
`rateLimit.enabled`, dont le défaut est `isProduction` — vrai dans l'image Docker
(`ENV NODE_ENV=production`), **faux en développement**. Aucun plafond applicatif ne double
celui-ci : `PLAFONDS` ([rate-limit.ts:57](../src/lib/rate-limit.ts#L57)) ne couvre pas cette route.
- **Durée de vie du lien** : 15 min, usage unique ([auth.ts:21](../src/lib/auth.ts#L21)).
### 2.2 — Invitation au foyer
- **Déclencheur** : `POST /api/invitations` ([route.ts](../src/app/api/invitations/route.ts)).
- **Conditions**, dans l'ordre où elles sont évaluées :
1. session valide, sinon 401 ;
2. ≤ 10 invitations / utilisateur / 24 h, sinon 429 ;
3. l'appelant est membre du foyer visé, sinon 403 ;
4. aucune invitation encore valable pour ce couple (foyer, adresse), sinon **409** ;
5. ≤ 3 invitations reçues par cette adresse / 24 h, tous foyers et tous invitants confondus,
sinon 429.
- **Deux durées de vie qui ne coïncident pas** : le jeton d'invitation vit **7 jours**, le lien
magique qui le transporte **15 minutes**. Voir § 4.5.
### 2.3 — Activation après paiement
- **Déclencheur** : webhook Stripe `checkout.session.completed`
([webhook/route.ts:88](../src/app/api/stripe/webhook/route.ts#L88)).
- **Conditions** : signature Stripe valide, `metadata.householdId` présent, **et
`payerUserId` absent** — c'est-à-dire personne n'était connecté avant de payer. Si l'acheteur
avait une session, il est inscrit membre directement et **aucun mail ne part** (correct : il est
déjà chez lui). Il faut en plus que Stripe ait remonté un `customer_details.email`.
- **Ce mail est la seule voie de réclamation d'un foyer payé sans compte préalable.** Voir § 4.4.
### 2.4 — « Je te renvoie ce lien » (prospect)
- **Déclencheur** : `POST /api/interet` avec un `householdId`, depuis
[garder-lien.tsx:53](../src/app/foyer/[id]/garder-lien.tsx#L53) — l'encart n'apparaît qu'après
que le calendrier a été parcouru (sentinelle d'intersection), et son refus est mémorisé en
`localStorage`.
- **Conditions** : ≤ 10 requêtes / heure / IP, adresse valide. **Aucune vérification que
l'appelant a le moindre rapport avec le `householdId` fourni**, ni que l'adresse est celle de
qui que ce soit.
- L'insertion dans `leads` a lieu d'abord et n'est jamais remise en cause par un échec d'envoi.
Elle n'est pas dédupliquée : trois demandes = trois lignes.
### 2.5 — Rappel d'échéances (abonné)
C'est la seule file planifiée qui parle à l'extérieur : `0 8 * * *`, `Europe/Paris`, après la
veille (3 h) et la réconciliation (4 h) ([instrumentation.ts:263](../src/instrumentation.ts#L263)).
`retryLimit: 1`, `retryDelay: 1 h`, `expireInSeconds: 30 min`.
**Sept portes, en cascade** ([jobs/notifications.ts](../src/jobs/notifications.ts) puis
[engine/notifications.ts](../src/engine/notifications.ts)) :
| # | Porte | Où | Règle |
|---|-------|----|-------|
| 1 | Abonnement | `rappelsQuotidiens` | `subscription_status = 'active'` **et** (`period_end` nul ou ≥ aujourd'hui) |
| 2 | Destinataire | `destinatairesFoyer` | ≥ 1 ligne `memberships` jointe à un `user.email` |
| 3 | Échéance vivante | `candidatsFoyer` | `archived = false`, `muted = false`, `fenetre_start` non nul, fiche encore présente dans le repo |
| 4 | Idempotence | `dejaNotifiees` | rien de déjà journalisé `envoye` pour ce couple (occurrence, fenêtre) |
| 5 | Bon moment | `estDu` | `connue` → 21 j avant ; `estimee` → le jour de la fenêtre ; `libre`**jamais** |
| 6 | Regroupement | `grouperCandidats` | clé stricte (template, sujet, date, label, confiance) — trois enfants = une ligne, trois occurrences journalisées |
| 7 | Budget | `planRappel` | urgence = `legal+assurance` **et** `connue` → passe hors budget, **seule** ; sinon ≤ 2 envois/mois et ≤ 3 lignes |
Un espacement de 500 ms sépare deux foyers. Un envoi par foyer et par jour au maximum.
La journalisation dans `notifications_log` a lieu **après** l'envoi — une panne SMTP ne rend pas
le foyer muet définitivement — et un échec est écrit en `echec`, donc rejugé le lendemain
(`onConflictDoUpdate`, pas `doNothing`).
**Les six fiches qui peuvent traverser le budget** (`enjeu.type: legal+assurance`) :
`chaudiere-gaz-entretien`, `chaudiere-fioul-entretien`, `ramonage`,
`piscine-securite-dispositif`, `debroussaillement-old`, `catnat-declaration-sinistre`. Les cinq
premières ont une fenêtre saisonnière donc `estimee` — elles ne franchissent **jamais** le test
`horsBudget`, qui exige `connue`. **En pratique, une seule fiche du produit peut déclencher un
envoi hors budget : la déclaration de catastrophe naturelle.**
---
## 3. Ce que contient chaque message
### 3.1 — Les trois liens magiques (un seul gabarit, trois textes)
| Contexte | Objet | Titre affiché | Bouton |
|----------|-------|---------------|--------|
| `connexion` | Ton lien de connexion fokan | Ton lien de connexion fokan | Me connecter |
| `invitation` | Tu es invité·e à rejoindre un foyer sur fokan | *`<email de l'invitant>`* t'invite à rejoindre son foyer sur fokan | Rejoindre le foyer |
| `activation` | Ton foyer est activé — accède à ton calendrier | Ton foyer est activé sur fokan | Voir mon foyer |
Corps : une phrase par contexte. Pied de page commun : « Si tu n'es pas à l'origine de cette
demande, ignore simplement ce mail — rien ne se passera. » Le corps `connexion` dit « il expire
dans quelques minutes » là où l'écran de départ annonce 15 minutes.
Le titre d'invitation affiche **l'adresse mail brute de l'invitant**, pas un prénom. React
échappe la valeur (pas d'injection HTML), mais le contenu reste choisi par l'appelant — voir § 4.1.
### 3.2 — « Je te renvoie ce lien »
Objet « Le lien de ton foyer fokan ». Corps : « Ton foyer compte **{total}** échéance(s) sous
contrôle. Garde ce mail — c'est le moyen le plus simple d'y revenir. » `total` vient de
`buildHub`, avec `?? 0` en cas d'échec : un hub indisponible produit « Ton foyer compte 0 échéance
sous contrôle ». Bouton vers `/foyer/<uuid>` — l'aperçu gratuit, volontairement accessible à qui
détient le lien. Même pied de page « ignore ce mail ».
### 3.3 — Le rappel
**Objet** ([`objetRappel`](../src/engine/notifications.ts#L212)) :
- une seule ligne → l'objet **rédigé dans la fiche** (`contenus.mail.objet`), à défaut son titre de
hub. Exemple : « Ta chaudière gaz mérite son entretien annuel avant l'hiver » ;
- plusieurs lignes → « N choses ce mois-ci » si toutes tombent dans le mois courant, sinon
« N choses à prévoir ». **Aucun mois n'est jamais nommé** dans l'objet : une version antérieure
annonçait « d'ici septembre » un 1er octobre, les items étant triés par priorité et non par date.
**Corps** : un bloc par ligne, composé de quatre éléments, tous issus de la fiche ou de l'instance —
le composant met en page, il ne rédige pas :
1. le titre de hub, suivi du `sujetLabel` en gris s'il existe ;
2. le `fenetreLabel` en vert (« septembre 2026 », « avant le 12/09/2026 », « en vigueur jusqu'au… ») ;
3. les `assetLabels` concernés, joints par des virgules (« Enfant 1, Enfant 2, Enfant 3 ») ;
4. le `contenus.mail.corps` de la fiche, à défaut `enjeu.resume`.
Un chapô n'apparaît **que** sur les envois multiples : « Rien d'urgent, rien à faire tout de
suite — juste ce qui arrive, pour que tu n'aies pas à y penser toi-même. » Puis un bouton
« Voir mon foyer » vers `/foyer/<uuid>`, puis le pied de page.
**Couverture des fiches** : les **76** fiches de `knowledge/templates/` ont toutes un bloc
`contenus.mail` complet (objet **et** corps). Zéro fiche nue. Mais 31 d'entre elles ne peuvent
jamais s'en servir — § 4.3.
---
## 4. Défauts
### 4.1 — `metadata` est un champ **public** du endpoint magic-link · **critique**
`signInMagicLinkBodySchema` déclare `metadata: z.record(z.string(), z.any()).optional()`. Ce
champ est celui que [auth.ts:23](../src/lib/auth.ts#L23) lit pour choisir le contexte. Il n'y a
aucune session à présenter : un `POST /api/auth/sign-in/magic-link` non authentifié avec
```json
{ "email": "victime@exemple.fr", "callbackURL": "/",
"metadata": { "context": "activation" } }
```
fait partir, depuis `noreply@fokan.fr` — domaine aligné SPF/DKIM/DMARC —, un mail intitulé
« Ton foyer est activé sur fokan » affirmant « Le paiement est passé », avec un lien de connexion
qui fonctionne. Avec `"context": "invitation"` et `"invitedByEmail": "<n'importe quoi>"`, le
titre du mail devient une chaîne choisie par l'appelant.
C'est exactement le trou que l'audit du 28/07 a fermé sur `/api/invitations` (S2), une route plus
loin. Les trois garde-fous ajoutés là-bas (membre du foyer, plafond par invitant, plafond par
adresse invitée) sont contournés en s'adressant directement à better-auth. Il ne reste que
5 requêtes / 60 s / IP, soit 300 messages/heure vers des adresses arbitraires, et **rien du tout
si `NODE_ENV` n'est pas `production`**.
Le § 26 fait de la réputation d'envoi une infrastructure vitale. Ici elle est ouverte à
l'hameçonnage sous notre propre domaine.
**Correctif** : ne jamais dériver le contexte d'une valeur venue du client. Le plus simple est un
jeton interne — `sendMagicLink` n'honore `context` que si `metadata.__interne` vaut un secret
d'environnement, et retombe sur `connexion` sinon ; les deux appelants légitimes
(`/api/invitations`, webhook Stripe) passent par `auth.api.signInMagicLink` côté serveur et
peuvent le fournir. Ajouter au passage un plafond applicatif sur cette route, indépendant de
`NODE_ENV`.
### 4.2 — La CatNat perd 9 de ses 30 jours dans le planificateur · **élevé**
`catnatWindow` pose `start = limite`, où `limite` = publication au JO **+ 30 jours**
([windows.ts:284](../src/engine/windows.ts#L284), `DELAI_DECLARATION_JOURS = 30`). `estDu`
applique ensuite l'avance générique des fenêtres `connue` : 21 jours **avant** `start`
([notifications.ts:103](../src/engine/notifications.ts#L103)).
Le mail part donc à **J+9 après la publication**, et annonce « 30 jours pour déclarer » alors
qu'il en reste 21.
C'est le gaspillage précis que la veille quotidienne a été construite pour éviter : son
commentaire dit qu'« un passage hebdomadaire aurait consommé jusqu'à un quart » du délai. Le
planificateur en consomme **30 %**, tous les jours, sans que rien ne le signale. La sémantique
n'est pas fausse — pour un contrôle technique ou une ZFE, trois semaines avant l'échéance est le
bon moment. C'est la constante qui est inadaptée à une fenêtre légale d'un mois.
**Correctif** : l'avance doit pouvoir être portée par la stratégie et non seulement par la
confiance. Une fenêtre de type « délai qui court » (CatNat, et demain tout délai franc) se
notifie à son ouverture, pas 21 jours avant sa fermeture.
### 4.3 — 31 fiches sur 76 ont un corps de mail qui ne peut jamais partir · **élevé**
`AVANCE_JOURS.libre === null`, donc `estDu` rend toujours `false`. Toute fiche dont
`computeWindow` produit une confiance `libre` est muette par construction. Recensé sur les
76 fiches :
| | Fiches | Détail |
|---|---|---|
| Notifiables | 45 | stratégie datée (22) ou `recurrence.saison` (23) |
| **Muettes** | **31** | 23 structurellement, 8 sous condition de déclaration |
Les **8 conditionnelles** sont des contrats (`asset_type: contrat`) : elles deviennent `connue` si
le foyer a déclaré un `echeance_mois` au quiz — c'est l'unique saisie opt-in du produit
(`assurance-habitation`, `assurance-auto`, `assurance-animale`, `assurance-emprunteur`,
`mutuelle-sante`, `box-internet`, `forfait-mobile`, `salle-de-sport`).
Les **23 autres n'ont aucun mécanisme** qui puisse leur donner une date, puisque aucun geste
d'état utilisateur n'existe (§ 4.6). Parmi elles, des fiches à enjeu `legal` ou `securite` :
`iode-pastilles`, `plomb-crep`, `amiante-etat`, `decence-energetique`, `recensement-jdc`,
`declaration-fonciere-90j`, `inondation-gestes`, `carte-grise-changement-adresse`,
`critair-vignette`, `zfe-restriction-actuelle`, `spanc-controle-periodique`,
`fosse-septique-vidange`, `vaccins-rappel`, `vermifuge`, `bilan-veterinaire-senior`
Ce n'est pas nécessairement un défaut du planificateur — « sans date, il n'y a pas de bon
moment » est une décision assumée, et le hub les affiche toujours. Mais **41 % du travail de
rédaction du canal mail est aujourd'hui du contenu mort**, et ce n'est écrit nulle part. Le
constat existait pour le seul pilier papiers (5 fiches sur 7, audit du 28/07) ; il est trois fois
plus large.
### 4.4 — L'acheteur qui lit son mail 20 minutes trop tard perd le foyer qu'il vient de payer · **élevé**
Le lien magique expire en 15 min ([auth.ts:21](../src/lib/auth.ts#L21)), pour les trois contextes.
Or dans le cas 2 du webhook Stripe (personne n'était connecté avant de payer), ce mail est **la
seule chose** qui porte l'URL du foyer : `resolveMembership` n'inscrit le premier membre qu'à la
visite de `/foyer/<uuid>`, et cette visite n'est possible qu'en suivant ce lien.
Passé 15 minutes, il n'existe aucun chemin de rattrapage. Se connecter depuis `/connexion` mène à
`/mon-espace`, qui liste les foyers **par appartenance** — l'acheteur n'en a aucune. Il a payé
19,99 €, son foyer est `active` en base, et il ne peut pas y accéder.
**Correctif** : allonger `expiresIn` pour ce contexte, ou mieux, découpler — le mail d'activation
n'a pas besoin d'être un lien magique éphémère, il a besoin de porter l'URL du foyer. Le lien
magique s'y ajoute ; il ne le remplace pas.
### 4.5 — L'invitation est irrattrapable pendant 7 jours · **moyen**
Même expiration de 15 min, appliquée à un jeton d'invitation valable 7 jours. L'invité qui clique
le lendemain tombe sur une erreur. L'invitant qui réessaie reçoit **409 « une invitation est déjà
en cours pour cette adresse »**, garde-fou n° 2 de la route — et ce refus tient jusqu'à
l'expiration du jeton, soit une semaine.
Les deux protections se combinent en un blocage : ni l'invité ni l'invitant ne peuvent avancer.
**Correctif** : permettre le renvoi d'un lien pour une invitation pendante (en la comptant dans
le plafond par adresse, qui reste la bonne défense contre le harcèlement) plutôt que de la refuser.
### 4.6 — Chaque mail promet des gestes qui n'existent pas · **élevé** *(déjà connu)*
Le pied de page du rappel : « Tout ça se règle depuis ton foyer, en un tap : marquer comme fait,
changer de responsable, ou couper définitivement un rappel qui ne te sert à rien. » Et la plupart
des 76 corps de fiche se terminent par « Si c'est déjà fait, dis-le-moi et je me tais. »
Aucun de ces gestes n'est implémenté : les colonnes `muted`, `statut`, les responsables existent,
rien ne les écrit — aucune occurrence de `muted` ou d'une action « fait » dans `src/app/`. Détail
et conséquences chiffrées en [audit-2026-07-30 § 1](audit-2026-07-30.md).
Conséquence propre au canal mail : **le seul moyen dont dispose un abonné pour faire taire un
rappel est de ne rien faire** — et comme `estDu` reste vrai après coup, la ligne repartira à
chaque nouvelle fenêtre. C'est la promesse la plus répétée du produit, et la seule qu'aucun écran
ne tient.
### 4.7 — Aucun mécanisme de désabonnement · **élevé**
Recherche exhaustive : aucune occurrence de `unsubscribe`, `List-Unsubscribe`, `désabonn` ou
équivalent dans `src/` ni dans `knowledge/`. Aucun en-tête, aucun lien, aucune table de
préférences.
Deux conséquences distinctes :
1. **Délivrabilité.** Depuis 2024, Gmail et Yahoo exigent `List-Unsubscribe` **et**
`List-Unsubscribe-Post: List-Unsubscribe=One-Click` de tout expéditeur d'envois groupés.
Le rappel quotidien est exactement cela.
2. **Consentement.** Le mail « garder le lien » part vers une adresse saisie dans un formulaire
**non authentifié** avec un `householdId` que l'appelant n'a pas à justifier (§ 2.4) : un tiers
peut faire écrire à une adresse qui n'est pas la sienne, et le destinataire n'a aucun moyen de
s'y opposer. La table `leads` stocke l'adresse sans trace de consentement, sans déduplication
et sans purge.
### 4.8 — Une défaillance partielle sur un foyer à plusieurs membres est définitive · **moyen**
[jobs/notifications.ts:177-219](../src/jobs/notifications.ts#L177) : la boucle d'envoi met
`envoye = true` dès qu'**un** destinataire a été servi, et la journalisation est faite **par
occurrence**, jamais par destinataire.
Foyer de deux membres, la première adresse passe, la seconde rebondit : la ligne est écrite
`envoye`, la porte n° 4 (idempotence) se referme, et le second membre ne recevra jamais ce rappel
— ni le lendemain, ni jamais. Le § 6.3 promet pourtant que chaque membre reçoit ses propres
rappels « sans jamais passer par quelqu'un d'autre ».
`erreur` n'est pas non plus fiable : la variable est écrasée à chaque échec, seule la dernière
survit, et elle n'est écrite que si `envoye` est faux pour tout le monde.
**Correctif** : `notifications_log` doit porter le destinataire dans sa clé d'unicité, ou le
statut doit être `envoye` seulement si **tous** les destinataires ont été servis.
### 4.9 — Aucun moyen de relier un envoi à ce qu'il devient · **moyen**
La colonne `notifications_log.message_id` existe et n'est **jamais écrite** : `sendMail` rend
`{ sent: boolean }` et jette le `messageId` que nodemailer retourne. Il n'y a par ailleurs ni
webhook de rebond, ni boîte de retour surveillée, ni compteur de plaintes.
Pour une infrastructure dont le § 26 dit qu'un rappel en spam est la promesse rompue, il n'existe
aujourd'hui aucun signal permettant de savoir qu'un foyer ne reçoit plus rien.
### 4.10 — Les messages sont en HTML seul · **moyen**
`render()` est appelé sans option, alors que `@react-email/render` expose `{ plainText: true }`
(vérifié dans le paquet installé). `sendMail` ne passe que `html` à nodemailer : pas de partie
`text`, donc pas de `multipart/alternative`. Ni `Reply-To`, ni `List-Unsubscribe` (§ 4.7).
Un message HTML sans alternative texte est un signal de spam classique.
### 4.11 — Le transport n'a aucun délai de garde · **moyen**
`nodemailer.createTransport` est configuré sans `connectionTimeout`, `greetingTimeout` ni
`socketTimeout`, et sans `pool`. Le passage quotidien est strictement séquentiel — un foyer, puis
500 ms, puis le suivant. Une connexion SMTP qui pend bloque tout le reste de la file jusqu'à
l'`expireInSeconds: 30 min` de pg-boss, après quoi le job est rejoué depuis le début : les foyers
déjà servis sont protégés par l'outbox, mais l'heure d'envoi de tous les autres est perdue.
À l'échelle du cadrage (~300 foyers), les 500 ms d'espacement représentent déjà 2 min 30
incompressibles.
### 4.12 — Le mot de passe est activé sans aucun chemin pour en avoir un · **faible**
`emailAndPassword: { enabled: true }` ouvre `/sign-up/email` et `/forget-password`. Or :
- aucune interface d'inscription n'existe (aucune occurrence de `signUp` dans `src/`) ;
- `sendResetPassword` n'est pas configuré, donc aucun mail de réinitialisation ne peut partir ;
- `requireEmailVerification` n'est pas posé, donc `/sign-up/email` crée des comptes non vérifiés.
L'écran `/connexion` propose « J'ai un mot de passe » — une branche que personne ne peut atteindre
par les chemins du produit, et dont personne ne pourrait sortir en cas d'oubli.
### 4.13 — Objet et titre divergent sur les envois groupés · **cosmétique**
`objetRappel` produit « 3 choses ce mois-ci » ; `RappelEmail` affiche « 3 choses à regarder » en
titre, et c'est aussi le texte de `<Preview>` (la ligne d'aperçu des clients mail). Le
destinataire voit donc deux formulations différentes pour le même message.
### 4.14 — La frontière I/O n'est pas testée
22 tests, tous verts, tous sur le module pur `engine/notifications.ts` : `estDu`, `horsBudget`,
`planRappel`, `objetRappel`, `grouperCandidats`. C'est la bonne priorité et la couverture y est
sérieuse.
En revanche **zéro test** sur `jobs/notifications.ts`, c'est-à-dire sur les portes n° 1 à 4 : le
filet de date de l'abonnement (ajouté après coup, et c'est précisément le genre de règle qui se
perd), la résolution des destinataires, la clé d'idempotence, le comptage du budget mensuel.
Zéro test sur les trois gabarits et zéro sur l'adapter.
---
## 5. Lot A — correctifs appliqués le 31/07/2026
Dix items, tous du même genre : un défaut, un correctif, aucune question posée au produit. Le
tableau d'état en tête du document dit lesquels. Deux enseignements méritent d'être gardés :
- **L'avance CatNat se compte depuis la fin du délai, pas depuis son ouverture.** La première
version posait `AVANCE_PAR_STRATEGIE.catnat_declaration = 0`, ce qui aurait fait partir le mail
le **dernier** jour des 30 — l'exact inverse du correctif. La bonne valeur est le délai lui-même,
30, et un test croise la constante avec `DELAI_DECLARATION_JOURS` de `adapters/catnat.ts` pour
que la duplication (le moteur pur ne peut pas importer l'adapter) ne dérive pas.
- **Une porte manquait, qui n'était dans aucune liste.** `envoyerRappelFoyer` est exporté et
appelé directement par les scripts : la seule vérification d'abonnement vivait dans le `where`
du passage collectif, que ces appels ne traversent jamais. `abonnementOuvert()` est désormais
posée aussi à l'entrée par foyer. La duplication avec le SQL est délibérée — le SQL filtre en
masse, la fonction vérifie à l'unité — et les deux sont testées ensemble.
---
## 6. Lot B — décisions produit en attente
Quatre arbitrages. Aucun n'est un bug : chacun demande de choisir ce que le produit veut être,
pas de réparer ce qu'il fait mal. Ils sont classés par ce qu'ils bloquent, pas par difficulté.
### 6.1 — Désabonnement : quel périmètre ? *(défaut § 4.7)*
**Ce qui l'impose.** Depuis 2024, Gmail et Yahoo exigent `List-Unsubscribe` **et**
`List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) de tout expéditeur d'envois
groupés — le rappel quotidien en est un. Le one-click impose un endpoint qui accepte un `POST`
et agit **sans page de confirmation**.
**La nuance à ne pas perdre.** Les deux canaux n'ont pas le même statut. Le rappel est le service
qu'un abonné paie : s'en désabonner, c'est éteindre ce pour quoi il a payé. Le mail « garder le
lien » part vers une adresse saisie dans un formulaire **non authentifié**, avec un `householdId`
que l'appelant n'a pas à justifier — un tiers peut faire écrire à une adresse qui n'est pas la
sienne, et le destinataire n'a aujourd'hui aucun moyen de s'y opposer.
| Niveau | Ce que ça fait | Coût | Ce que ça laisse ouvert |
|--------|----------------|------|-------------------------|
| 1 | En-têtes `List-Unsubscribe` pointant vers une page du hub | faible | Le one-click reste non conforme (RFC 8058 veut un POST qui agit) |
| **2** | **+ état d'opt-out au niveau du foyer** (`households.rappels_coupes_le`), endpoint one-click, réactivation depuis le hub | moyen — 1 colonne, 1 route, 1 bouton | L'opt-out d'un membre coupe le foyer entier |
| 3 | + opt-out **par membre** (`memberships.rappels_coupes_le`) | moyen+ — la porte n° 2 du § 2.5 devient conditionnelle | rien |
**Recommandation : niveau 2.** C'est aussi « couper définitivement un rappel qui ne te sert à
rien » que le pied de page promet déjà, au moins à l'échelle du foyer. Le niveau 3 devient
naturel le jour où § 6.2 est tranché — c'est le même geste, au même endroit.
**À traiter dans le même mouvement** : § 7.2 (la table `leads`), qui est le même sujet vu du
côté prospect.
### 6.2 — Les gestes promis : implémenter, ou corriger la copie ? *(défaut § 4.6)*
**L'état.** Le pied de page du rappel promet « marquer comme fait, changer de responsable, ou
couper définitivement un rappel ». La plupart des **76** corps de fiche finissent par « Si c'est
déjà fait, dis-le-moi et je me tais ». Aucun de ces gestes n'est implémenté : les colonnes
existent, rien ne les écrit.
| Option | Coût | Conséquence |
|--------|------|-------------|
| Implémenter les gestes | élevé — c'est § 7.1 | Le mail devient vrai, et § 6.3 se résout au passage |
| **Corriger la copie du pied de page** | 10 min | Le mail cesse de mentir ; les 76 fiches continuent de promettre |
| Corriger la copie **et** les 76 fiches | ~1 j | Tout est vrai, mais il faudra tout réécrire à l'inverse ensuite |
**Recommandation : corriger le pied de page maintenant, laisser les fiches.** C'est la doctrine
déjà appliquée à F1 le 28/07 (« tant qu'il n'existe pas, corriger la copie, sans promettre
d'envoi »), et réécrire 76 corps deux fois n'a pas de sens si § 7.1 est proche. Si § 7.1 est
lointain, alors les fiches passent aussi.
### 6.3 — Les 31 fiches muettes : assumer ou ouvrir ? *(défaut § 4.3)*
**L'état.** 31 fiches sur 76 ont un contenu mail rédigé qui ne peut jamais partir. 8 sont des
contrats et deviennent notifiables si le foyer déclare un `echeance_mois` (opt-in du quiz). Les
**23 autres** n'ont aucun mécanisme qui puisse leur donner une date.
Ce n'est pas un bug : « sans date, il n'y a pas de bon moment » est une décision assumée, et le
hub les affiche toujours. Mais 41 % du travail de rédaction du canal mail est inerte, et ce n'est
écrit nulle part.
| Option | Ce que ça coûte | Ce que ça risque |
|--------|-----------------|------------------|
| **Documenter et assumer** | une section par pilier | rien |
| Donner une `saison` par défaut aux fiches muettes | faible | Fabriquer un moment qu'on ne connaît pas — précisément la to-do list déguisée que le § 4.4 du cadrage refuse |
| Implémenter la « date apprise » | c'est § 7.1 | rien |
**Recommandation : documenter maintenant** dans les fiches piliers concernées, et laisser § 7.1
le résoudre. Surtout **pas** l'option 2 : elle achèterait des notifications contre la règle d'or.
### 6.4 — Mot de passe : retirer ou compléter ? *(défaut § 4.12)*
**L'état.** `emailAndPassword: { enabled: true }` ouvre `/sign-up/email` et `/forget-password`.
Or aucune interface d'inscription n'existe, `sendResetPassword` n'est pas configuré (donc aucun
mail de réinitialisation ne peut partir), et `requireEmailVerification` n'est pas posé (donc les
comptes créés par l'API ne sont pas vérifiés). L'écran `/connexion` propose « J'ai un mot de
passe » — une branche que personne ne peut atteindre, et dont personne ne pourrait sortir.
**Le fait qui rend la décision facile aujourd'hui** : la base compte **0 utilisateur**. Désactiver
ne casse le compte de personne. Ce ne sera plus vrai après la mise en service.
| Option | Coût | Conséquence |
|--------|------|-------------|
| **Désactiver `emailAndPassword`, retirer la branche de `/connexion`** | 15 min | Un seul chemin d'authentification, celui que le cadrage donne comme prioritaire |
| Compléter : inscription + `sendResetPassword` + vérification | ~1 j | Un 6ᵉ message à écrire et à maintenir, pour un chemin que le produit ne met en avant nulle part |
**Recommandation : désactiver**, tant que c'est gratuit.
---
## 7. Lot C — hors périmètre du service mail
Deux chantiers qui débordent du canal, et qu'il ne faut pas fondre dans un correctif mail : ils
appartiennent à d'autres parties du produit et se décident à cette échelle-là.
### 7.1 — Les gestes d'état utilisateur
« Fait », muet, responsable, date apprise. C'est [audit-2026-07-30 § 1](audit-2026-07-30.md), et
c'est la clé de voûte de § 6.2 comme de § 6.3 — les deux se résolvent d'eux-mêmes le jour où ce
chantier atterrit, et aucun des deux ne se résout durablement sans lui.
Ce que le service mail lui demande précisément, et rien de plus :
- un geste « c'est fait » atteignable **depuis le mail** (donc une URL-capacité, pas une session) ;
- un geste « ne me redis plus ça » qui écrive `deadlines.muted` — la porte n° 3 du § 2.5 le lit
déjà, elle n'attend que quelqu'un pour l'écrire ;
- une date apprise qui fasse passer une fenêtre `libre` en `connue`, ce qui rend notifiables les
23 fiches structurellement muettes de § 6.3.
### 7.2 — La table `leads` et `POST /api/interet`
Non touchée par le lot A, et distincte de § 6.1 bien qu'elle en relève :
- `leads` stocke une adresse sans trace de consentement, sans déduplication (trois demandes =
trois lignes) et sans purge ;
- `POST /api/interet` n'exige aucun lien entre l'appelant et le `householdId` fourni, ni entre
l'appelant et l'adresse saisie. Le plafond de 10/h/IP est la seule barrière.
À reprendre avec § 6.1 : c'est le même sujet — qui a le droit de faire écrire à qui, et comment
on cesse de recevoir.
---
## 8. Ordre de traitement suggéré pour la suite
| Rang | Item | Pourquoi |
|------|------|----------|
| 1 | **§ 6.4** — mot de passe | 15 minutes, et gratuit seulement tant que la base est vide |
| 2 | **§ 6.1** — désabonnement (+ § 7.2) | Condition de délivrabilité Gmail/Yahoo avant toute montée en volume |
| 3 | **§ 6.2** — copie du pied de page | 10 minutes, et le mail cesse de promettre ce qu'aucun écran ne tient |
| 4 | **§ 7.1** — gestes d'état utilisateur | Débloque § 6.2 en entier et § 6.3 |
| 5 | **§ 6.3** — les 31 fiches muettes | Se documente maintenant, se résout avec § 7.1 |

View file

@ -1,5 +1,54 @@
# Journal des décisions post-cadrage
## D-039 — 31 juillet 2026 · L'outbox nomme son destinataire, et l'avance de rappel cesse d'être une affaire de confiance seule (révise les §§ 2, 6 et 7 de D-013)
**Contexte** : audit de bout en bout du service mail, demandé le 30/07/2026 et consigné dans [docs/audit-mail-2026-07-30.md](audit-mail-2026-07-30.md) — cinq messages peuvent partir, pour trois gabarits. Quatorze défauts relevés ; dix corrigés le 31/07 (« lot A »), quatre laissés en décision produit ouverte (§ 6 de l'audit). Le SMTP est configuré et actif depuis D-014, ce qui change la nature de ces défauts : ils ne sont plus théoriques.
Trois des correctifs ne réparent pas un écart au dessin de D-013 — ils **changent ce dessin**. D'où cette entrée.
### Ce qui change
**1. `metadata` n'est plus un canal de confiance.** `POST /sign-in/magic-link` accepte un champ `metadata` que better-auth déclare dans son schéma **public** et transmet tel quel à `sendMagicLink`, d'où le contexte du message était lu. Sans compte ni session, un appel forgé faisait donc partir depuis `noreply@fokan.fr` — domaine aligné SPF/DKIM/DMARC — un message intitulé « Ton foyer est activé sur fokan » affirmant « Le paiement est passé », ou une invitation dont le titre affichait une adresse choisie par l'attaquant. C'est le trou que l'audit du 28/07 avait fermé sur `/api/invitations` (S2), contourné une route plus loin.
Un sceau dérivé de `BETTER_AUTH_SECRET` (`src/lib/lien-magique.ts`) distingue désormais l'appel interne de l'appel réseau ; tout écart retombe sur « connexion », le seul contexte qui n'affirme rien. La règle générale vaut au-delà du mail : **un champ que le framework expose au client n'est pas un canal de confiance, même quand seul notre code l'écrit aujourd'hui.**
**2. L'avance de rappel peut dépendre de la stratégie, et non plus de la seule confiance** (révise le § 2 de D-013). `catnatWindow` pose `fenetreDue` = publication au Journal officiel **+ 30 jours**. Traitée comme n'importe quelle fenêtre `connue`, elle partait 21 jours avant cette limite, c'est-à-dire **9 jours après la publication** — un mail annonçant « 30 jours pour déclarer » quand il en restait 21. C'est le gaspillage exact que la veille quotidienne de D-012 a été bâtie pour éviter : son commentaire s'inquiète qu'un passage hebdomadaire consomme « jusqu'à un quart » du délai, quand le planificateur en consommait 30 %, tous les jours, sans que rien ne le signale.
`AVANCE_PAR_STRATEGIE` porte l'exception, nommée et unique. Trois semaines restent le bon réglage pour un contrôle technique ou une échéance ZFE : ce n'est pas la sémantique de `connue` qui était fausse, c'est la constante qui est inadaptée à une fenêtre légale d'un mois. Une avance de stratégie ne ressuscite jamais une fenêtre `libre`.
**3. L'outbox porte le destinataire dans sa clé** (révise le § 6 de D-013, et lève le troisième « sacrifié en connaissance de cause »). La boucle d'envoi marquait `envoye` dès qu'**un** membre avait été servi, et journalisait par occurrence. Sur un foyer à deux : la première adresse passe, la seconde rebondit, la porte d'idempotence se referme, et le second membre ne reçoit jamais ce rappel — ni le lendemain, ni jamais, puisque seul `envoye` fait taire l'outbox. Le § 6.3 promet pourtant à chaque membre ses propres rappels, « sans jamais passer par quelqu'un d'autre ».
La clé devient `(occurrence, canal, fenêtre notifiée, destinataire)`, et `envoyerRappelFoyer` calcule **un plan par membre** : si une adresse a échoué hier, lui seul redevient candidat aujourd'hui. Dans le cas courant les plans sont identiques et le comportement est celui d'avant.
Deux précisions qui ne se devinent pas :
- **c'est le `user_id` qui est stocké, jamais l'adresse** — la minimisation du § 8 tient, l'adresse se relit chez l'utilisateur ;
- **l'`envoi_id` reste unique par foyer et par jour.** En donner un par destinataire aurait divisé le budget mensuel d'un foyer par son nombre de membres, puisque `envoisCeMois` compte des `envoi_id` distincts.
Les lignes antérieures, sans destinataire, valent « parti à tout le foyer » et font taire l'échéance pour tous — c'est ce qu'elles voulaient dire quand elles ont été écrites, et les relire autrement aurait fait repartir d'anciens rappels à tout le monde.
**4. Une troisième porte, à l'unité** (complète le § 7 de D-013). `envoyerRappelFoyer` est exporté et appelé directement par les scripts de mise au point : la seule vérification d'abonnement vivait dans le `where` du passage collectif, que ces appels ne traversent jamais. `abonnementOuvert()` — pure, testée — est désormais posée aussi à l'entrée par foyer. La duplication avec le SQL est délibérée : le SQL filtre en masse, la fonction vérifie à l'unité.
**5. Un mail d'activation qui survit à son lien.** Le lien magique expire en 15 minutes. Dans le cas 2 du webhook Stripe — personne n'était connecté avant de payer —, ce mail était la **seule** chose qui portait l'adresse du foyer : `resolveMembership` n'inscrit le premier membre qu'à la visite de `/foyer/<id>`. Passé le quart d'heure, un foyer payé 19,99 € devenait inatteignable, `/mon-espace` listant les foyers par appartenance et l'acheteur n'en ayant précisément aucune. L'URL du foyer voyage désormais dans le message ; le lien magique reste le chemin rapide, il n'est plus le chemin unique.
**6. Une invitation se renvoie.** Le jeton d'invitation vit 7 jours, le lien magique 15 minutes, et le garde-fou « une invitation est déjà en cours » (audit S2) refusait tout second envoi : ni l'invité ni l'invitant ne pouvaient avancer pendant une semaine. Un renvoi est désormais possible sur l'invitation pendante, avec son jeton d'origine, sous un plafond propre — 5 par adresse et par jour, distinct du plafond de création qui ne compte que des lignes en base.
**7. Hygiène d'envoi** : `multipart/alternative` sur les trois gabarits (un seul arbre React rendu deux fois, les parties ne peuvent pas diverger) ; `Reply-To` vers `SMTP_USER` à défaut de `REPLY_TO_EMAIL`, parce que `noreply@` n'est pas une boîte ; délais de garde SMTP, sans lesquels une connexion qui pend bloquait le passage entier jusqu'à l'expiration pg-boss de 30 min ; `message_id` enfin écrit dans la colonne qui l'attendait, seul lien possible entre un envoi et un rebond ; un plafond applicatif de 10/h/IP sur la demande de lien magique, indépendant de `NODE_ENV` — celui de better-auth ne s'active qu'en production.
### Deux défauts trouvés en vérifiant, pas en relisant
- **L'avance CatNat posée à zéro faisait l'inverse du correctif.** L'avance se compte depuis `fenetreDue`, qui **est** la fin du délai : zéro aurait fait partir le mail le dernier jour des 30. La bonne valeur est le délai lui-même, 30. Un test croise la constante avec `DELAI_DECLARATION_JOURS` de `adapters/catnat.ts` — que le moteur pur ne peut pas importer sans cesser d'être pur — pour que la duplication ne dérive pas.
- **Aucun gabarit mail ne pouvait être testé.** `tsconfig.json` laisse `jsx: "preserve"` puisque Next.js transforme en production ; vitest n'a pas ce relais et tout rendu échouait sur « React is not defined ». C'est la raison pour laquelle les trois gabarits n'avaient jamais eu un seul test : l'outil ne le permettait pas, et personne ne l'avait constaté. `esbuild: { jsx: "automatic" }` dans `vitest.config.ts`.
### Ce qui reste ouvert, et pourquoi ce n'est pas ici
Quatre décisions produit, détaillées avec leurs options et une recommandation en [§ 6 de l'audit](audit-mail-2026-07-30.md) : le périmètre du désabonnement (§ 6.1 — exigence Gmail/Yahoo depuis 2024, RFC 8058), les gestes que chaque mail promet sans qu'aucun écran ne les tienne (§ 6.2), les **31 fiches sur 76** dont le contenu mail ne peut jamais partir (§ 6.3), et le mot de passe activé sans aucun chemin pour en obtenir un (§ 6.4). Elles ne sont pas tranchées : elles n'ont donc pas leur place dans ce journal, et y viendront quand elles le seront.
### Vérification
`npm test` : **645 tests**, dont **31 ajoutés** par ce lot — 10 sur le sceau du lien magique (dont le scénario d'attaque exact, contexte forgé et sceau volé à une autre configuration), 10 sur les trois gabarits, 7 sur les portes du passage de rappels, 4 sur l'avance CatNat. `tsc --noEmit` et `next build` passent, sans avertissement de lint nouveau. Migration `0016_right_jamie_braddock` générée, jouée et vérifiée en base : colonne `destinataire_id`, index unique reconstruit sur quatre colonnes, clé étrangère vers `user`.
Le chemin d'envoi lui-même n'a **pas** été rejoué de bout en bout contre un SMTP : la base de développement est vide (0 foyer, 0 membre, 0 ligne d'outbox), contrairement à la vérification de D-013 qui disposait de données réelles.
## D-038 — 30 juillet 2026 · Une zone garde tous ses anneaux, plus seulement le plus étendu
**Contexte** : D-037 § 4 signalait ce défaut sans le corriger, l'ayant jugé « d'affichage seulement ». Consigne du porteur de projet : « il faut que la carte soit exhaustive ». Elle tranche juste — une carte qui omet un secteur réglementé ne se trompe pas d'un pixel, elle ment par omission à qui y habite.
@ -375,7 +424,7 @@ Trois vérifications qui ne se devinaient pas, toutes faites sur les données r
### 4. Le cuivre : une source qu'on avait déclarée inaccessible, et qui ne l'était pas
[pilier-maison § 2.6](pilier-maison.md) portait « ❌ non testé — pas d'API propre ». C'était faux, et un seul appel le montrait : le jeu `fermeture-reseau-cuivre` est ouvert, **indexé par code INSEE** — la clé qu'on possède déjà — et pèse **760 ko en cinq secondes** hors géométries. Il est donc recopié en entier chaque semaine plutôt qu'interrogé par foyer : la date de coupure d'une commune est en base **avant** que le quiz ne la demande, ce qui rend le révélé complet à sa date sans un appel sortant dans l'onboarding (D-027).
[pilier-logement § 2.6](pilier-logement.md) portait « ❌ non testé — pas d'API propre ». C'était faux, et un seul appel le montrait : le jeu `fermeture-reseau-cuivre` est ouvert, **indexé par code INSEE** — la clé qu'on possède déjà — et pèse **760 ko en cinq secondes** hors géométries. Il est donc recopié en entier chaque semaine plutôt qu'interrogé par foyer : la date de coupure d'une commune est en base **avant** que le quiz ne la demande, ce qui rend le révélé complet à sa date sans un appel sortant dans l'onboarding (D-027).
Deux pièges relevés sur les 35 305 lignes :
- la source publie **une ligne par couple (commune, code postal)** — Toulouse en compte six. Vérifié : sur les 34 916 codes INSEE distincts, **aucun** ne porte deux dates différentes ni un mélange date/absence. Le code INSEE est donc une clé sûre, et le déduplicateur existe pour le jour où il cesserait de l'être ;
@ -646,7 +695,7 @@ Le panneau de couches, appelé par un bouton posé sur la carte, porte désormai
---
## D-024 — 29 juillet 2026 · Un périmètre nucléaire n'est pas un rayon unique, et un PPI n'est pas un droit à l'iode (corrige D-022 et le § 4 de [pilier-maison](pilier-maison.md))
## D-024 — 29 juillet 2026 · Un périmètre nucléaire n'est pas un rayon unique, et un PPI n'est pas un droit à l'iode (corrige D-022 et le § 4 de [pilier-logement](pilier-logement.md))
**Contexte** : question du porteur de projet — « les 20 km ne s'appliquent qu'aux centrales ? Saclay et Fontenay-aux-Roses sont liés au CEA, il semble que le PPI soit différent dans ce cas (2,5 km ?) ». La réponse est oui, et la source la publiait dans des champs qu'on ne lisait pas.
@ -1014,7 +1063,7 @@ Chaîne complète éprouvée sur un foyer de test à Nantes (académie de Nantes
Jusqu'ici, un foyer se déclarait une fois pour toutes : rien ne permettait de corriger un véhicule vendu, un animal arrivé, un contrat oublié, sans repasser par un nouveau quiz — donc un nouveau foyer, une nouvelle veille, un doublon. **Un abonné peut désormais rejouer son quiz** (`/quiz?rejouer=<id>`), réponses précédentes préremplies et modifiables, avec un **garde-fou d'un rejeu par mois** pour qu'un rejeu répété ne devienne pas un vecteur d'abus.
Ce n'est pas neutre pour l'invariant n° 3 (état utilisateur inviolable) : un compromis a dû être tranché. Les assets à **instance unique** (foyer, logement) sont mis à jour **en place**, même `id` — leurs échéances, qui couvrent la quasi-totalité du pilier maison, gardent leur état (muet, responsable, historique). Les assets à **instances multiples** (véhicules, personnes, animaux, contrats) sont **remplacés entièrement** : il n'existe aucune correspondance fiable entre « l'ancienne deuxième voiture » et « la nouvelle deuxième voiture » d'une liste rejouée sans identifiant stable, et deviner aurait produit une correspondance fausse en silence — le même raisonnement qui gouverne déjà `accorderDistribution` (D-017 § 5 : « aucune correspondance approchée »). Un remplacement franc, annoncé à l'écran avant validation, a été préféré à une correspondance devinée. **Point à réexaminer si l'usage montre que la perte d'état sur ces échéances-là gêne les foyers qui rejouent** — une correspondance par clé naturelle (`reception_numero` pour un véhicule, `type` pour un contrat) resterait possible plus tard, non faite ici par prudence.
Ce n'est pas neutre pour l'invariant n° 3 (état utilisateur inviolable) : un compromis a dû être tranché. Les assets à **instance unique** (foyer, logement) sont mis à jour **en place**, même `id` — leurs échéances, qui couvrent la quasi-totalité du pilier logement, gardent leur état (muet, responsable, historique). Les assets à **instances multiples** (véhicules, personnes, animaux, contrats) sont **remplacés entièrement** : il n'existe aucune correspondance fiable entre « l'ancienne deuxième voiture » et « la nouvelle deuxième voiture » d'une liste rejouée sans identifiant stable, et deviner aurait produit une correspondance fausse en silence — le même raisonnement qui gouverne déjà `accorderDistribution` (D-017 § 5 : « aucune correspondance approchée »). Un remplacement franc, annoncé à l'écran avant validation, a été préféré à une correspondance devinée. **Point à réexaminer si l'usage montre que la perte d'état sur ces échéances-là gêne les foyers qui rejouent** — une correspondance par clé naturelle (`reception_numero` pour un véhicule, `type` pour un contrat) resterait possible plus tard, non faite ici par prudence.
Comble au passage le trou ouvert par D-007 (l'opt-in post-quiz des contrats retiré « en connaissance de cause ») : un contrat devenu pertinent après le premier quiz (nouveau véhicule, nouvel animal) se déclare de nouveau via un rejeu, sans réintroduire le rattrapage post-quiz que D-007 avait explicitement fermé.
@ -1188,7 +1237,7 @@ Le parc réel vient d'une nouvelle table `parc_immatricule`, alimentée par le r
## D-013 — 26 juillet 2026 · Le scheduler de notifications entre : le produit se met enfin à parler
**Précise le § 20.5 du [cadrage](cadrage-projet-fokan.md)** et complète le § 5.e de [docs/pilier-maison.md](pilier-maison.md).
**Précise le § 20.5 du [cadrage](cadrage-projet-fokan.md)** et complète le § 5.e de [docs/pilier-logement.md](pilier-logement.md).
**Contexte** : constat fait en faisant l'état des lieux après la Phase 2. Le produit fabriquait **465 échéances actives sur 26 foyers, dont 326 datées dans le futur — et aucun mail n'était jamais sorti de l'application**. `sendMail` n'était appelé nulle part ailleurs que pour les liens magiques de connexion. Chaque phase depuis la Phase 0 avait ajouté de l'inventaire, aucune n'avait ajouté de la livraison. Le garde-fou notifications de D-012 n'avait pas pu être implémenté pour cette raison exacte, et l'abonnement — dont les rappels *sont* le produit (§ 10.2) — n'avait aucune contrepartie technique.
@ -1225,9 +1274,9 @@ Simulation sur les données réelles d'un foyer (20 échéances) aux dates du 1e
`npm run typecheck`, `npm run knowledge:check` (64 fiches) et `npm test` (273 tests, dont 18 sur la politique du silence) passent. Conteneur reconstruit, file `rappels-quotidiens` planifiée à 8 h.
## D-012 — 26 juillet 2026 · Le moteur apprend la notion de sujet, et la veille événementielle entre (Phase 2 du pilier maison)
## D-012 — 26 juillet 2026 · Le moteur apprend la notion de sujet, et la veille événementielle entre (Phase 2 du pilier logement)
**Révise le § 5 de [docs/pilier-maison.md](pilier-maison.md)** et [docs/phase-0/quiz.md](phase-0/quiz.md) (écran de fabrication).
**Révise le § 5 de [docs/pilier-logement.md](pilier-logement.md)** et [docs/phase-0/quiz.md](phase-0/quiz.md) (écran de fabrication).
**Contexte** : jusqu'ici le cron recalculait. Il rejouait des règles connues sur des données stables — un entretien de chaudière par logement, un contrôle technique par véhicule — et l'identité d'une échéance tenait dans le couple `template × asset`. La Phase 2 lui demande autre chose : **observer le monde extérieur**, c'est-à-dire des événements datés qui apparaissent sans prévenir et repartent seuls.
@ -1274,9 +1323,9 @@ Passage quotidien réel : 3 communes, 24 foyers, 0 échec, 14,3 s — dont un fo
`npm run typecheck`, `npm run knowledge:check` (64 fiches) et `npm test` (255 tests, 43 de plus qu'en Phase 1, dont 6 sur le moteur — inviolabilité de l'état utilisateur avec sujets, rétrocompatibilité de la clé, idempotence du rejeu) passent.
## D-011 — 26 juillet 2026 · Crit'Air et ZFE entrent, sans qu'aucune règle ZFE ne soit gravée (lève le refus du § 7 du plan pilier maison)
## D-011 — 26 juillet 2026 · Crit'Air et ZFE entrent, sans qu'aucune règle ZFE ne soit gravée (lève le refus du § 7 du plan pilier logement)
**Révise le § 2.6 et le § 7 de [docs/pilier-maison.md](pilier-maison.md)** (« Base nationale consolidée des ZFE — à ne pas graver, statut politique instable » ; « ZFE — statut politique instable, et de toute façon pilier véhicules ») ainsi que [docs/phase-0/quiz.md](phase-0/quiz.md).
**Révise le § 2.6 et le § 7 de [docs/pilier-logement.md](pilier-logement.md)** (« Base nationale consolidée des ZFE — à ne pas graver, statut politique instable » ; « ZFE — statut politique instable, et de toute façon pilier véhicules ») ainsi que [docs/phase-0/quiz.md](phase-0/quiz.md).
**Contexte** : le sujet avait été écarté deux fois, pour une raison qui reste entièrement valable — et qui s'est même vérifiée depuis. La suppression des ZFE a été votée par les deux chambres du Parlement en avril 2026 dans la loi de simplification de la vie économique, avant d'être **censurée par le Conseil constitutionnel le 21 mai 2026 pour cavalier législatif** : un motif de procédure, pas un examen du fond. Les zones sont donc pleinement en vigueur, mais rien n'empêche le législateur de recommencer dans un véhicule approprié.
@ -1328,11 +1377,11 @@ Sync réelle : 35 zones ingérées depuis la BNZFE, 23 périodes actives, 9 durc
**2. Deux fiches nouvelles**, ciblées propriétaires — c'est au vendeur ou au bailleur que le document est réclamé : `plomb-crep` (`epoque_construction: avant_1949`) et `amiante-etat` (`avant_1949` ou `1949_1997`).
**3. `ramonage` passe en v3, sur une erreur factuelle qui était publiée.** La fiche et sa page affirmaient que le décret n° 2023-641 avait « unifié la règle au niveau national ». Il pose un **plancher** de 12 mois : l'art. R1331-71 du Code de la santé publique laisse les arrêtés préfectoraux imposer davantage, dont un passage en période de chauffe, ce que fait une majorité de départements. Aucun jeu national ne recense ces arrêtés (§ 2.7 du plan pilier maison) : la fiche énonce le plancher et renvoie à la préfecture, même réserve que pour le PPRT.
**3. `ramonage` passe en v3, sur une erreur factuelle qui était publiée.** La fiche et sa page affirmaient que le décret n° 2023-641 avait « unifié la règle au niveau national ». Il pose un **plancher** de 12 mois : l'art. R1331-71 du Code de la santé publique laisse les arrêtés préfectoraux imposer davantage, dont un passage en période de chauffe, ce que fait une majorité de départements. Aucun jeu national ne recense ces arrêtés (§ 2.7 du plan pilier logement) : la fiche énonce le plancher et renvoie à la préfecture, même réserve que pour le PPRT.
**4. `declaration-revenus` passe en v3 avec une stratégie de fenêtre.** L'inférence départementale promise est réelle, mais elle ne change pas *si* la fiche s'applique — elle change *quand*. Nouvelle stratégie `declaration_revenus` (`src/engine/windows.ts`) : zones 01-19, 20-54, 55 et au-delà. La fiche annonçait « avril » à tout le monde, c'est-à-dire le mois de l'**ouverture** du service et non celui de l'échéance ; les zones 1 et 2 sont désormais calées sur mai, la zone 3 sur juin. La Corse est traitée à part, `Number("2A")` valant NaN — sans quoi elle aurait perdu toute inférence départementale en silence.
**5. ZFE/Crit'Air est retiré des promesses de quiz.md** : déjà écarté au § 7 du plan pilier maison (statut politique instable), et de toute façon pilier véhicules.
**5. ZFE/Crit'Air est retiré des promesses de quiz.md** : déjà écarté au § 7 du plan pilier logement (statut politique instable), et de toute façon pilier véhicules.
### Ce qui est sacrifié, en connaissance de cause
@ -1346,7 +1395,7 @@ Quatre profils testés en production : avant 1949 + propriétaire → plomb **et
## D-009 — 26 juillet 2026 · L'écran de fabrication devient le moment où le travail se fait (complète D-008)
**Révise [docs/phase-0/quiz.md](phase-0/quiz.md)** (écran final) et la mise en œuvre de la Phase 1 du pilier maison.
**Révise [docs/phase-0/quiz.md](phase-0/quiz.md)** (écran final) et la mise en œuvre de la Phase 1 du pilier logement.
**Contexte** : D-008 avait placé le relevé Géorisques en tâche de fond, pour ne pas suspendre l'onboarding à une API d'État. C'était le bon réflexe et le mauvais résultat. Mesures : `POST /api/quiz` répond en **60 ms**, le hub se rend en **43 ms**, et le relevé prend **1 à 6 s** — donc l'aperçu s'affichait toujours avant que les risques n'existent, sur une page que personne ne recharge. Un foyer de Bormes-les-Mimosas voyait 26 échéances au lieu de 28 et ne saurait jamais que le débroussaillement et le radon en faisaient partie. Pendant ce temps, l'écran « On monte ta veille… » faisait défiler quatre étapes sur un `setInterval` de 420 ms derrière un plancher artificiel de 1 700 ms — soit 96 % d'un écran qui ne recouvrait aucun travail.
@ -1367,9 +1416,9 @@ Quatre profils testés en production : avant 1949 + propriétaire → plomb **et
Flux horodaté en production sur trois parcours : Bormes + véhicule à chaud (plan à 0 s, trois étapes en 30 ms, risques à 0,86 s), le même à froid (plafond à 8,21 s, bascule en file), et un foyer sans adresse ni véhicule (plan réduit à deux étapes, fin à 20 ms). Hub vérifié au **premier** rendu : 28 échéances dont `debroussaillement-old` et `radon-depistage`, là où le fond de tâche seul en donnait 26. Réponses invalides : toujours un 400 JSON, jamais un flux. `npm run typecheck` et `npm test` (155 tests) passent. Foyers de test supprimés.
## D-008 — 25 juillet 2026 · Un aléa géographique dérivé alimente le moteur sans confirmation (précise le § 1 du plan pilier maison)
## D-008 — 25 juillet 2026 · Un aléa géographique dérivé alimente le moteur sans confirmation (précise le § 1 du plan pilier logement)
**Précise l'invariant du § 1 de [docs/pilier-maison.md](pilier-maison.md)** (« une valeur dérivée ne produit jamais une échéance à elle seule »), à l'occasion de la livraison de la Phase 1 du pilier maison.
**Précise l'invariant du § 1 de [docs/pilier-logement.md](pilier-logement.md)** (« une valeur dérivée ne produit jamais une échéance à elle seule »), à l'occasion de la livraison de la Phase 1 du pilier logement.
**Contexte** : cet invariant a été écrit face au DPE, et il reste entièrement justifié pour lui — un diagnostic de 2019 décrit un logement qui a pu être isolé depuis, sans que la source le sache. D'où le mécanisme des trois niveaux de vérité, `contexte` jamais lu par `applicabilite.when`, et l'écran de confirmation dans le quiz. La Phase 1 (Géorisques) apporte une donnée dérivée d'une autre nature : elle ne décrit pas le logement mais **le lieu** — zone d'obligation de débroussaillement, potentiel radon de la commune, exposition du sol aux argiles, périmètre d'une centrale, aléa d'inondation à l'adresse.
@ -1383,13 +1432,13 @@ Flux horodaté en production sur trois parcours : Bormes + véhicule à chaud (p
- `contexte` / `contexte_confirme` restent réservés à ce pour quoi ils ont été créés : le DPE et tout ce qui décrira le logement. `src/knowledge/dpe-confirmation.ts` et son écran de quiz sont intacts.
- Le moteur reste 100 % déterministe (invariant n° 5) : l'adapter est une frontière, il écrit des scalaires, le DSL ne fait que les comparer.
- Le test du geste (§ 1 du plan pilier maison) reste le filtre d'entrée : le séisme, les mouvements de terrain et tout score global ne sont pas collectés du tout, faute d'action à proposer.
- Le test du geste (§ 1 du plan pilier logement) reste le filtre d'entrée : le séisme, les mouvements de terrain et tout score global ne sont pas collectés du tout, faute d'action à proposer.
### Ce qui est sacrifié, en connaissance de cause
- **Un aléa mal rattaché produit une fiche que personne n'a demandée.** Quatre garde-fous, tous vérifiés en production sur des adresses réelles : seul `libelleStatutAdresse` est lu, jamais le statut de la commune (à Toulouse, l'inondation est « Existant » sur la commune mais « non Connu » à l'adresse) ; le débroussaillement n'est retenu que si le code commune de la zone est celui de l'adresse (l'endpoint renvoie volontiers la zone du voisin — relevé à Fréjus, qui remonte Mandelieu-la-Napoule) ; le volet technologique se limite aux installations classées, à l'exclusion de la pollution des sols et des canalisations, « Concerne » à l'adresse dans la moitié des villes sans qu'aucun travail n'en découle ; et une source qui ne répond pas ne fait écrire aucun attribut, jamais un `false` de complaisance.
- ~~**Aucun rafraîchissement périodique** n'est mis en place : le relevé se fait une fois, à la création du foyer. La carte des argiles a pourtant été refondue par arrêté le 9 janvier 2026. Le job étant idempotent, le rejouer suffira — c'est un cron à ajouter, pas une reprise de conception, et ça relève de la Phase 2 (veille événementielle).~~ **Comblé le 26/07/2026** : `rafraichirRisquesTousFoyers` rejoue le relevé pour tous les foyers dans le passage hebdomadaire, en quatrième et dernier segment (`src/instrumentation.ts`). Comme prévu, il n'y a rien eu à concevoir — le job était déjà idempotent. Mesuré en production : 23 foyers, 7 enrichis (les seuls à avoir donné une adresse), 0 échec, 40 s, soit ~1,75 s par foyer et une dizaine de minutes à l'échelle de sortie du cadrage. Une seconde d'écart entre deux foyers, pour ne pas marteler une API d'État sans quota documenté.
- **Pas de compte Cerbère** (décision ouverte n° 4 du plan pilier maison) : l'API v1 reste libre et suffit. Le jour où elle ferme, seul `src/adapters/georisques.ts` est concerné — l'en-tête d'authentification s'ajoute dans une fonction, `appel()`.
- **Pas de compte Cerbère** (décision ouverte n° 4 du plan pilier logement) : l'API v1 reste libre et suffit. Le jour où elle ferme, seul `src/adapters/georisques.ts` est concerné — l'en-tête d'authentification s'ajoute dans une fonction, `appel()`.
### Vérification

View file

@ -0,0 +1,4 @@
DROP INDEX "notifications_log_cle_idx";--> statement-breakpoint
ALTER TABLE "notifications_log" ADD COLUMN "destinataire_id" text;--> statement-breakpoint
ALTER TABLE "notifications_log" ADD CONSTRAINT "notifications_log_destinataire_id_user_id_fk" FOREIGN KEY ("destinataire_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "notifications_log_cle_idx" ON "notifications_log" USING btree ("occurrence_id","canal","fenetre_notifiee","destinataire_id");

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,13 @@
"when": 1785451012067,
"tag": "0015_bitter_eternity",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1785487561260,
"tag": "0016_right_jamie_braddock",
"breakpoints": true
}
]
}

View file

@ -7,7 +7,23 @@ import nodemailer from "nodemailer";
* inspiré du comportement de shared/email/client.py de kankwa.
*/
export type Mail = { to: string; subject: string; html: string };
/**
* `text` est la partie texte brut du message (audit mail § 4.10). Facultative dans le type mais
* fournie par les trois gabarits : un message HTML sans alternative texte n'est pas un
* `multipart/alternative`, et c'est un signal de spam classique or le § 26 fait de la
* réputation d'envoi une infrastructure vitale.
*/
export type Mail = { to: string; subject: string; html: string; text?: string };
/**
* Délais de garde (audit mail § 4.11). Sans eux, une connexion SMTP qui pend bloque tout le
* passage quotidien les foyers sont servis en série jusqu'à l'expiration pg-boss de 30 min,
* après quoi le job repart du début et l'heure d'envoi de tous les autres est perdue. Mieux vaut
* perdre un foyer sur un timeout, que l'outbox rendra candidat dès demain, que perdre la journée.
*/
const TIMEOUT_CONNEXION_MS = 10_000;
const TIMEOUT_ACCUEIL_MS = 10_000;
const TIMEOUT_SOCKET_MS = 20_000;
function transport() {
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD } = process.env;
@ -17,13 +33,27 @@ function transport() {
port: Number(SMTP_PORT ?? 587),
secure: Number(SMTP_PORT ?? 587) === 465,
auth: { user: SMTP_USER, pass: SMTP_PASSWORD },
connectionTimeout: TIMEOUT_CONNEXION_MS,
greetingTimeout: TIMEOUT_ACCUEIL_MS,
socketTimeout: TIMEOUT_SOCKET_MS,
});
}
export async function sendMail({ to, subject, html }: Mail): Promise<{ sent: boolean }> {
/**
* `messageId` remonte désormais jusqu'à l'appelant (audit mail § 4.9) : la colonne
* `notifications_log.message_id` existait et n'était jamais écrite, ce qui rendait tout rebond
* irrattachable à un envoi. C'est le seul identifiant que le serveur distant nous renvoie.
*/
export async function sendMail({ to, subject, html, text }: Mail): Promise<{ sent: boolean; messageId?: string }> {
const t = transport();
const from = process.env.FROM_EMAIL ?? "noreply@fokan.fr";
const fromName = process.env.FROM_NAME ?? "fokan";
/**
* Une réponse doit tomber dans une boîte lue. `FROM_EMAIL` est un alias sans destinataire
* (`noreply@`) : à défaut de `REPLY_TO_EMAIL`, on retombe sur la boîte qui s'authentifie en
* SMTP, qui est par construction une vraie boîte (`contact@fokan.fr`).
*/
const replyTo = process.env.REPLY_TO_EMAIL ?? process.env.SMTP_USER;
if (!t) {
// Jamais de secret dans les logs. En dev, le lien magique lui-même n'est pas un secret
@ -32,6 +62,6 @@ export async function sendMail({ to, subject, html }: Mail): Promise<{ sent: boo
return { sent: false };
}
await t.sendMail({ from: `"${fromName}" <${from}>`, to, subject, html });
return { sent: true };
const info = await t.sendMail({ from: `"${fromName}" <${from}>`, to, subject, html, text, replyTo });
return { sent: true, messageId: info.messageId };
}

View file

@ -1,4 +1,29 @@
import { NextResponse } from "next/server";
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
import { autoriser, ipDe, PLAFONDS } from "@/lib/rate-limit";
export const { GET, POST } = toNextJsHandler(auth);
const handler = toNextJsHandler(auth);
export const GET = handler.GET;
/**
* Le seul chemin de better-auth qui fait sortir un mail vers une adresse choisie par l'appelant,
* sans compte ni session : `POST /api/auth/sign-in/magic-link` (audit mail § 4.1).
*
* Le plugin pose bien son propre plafond (5 / 60 s / IP), mais seulement si `rateLimit.enabled`,
* dont le défaut est `isProduction`. Hors production et donc sur toute instance de recette
* exposée la route n'était plafonnée par rien. Celui-ci s'applique quoi qu'il arrive, en amont,
* et avec la même mécanique que les autres routes du produit.
*
* Volontairement au niveau de la route et non du plugin : `PLAFONDS` est l'endroit où l'on va
* lire ce que le produit accepte, et une limite qui vit dans une dépendance ne s'y lit pas.
*/
export async function POST(req: Request) {
if (new URL(req.url).pathname.endsWith("/sign-in/magic-link")) {
if (!autoriser(`lien-magique:${ipDe(req)}`, PLAFONDS.lienMagique.limite, PLAFONDS.lienMagique.fenetreMs)) {
return NextResponse.json({ error: "trop de demandes, réessaie plus tard" }, { status: 429 });
}
}
return handler.POST(req);
}

View file

@ -32,8 +32,8 @@ export async function POST(req: NextRequest) {
if (householdId) {
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
const hub = await buildHub(householdId).catch(() => null);
const html = await renderGarderLienEmail({ url: `${base}/foyer/${householdId}`, total: hub?.total ?? 0 });
await sendMail({ to: email, subject: "Le lien de ton foyer fokan", html }).catch((e) => {
const { html, text } = await renderGarderLienEmail({ url: `${base}/foyer/${householdId}`, total: hub?.total ?? 0 });
await sendMail({ to: email, subject: "Le lien de ton foyer fokan", html, text }).catch((e) => {
// Le dépôt en base a déjà eu lieu : un échec d'envoi ne doit jamais le remettre en cause.
console.error("[interet] envoi du lien impossible :", String(e));
});

View file

@ -2,7 +2,7 @@ import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { auth, metadataInterne } from "@/lib/auth";
import { db, memberships as membershipsTable, invitations as invitationsTable } from "@/db";
import { autoriser, PLAFONDS } from "@/lib/rate-limit";
@ -15,6 +15,13 @@ const SEPT_JOURS = 7 * 24 * 3600 * 1000;
const VINGT_QUATRE_HEURES = 24 * 3600 * 1000;
/** Plafond par adresse invitée, tous foyers et tous invitants confondus (audit S2). */
const MAX_INVITATIONS_PAR_EMAIL_JOUR = 3;
/**
* Plafond de RENVOIS d'un lien sur une invitation déjà créée (audit mail § 4.5). Distinct du
* précédent, qui compte des lignes en base : un renvoi n'en crée aucune, il ne serait donc jamais
* compté par lui. Plus large, parce qu'un renvoi ne fait que redonner accès à une invitation que
* l'invité a déjà acceptée de recevoir — mais borné, parce qu'il fait quand même partir un mail.
*/
const MAX_RENVOIS_PAR_EMAIL_JOUR = 5;
/**
* Inviter un membre du foyer (§ 9.3) réservé aux membres existants de CE foyer.
@ -44,10 +51,22 @@ export async function POST(req: NextRequest) {
.where(and(eq(membershipsTable.householdId, householdId), eq(membershipsTable.userId, session.user.id)));
if (!membership) return NextResponse.json({ error: "pas membre de ce foyer" }, { status: 403 });
// 2. Refus si une invitation encore valable existe déjà pour ce couple (foyer, adresse) —
// sans ce garde-fou, chaque nouvel appel renvoyait un lien magique de plus à la même adresse.
/**
* 2. Une invitation encore valable pour ce couple (foyer, adresse) n'en fait pas naître une
* seconde : on renvoie un lien sur CELLE-, avec son jeton d'origine.
*
* Ce point refusait autrefois en 409 (audit mail § 4.5), et les deux garde-fous se combinaient
* en blocage : le lien magique expire en 15 minutes, le jeton d'invitation vit 7 jours. L'invité
* qui cliquait le lendemain tombait sur une erreur, et l'invitant qui réessayait s'entendait
* répondre « une invitation est déjà en cours » pendant une semaine, ni l'un ni l'autre ne
* pouvait avancer.
*
* Renvoyer n'ouvre pas de vecteur d'abus : le plafond n° 3 (par adresse invitée, tous foyers et
* tous invitants confondus) compte les invitations créées, et le plafond n° 1 borne l'invitant.
* On applique donc ici le SEUL plafond que le renvoi peut contourner, celui par adresse.
*/
const [pendante] = await database
.select({ id: invitationsTable.id })
.select({ id: invitationsTable.id, token: invitationsTable.token })
.from(invitationsTable)
.where(and(
eq(invitationsTable.householdId, householdId),
@ -55,8 +74,20 @@ export async function POST(req: NextRequest) {
isNull(invitationsTable.usedAt),
gte(invitationsTable.expiresAt, new Date()),
));
if (pendante) {
return NextResponse.json({ error: "une invitation est déjà en cours pour cette adresse" }, { status: 409 });
if (!autoriser(`invitation-renvoi:${emailNorm}`, MAX_RENVOIS_PAR_EMAIL_JOUR, VINGT_QUATRE_HEURES)) {
return NextResponse.json({ error: "trop de renvois pour cette adresse aujourd'hui" }, { status: 429 });
}
await auth.api.signInMagicLink({
body: {
email: emailNorm,
callbackURL: `/foyer/${householdId}?invite=${pendante.token}`,
metadata: metadataInterne("invitation", { invitedByEmail: session.user.email }),
},
headers: req.headers,
});
return NextResponse.json({ ok: true, renvoi: true });
}
// 3. Plafond par adresse invitée, tous foyers confondus — protège une victime ciblée depuis
@ -85,7 +116,7 @@ export async function POST(req: NextRequest) {
body: {
email: emailNorm,
callbackURL: `/foyer/${householdId}?invite=${token}`,
metadata: { context: "invitation", invitedByEmail: session.user.email },
metadata: metadataInterne("invitation", { invitedByEmail: session.user.email }),
},
headers: req.headers,
});

View file

@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { db, households as householdsTable, memberships as membershipsTable } from "@/db";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { auth, metadataInterne } from "@/lib/auth";
/** Active/désactive l'abonnement du foyer — jamais de statut mis à jour ailleurs (§ 21). */
export async function POST(req: NextRequest) {
@ -85,8 +85,18 @@ export async function POST(req: NextRequest) {
*/
const email = session.customer_details?.email;
if (email) {
/**
* L'URL du foyer voyage avec le lien magique (audit mail § 4.4) : celui-ci expire en
* 15 minutes, et c'était jusqu'ici la seule chose qui portait l'adresse du foyer à
* l'acheteur. Passé le quart d'heure, un foyer payé devenait inatteignable.
*/
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
await auth.api.signInMagicLink({
body: { email, callbackURL: `/foyer/${householdId}`, metadata: { context: "activation" } },
body: {
email,
callbackURL: `/foyer/${householdId}`,
metadata: metadataInterne("activation", { urlFoyer: `${base}/foyer/${householdId}` }),
},
headers: req.headers,
}).catch((e) => {
// Le foyer reste actif quoi qu'il arrive : un mail de bienvenue manqué ne doit

View file

@ -933,8 +933,14 @@ export const vacancesScolaires = pgTable("vacances_scolaires", {
* fenêtre glisse d'une année sur l'autre (§ 4.5, glissement silencieux). Ne dédupliquer que sur
* l'occurrence aurait donc rendu muet, à vie, tout rappel déjà envoyé une fois.
*
* Aucune adresse mail n'est stockée ici : les destinataires se déduisent des membres du foyer au
* moment de l'envoi. Journaliser l'adresse en aurait fait une seconde copie à purger (§ 8).
* Aucune adresse mail n'est stockée ici : le destinataire est désigné par son `user_id`, et
* l'adresse se relit chez lui. Journaliser l'adresse en aurait fait une seconde copie à purger (§ 8).
*
* Le destinataire fait partie de la clé depuis l'audit mail § 4.8. Sans lui, la boucle d'envoi
* marquait `envoye` dès qu'UN membre avait é servi : sur un foyer à deux, la première adresse
* passait, la seconde rebondissait, et la porte d'idempotence se refermait sur un rappel que le
* second membre ne recevrait jamais ni le lendemain, ni jamais. Le § 6.3 promet pourtant à
* chaque membre ses propres rappels, « sans jamais passer par quelqu'un d'autre ».
*/
export const notificationsLog = pgTable("notifications_log", {
id: uuid("id").primaryKey().defaultRandom(),
@ -942,6 +948,13 @@ export const notificationsLog = pgTable("notifications_log", {
occurrenceId: uuid("occurrence_id").references(() => occurrences.id).notNull(),
deadlineId: uuid("deadline_id").references(() => deadlines.id).notNull(),
canal: text("canal").notNull().default("mail"),
/**
* À qui ce message est parti. Nullable pour une seule raison : les lignes écrites avant l'audit
* mail ne désignaient personne, et les réécrire aurait demandé de deviner un destinataire. Une
* ligne sans destinataire vaut « parti à tout le foyer » et fait taire l'échéance pour tous
* c'est bien ce qu'elle voulait dire à l'époque. Tout ce qui s'écrit désormais est renseigné.
*/
destinataireId: text("destinataire_id").references(() => user.id),
/** Regroupe les lignes parties dans le même message — « 3 choses ce mois-ci » en fait trois. */
envoiId: uuid("envoi_id").notNull(),
/** La fenêtre pour laquelle ce rappel est parti, telle qu'elle était au moment de l'envoi. */
@ -949,11 +962,17 @@ export const notificationsLog = pgTable("notifications_log", {
statut: text("statut").notNull(), // envoye | echec
/** Vrai pour un envoi qui n'a pas consommé le budget du mois (§ 20.5). */
horsBudget: boolean("hors_budget").notNull().default(false),
/** L'identifiant rendu par le serveur SMTP — le seul lien entre un envoi et un éventuel rebond. */
messageId: text("message_id"),
erreur: text("erreur"),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
uniqueIndex("notifications_log_cle_idx").on(table.occurrenceId, table.canal, table.fenetreNotifiee),
uniqueIndex("notifications_log_cle_idx").on(
table.occurrenceId,
table.canal,
table.fenetreNotifiee,
table.destinataireId,
),
index("notifications_log_foyer_idx").on(table.householdId, table.createdAt),
]);

135
src/emails/emails.test.tsx Normal file
View file

@ -0,0 +1,135 @@
import { describe, it, expect } from "vitest";
import { renderRappelEmail } from "./rappel";
import { renderMagicLinkEmail } from "./magic-link";
import { renderGarderLienEmail } from "./garder-lien";
import type { LigneRappel } from "@/engine/notifications";
/**
* Les trois gabarits, rendus pour de vrai (audit mail § 4.14 : ils n'avaient aucun test).
*
* On ne teste pas la mise en page elle changera. On teste ce qui, s'il cassait, produirait un
* message faux ou muet chez le destinataire : les deux parties du multipart, l'objet unique, et
* l'adresse durable du foyer.
*/
function ligne(o: Partial<LigneRappel> & { templateId: string }): LigneRappel {
return {
enjeuType: "legal",
confiance: "estimee",
fenetreDue: new Date("2026-09-01T00:00:00"),
fenetreLabel: "septembre 2026",
titre: "Entretien de la chaudière gaz",
pourquoi: "Obligatoire chaque année.",
assetLabels: ["La maison"],
occurrences: [{ occurrenceId: "o1", deadlineId: "d1" }],
...o,
};
}
describe("renderRappelEmail", () => {
it("rend le HTML et sa contrepartie texte", async () => {
const { html, text } = await renderRappelEmail({
rappel: { urgent: false, items: [ligne({ templateId: "chaudiere-gaz-entretien" })] },
urlFoyer: "https://fokan.fr/foyer/abc",
objet: "Ta chaudière gaz mérite son entretien annuel",
});
expect(html).toContain("<html");
// La partie texte doit être du texte : sans elle, pas de multipart/alternative (§ 4.10).
expect(text).not.toContain("<html");
expect(text).toContain("Entretien de la chaudière gaz");
expect(text).toContain("https://fokan.fr/foyer/abc");
});
it("affiche l'objet reçu, et n'en invente pas un second", async () => {
// Le composant fabriquait « 3 choses à regarder » quand l'objet disait « 3 choses ce
// mois-ci » : deux formulations du même message, l'une dans la liste, l'autre à l'ouverture.
const objet = "3 choses ce mois-ci";
const { html } = await renderRappelEmail({
rappel: {
urgent: false,
items: [ligne({ templateId: "a" }), ligne({ templateId: "b" }), ligne({ templateId: "c" })],
},
urlFoyer: "https://fokan.fr/foyer/abc",
objet,
});
expect(html).toContain(objet);
expect(html).not.toContain("choses à regarder");
});
it("nomme tous les assets d'une ligne regroupée", async () => {
const { text } = await renderRappelEmail({
rappel: {
urgent: false,
items: [ligne({ templateId: "assurance-scolaire", assetLabels: ["Enfant 1", "Enfant 2", "Enfant 3"] })],
},
urlFoyer: "https://fokan.fr/foyer/abc",
objet: "Assurance scolaire",
});
expect(text).toContain("Enfant 1, Enfant 2, Enfant 3");
});
it("se rabat sur l'enjeu quand la fiche n'a pas de corps de mail", async () => {
const { text } = await renderRappelEmail({
rappel: { urgent: false, items: [ligne({ templateId: "x", pourquoi: "Parce que c'est obligatoire." })] },
urlFoyer: "https://fokan.fr/foyer/abc",
objet: "Objet",
});
expect(text).toContain("Parce que c'est obligatoire.");
});
});
describe("renderMagicLinkEmail", () => {
it("porte l'adresse durable du foyer quand elle est fournie", async () => {
// Sans elle, un lien expiré en 15 min laissait un foyer payé inatteignable (§ 4.4).
const { html, text } = await renderMagicLinkEmail({
url: "https://fokan.fr/api/auth/magic-link/verify?token=x",
context: "activation",
urlFoyer: "https://fokan.fr/foyer/abc",
});
expect(html).toContain("https://fokan.fr/foyer/abc");
expect(text).toContain("https://fokan.fr/foyer/abc");
});
it("n'affiche aucune adresse de repli quand il n'y en a pas", async () => {
const { html } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context: "connexion" });
expect(html).not.toContain("garde ce mail");
});
it("nomme l'invitant dans une invitation", async () => {
const { text } = await renderMagicLinkEmail({
url: "https://fokan.fr/x",
context: "invitation",
invitedByEmail: "membre@foyer.fr",
});
// La conversion en texte brut met les titres en capitales — d'où la comparaison insensible.
expect(text.toLowerCase()).toContain("membre@foyer.fr");
});
it("rend les deux parties dans les trois contextes", async () => {
for (const context of ["connexion", "invitation", "activation"] as const) {
const { html, text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context });
expect(html).toContain("<html");
expect(text.length).toBeGreaterThan(0);
expect(text).not.toContain("<html");
}
});
});
describe("renderGarderLienEmail", () => {
it("accorde le pluriel des échéances", async () => {
const une = await renderGarderLienEmail({ url: "https://fokan.fr/foyer/abc", total: 1 });
expect(une.text).toContain("1 échéance sous contrôle");
const plusieurs = await renderGarderLienEmail({ url: "https://fokan.fr/foyer/abc", total: 12 });
expect(plusieurs.text).toContain("12 échéances sous contrôle");
});
it("rend les deux parties", async () => {
const { html, text } = await renderGarderLienEmail({ url: "https://fokan.fr/foyer/abc", total: 3 });
expect(html).toContain("<html");
expect(text).toContain("https://fokan.fr/foyer/abc");
expect(text).not.toContain("<html");
});
});

View file

@ -65,6 +65,9 @@ function GarderLienEmail({ url, total }: Props) {
);
}
export async function renderGarderLienEmail(props: Props): Promise<string> {
return render(<GarderLienEmail {...props} />);
/** HTML et texte brut du même arbre (audit mail § 4.10) — cf. `renderRappelEmail`. */
export async function renderGarderLienEmail(props: Props): Promise<{ html: string; text: string }> {
const element = <GarderLienEmail {...props} />;
const [html, text] = await Promise.all([render(element), render(element, { plainText: true })]);
return { html, text };
}

View file

@ -16,9 +16,23 @@ type Props = {
/** Ton différent selon le contexte — jamais dans le quiz, toujours au bon moment (§ 5.6, § 6.3). */
context: "connexion" | "invitation" | "activation";
invitedByEmail?: string;
/**
* L'URL durable du foyer, affichée sous le bouton quand elle est connue (audit mail § 4.4).
*
* Le lien magique expire en 15 minutes. Dans le cas 2 du webhook Stripe personne n'était
* connecté avant de payer ce mail était la SEULE chose qui portait l'adresse du foyer :
* `resolveMembership` n'inscrit le premier membre qu'à la visite de `/foyer/<id>`, et cette
* visite n'était possible qu'en suivant ce lien. Passé le quart d'heure, un foyer payé
* 19,99 devenait inatteignable `/mon-espace` liste les foyers par appartenance, et
* l'acheteur n'en avait précisément aucune.
*
* L'URL du foyer, elle, ne périme pas. Le lien magique reste le chemin rapide ; il n'est plus
* le chemin unique.
*/
urlFoyer?: string;
};
function MagicLinkEmail({ url, context, invitedByEmail }: Props) {
function MagicLinkEmail({ url, context, invitedByEmail, urlFoyer }: Props) {
const titre =
context === "invitation"
? `${invitedByEmail ?? "Quelqu'un"} t'invite à rejoindre son foyer sur fokan`
@ -59,6 +73,15 @@ function MagicLinkEmail({ url, context, invitedByEmail }: Props) {
{context === "invitation" ? "Rejoindre le foyer" : context === "activation" ? "Voir mon foyer" : "Me connecter"}
</Link>
</Section>
{urlFoyer ? (
<Text style={{ color: "#6b7570", fontSize: 13, lineHeight: 1.6 }}>
Ce bouton ne vaut que 15 minutes. Passé ce délai, ton foyer reste accessible ici,
et cette adresse- ne périme pas garde ce mail :<br />
<Link href={urlFoyer} style={{ color: "#2e6650", wordBreak: "break-all" }}>
{urlFoyer}
</Link>
</Text>
) : null}
<Text style={{ color: "#6b7570", fontSize: 13 }}>
Si tu n&apos;es pas à l&apos;origine de cette demande, ignore simplement ce mail
rien ne se passera.
@ -69,6 +92,9 @@ function MagicLinkEmail({ url, context, invitedByEmail }: Props) {
);
}
export async function renderMagicLinkEmail(props: Props): Promise<string> {
return render(<MagicLinkEmail {...props} />);
/** HTML et texte brut du même arbre (audit mail § 4.10) — cf. `renderRappelEmail`. */
export async function renderMagicLinkEmail(props: Props): Promise<{ html: string; text: string }> {
const element = <MagicLinkEmail {...props} />;
const [html, text] = await Promise.all([render(element), render(element, { plainText: true })]);
return { html, text };
}

View file

@ -29,6 +29,13 @@ type Props = {
rappel: Rappel;
/** Lien vers le hub du foyer : la destination, le mail n'est qu'un déclencheur (§ 6.1). */
urlFoyer: string;
/**
* L'objet du message, calculé par `objetRappel` et réutilisé tel quel en titre et en aperçu
* (audit mail § 4.13). Le composant en fabriquait un second de son côté (« N choses à
* regarder » quand l'objet disait « N choses ce mois-ci ») : le destinataire lisait donc deux
* formulations du même message, l'une dans sa liste de mails, l'autre en l'ouvrant.
*/
objet: string;
};
const ENCRE = "#26302c";
@ -53,16 +60,13 @@ function Echeance({ titre, sujetLabel, assetLabel, fenetreLabel, corps }: {
);
}
function RappelEmail({ rappel, urlFoyer }: Props) {
function RappelEmail({ rappel, urlFoyer, objet }: Props) {
const multiple = rappel.items.length > 1;
const titre = multiple
? `${rappel.items.length} choses à regarder`
: (rappel.items[0].mailObjet ?? rappel.items[0].titre);
return (
<Html lang="fr">
<Head />
<Preview>{titre}</Preview>
<Preview>{objet}</Preview>
<Body style={{ backgroundColor: "#faf8f4", fontFamily: "sans-serif", padding: "2rem 0" }}>
<Container style={{ backgroundColor: "#ffffff", borderRadius: 14, padding: "2rem", maxWidth: 520 }}>
<Text style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 1, color: ACCENT, fontWeight: 700, margin: 0 }}>
@ -71,7 +75,7 @@ function RappelEmail({ rappel, urlFoyer }: Props) {
{multiple ? (
<>
<Heading style={{ fontSize: 20, color: ENCRE, marginBottom: "0.25rem" }}>{titre}</Heading>
<Heading style={{ fontSize: 20, color: ENCRE, marginBottom: "0.25rem" }}>{objet}</Heading>
<Text style={{ color: SOURDINE, fontSize: 14.5, lineHeight: 1.6, marginTop: 0 }}>
Rien d&apos;urgent, rien à faire tout de suite juste ce qui arrive, pour que tu
n&apos;aies pas à y penser toi-même.
@ -127,6 +131,13 @@ function RappelEmail({ rappel, urlFoyer }: Props) {
);
}
export async function renderRappelEmail(props: Props): Promise<string> {
return render(<RappelEmail {...props} />);
/**
* Rend les DEUX parties du message (audit mail § 4.10) : le HTML et son équivalent texte brut,
* pour un `multipart/alternative` en règle. Un seul arbre React rendu deux fois les deux
* parties ne peuvent donc pas diverger.
*/
export async function renderRappelEmail(props: Props): Promise<{ html: string; text: string }> {
const element = <RappelEmail {...props} />;
const [html, text] = await Promise.all([render(element), render(element, { plainText: true })]);
return { html, text };
}

View file

@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import { DELAI_DECLARATION_JOURS } from "@/adapters/catnat";
import {
AVANCE_PAR_STRATEGIE,
estDu,
horsBudget,
MAX_ENVOIS_MOIS,
@ -59,6 +61,55 @@ describe("estDu", () => {
const c = candidat({ templateId: "taxe-fonciere", fenetreDue: new Date("2026-06-01") });
expect(estDu(c, AUJOURDHUI)).toBe(true);
});
/**
* L'avance de la fiche prime sur celle de la confiance (audit mail § 4.2). Le cas qui l'impose
* est la CatNat : sa `fenetreDue` n'est pas une date à préparer, c'est la fin d'un délai qui
* court déjà publication au Journal officiel + 30 jours.
*/
it("notifie une CatNat le jour de la publication, pas 21 jours avant la fin du délai", () => {
const publication = new Date("2026-09-01");
const limite = new Date("2026-10-01"); // publication + 30 jours
const c = candidat({
templateId: "catnat-declaration-sinistre",
enjeuType: "legal+assurance",
confiance: "connue",
fenetreDue: limite,
avanceJours: AVANCE_PAR_STRATEGIE.catnat_declaration,
});
// Le jour de la publication, le mail part : les 30 jours sont entiers.
expect(estDu(c, publication)).toBe(true);
// Et pas la veille : la fenêtre n'existe qu'une fois l'arrêté publié.
expect(estDu(c, new Date("2026-08-31"))).toBe(false);
// Sans l'avance de stratégie, les 21 jours génériques l'auraient retenu jusqu'au 10 septembre,
// soit 9 jours perdus sur 30 — et un message annonçant « 30 jours » quand il en restait 21.
const sansAvance = { ...c, avanceJours: undefined };
expect(estDu(sansAvance, publication)).toBe(false);
expect(estDu(sansAvance, new Date("2026-09-09"))).toBe(false);
expect(estDu(sansAvance, new Date("2026-09-10"))).toBe(true);
});
it("laisse les trois semaines d'avance aux fiches qui n'ont pas d'avance propre", () => {
// Ce n'est pas la sémantique de `connue` qui était fausse : pour un contrôle technique ou une
// échéance ZFE, trois semaines restent le bon réglage. Seule la CatNat fait exception.
const c = candidat({ templateId: "controle-technique", confiance: "connue", fenetreDue: new Date("2026-10-01") });
expect(estDu(c, new Date("2026-09-09"))).toBe(false);
expect(estDu(c, new Date("2026-09-10"))).toBe(true);
});
it("ne ressuscite jamais une fenêtre libre, même avec une avance de stratégie", () => {
const c = candidat({ templateId: "x", confiance: "libre", avanceJours: 30 });
expect(estDu(c, new Date("2030-01-01"))).toBe(false);
});
it("l'avance CatNat vaut le délai légal lui-même, sinon le mail part le dernier jour", () => {
// L'avance se compte depuis `fenetreDue`, qui EST la fin du délai : la faire tomber à zéro
// aurait produit l'inverse du correctif. Croisé avec la source de vérité de l'adapter.
expect(AVANCE_PAR_STRATEGIE.catnat_declaration).toBe(DELAI_DECLARATION_JOURS);
});
});
describe("horsBudget", () => {

View file

@ -33,6 +33,11 @@ export type CandidatRappel = {
confiance: Confiance;
fenetreDue: Date;
fenetreLabel: string;
/**
* Avance de notification propre à cette échéance, en jours, quand la confiance ne suffit pas à
* la décider (cf. `AVANCE_JOURS`). Absente pour l'immense majorité des fiches.
*/
avanceJours?: number;
/** Contenus issus de la fiche — le moteur les transporte, il ne les écrit jamais. */
titre: string;
pourquoi: string;
@ -106,6 +111,31 @@ export const AVANCE_JOURS: Record<Confiance, number | null> = {
libre: null,
};
/**
* Les stratégies dont l'avance ne se déduit PAS de la confiance, parce que leur `fenetreDue`
* n'est pas une date à préparer mais la fin d'un délai qui court déjà (audit mail § 4.2).
*
* `catnat_declaration` pose `fenetreDue` = publication au Journal officiel + 30 jours. Traitée
* comme n'importe quelle fenêtre `connue`, elle partait 21 jours avant cette limite, c'est-à-dire
* **9 jours après la publication** un mail annonçant « 30 jours pour déclarer » quand il en
* restait 21. C'est exactement le gaspillage que la veille quotidienne a é bâtie pour éviter :
* son commentaire s'inquiète qu'un passage hebdomadaire consomme « jusqu'à un quart » du délai,
* quand le planificateur en consommait 30 %, tous les jours, sans que rien ne le signale.
*
* Trois semaines d'avance restent le bon réglage pour un contrôle technique ou une échéance ZFE :
* ce n'est pas la sémantique de `connue` qui est fausse, c'est la constante qui est inadaptée à
* une fenêtre légale d'un mois. D' une exception nommée, et non un changement du défaut.
*
* **L'avance se compte depuis `fenetreDue`, pas depuis l'ouverture du délai.** Pour partir le jour
* de la publication au JO, il faut donc une avance égale au délai lui-même 30 jours, et non
* zéro. Zéro aurait fait exactement l'inverse de ce qu'on cherche : un mail le dernier jour.
* La valeur duplique `DELAI_DECLARATION_JOURS` de `adapters/catnat.ts`, que ce module ne peut pas
* importer sans cesser d'être pur ; un test croise les deux.
*/
export const AVANCE_PAR_STRATEGIE: Record<string, number> = {
catnat_declaration: 30,
};
/** L'obligatoire légal avant le confort (§ 4.4). Plus petit = plus prioritaire. */
export const PRIORITE: Record<EnjeuType, number> = {
"legal+assurance": 0,
@ -139,8 +169,16 @@ function memeJourOuAvant(a: Date, b: Date): boolean {
* Une fenêtre déjà passée reste due tant que rien n'a été envoyé : c'est le rattrapage d'un cron
* manqué, pas un retard reproché. Rien dans le message ne dira jamais « en retard » (§ 4.5).
*/
export function estDu(candidat: Pick<CandidatRappel, "confiance" | "fenetreDue">, aujourdhui: Date): boolean {
const avance = AVANCE_JOURS[candidat.confiance];
export function estDu(
candidat: Pick<CandidatRappel, "confiance" | "fenetreDue" | "avanceJours">,
aujourdhui: Date,
): boolean {
/**
* L'avance de la fiche prime sur celle de la confiance, mais ne ressuscite jamais une fenêtre
* `libre` : sans date, il n'y a pas de bon moment, quelle que soit la stratégie.
*/
if (AVANCE_JOURS[candidat.confiance] === null) return false;
const avance = candidat.avanceJours ?? AVANCE_JOURS[candidat.confiance];
if (avance === null) return false;
return memeJourOuAvant(ajouterJours(candidat.fenetreDue, avance), aujourdhui);
}

View file

@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import { abonnementOuvert, cleNotification } from "./notifications";
/**
* Les portes du passage de rappels qui ne vivaient dans aucun test (audit mail § 4.14) : la seule
* couverture portait sur le module pur `engine/notifications.ts`, c'est-à-dire sur le QUAND et le
* COMBIEN jamais sur le À QUI ni sur le « a-t-il encore le droit de recevoir ».
*/
const AUJOURDHUI = new Date("2026-09-15T08:00:00");
describe("abonnementOuvert", () => {
it("ouvre un abonnement actif sans date de fin", () => {
expect(abonnementOuvert({ subscriptionStatus: "active", subscriptionPeriodEnd: null }, AUJOURDHUI)).toBe(true);
});
it("ouvre un abonnement actif dont la période court encore", () => {
const fin = new Date("2026-10-01");
expect(abonnementOuvert({ subscriptionStatus: "active", subscriptionPeriodEnd: fin }, AUJOURDHUI)).toBe(true);
});
/**
* Le filet de date : le statut vient de Stripe par webhook, et un webhook manqué à l'échéance
* laissait un `active` périmé faire écrire à un foyer qui ne paie plus. Le reste du produit
* (calendrier, flux .ics) applique déjà ce filet le seul endroit qui l'ignorait était celui
* qui parle à l'extérieur.
*/
it("ferme un abonnement actif dont la période est passée", () => {
const fin = new Date("2026-09-14T23:59:00");
expect(abonnementOuvert({ subscriptionStatus: "active", subscriptionPeriodEnd: fin }, AUJOURDHUI)).toBe(false);
});
it("ferme tout ce qui n'est pas actif", () => {
for (const statut of ["canceled", "expired", "trialing", "past_due", null]) {
expect(abonnementOuvert({ subscriptionStatus: statut, subscriptionPeriodEnd: null }, AUJOURDHUI)).toBe(false);
}
});
});
/**
* La clé d'idempotence porte désormais le destinataire (audit mail § 4.8). Sans lui, un envoi
* réussi pour un membre faisait taire l'échéance pour tous les autres définitivement, puisque
* seul le statut `envoye` referme l'outbox.
*/
describe("cleNotification", () => {
const fenetre = new Date("2026-09-01T00:00:00Z");
it("distingue deux membres du même foyer sur la même échéance", () => {
expect(cleNotification("occ-1", fenetre, "user-a")).not.toBe(cleNotification("occ-1", fenetre, "user-b"));
});
it("distingue deux fenêtres du même couple (occurrence, destinataire)", () => {
// Une échéance annuelle garde son occurrence tant que personne n'a répondu « fait », mais sa
// fenêtre glisse : ne pas la distinguer rendrait le rappel muet à vie après un seul envoi.
const anneeSuivante = new Date("2027-09-01T00:00:00Z");
expect(cleNotification("occ-1", fenetre, "user-a")).not.toBe(cleNotification("occ-1", anneeSuivante, "user-a"));
});
it("est stable pour un même triplet", () => {
expect(cleNotification("occ-1", fenetre, "user-a")).toBe(cleNotification("occ-1", new Date(fenetre), "user-a"));
});
});

View file

@ -14,6 +14,7 @@ import { sendMail } from "@/adapters/mail";
import { renderRappelEmail } from "@/emails/rappel";
import { loadTemplates } from "@/knowledge/loader";
import {
AVANCE_PAR_STRATEGIE,
objetRappel,
planRappel,
type CandidatRappel,
@ -41,7 +42,30 @@ export const QUEUE_RAPPELS = "rappels-quotidiens";
/** Un envoi par foyer et par jour au plus ; on espace quand même, l'infra d'envoi est modeste. */
const ENTRE_FOYERS_MS = 500;
type Destinataire = { email: string };
type Destinataire = { userId: string; email: string };
/**
* L'abonnement est-il ouvert aujourd'hui ?
*
* Le même filet de date que `getSubscription` : un `active` dont la période est passée est traité
* comme inactif partout dans le produit. Extrait en fonction pure pour deux raisons il devient
* testable sans base, et il peut être posé à l'entrée par foyer, pas seulement dans le `where` du
* passage collectif. `envoyerRappelFoyer` est exporté et appelé directement (scripts de mise au
* point) : la seule porte de l'abonnement vivait donc dans une requête que ces appels ne
* traversent jamais.
*
* La duplication avec le `where` de `rappelsQuotidiens` est délibérée : le SQL filtre en masse,
* ceci vérifie à l'unité. C'est la même règle énoncée deux fois pour deux usages, et les deux
* sont testées ensemble ci-dessous.
*/
export function abonnementOuvert(
foyer: { subscriptionStatus: string | null; subscriptionPeriodEnd: Date | null },
aujourdhui: Date,
): boolean {
if (foyer.subscriptionStatus !== "active") return false;
if (!foyer.subscriptionPeriodEnd) return true;
return foyer.subscriptionPeriodEnd.getTime() >= aujourdhui.getTime();
}
/**
* Les échéances candidates d'un foyer, contenus déjà résolus depuis les fiches.
@ -103,18 +127,45 @@ async function candidatsFoyer(householdId: string): Promise<CandidatRappel[]> {
mailCorps: template.contenus.mail?.corps,
assetLabel: r.assetLabel,
sujetLabel: r.sujetLabel ?? undefined,
// L'avance propre à la stratégie, quand la confiance ne suffit pas à la décider (§ 4.2 de
// l'audit mail) : une CatNat se notifie à l'ouverture du délai, pas 21 jours avant sa fin.
avanceJours: template.strategy ? AVANCE_PAR_STRATEGIE[template.strategy] : undefined,
});
}
return candidats;
}
/** Ce qui est déjà parti pour ce couple (occurrence, fenêtre) — la garantie d'idempotence. */
async function dejaNotifiees(householdId: string): Promise<Set<string>> {
/** La clé d'une notification déjà partie : occurrence, fenêtre, destinataire. */
export function cleNotification(occurrenceId: string, fenetreDue: Date, destinataireId: string): string {
return `${occurrenceId}::${fenetreDue.toISOString()}::${destinataireId}`;
}
/**
* Ce qui est déjà parti, par destinataire la garantie d'idempotence (audit mail § 4.8).
*
* Rend deux ensembles plutôt qu'un : les lignes modernes, qui nomment leur destinataire, et les
* lignes héritées, qui n'en nomment aucun. Ces dernières valent « parti à tout le foyer » et
* font taire l'échéance pour tout le monde — c'est ce qu'elles voulaient dire quand elles ont é
* écrites, et les relire autrement ferait repartir d'anciens rappels à tous les membres.
*/
async function dejaNotifiees(householdId: string): Promise<{ parDestinataire: Set<string>; pourTous: Set<string> }> {
const rows = await db()
.select({ occurrenceId: notificationsLog.occurrenceId, fenetre: notificationsLog.fenetreNotifiee })
.select({
occurrenceId: notificationsLog.occurrenceId,
fenetre: notificationsLog.fenetreNotifiee,
destinataireId: notificationsLog.destinataireId,
})
.from(notificationsLog)
.where(and(eq(notificationsLog.householdId, householdId), eq(notificationsLog.statut, "envoye")));
return new Set(rows.map((r) => `${r.occurrenceId}::${r.fenetre?.toISOString() ?? ""}`));
const parDestinataire = new Set<string>();
const pourTous = new Set<string>();
for (const r of rows) {
const fenetre = r.fenetre?.toISOString() ?? "";
if (r.destinataireId) parDestinataire.add(`${r.occurrenceId}::${fenetre}::${r.destinataireId}`);
else pourTous.add(`${r.occurrenceId}::${fenetre}`);
}
return { parDestinataire, pourTous };
}
/** Les envois du mois en cours qui ont consommé le budget — les urgences n'y comptent pas. */
@ -136,89 +187,154 @@ async function envoisCeMois(householdId: string, aujourdhui: Date): Promise<numb
async function destinatairesFoyer(householdId: string): Promise<Destinataire[]> {
const rows = await db()
.select({ email: userTable.email })
.select({ userId: userTable.id, email: userTable.email })
.from(membershipsTable)
.innerJoin(userTable, eq(userTable.id, membershipsTable.userId))
.where(eq(membershipsTable.householdId, householdId));
return rows.filter((r) => r.email).map((r) => ({ email: r.email }));
return rows.filter((r) => r.email).map((r) => ({ userId: r.userId, email: r.email }));
}
export type ResultatRappel = { envoye: boolean; items: number; urgent: boolean; motif?: string };
export type ResultatRappel = {
envoye: boolean;
items: number;
urgent: boolean;
/** Membres effectivement servis, et membres pour qui l'envoi a échoué (audit mail § 4.8). */
servis: number;
echecs: number;
motif?: string;
};
/**
* Décide et envoie le rappel du jour pour un foyer. Rejouable : appelé deux fois le même jour, le
* second appel ne trouve plus rien à envoyer.
*
* **Un plan par destinataire depuis l'audit mail § 4.8.** L'idempotence se joue désormais par
* membre : si l'adresse de l'un a rebondi hier, lui seul redevient candidat aujourd'hui, et les
* autres restent silencieux. Dans le cas courant personne n'a échoué les plans sont
* identiques et le comportement est celui d'avant.
*
* L'identifiant d'envoi, lui, reste unique pour le foyer et pour la journée : c'est le même
* message, et c'est lui que compte le budget mensuel. En donner un par destinataire aurait divisé
* le budget d'un foyer par son nombre de membres.
*/
export async function envoyerRappelFoyer(
householdId: string,
aujourdhui = new Date(),
): Promise<ResultatRappel> {
const destinataires = await destinatairesFoyer(householdId);
if (!destinataires.length) return { envoye: false, items: 0, urgent: false, motif: "aucun destinataire" };
const tous = await candidatsFoyer(householdId);
const deja = await dejaNotifiees(householdId);
const candidats = tous.filter((c) => !deja.has(`${c.occurrenceId}::${c.fenetreDue.toISOString()}`));
const rappel = planRappel({
candidats,
aujourdhui,
envoisCeMois: await envoisCeMois(householdId, aujourdhui),
});
if (!rappel) return { envoye: false, items: 0, urgent: false, motif: "rien à dire" };
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
const html = await renderRappelEmail({ rappel, urlFoyer: `${base}/foyer/${householdId}` });
const subject = objetRappel(rappel, aujourdhui);
const envoiId = randomUUID();
let envoye = false;
let erreur: string | null = null;
for (const { email } of destinataires) {
// Chaque membre reçoit ses propres rappels, sans passer par quelqu'un d'autre (§ 6.3).
const resultat = await sendMail({ to: email, subject, html }).catch((e) => {
erreur = String(e);
return { sent: false };
});
if (resultat.sent) envoye = true;
}
const vide = { envoye: false, items: 0, urgent: false, servis: 0, echecs: 0 };
/**
* Journalisation APRÈS l'envoi, une ligne par ÉCHÉANCE et non par ligne de mail : c'est elle qui
* rend le passage idempotent. Une ligne regroupée (D-020) en porte plusieurs les trois enfants
* assurés d'un coup sont trois occurrences, et n'en journaliser qu'une aurait fait repartir les
* deux autres dès le lendemain, dans un mail disant exactement la même chose.
*
* Un échec est journalisé aussi, mais en `echec` donc réessayé demain.
* La porte de l'abonnement, à l'unité (§ 10.2). `rappelsQuotidiens` ne parcourt déjà que les
* foyers actifs, mais cette fonction est exportée et appelée directement : sans ce contrôle,
* un appel manuel pouvait faire écrire à un foyer qui ne paie plus.
*/
await db()
.insert(notificationsLog)
.values(
rappel.items.flatMap((item) =>
item.occurrences.map((o) => ({
householdId,
occurrenceId: o.occurrenceId,
deadlineId: o.deadlineId,
canal: "mail",
envoiId,
fenetreNotifiee: item.fenetreDue,
statut: envoye ? "envoye" : "echec",
horsBudget: rappel.urgent,
erreur,
})),
),
)
/**
* Mise à jour et non `doNothing` : une tentative en échec laisse une ligne sur la clé, et
* l'ignorer aurait figé ce `echec` pour toujours. Le rappel serait alors reparti chaque jour,
* y compris une fois l'envoi réparé, puisque seul le statut `envoye` fait taire l'outbox.
*/
.onConflictDoUpdate({
target: [notificationsLog.occurrenceId, notificationsLog.canal, notificationsLog.fenetreNotifiee],
set: { statut: envoye ? "envoye" : "echec", envoiId, erreur, horsBudget: rappel.urgent, createdAt: new Date() },
const [foyer] = await db()
.select({
subscriptionStatus: householdsTable.subscriptionStatus,
subscriptionPeriodEnd: householdsTable.subscriptionPeriodEnd,
})
.from(householdsTable)
.where(eq(householdsTable.id, householdId));
if (!foyer || !abonnementOuvert(foyer, aujourdhui)) return { ...vide, motif: "abonnement inactif" };
const destinataires = await destinatairesFoyer(householdId);
if (!destinataires.length) return { ...vide, motif: "aucun destinataire" };
const tous = await candidatsFoyer(householdId);
const { parDestinataire, pourTous } = await dejaNotifiees(householdId);
const budget = await envoisCeMois(householdId, aujourdhui);
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
const urlFoyer = `${base}/foyer/${householdId}`;
const envoiId = randomUUID();
let servis = 0;
let echecs = 0;
let items = 0;
let urgent = false;
let riensADire = 0;
for (const { userId, email } of destinataires) {
// Chaque membre reçoit ses propres rappels, sans passer par quelqu'un d'autre (§ 6.3).
const candidats = tous.filter((c) => {
const fenetre = c.fenetreDue.toISOString();
if (pourTous.has(`${c.occurrenceId}::${fenetre}`)) return false;
return !parDestinataire.has(cleNotification(c.occurrenceId, c.fenetreDue, userId));
});
return { envoye, items: rappel.items.length, urgent: rappel.urgent };
const rappel = planRappel({ candidats, aujourdhui, envoisCeMois: budget });
if (!rappel) {
riensADire++;
continue;
}
const objet = objetRappel(rappel, aujourdhui);
const { html, text } = await renderRappelEmail({ rappel, urlFoyer, objet });
let erreur: string | null = null;
const resultat = await sendMail({ to: email, subject: objet, html, text }).catch((e) => {
erreur = String(e);
return { sent: false, messageId: undefined };
});
if (resultat.sent) servis++;
else echecs++;
items = Math.max(items, rappel.items.length);
urgent = urgent || rappel.urgent;
/**
* Journalisation APRÈS l'envoi, une ligne par ÉCHÉANCE et par DESTINATAIRE : c'est elle qui
* rend le passage idempotent. Une ligne regroupée (D-020) en porte plusieurs les trois
* enfants assurés d'un coup sont trois occurrences, et n'en journaliser qu'une aurait fait
* repartir les deux autres dès le lendemain, dans un mail disant exactement la même chose.
*
* Un échec est journalisé aussi, mais en `echec` donc réessayé demain, pour ce membre seul.
*/
await db()
.insert(notificationsLog)
.values(
rappel.items.flatMap((item) =>
item.occurrences.map((o) => ({
householdId,
occurrenceId: o.occurrenceId,
deadlineId: o.deadlineId,
canal: "mail",
destinataireId: userId,
envoiId,
fenetreNotifiee: item.fenetreDue,
statut: resultat.sent ? "envoye" : "echec",
horsBudget: rappel.urgent,
messageId: resultat.messageId ?? null,
erreur,
})),
),
)
/**
* Mise à jour et non `doNothing` : une tentative en échec laisse une ligne sur la clé, et
* l'ignorer aurait figé ce `echec` pour toujours. Le rappel serait alors reparti chaque
* jour, y compris une fois l'envoi réparé, puisque seul le statut `envoye` fait taire
* l'outbox.
*/
.onConflictDoUpdate({
target: [
notificationsLog.occurrenceId,
notificationsLog.canal,
notificationsLog.fenetreNotifiee,
notificationsLog.destinataireId,
],
set: {
statut: resultat.sent ? "envoye" : "echec",
envoiId,
erreur,
messageId: resultat.messageId ?? null,
horsBudget: rappel.urgent,
createdAt: new Date(),
},
});
}
if (riensADire === destinataires.length) return { ...vide, motif: "rien à dire" };
return { envoye: servis > 0, items, urgent, servis, echecs };
}
/**
@ -268,6 +384,8 @@ export async function rappelsQuotidiens(aujourdhui = new Date()): Promise<{
echecs++;
continue;
}
// Un membre non servi est un échec, même si ses colocataires ont reçu le message (§ 4.8).
echecs += resultat.echecs;
if (resultat.envoye) {
envois++;
echeances += resultat.items;
@ -278,19 +396,34 @@ export async function rappelsQuotidiens(aujourdhui = new Date()): Promise<{
return { foyers: foyers.length, envois, echeances, urgents, echecs };
}
/** Ce qui partirait aujourd'hui, sans rien envoyer ni journaliser — pour la mise au point. */
/**
* Ce qui partirait aujourd'hui, sans rien envoyer ni journaliser pour la mise au point.
*
* Rend un plan PAR destinataire, puisque c'est ce que fait l'envoi : sur un foyer une adresse
* a rebondi hier, les plans diffèrent, et une simulation qui n'en montrerait qu'un mentirait.
*/
export async function simulerRappelFoyer(householdId: string, aujourdhui = new Date()) {
const tous = await candidatsFoyer(householdId);
const deja = await dejaNotifiees(householdId);
const candidats = tous.filter((c) => !deja.has(`${c.occurrenceId}::${c.fenetreDue.toISOString()}`));
const rappel = planRappel({ candidats, aujourdhui, envoisCeMois: await envoisCeMois(householdId, aujourdhui) });
return {
destinataires: (await destinatairesFoyer(householdId)).length,
candidats: candidats.length,
rappel: rappel && {
urgent: rappel.urgent,
objet: objetRappel(rappel, aujourdhui),
items: rappel.items.map((i) => `${i.templateId}${i.fenetreLabel} (${i.enjeuType})`),
},
};
const { parDestinataire, pourTous } = await dejaNotifiees(householdId);
const budget = await envoisCeMois(householdId, aujourdhui);
const destinataires = await destinatairesFoyer(householdId);
const plans = destinataires.map(({ userId, email }) => {
const candidats = tous.filter((c) => {
if (pourTous.has(`${c.occurrenceId}::${c.fenetreDue.toISOString()}`)) return false;
return !parDestinataire.has(cleNotification(c.occurrenceId, c.fenetreDue, userId));
});
const rappel = planRappel({ candidats, aujourdhui, envoisCeMois: budget });
return {
email,
candidats: candidats.length,
rappel: rappel && {
urgent: rappel.urgent,
objet: objetRappel(rappel, aujourdhui),
items: rappel.items.map((i) => `${i.templateId}${i.fenetreLabel} (${i.enjeuType})`),
},
};
});
return { destinataires: destinataires.length, candidats: tous.length, plans };
}

View file

@ -6,11 +6,18 @@ import { db } from "@/db";
import * as schema from "@/db/schema";
import { renderMagicLinkEmail } from "@/emails/magic-link";
import { sendMail } from "@/adapters/mail";
import { lireMetadata, type ContexteLienMagique } from "@/lib/lien-magique";
/**
* Comptes légers (§ 6.3, § 25) : lien magique par mail (prioritaire) + mot de passe (chemin
* familier). Auto-hébergé, aucun SaaS tiers better-auth persiste tout dans notre Postgres.
*
* Le contexte du lien magique n'est jamais lu tel quel depuis `metadata`, qui est un champ public
* du corps de la requête : il passe par `lireMetadata` (audit mail § 4.1, `lib/lien-magique.ts`).
*/
export { metadataInterne, sceauInterne, type ContexteLienMagique } from "@/lib/lien-magique";
export const auth = betterAuth({
database: drizzleAdapter(db(), { provider: "pg", schema }),
secret: process.env.BETTER_AUTH_SECRET,
@ -20,16 +27,14 @@ export const auth = betterAuth({
magicLink({
expiresIn: 60 * 15, // liens à durée limitée (§ 25)
sendMagicLink: async ({ email, url, metadata }) => {
const context = metadata?.context === "invitation" || metadata?.context === "activation"
? metadata.context
: "connexion";
const html = await renderMagicLinkEmail({ url, context, invitedByEmail: metadata?.invitedByEmail });
const subjects: Record<typeof context, string> = {
const { context, invitedByEmail, urlFoyer } = lireMetadata(metadata);
const { html, text } = await renderMagicLinkEmail({ url, context, invitedByEmail, urlFoyer });
const subjects: Record<ContexteLienMagique, string> = {
invitation: "Tu es invité·e à rejoindre un foyer sur fokan",
activation: "Ton foyer est activé — accède à ton calendrier",
connexion: "Ton lien de connexion fokan",
};
const { sent } = await sendMail({ to: email, subject: subjects[context], html });
const { sent } = await sendMail({ to: email, subject: subjects[context], html, text });
// Filet de test local : sans SMTP configuré, le lien (à usage unique, expire en 15 min)
// est journalisé pour permettre de tester le flux sans dépendre d'une vraie boîte mail.
if (!sent) console.log(`[mail] lien magique (dev) : ${url}`);

View file

@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { lireMetadata, metadataInterne, sceauInterne } from "./lien-magique";
/**
* Le contrôle d'accès au CONTENU du mail de lien magique (audit mail § 4.1).
*
* Ce qui est testé ici n'est pas une authentification — le lien magique n'en demande pas, et
* c'est voulu. C'est le droit d'AFFIRMER quelque chose dans un message qui part sous notre
* domaine : « le paiement est passé », « untel t'invite ». `metadata` étant un champ public du
* corps de la requête, ces affirmations étaient à la portée de n'importe qui.
*/
const SECRET_ORIGINAL = process.env.BETTER_AUTH_SECRET;
beforeEach(() => {
process.env.BETTER_AUTH_SECRET = "secret-de-test-suffisamment-long";
});
afterEach(() => {
if (SECRET_ORIGINAL === undefined) delete process.env.BETTER_AUTH_SECRET;
else process.env.BETTER_AUTH_SECRET = SECRET_ORIGINAL;
});
describe("lireMetadata", () => {
it("honore un contexte scellé par un appelant interne", () => {
expect(lireMetadata(metadataInterne("activation", { urlFoyer: "https://fokan.fr/foyer/abc" }))).toEqual({
context: "activation",
invitedByEmail: undefined,
urlFoyer: "https://fokan.fr/foyer/abc",
});
});
it("honore une invitation scellée, avec l'adresse de l'invitant", () => {
expect(lireMetadata(metadataInterne("invitation", { invitedByEmail: "membre@foyer.fr" }))).toEqual({
context: "invitation",
invitedByEmail: "membre@foyer.fr",
urlFoyer: undefined,
});
});
/**
* Le cas d'attaque exact : un POST non authentifié sur /api/auth/sign-in/magic-link avec un
* `metadata` forgé. Sans sceau, le message affirmait un paiement qui n'a pas eu lieu.
*/
it("refuse un contexte « activation » non scellé", () => {
expect(lireMetadata({ context: "activation" }).context).toBe("connexion");
});
it("refuse une invitation non scellée, et jette l'adresse qu'elle prétendait afficher", () => {
const forge = { context: "invitation", invitedByEmail: "direction@banque-connue.fr" };
expect(lireMetadata(forge)).toEqual({ context: "connexion" });
});
it("refuse un sceau qui n'est pas le bon", () => {
expect(lireMetadata({ sceau: "interne:devine", context: "activation" }).context).toBe("connexion");
});
it("refuse un sceau volé à une autre configuration", () => {
const scelleAilleurs = metadataInterne("activation");
process.env.BETTER_AUTH_SECRET = "un-autre-secret";
expect(lireMetadata(scelleAilleurs).context).toBe("connexion");
});
it("refuse tout contexte quand aucun secret n'est configuré — le sceau serait devinable", () => {
const scelle = metadataInterne("activation");
delete process.env.BETTER_AUTH_SECRET;
expect(lireMetadata(scelle).context).toBe("connexion");
expect(sceauInterne()).toBe("interne:");
});
it("retombe sur « connexion » sans metadata du tout", () => {
expect(lireMetadata(undefined)).toEqual({ context: "connexion" });
});
it("ignore un contexte inconnu, même scellé", () => {
const scelle = { ...metadataInterne("connexion"), context: "remboursement" };
expect(lireMetadata(scelle).context).toBe("connexion");
});
it("ignore des champs de type inattendu plutôt que de les afficher", () => {
const scelle = { ...metadataInterne("invitation"), invitedByEmail: { toString: () => "x" }, urlFoyer: 42 };
expect(lireMetadata(scelle)).toEqual({ context: "invitation", invitedByEmail: undefined, urlFoyer: undefined });
});
});

65
src/lib/lien-magique.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* Le sceau qui distingue un appel interne d'un appel venu du réseau (audit mail § 4.1).
*
* `metadata` est un champ PUBLIC du corps de `POST /sign-in/magic-link` better-auth le déclare
* dans `signInMagicLinkBodySchema` et le transmet tel quel à `sendMagicLink`. Sans ce sceau,
* n'importe qui, sans compte, pouvait faire partir depuis `noreply@fokan.fr` domaine aligné
* SPF/DKIM/DMARC un message intitulé « Ton foyer est activé sur fokan » affirmant « Le paiement
* est passé », ou une invitation dont le titre affichait une adresse de son choix, avec un lien
* de connexion qui fonctionnait. C'est le trou que l'audit du 28/07 avait fermé sur
* `/api/invitations` (S2), contourné en s'adressant à better-auth une route plus loin.
*
* On ne signe pas la charge : on prouve seulement que l'appelant s'exécute chez nous. Les deux
* seuls émetteurs légitimes de contextes (`/api/invitations`, webhook Stripe) passent par
* `auth.api.signInMagicLink` côté serveur et disposent donc du secret. Tout le reste c'est-à-dire
* tout ce qui vient du réseau retombe sur « connexion », le seul contexte qui n'affirme rien.
*
* Module séparé de `lib/auth.ts` pour qu'il soit testable : `auth.ts` ouvre un pool Postgres dès
* son import.
*/
export type ContexteLienMagique = "connexion" | "invitation" | "activation";
export type MetadataLienMagique = {
context: ContexteLienMagique;
invitedByEmail?: string;
urlFoyer?: string;
};
/**
* Dérivé de `BETTER_AUTH_SECRET` plutôt qu'ajouté à la configuration : une variable de plus est
* une variable qu'on oublie de poser en production, et l'oubli serait ici silencieux les mails
* partiraient, simplement tous en « connexion ».
*/
export function sceauInterne(): string {
return `interne:${process.env.BETTER_AUTH_SECRET ?? ""}`;
}
/** Le `metadata` à joindre à un appel serveur pour que son contexte soit honoré. */
export function metadataInterne(
context: ContexteLienMagique,
extra: { invitedByEmail?: string; urlFoyer?: string } = {},
): Record<string, unknown> {
return { sceau: sceauInterne(), context, ...extra };
}
/** Le contexte neutre : celui qui n'affirme ni paiement, ni invitation, ni tiers. */
const NEUTRE: MetadataLienMagique = { context: "connexion" };
/**
* Lit un `metadata` d'origine inconnue et n'en retient que ce qui est prouvé interne.
* Tout écart sceau absent, faux, secret non configuré retombe sur « connexion ».
*/
export function lireMetadata(metadata: Record<string, unknown> | undefined): MetadataLienMagique {
// Un `BETTER_AUTH_SECRET` absent rendrait le sceau devinable : on refuse alors tout contexte.
if (!process.env.BETTER_AUTH_SECRET) return NEUTRE;
if (!metadata || metadata.sceau !== sceauInterne()) return NEUTRE;
const context: ContexteLienMagique =
metadata.context === "invitation" || metadata.context === "activation" ? metadata.context : "connexion";
return {
context,
invitedByEmail: typeof metadata.invitedByEmail === "string" ? metadata.invitedByEmail : undefined,
urlFoyer: typeof metadata.urlFoyer === "string" ? metadata.urlFoyer : undefined,
};
}

View file

@ -61,4 +61,12 @@ export const PLAFONDS = {
interet: { limite: 10, fenetreMs: HEURE },
event: { limite: 120, fenetreMs: MINUTE },
invitationsParUtilisateur: { limite: 10, fenetreMs: 24 * HEURE },
/**
* Demandes de lien magique par IP (audit mail § 4.1). Le plugin better-auth en pose déjà un
* (5 / 60 s), mais son activation par défaut vaut `isProduction` : hors production, la route
* qui fait partir un mail vers une adresse arbitraire n'était plafonnée par rien du tout.
* Celui-ci ne dépend d'aucun `NODE_ENV`, et raisonne à l'heure plutôt qu'à la minute cinq
* demandes par minute font tout de même 300 messages par heure.
*/
lienMagique: { limite: 10, fenetreMs: HEURE },
} as const;

View file

@ -3,5 +3,9 @@ import path from "node:path";
export default defineConfig({
test: { environment: "node" },
// `tsconfig.json` laisse `jsx: "preserve"` — c'est Next.js qui transforme en production. Vitest
// n'a pas ce relais : sans cette ligne, esbuild retombe sur la transformation classique et tout
// rendu de gabarit mail échoue sur « React is not defined ».
esbuild: { jsx: "automatic" },
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});