Les mails posaient leur fond sur un <body> que Gmail retire, et le compte pendait à un lien de 15 minutes

Le crème vivait sur `<body>`, que Gmail supprime en réécrivant le message : la
carte blanche se retrouvait collée au fond du client, blanc sur blanc chez les
uns, bloc éblouissant sur fond noir chez les autres. Elle n'a plus de fond du tout
— autour d'elle passe celui du lecteur, et c'est la bordure qui délimite, ce qui
reste juste sur n'importe quel fond, y compris ceux qu'on ne connaît pas. Le mode
sombre est déclaré (`color-scheme`) plutôt que subi : sans cette déclaration, Gmail
et Apple inversent au jugé et le vert de marque en ressort délavé.

Un piège qui ne se voyait qu'à l'écran : la marge intérieure était posée sur les
`<table>`. En modèle de bordures fusionnées — celui de tout mail, et celui
qu'Outlook applique de toute façon — la spécification CSS ignore la marge d'un
élément `table`. Tout le message se collait aux bords. Elle est passée sur les
`<td>`. Un châssis unique remplace les trois mises en page séparées.

Le logo entre dans les mails, en image et non en texte, sur un bandeau qui reste
sombre dans les deux modes : il n'a ainsi qu'une seule version à exister, juste
partout. `public/` n'existait pas, et `output: "standalone"` ne le trace pas —
sans la ligne ajoutée au Dockerfile le logo aurait répondu 404 chez tous les
destinataires, panne invisible en développement.

Les fiches promettaient 24 fois un geste qui n'existe pas — « si c'est déjà fait,
dis-le-moi et je me tais », « dis-moi sa date d'expiration et je gère le tempo ».
Le pied de page avait déjà été purgé de la même promesse ; le corps la répétait.
Retirées sans remplacement : elles reviendront le jour où l'état utilisateur
s'écrira. Le bloc d'accompagnement du rappel groupé disparaît aussi — « pour que
tu n'aies pas à y penser toi-même » se démentait dans le seul message qui demande
justement d'y penser. Et chaque échéance dont la fiche a une page publique porte
désormais un lien vers elle : la bicéphalie ne servait que le SEO, alors que c'est
au destinataire d'un rappel que l'explication entière manque le plus.

Enfin, le lien d'activation. Il n'était pas un raccourci : sa vérification était
la seule chose au monde capable de créer le compte de l'acheteur, better-auth ne
le créant qu'à ce moment, `resolveMembership` n'inscrivant le premier membre que
s'il y a une session, et `destinatairesFoyer` ne connaissant que les
appartenances. Un mail lu le soir, un lien ouvert deux fois, un antispam qui le
déclenche à vide, et un foyer payé 19,99 € restait sans compte, sans appartenance,
donc sans destinataire de rappel, définitivement. Ce n'est pas un cas de bord : ce
message n'est envoyé que quand personne n'était connecté avant de payer, donc
l'unique mail d'activation qui existe était précisément celui dont l'expiration
était fatale. Le webhook crée le compte et l'appartenance lui-même, depuis
l'adresse que Stripe vient de collecter. Contrepartie assumée : Stripe ne prouve
pas la possession de la boîte, et qui paierait pour l'adresse d'un tiers lui
rattacherait un foyer — un ajout indésirable, jamais une prise de contrôle, et
facturé à celui qui le tente.

`npm run mails:echantillon` rend un exemplaire de chaque message depuis les vraies
fiches, et, si une adresse est donnée, le rappel réel de ses foyers.

D-048, D-049.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gautier Stefanini 2026-08-05 21:04:27 +00:00
parent df613bb99c
commit 3ad61a6878
22 changed files with 830 additions and 235 deletions

3
.gitignore vendored
View file

@ -9,3 +9,6 @@ dist/
*.tsbuildinfo
next-env.d.ts
# Échantillons de mails rendus en local (npm run mails:echantillon).
.mails/

View file

@ -21,6 +21,11 @@ WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# `output: "standalone"` ne trace ni `public/` ni `.next/static` : Next.js laisse les deux à
# copier à la main. Sans cette ligne, le logo des mails répondrait 404 chez tous les destinataires
# — une image cassée dans l'en-tête de chaque message, invisible en développement où le serveur
# sert `public/` depuis le disque.
COPY --from=builder /app/public ./public
COPY --from=builder /app/knowledge ./knowledge
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/scripts/migrate.ts ./scripts/migrate.ts

View file

@ -1,5 +1,51 @@
# Journal des décisions post-cadrage
## D-049 — 5 août 2026 · Le compte naît du paiement, plus du clic dans le quart d'heure
**Contexte** : le mail d'activation portait un lien magique valable 15 minutes et à usage unique. Ce lien n'était pas un raccourci — il était **la seule chose au monde capable de faire exister le compte de l'acheteur**. better-auth ne crée l'utilisateur qu'à la vérification du lien ; `resolveMembership` (`src/lib/membership.ts`) n'inscrit le premier membre que s'il y a une session ; `destinatairesFoyer` ne connaît que les appartenances. Toute la chaîne pendait donc à un clic.
**Ce que ça coûtait, concrètement** : un mail lu le soir, un lien ouvert deux fois, un antispam qui le déclenche à vide en le scannant — et un foyer payé 19,99 € restait sans compte, sans appartenance, donc **sans aucun destinataire de rappel, définitivement**. `/mon-espace` liste les foyers par appartenance : l'acheteur n'en avait aucune, il n'avait donc rien à retrouver. Le produit encaissait et ne livrait rien, sans que personne n'ait rien fait de mal.
Ce cas n'est pas marginal : le mail d'activation n'est envoyé **que** dans le cas 2 du webhook (personne n'était connecté avant de payer). Dans le cas 1, le compte existe déjà et aucun mail ne part. Autrement dit, le seul message d'activation qui existe était précisément celui dont l'expiration était fatale.
**Décision** : le webhook Stripe crée le compte et l'appartenance lui-même, à réception de `checkout.session.completed`, depuis l'adresse collectée par Stripe Checkout. On passe par `auth.$context``internalAdapter.createUser` plutôt que par un `insert` maison : c'est lui qui fabrique l'identifiant, joue les hooks et respecte le schéma — exactement ce que le plugin magic-link appelle de son côté. Le compte est marqué vérifié parce que Stripe vient d'envoyer un reçu à cette adresse.
Le lien magique part toujours : un tap reste un tap. Il n'est simplement plus une bouée — son expiration ne coûte plus qu'un aller-retour par `/connexion`, ce que le mail dit désormais en toutes lettres.
**Contrepartie assumée** : Stripe ne prouve pas la possession de la boîte. Quelqu'un qui paierait 19,99 € en donnant l'adresse d'un tiers rattacherait un foyer au compte de ce tiers — un ajout indésirable, jamais une prise de contrôle, et facturé à celui qui le tente. En face, l'ancien comportement perdait un client payant sur un simple délai de lecture. Le déséquilibre entre les deux risques n'est pas discutable.
**Autre correction, même journée** : plus aucun fond de page dans les mails (amende D-048). L'aplat crème faisait une tache là où le client est blanc et un cadre clair là où il est sombre. Il ne reste que la carte et sa bordure — qui dit la même chose et reste juste sur n'importe quel fond, y compris ceux qu'on ne connaît pas. Le pied de page se distingue par un filet, plus par une teinte.
**Outillage** : `npm run mails:echantillon -- <adresse>` ajoute désormais, aux six messages types, **le rappel réel des foyers de cette adresse** — vraies échéances, vrai moteur. `apercuRappelFoyer` met de côté l'outbox et le budget mensuel (deux règles qui évitent de réémettre, et ne changent pas une ligne de ce qui serait écrit) ; si rien n'est encore dû, l'aperçu est daté du jour où la première échéance entrera dans sa fenêtre, et l'objet le dit. `--essai` écrit sur le disque au lieu d'envoyer.
## D-048 — 5 août 2026 · Un seul châssis pour les mails ; les fiches cessent de promettre des gestes qui n'existent pas
**Contexte** : relecture de bout en bout des messages réellement reçus. Six défauts, dont trois qu'aucun test ne pouvait voir parce qu'ils n'apparaissent que dans un client mail.
**1. Le fond ne tenait pas, et c'est un mécanisme, pas un goût.** Le crème était posé sur `<body>` — que **Gmail supprime** en réécrivant le message. La carte blanche se retrouvait donc collée au fond du client : blanc sur blanc chez les uns, bloc éblouissant sur fond noir chez les autres. Le fond vit désormais sur une `<table>` pleine largeur, seule couche qu'aucun client ne retire, et la carte porte une bordure qui la définit même si le fond saute quand même.
Le mode sombre est **déclaré** (`color-scheme`, `supported-color-schemes`) plutôt que subi : sans cette déclaration, Gmail et Apple Mail inversent les couleurs claires au jugé, et le vert de marque en ressort délavé. Avec elle, ils appliquent nos règles — doublées de sélecteurs `[data-ogsc]` pour Outlook.com, qui ne connaît pas `prefers-color-scheme`.
**Un piège qui ne se voit qu'à l'écran** : la marge intérieure était posée sur les `<table>`. En modèle de bordures fusionnées — celui de tout mail, et celui qu'Outlook applique de toute façon — **la spécification CSS elle-même ignore la marge d'un élément `table`**. Tout le message se collait aux bords. La marge se porte sur le `<td>`, jamais ailleurs. Vérifié au navigateur, clair et sombre, 380 px et 700 px.
**2. Le logo entre dans les mails, en image et non en texte.** Un bandeau encre en tête de chaque message, avec le lockup (le F sert d'initiale à « okan », géométrie reprise au pixel près de `src/components/logo.tsx`, mot converti en tracés — aucune fonte à charger). **Le bandeau est sombre dans les deux modes** : c'est ce qui donne au logo une seule version à exister, juste partout. Un logo encre sur carte blanche redevient invisible chez le premier client qui bascule la carte en sombre sans lire nos règles.
Deux conséquences hors des gabarits : `public/` n'existait pas, et `output: "standalone"` ne le trace pas — sans la ligne ajoutée au Dockerfile, le logo aurait répondu 404 chez tous les destinataires, panne invisible en développement. Et `/email/*` est mis en cache un an : c'est le seul fichier du produit que des mandataires d'images tiers rappellent une fois par destinataire et par message.
**3. Les fiches promettaient un geste qui n'existe pas — 24 fois.** « Si c'est déjà fait, dis-le-moi et je me tais », « Fait ? Dis-le-moi et à l'an prochain », « Dis-moi sa date d'expiration et je gère le tempo » : aucun de ces gestes n'est implémenté (cf. l'avertissement du CLAUDE.md et [audit-2026-07-30 § 1](audit-2026-07-30.md)). Le pied de page avait déjà été purgé de la même promesse ; les fiches, elles, la répétaient dans le corps du message. **Retirées, sans remplacement** : elles reviendront d'elles-mêmes le jour où l'état utilisateur s'écrira, ou quand un test d'éligibilité rendra la question inutile (chèque énergie). Aucune fiche n'y perd d'information — la phrase était toujours la dernière, et toujours conversationnelle.
**4. Le bloc d'accompagnement du rappel groupé disparaît.** « Rien d'urgent, rien à faire tout de suite — juste ce qui arrive, pour que tu n'aies pas à y penser toi-même » n'ajoutait rien aux échéances listées dessous, et sa dernière proposition **se démentait au moment où on la lisait** : ce message est précisément celui qui demande au foyer d'y penser. Le titre, puis les échéances.
**5. Le rappel renvoie au sujet, pas seulement au hub.** Chaque échéance dont la fiche a une page publique porte un « Lire le guide → ». La bicéphalie de l'invariant n° 6 ne servait jusqu'ici que le SEO, alors que c'est au destinataire d'un rappel que l'explication entière manque le plus. Aucun lien fabriqué pour la symétrie : la fiche sans page n'en affiche pas.
**6. Le lien d'activation tient en un bloc.** Le bouton et l'adresse durable du foyer formaient deux étages séparés par trois lignes d'explication, pour deux liens qui mènent au même écran. Une ligne suffit sous le bouton.
**Ce qui n'a PAS changé, et pourquoi** : le bouton du mail d'activation reste le lien magique, pas l'URL permanente. Ce message n'est envoyé que dans le **cas 2** du webhook Stripe — personne n'était connecté avant de payer — et dans ce cas précis c'est la vérification du lien magique qui **crée le compte** ; `resolveMembership` n'inscrit ensuite le premier membre que s'il y a une session (`src/lib/membership.ts`). Un bouton pointant l'URL permanente laisserait donc un foyer payé sans compte, sans appartenance, et par conséquent **sans aucun destinataire de rappel**`destinatairesFoyer` lit les appartenances. Le rendre permanent suppose d'abord de créer le compte et l'appartenance dans le webhook, à partir de l'e-mail que Stripe a déjà vérifié ; c'est un chantier distinct, pas un réglage de gabarit.
**Icône expéditeur (BIMI)** : `public/bimi/fokan.svg` est publié au format SVG Tiny PS conforme (carré, fond plein, `<title>`, aucun script). Il ne suffit pas : Gmail, Yahoo et Apple Mail n'affichent le logo qu'avec un **VMC** (certificat de marque vérifiée, ~1 000 €/an, adossé à une marque déposée), et BIMI exige au préalable un DMARC en application (`p=quarantine` ou `p=reject`, aujourd'hui à vérifier côté DNS). L'enregistrement `default._bimi.fokan.fr TXT "v=BIMI1; l=https://…/bimi/fokan.svg;"` peut être posé dès maintenant — il ne coûte rien et sera honoré par les clients qui n'exigent pas de VMC — mais **l'avatar Gmail restera l'initiale tant que le certificat n'est pas acheté**. À arbitrer, pas à supposer résolu.
**Outillage** : `npm run mails:echantillon` rend un exemplaire de chacun des six messages (le lien magique comptant pour trois selon son contexte) depuis les vraies fiches — sur disque pour relecture, ou envoyés à une boîte réelle. C'est le seul moyen de voir ce qu'un client mail fait d'un HTML qui passe tous les tests.
## D-047 — 5 août 2026 · Les scripts d'installation npm sont coupés par défaut ; le `.env` sort de l'image
**Contexte** : ver de chaîne d'approvisionnement « ChainDrop » publié sur npm le 4 août 2026 vers 09:35 UTC. Point d'entrée `keyv`, `cacheable`, `flat-cache`, `file-entry-cache`, puis plus de 1 300 versions en une matinée par auto-propagation : un hook `preinstall` vole les jetons npm, GitHub, AWS, les clés SSH et les chaînes de connexion, et republie avec les jetons volés.

View file

@ -11,7 +11,7 @@
hub: { titre: "Rappel de vaccins", description: "Une visite véto par an, à la date anniversaire du dernier vaccin." }
mail:
objet: "Le rappel de vaccins approche — une visite véto et c'est réglé"
corps: "Un rappel en retard peut obliger à reprendre le protocole depuis le début (deux injections au lieu d'une). Et si des vacances se profilent : les pensions exigent un carnet à jour, et le vaccin rage — obligatoire pour passer une frontière — ne devient valide que 21 jours après l'injection. Si c'est déjà fait, dis-le-moi et je recale la date."
corps: "Un rappel en retard peut obliger à reprendre le protocole depuis le début (deux injections au lieu d'une). Et si des vacances se profilent : les pensions exigent un carnet à jour, et le vaccin rage — obligatoire pour passer une frontière — ne devient valide que 21 jours après l'injection."
seo:
slug: vaccins-chien-chat-obligatoires
titre: "Vaccins chien et chat : lesquels sont obligatoires ?"
@ -57,7 +57,7 @@
hub: { titre: "Vermifuge", description: "Un traitement par trimestre pour un adulte, mensuel les 6 premiers mois d'un chiot ou chaton." }
mail:
objet: "Le vermifuge trimestriel — le rappel qu'on oublie facilement"
corps: "Contrairement aux antipuces, le vermifuge ne se voit pas : pas de démangeaison, pas de signe visible, juste des parasites internes qui s'installent. Certains sont transmissibles à l'humain, un argument de plus avec des enfants à la maison. Un comprimé tous les 3 mois (mensuel les 6 premiers mois de vie) suffit. Fait ? Dis-le-moi, je recale le trimestre suivant."
corps: "Contrairement aux antipuces, le vermifuge ne se voit pas : pas de démangeaison, pas de signe visible, juste des parasites internes qui s'installent. Certains sont transmissibles à l'humain, un argument de plus avec des enfants à la maison. Un comprimé tous les 3 mois (mensuel les 6 premiers mois de vie) suffit."
seo:
slug: vermifuge-chien-chat-frequence
titre: "Vermifuge chien et chat : fréquence et pourquoi c'est important"

View file

@ -11,7 +11,7 @@
hub: { titre: "Entretien de la chaudière gaz", description: "Un passage annuel du chauffagiste, idéalement avant l'hiver." }
mail:
objet: "Ta chaudière gaz mérite son entretien annuel avant l'hiver"
corps: "C'est obligatoire une fois par an, et ton assurance peut réclamer l'attestation en cas de sinistre. Les chauffagistes sont pris d'assaut dès novembre — septembre-octobre, c'est le bon créneau. Si c'est déjà fait, dis-le-moi et je me tais."
corps: "C'est obligatoire une fois par an, et ton assurance peut réclamer l'attestation en cas de sinistre. Les chauffagistes sont pris d'assaut dès novembre — septembre-octobre, c'est le bon créneau."
seo:
slug: entretien-chaudiere-gaz-obligatoire
titre: "Entretien chaudière gaz : obligation, fréquence, prix (2026)"
@ -35,7 +35,7 @@
hub: { titre: "Entretien de la chaudière fioul", description: "Un passage annuel du chauffagiste, avant la saison de chauffe — la citerne se vérifie au même moment." }
mail:
objet: "La chaudière fioul mérite son entretien annuel avant l'hiver"
corps: "Comme pour le gaz, c'est obligatoire une fois par an, et ton assurance peut réclamer l'attestation en cas de sinistre. Le fioul encrasse plus vite qu'un gaz naturel : un entretien suivi préserve le rendement et évite les pannes de plein hiver. Profites-en pour vérifier l'étanchéité de la citerne. Si c'est déjà fait, dis-le-moi et je me tais."
corps: "Comme pour le gaz, c'est obligatoire une fois par an, et ton assurance peut réclamer l'attestation en cas de sinistre. Le fioul encrasse plus vite qu'un gaz naturel : un entretien suivi préserve le rendement et évite les pannes de plein hiver. Profites-en pour vérifier l'étanchéité de la citerne."
seo:
slug: entretien-chaudiere-fioul-obligatoire
titre: "Entretien chaudière fioul : obligation, prix, citerne"
@ -59,7 +59,7 @@
hub: { titre: "Entretien pompe à chaleur / clim", description: "Contrôle d'étanchéité et de performance par un pro, tous les 2 ans." }
mail:
objet: "Ta pompe à chaleur a droit à son entretien des 2 ans"
corps: "C'est obligatoire tous les 2 ans pour les PAC et clims de 4 à 70 kW : contrôle d'étanchéité du circuit, nettoyage, réglages. Une PAC entretenue garde son rendement — c'est elle qui paie ta facture d'électricité. Le printemps est le bon créneau, avant la saison de clim. Si c'est déjà fait, dis-le-moi et je recale le compteur."
corps: "C'est obligatoire tous les 2 ans pour les PAC et clims de 4 à 70 kW : contrôle d'étanchéité du circuit, nettoyage, réglages. Une PAC entretenue garde son rendement — c'est elle qui paie ta facture d'électricité. Le printemps est le bon créneau, avant la saison de clim."
seo:
slug: entretien-pompe-a-chaleur-obligatoire
titre: "Entretien pompe à chaleur : obligatoire tous les 2 ans ?"
@ -88,7 +88,7 @@
hub: { titre: "Ramonage cheminée / poêle", description: "Au moins un ramonage mécanique par an, avant la saison de chauffe — beaucoup d'arrêtés préfectoraux en imposent un second pendant l'hiver. Le certificat se garde précieusement." }
mail:
objet: "Le ramonage annuel, c'est avant la saison de chauffe"
corps: "Le minimum national est d'un ramonage mécanique tous les douze mois — mais l'arrêté préfectoral de ton département peut en imposer deux, dont un pendant la période de chauffe, et beaucoup le font. Ça vaut le coup de vérifier une fois pour toutes. Sans certificat, l'amende peut monter à 450 € — et surtout, l'assurance peut refuser d'indemniser un feu de cheminée. Le ramoneur te le remet sous 15 jours : garde-le. Si c'est déjà fait, dis-le-moi et je me tais."
corps: "Le minimum national est d'un ramonage mécanique tous les douze mois — mais l'arrêté préfectoral de ton département peut en imposer deux, dont un pendant la période de chauffe, et beaucoup le font. Ça vaut le coup de vérifier une fois pour toutes. Sans certificat, l'amende peut monter à 450 € — et surtout, l'assurance peut refuser d'indemniser un feu de cheminée. Le ramoneur te le remet sous 15 jours : garde-le."
seo:
slug: ramonage-obligatoire-frequence
titre: "Ramonage : obligation, fréquence, prix, sanctions (2026)"
@ -155,7 +155,7 @@
hub: { titre: "Détartrage du chauffe-eau", description: "Tous les 2 à 4 ans selon la dureté de l'eau — plus souvent en eau calcaire." }
mail:
objet: "Le chauffe-eau mérite son détartrage"
corps: "En eau calcaire, un ballon s'entartre en 2-3 ans : l'eau chaude arrive moins vite, la consommation grimpe, et la résistance finit par lâcher. Un détartrage (100-250 €) prolonge la vie du ballon de plusieurs années — un remplacement complet en coûte 800 à 1 500. Profites-en pour faire vérifier le groupe de sécurité. Déjà fait ? Dis-le-moi, je recale."
corps: "En eau calcaire, un ballon s'entartre en 2-3 ans : l'eau chaude arrive moins vite, la consommation grimpe, et la résistance finit par lâcher. Un détartrage (100-250 €) prolonge la vie du ballon de plusieurs années — un remplacement complet en coûte 800 à 1 500. Profites-en pour faire vérifier le groupe de sécurité."
seo:
slug: detartrage-chauffe-eau-frequence
titre: "Détartrage du chauffe-eau : fréquence, prix, signes d'alerte"
@ -199,7 +199,7 @@
hub: { titre: "Vidange de la fosse septique", description: "Environ tous les 4 ans, par un vidangeur agréé — bordereau à conserver." }
mail:
objet: "La fosse septique approche de sa vidange"
corps: "La règle : vidange quand les boues atteignent la moitié du volume — en pratique tous les 4 ans environ. Elle doit être faite par un vidangeur agréé par la préfecture, qui te remet un bordereau : c'est lui que le SPANC réclame lors de son contrôle. Compte 150 à 300 €. Si c'est déjà fait, dis-le-moi et je recale le compteur."
corps: "La règle : vidange quand les boues atteignent la moitié du volume — en pratique tous les 4 ans environ. Elle doit être faite par un vidangeur agréé par la préfecture, qui te remet un bordereau : c'est lui que le SPANC réclame lors de son contrôle. Compte 150 à 300 €."
seo:
slug: vidange-fosse-septique-frequence
titre: "Vidange de fosse septique : fréquence, prix, obligations"
@ -223,7 +223,7 @@
hub: { titre: "Nettoyage des gouttières", description: "Après la chute des feuilles, avant les grosses pluies d'hiver." }
mail:
objet: "Les feuilles sont tombées — un œil aux gouttières avant les pluies"
corps: "Une gouttière pleine de feuilles déborde sur la façade, et l'eau finit toujours par trouver un chemin : traces vertes, enduit qui cloque, infiltrations. Le nettoyage d'automne (soi-même ou 100-200 € par un pro) évite des réparations sans commune mesure. Si le toit est cerné d'arbres, c'est même deux fois par an. Fait ? Dis-le-moi et à l'an prochain."
corps: "Une gouttière pleine de feuilles déborde sur la façade, et l'eau finit toujours par trouver un chemin : traces vertes, enduit qui cloque, infiltrations. Le nettoyage d'automne (soi-même ou 100-200 € par un pro) évite des réparations sans commune mesure. Si le toit est cerné d'arbres, c'est même deux fois par an."
seo:
slug: nettoyage-gouttieres-quand
titre: "Nettoyage des gouttières : quand, comment, à quel prix ?"
@ -267,7 +267,7 @@
hub: { titre: "Dispositif de sécurité piscine", description: "Barrière, alarme, couverture ou abri conforme — à vérifier chaque saison avant la baignade." }
mail:
objet: "La piscine rouvre bientôt — le dispositif de sécurité est-il en état ?"
corps: "Toute piscine enterrée ou semi-enterrée non close doit être équipée d'un dispositif conforme (barrière, alarme, couverture ou abri) : jusqu'à 45 000 € d'amende sans lui, et c'est surtout ce qui évite un drame. Un contrôle rapide avant la saison : l'alarme se déclenche-t-elle encore, la barrière ferme-t-elle bien ? Déjà vérifié ? Dis-le-moi et je repasse l'an prochain."
corps: "Toute piscine enterrée ou semi-enterrée non close doit être équipée d'un dispositif conforme (barrière, alarme, couverture ou abri) : jusqu'à 45 000 € d'amende sans lui, et c'est surtout ce qui évite un drame. Un contrôle rapide avant la saison : l'alarme se déclenche-t-elle encore, la barrière ferme-t-elle bien ?"
seo:
slug: piscine-securite-dispositif-obligatoire
titre: "Sécurité piscine : dispositif obligatoire, amende, normes"
@ -333,7 +333,7 @@
hub: { titre: "Taxe foncière", description: "Avis fin août-septembre, paiement pour mi-octobre (ou mensualisation)." }
mail:
objet: "La taxe foncière arrive — l'échéance est mi-octobre"
corps: "L'avis est disponible dans ton espace impots.gouv.fr (fin août, ou fin septembre si tu es mensualisé). Date limite : le 15 octobre par les moyens classiques, quelques jours de plus en paiement en ligne. Passé la date, c'est 10 % de majoration, sans rappel préalable. Si c'est déjà réglé ou mensualisé, dis-le-moi et je me tais."
corps: "L'avis est disponible dans ton espace impots.gouv.fr (fin août, ou fin septembre si tu es mensualisé). Date limite : le 15 octobre par les moyens classiques, quelques jours de plus en paiement en ligne. Passé la date, c'est 10 % de majoration, sans rappel préalable."
seo:
slug: taxe-fonciere-date-limite-paiement
titre: "Taxe foncière : dates limites, majoration, mensualisation"
@ -378,7 +378,7 @@
hub: { titre: "Assurance habitation", description: "Comparable et résiliable à tout moment après 1 an — le nouvel assureur s'occupe de tout." }
mail:
objet: "Ton assurance habitation — 10 minutes qui valent 50 à 150 €"
corps: "Après un an de contrat, tu peux changer d'assurance habitation à tout moment (loi Hamon) : le nouvel assureur s'occupe de la résiliation, sans coupure de couverture. Un devis comparatif prend 10 minutes et fait gagner 50 à 150 € par an en moyenne. Si tu as déjà comparé récemment, dis-le-moi et je me tais un an."
corps: "Après un an de contrat, tu peux changer d'assurance habitation à tout moment (loi Hamon) : le nouvel assureur s'occupe de la résiliation, sans coupure de couverture. Un devis comparatif prend 10 minutes et fait gagner 50 à 150 € par an en moyenne."
seo:
slug: resilier-assurance-habitation-loi-hamon
titre: "Résilier son assurance habitation : loi Hamon, mode d'emploi"
@ -476,7 +476,7 @@
hub: { titre: "Fin de validité du diagnostic (DPE)", description: "À refaire avant l'échéance si tu envisages de vendre ou de louer." }
mail:
objet: "Le diagnostic énergétique de ton logement arrive en fin de validité"
corps: "Le DPE que tu nous avais confirmé arrive à échéance. Tant que tu ne vends ni ne loues, rien ne t'oblige à le refaire dans l'immédiat — mais un DPE expiré bloque toute transaction : ni vente, ni nouvelle location tant qu'un diagnostic à jour n'est pas produit. Mieux vaut le savoir avant d'en avoir besoin dans l'urgence. Si tu l'as déjà refait, dis-le-moi et je recale la date."
corps: "Le DPE que tu nous avais confirmé arrive à échéance. Tant que tu ne vends ni ne loues, rien ne t'oblige à le refaire dans l'immédiat — mais un DPE expiré bloque toute transaction : ni vente, ni nouvelle location tant qu'un diagnostic à jour n'est pas produit. Mieux vaut le savoir avant d'en avoir besoin dans l'urgence."
seo:
slug: dpe-fin-validite-renouvellement
titre: "Fin de validité du DPE : ce qui se bloque, comment renouveler"
@ -502,7 +502,7 @@
hub: { titre: "Logement classé F ou G : ce que ça bloque déjà", description: "Location interdite pour le G, loyer gelé, revente impactée — et un calendrier qui se resserre." }
mail:
objet: "Ton logement est classé énergivore — ce que ça bloque concrètement"
corps: "Un logement classé G ne peut plus être proposé à la location depuis le 1er janvier 2025 ; le F suivra au 1er janvier 2028, puis le E au 1er janvier 2034. S'il est déjà loué, le loyer est gelé depuis août 2022 : ni révision, ni hausse au renouvellement. Ça pèse aussi sur le prix de revente — l'étiquette DPE est affichée dans toute annonce. Bonne nouvelle : les travaux qui font sortir de F/G ouvrent droit à MaPrimeRénov' rénovation d'ampleur. Si un DPE plus récent existe, dis-le-moi et je réévalue."
corps: "Un logement classé G ne peut plus être proposé à la location depuis le 1er janvier 2025 ; le F suivra au 1er janvier 2028, puis le E au 1er janvier 2034. S'il est déjà loué, le loyer est gelé depuis août 2022 : ni révision, ni hausse au renouvellement. Ça pèse aussi sur le prix de revente — l'étiquette DPE est affichée dans toute annonce. Bonne nouvelle : les travaux qui font sortir de F/G ouvrent droit à MaPrimeRénov' rénovation d'ampleur."
seo:
slug: decence-energetique-passoire-thermique
titre: "Passoire énergétique (DPE F/G) : location interdite, loyer gelé"
@ -524,7 +524,7 @@
hub: { titre: "Chèque énergie — vérifier son éligibilité", description: "Attribution automatique en avril ; guichet de demande ouvert jusqu'à fin décembre pour les oubliés." }
mail:
objet: "Le chèque énergie — deux minutes pour vérifier que tu l'as reçu"
corps: "L'attribution automatique a lieu en avril, sur la base de ton revenu fiscal et de ton point de livraison électrique. Elle rate régulièrement les foyers ayant déménagé ou changé de contrat d'électricité dans l'année. Le simulateur officiel prend deux minutes ; si tu es éligible et que rien n'est arrivé, le guichet de demande reste ouvert jusqu'au 31 décembre. Si tu sais que tu n'y es pas éligible, dis-le-moi et je me tais l'an prochain."
corps: "L'attribution automatique a lieu en avril, sur la base de ton revenu fiscal et de ton point de livraison électrique. Elle rate régulièrement les foyers ayant déménagé ou changé de contrat d'électricité dans l'année. Le simulateur officiel prend deux minutes ; si tu es éligible et que rien n'est arrivé, le guichet de demande reste ouvert jusqu'au 31 décembre."
seo:
slug: cheque-energie-guichet-demande
titre: "Chèque énergie : attribution automatique et guichet de rattrapage"
@ -546,7 +546,7 @@
hub: { titre: "Contrôle SPANC de l'assainissement non collectif", description: "Périodicité fixée par ta commune, au maximum tous les 10 ans — à ne pas confondre avec la vidange." }
mail:
objet: "Le contrôle SPANC de ta fosse septique — pas la vidange, l'autre visite"
corps: "En plus de la vidange, le service public d'assainissement non collectif (SPANC) de ta commune doit contrôler le bon fonctionnement de l'installation, au maximum tous les 10 ans — la fréquence exacte est fixée par la collectivité. Si tu vends, ce rapport doit dater de moins de 3 ans, sinon c'est à toi de le faire refaire avant la vente. Si un contrôle a déjà eu lieu, dis-moi la date et je recale l'échéance."
corps: "En plus de la vidange, le service public d'assainissement non collectif (SPANC) de ta commune doit contrôler le bon fonctionnement de l'installation, au maximum tous les 10 ans — la fréquence exacte est fixée par la collectivité. Si tu vends, ce rapport doit dater de moins de 3 ans, sinon c'est à toi de le faire refaire avant la vente."
seo:
slug: spanc-controle-periodique-assainissement
titre: "Contrôle SPANC : périodicité, obligations, vente du logement"
@ -571,7 +571,7 @@
hub: { titre: "Déclaration foncière après travaux", description: "Piscine, abri de jardin, véranda : 90 jours pour déclarer, même sans permis de construire." }
mail:
objet: "Ta piscine (ou tout autre agrandissement) — la déclaration aux impôts, sous 90 jours"
corps: "Une piscine, un abri de jardin, une véranda ou tout agrandissement doivent être déclarés sur impots.gouv.fr (espace « Gérer mes biens immobiliers ») dans les 90 jours suivant la fin des travaux — même les installations démontables. C'est ce qui calcule la taxe d'aménagement et met à jour ta taxe foncière. Un oubli découvert plus tard s'accompagne de pénalités de retard. Si c'est déjà fait, dis-le-moi et je me tais."
corps: "Une piscine, un abri de jardin, une véranda ou tout agrandissement doivent être déclarés sur impots.gouv.fr (espace « Gérer mes biens immobiliers ») dans les 90 jours suivant la fin des travaux — même les installations démontables. C'est ce qui calcule la taxe d'aménagement et met à jour ta taxe foncière. Un oubli découvert plus tard s'accompagne de pénalités de retard."
seo:
slug: declaration-fonciere-90-jours
titre: "Déclaration foncière après travaux : le délai de 90 jours"
@ -620,7 +620,7 @@
hub: { titre: "Débroussaillement obligatoire (OLD)", description: "Ton adresse est dans une zone soumise à obligation légale de débroussaillement. À faire avant l'entrée dans la saison à risque, généralement fixée au 1er juin." }
mail:
objet: "Le débroussaillement se prépare en mars, pas fin mai"
corps: "Ton adresse est en zone d'obligation légale de débroussaillement : autour des constructions, sur un rayon fixé par l'arrêté préfectoral de ton département. Ce n'est pas du jardinage d'agrément — c'est ce qui empêche un feu de courir jusqu'aux murs, et ce qui permet aux pompiers de défendre la maison. Les contrôles se font désormais aussi par imagerie satellite, et l'amende peut atteindre 50 €/m². Les entreprises sont prises d'assaut en mai : mars-avril reste le bon créneau. Si c'est déjà fait cette année, dis-le-moi et je me tais."
corps: "Ton adresse est en zone d'obligation légale de débroussaillement : autour des constructions, sur un rayon fixé par l'arrêté préfectoral de ton département. Ce n'est pas du jardinage d'agrément — c'est ce qui empêche un feu de courir jusqu'aux murs, et ce qui permet aux pompiers de défendre la maison. Les contrôles se font désormais aussi par imagerie satellite, et l'amende peut atteindre 50 €/m². Les entreprises sont prises d'assaut en mai : mars-avril reste le bon créneau."
seo:
slug: debroussaillement-obligatoire-old
titre: "Débroussaillement obligatoire (OLD) : distances, amende, qui paie"

View file

@ -98,7 +98,7 @@
hub: { titre: "Passeport à vérifier", description: "Un œil sur la date, à temps pour refaire le titre s'il le faut : 1 à 3 mois de délai, et 6 mois de validité restante exigés par beaucoup de pays." }
mail:
objet: "Ton passeport — la date qui décide de tes prochains voyages"
corps: "Un passeport adulte vaut 10 ans, mais beaucoup de pays exigent 6 mois de validité restante à l'entrée. Ajoute 1 à 3 mois de délais réels (rendez-vous mairie + fabrication) : un passeport qui expire dans moins d'un an mérite d'être renouvelé avant de réserver, pas après. Dis-moi sa date d'expiration et je gère le tempo."
corps: "Un passeport adulte vaut 10 ans, mais beaucoup de pays exigent 6 mois de validité restante à l'entrée. Ajoute 1 à 3 mois de délais réels (rendez-vous mairie + fabrication) : un passeport qui expire dans moins d'un an mérite d'être renouvelé avant de réserver, pas après."
seo:
slug: passeport-validite-renouvellement
titre: "Passeport : validité 10 ans, règle des 6 mois, délais réels"
@ -150,7 +150,7 @@
hub: { titre: "Déclaration de revenus", description: "Ouverture en avril, date limite fin mai - début juin selon ton département." }
mail:
objet: "La déclaration de revenus est ouverte — ta date limite approche"
corps: "Le service en ligne est ouvert depuis début avril. Ta date limite dépend de ton département (fin mai à début juin, par tranches). En retard, c'est 10 % de majoration d'office. Même prélevé à la source, la déclaration reste obligatoire. Si c'est déjà envoyé, dis-le-moi et je me tais jusqu'à l'an prochain."
corps: "Le service en ligne est ouvert depuis début avril. Ta date limite dépend de ton département (fin mai à début juin, par tranches). En retard, c'est 10 % de majoration d'office. Même prélevé à la source, la déclaration reste obligatoire."
seo:
slug: declaration-revenus-dates-limites
titre: "Déclaration de revenus : dates limites par département"

View file

@ -16,7 +16,7 @@
hub: { titre: "Contrôle technique", description: "Fenêtre calculée depuis la première immatriculation — affinable à la date exacte de ta vignette." }
mail:
objet: "Le contrôle technique approche — mieux vaut s'y prendre maintenant"
corps: "Passé la date, c'est 135 € d'amende et une immobilisation possible. Les centres se remplissent vite en fin de mois ; en cas de contre-visite, tu as 2 mois pour repasser. Si c'est déjà fait, dis-le-moi et je me tais."
corps: "Passé la date, c'est 135 € d'amende et une immobilisation possible. Les centres se remplissent vite en fin de mois ; en cas de contre-visite, tu as 2 mois pour repasser."
seo:
slug: quand-controle-technique
titre: "Contrôle technique : quand le passer ? Délais et amende (2026)"
@ -79,7 +79,7 @@
hub: { titre: "Courroie de distribution", description: "La seule échéance que ta voiture ne t'annoncera jamais : elle n'a pas de voyant pour ça." }
mail:
objet: "La courroie de distribution — l'échéance sans voyant"
corps: "Ton tableau de bord t'annonce la révision, la pression des pneus, la batterie. Pas la courroie : aucune voiture n'a de voyant pour elle. Elle se change selon l'âge OU le kilométrage, au premier des deux — et l'âge court même quand la voiture dort au garage, parce que le caoutchouc vieillit tout seul. Une rupture, c'est le moteur : plusieurs milliers d'euros, souvent plus que la valeur de la voiture. Un contrôle en garage coûte quelques dizaines d'euros. Si c'est déjà fait, dis-le-moi et je me tais."
corps: "Ton tableau de bord t'annonce la révision, la pression des pneus, la batterie. Pas la courroie : aucune voiture n'a de voyant pour elle. Elle se change selon l'âge OU le kilométrage, au premier des deux — et l'âge court même quand la voiture dort au garage, parce que le caoutchouc vieillit tout seul. Une rupture, c'est le moteur : plusieurs milliers d'euros, souvent plus que la valeur de la voiture. Un contrôle en garage coûte quelques dizaines d'euros."
seo:
slug: courroie-distribution-quand-changer
titre: "Courroie de distribution : quand la changer ? (2026)"
@ -220,7 +220,7 @@
hub: { titre: "Assurance auto", description: "Comparable et résiliable à tout moment après 1 an — sans paperasse." }
mail:
objet: "Ton assurance auto — le bon moment pour comparer"
corps: "Après un an de contrat, l'assurance auto se résilie à tout moment (loi Hamon) : le nouvel assureur gère la résiliation et la continuité de couverture — obligatoire pour rouler. Les écarts entre assureurs atteignent couramment 100 à 300 € par an à garanties égales. Si tu as comparé récemment, dis-le-moi et je me tais."
corps: "Après un an de contrat, l'assurance auto se résilie à tout moment (loi Hamon) : le nouvel assureur gère la résiliation et la continuité de couverture — obligatoire pour rouler. Les écarts entre assureurs atteignent couramment 100 à 300 € par an à garanties égales."
seo:
slug: resilier-assurance-auto-loi-hamon
titre: "Résilier son assurance auto : loi Hamon, mode d'emploi"
@ -354,7 +354,7 @@
description: "Obligatoire depuis avril 2024 — premier contrôle avant les 5 ans, puis tous les 3 ans."
mail:
objet: "Le contrôle technique de ton deux-roues approche"
corps: "Il est obligatoire depuis le 15 avril 2024 pour tous les deux et trois-roues motorisés, cyclomoteurs compris — c'est encore mal connu, et beaucoup l'apprennent au contrôle routier. Le premier passage se fait dans les six mois qui précèdent le cinquième anniversaire de la mise en circulation, puis tous les trois ans. Sans lui : 135 € d'amende et une immobilisation possible. En cas de contre-visite, tu as deux mois pour repasser. Si c'est déjà fait, dis-le-moi et je me tais."
corps: "Il est obligatoire depuis le 15 avril 2024 pour tous les deux et trois-roues motorisés, cyclomoteurs compris — c'est encore mal connu, et beaucoup l'apprennent au contrôle routier. Le premier passage se fait dans les six mois qui précèdent le cinquième anniversaire de la mise en circulation, puis tous les trois ans. Sans lui : 135 € d'amende et une immobilisation possible. En cas de contre-visite, tu as deux mois pour repasser."
seo:
slug: controle-technique-moto-scooter
titre: "Contrôle technique moto et scooter : dates et périodicité"

View file

@ -21,6 +21,16 @@ const nextConfig: NextConfig = {
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
],
},
/**
* Le logo des mails est demandé par les mandataires d'images de Gmail, Yahoo et Outlook, une
* fois par destinataire et par message, sans jamais suivre de hachage de contenu : c'est le
* seul fichier du produit qu'un tiers rappelle en boucle. Un an de cache, et un nom de
* fichier qui changera si le dessin change.
*/
{
source: "/email/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
},
];
},
};

View file

@ -20,6 +20,7 @@
"distribution:seed": "tsx scripts/seed-distribution.ts",
"distribution:queue": "tsx scripts/distribution-a-sourcer.ts",
"rappels:simuler": "tsx scripts/simuler-rappel.ts",
"mails:echantillon": "tsx --tsconfig tsconfig.scripts.json scripts/mails-echantillon.ts",
"nucleaire:sync": "tsx scripts/sync-nucleaire.ts",
"cuivre:sync": "tsx scripts/sync-cuivre.ts",
"rappels:sync": "tsx scripts/sync-rappels.ts",

9
public/bimi/fokan.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" width="512" height="512" viewBox="0 0 512 512">
<title>fokan</title>
<rect width="512" height="512" fill="#1e2a2b"/>
<svg x="148.48000000000002" y="141.65848942598188" width="215.04" height="228.68302114803626" viewBox="0 0 331 352" overflow="visible">
<g transform="translate(-90.519884,-80.183817)"><g transform="matrix(1.193265,0,0,1.193265,-57.160195,9.183348)">
<path d="M319.5,169.549C301.353,166.486 283.687,163.032 265.588,163.194C261.39,163.231 257.304,163.818 253.243,164.813C243.025,167.316 236.27,173.542 232.825,183.475C228.984,194.548 228.788,205.945 229.234,217.051C230.706,218.652 232.095,218.116 233.374,218.052C254.99,216.978 276.604,215.874 298.221,214.811C300.547,214.697 302.894,214.651 305.214,214.814C311.799,215.276 317.331,220.349 318.272,226.638C319.53,235.05 316.151,241.477 309.111,244.028C305.922,245.183 302.591,245.233 299.25,245.32C278.281,245.869 257.37,247.357 236.462,249.007C231.589,249.391 230.885,251.38 231.003,255.607C231.379,269.077 232.566,282.538 231.246,296.037C229.75,311.328 226.682,326.035 216.849,338.44C208.56,348.897 197.595,354.13 184.314,354.181C175.857,354.214 169.711,348.699 168.776,340.866C167.821,332.863 172.237,325.605 180.85,324.685C192.592,323.431 196.301,315.966 198.735,306.341C201.008,297.35 201.034,288.201 201.053,279.052C201.095,259.224 198.724,239.505 198.196,219.705C197.746,202.802 198.999,186.029 205.46,170.177C214.873,147.079 233.078,136.061 257.2,133.659C278.516,131.537 299.291,135.933 320.152,139.111C327.555,140.239 334.94,141.566 342.254,143.166C350.194,144.903 354.412,149.2 355.349,155.749C356.833,166.122 349.993,174.079 339.546,173.238C332.95,172.708 326.459,170.881 319.5,169.549Z" fill="#fbfaf7"/><path d="M139.172,128.191C170.818,90.678 210.773,68.804 258.871,61.663C294.246,56.412 328.822,61.288 363.026,70.247C372.195,72.649 381.326,75.261 390.325,78.231C397.679,80.658 401.721,87.051 401.044,93.886C400.35,100.895 395.049,106.329 387.849,107.615C385.086,108.108 382.543,107.392 380.011,106.627C359.136,100.322 338.161,94.481 316.438,91.789C260.777,84.892 211.312,97.455 170.857,138.174C163.564,145.514 157.624,153.977 151.56,162.348C145.301,170.989 133.774,171.041 127.136,162.882C122.852,157.618 122.624,151.02 126.584,144.771C130.246,138.992 134.493,133.648 139.172,128.191Z" fill="#fbfaf7"/>
</g></g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,167 @@
import fs from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
import { db, memberships as membershipsTable, user as userTable } from "@/db";
import { loadTemplates } from "@/knowledge/loader";
import { renderRappelEmail } from "@/emails/rappel";
import { renderMagicLinkEmail } from "@/emails/magic-link";
import { renderGarderLienEmail } from "@/emails/garder-lien";
import { sendMail } from "@/adapters/mail";
import { apercuRappelFoyer } from "@/jobs/notifications";
import type { LigneRappel } from "@/engine/notifications";
/**
* Un exemplaire de CHACUN des messages que le produit sait envoyer (§ 1 de l'audit mail : ils sont
* cinq, le lien magique comptant pour trois selon son contexte). À écrire sur le disque pour
* relecture, ou à envoyer à une vraie boîte c'est le seul moyen de voir ce que fait réellement
* un client mail d'un HTML qui passe tous les tests.
*
* npm run mails:echantillon écrit dans .mails/
* npm run mails:echantillon -- moi@exemple.fr envoie, un message par cas
*
* Les contenus viennent des vraies fiches (`loadTemplates`) : un échantillon rédigé à la main
* montrerait une mise en page que personne ne recevra jamais.
*
* Quand une adresse est donnée et qu'elle appartient à un compte, les foyers de ce compte
* s'ajoutent à la série — avec leurs échéances à eux, passées par le vrai moteur. C'est le seul
* cas le message montré est littéralement celui que la boîte recevra.
*/
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
const FOYER = `${BASE}/foyer/8f1c0e6a-2b47-4d19-9a2e-5c3d7e0f1b84`;
/** Une ligne de rappel bâtie sur une fiche réelle, repérée par son id. */
function ligne(id: string, o: Partial<LigneRappel> & { assetLabels: string[]; fenetreLabel: string }): LigneRappel {
const t = loadTemplates().find((x) => x.id === id);
if (!t) throw new Error(`fiche introuvable : ${id}`);
return {
templateId: t.id,
enjeuType: t.enjeu.type,
confiance: "estimee",
fenetreDue: new Date("2026-09-15T00:00:00"),
titre: t.contenus.hub.titre,
pourquoi: t.enjeu.resume,
mailObjet: t.contenus.mail?.objet,
mailCorps: t.contenus.mail?.corps,
guideSlug: t.contenus.seo?.slug,
occurrences: [{ occurrenceId: `o-${t.id}`, deadlineId: `d-${t.id}` }],
...o,
};
}
const COUPER = `${BASE}/rappels?foyer=8f1c0e6a-2b47-4d19-9a2e-5c3d7e0f1b84&jeton=6c1f9a2d4e8b7c30`;
async function echantillons(): Promise<{ nom: string; objet: string; html: string; text: string }[]> {
const uneSeule = ligne("ramonage", { assetLabels: ["La maison"], fenetreLabel: "septembre 2026" });
const objetUnique = uneSeule.mailObjet ?? uneSeule.titre;
const trois = {
urgent: false,
items: [
ligne("controle-technique", { assetLabels: ["La voiture"], fenetreLabel: "avant le 30 septembre 2026" }),
ligne("taxe-fonciere", { assetLabels: ["La maison"], fenetreLabel: "15 octobre 2026" }),
ligne("vaccins-rappel", { assetLabels: ["Le chien", "Le chat"], fenetreLabel: "courant octobre 2026" }),
],
};
const [rappelUn, rappelTrois, connexion, invitation, activation, garderLien] = await Promise.all([
renderRappelEmail({ rappel: { urgent: false, items: [uneSeule] }, urlFoyer: FOYER, objet: objetUnique, urlCouper: COUPER }),
renderRappelEmail({ rappel: trois, urlFoyer: FOYER, objet: "3 choses ce mois-ci", urlCouper: COUPER }),
renderMagicLinkEmail({ url: `${BASE}/api/auth/magic-link/verify?token=demo`, context: "connexion" }),
renderMagicLinkEmail({
url: `${BASE}/api/auth/magic-link/verify?token=demo`,
context: "invitation",
invitedByEmail: "camille@exemple.fr",
}),
renderMagicLinkEmail({
url: `${BASE}/api/auth/magic-link/verify?token=demo`,
context: "activation",
urlFoyer: FOYER,
}),
renderGarderLienEmail({ url: FOYER, total: 23 }),
]);
return [
{ nom: "1-rappel-une-echeance", objet: `[échantillon] ${objetUnique}`, ...rappelUn },
{ nom: "2-rappel-trois-echeances", objet: "[échantillon] 3 choses ce mois-ci", ...rappelTrois },
{ nom: "3-lien-connexion", objet: "[échantillon] Ton lien de connexion fokan", ...connexion },
{ nom: "4-lien-invitation", objet: "[échantillon] camille@exemple.fr t'invite à rejoindre son foyer sur fokan", ...invitation },
{ nom: "5-lien-activation", objet: "[échantillon] Ton foyer est activé sur fokan", ...activation },
{ nom: "6-garder-lien", objet: "[échantillon] Le lien de ton foyer fokan", ...garderLien },
];
}
/**
* Les rappels des foyers dont cette adresse est membre le contenu réel, pas une maquette.
*
* `apercuRappelFoyer` ignore l'outbox et le budget du mois : ces deux règles évitent de réémettre,
* elles ne changent rien à ce qui serait écrit. Si rien n'est encore dû, l'aperçu est daté du jour
* la première échéance entrera dans sa fenêtre l'objet le dit, pour qu'on ne prenne pas un
* message de septembre pour un message d'aujourd'hui.
*/
async function foyersDe(email: string) {
const lignes = await db()
.select({ householdId: membershipsTable.householdId })
.from(membershipsTable)
.innerJoin(userTable, eq(membershipsTable.userId, userTable.id))
.where(eq(userTable.email, email));
const cas: { nom: string; objet: string; html: string; text: string }[] = [];
const aujourdhui = new Date();
for (const [i, { householdId }] of lignes.entries()) {
const apercu = await apercuRappelFoyer(householdId, aujourdhui);
if (!apercu) {
console.log(`foyer ${householdId} : aucune échéance datée, rien à montrer`);
continue;
}
const { html, text } = await renderRappelEmail({
rappel: apercu.rappel,
urlFoyer: apercu.urlFoyer,
objet: apercu.objet,
urlCouper: apercu.urlCouper,
});
const jour = apercu.quand.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
const daté = apercu.quand.getTime() === aujourdhui.getTime() ? "aujourd'hui" : `au ${jour}`;
cas.push({
nom: `7-foyer-${i + 1}-${householdId.slice(0, 8)}`,
objet: `[ton foyer, ${daté}] ${apercu.objet}`,
html,
text,
});
}
return cas;
}
async function main() {
const args = process.argv.slice(2);
// `--essai` relit sur le disque au lieu d'envoyer — y compris les foyers d'une adresse donnée.
const essai = args.includes("--essai");
const destinataire = args.find((a) => !a.startsWith("--"));
const cas = await echantillons();
if (destinataire) cas.push(...(await foyersDe(destinataire)));
if (!destinataire || essai) {
const dir = path.join(process.cwd(), ".mails");
fs.mkdirSync(dir, { recursive: true });
for (const { nom, html, text } of cas) {
fs.writeFileSync(path.join(dir, `${nom}.html`), html);
fs.writeFileSync(path.join(dir, `${nom}.txt`), text);
}
console.log(`${cas.length} messages écrits dans ${dir}`);
return;
}
for (const { nom, objet, html, text } of cas) {
const { sent, messageId } = await sendMail({ to: destinataire, subject: objet, html, text });
console.log(sent ? `envoyé ${nom}${destinataire} (${messageId})` : `NON ENVOYÉ ${nom} (SMTP non configuré)`);
}
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error("[mails-echantillon] échec :", e);
process.exit(1);
});

View file

@ -78,22 +78,53 @@ export async function POST(req: NextRequest) {
} else {
/**
* Cas 2 personne n'était connecté avant le paiement (§ 10.1 : aucun champ e-mail avant
* contrepartie, c'est Stripe Checkout qui le collecte). On ne peut pas créer le compte ni
* la cotisation ici : better-auth ne crée l'utilisateur qu'à la vérification du lien
* magique. Le lien envoyé mène donc chez nous, et c'est `resolveMembership`
* (src/lib/membership.ts) qui inscrit le premier membre mais seulement si le foyer est
* déjà `subscriptionStatus: "active"` au moment de la visite, condition posée par CE
* webhook juste au-dessus. Un foyer non payé ne peut donc plus jamais être réclamé par un
* visiteur quelconque, quel que soit l'ordre d'exécution.
* contrepartie, c'est Stripe Checkout qui le collecte).
*
* **Le compte se crée ICI depuis le 5 août 2026** (D-049), et plus à la vérification du
* lien magique. Auparavant, ce lien de 15 minutes à usage unique était la seule chose au
* monde capable de faire exister le compte de l'acheteur : `resolveMembership` n'inscrit
* le premier membre que s'il y a une session, et une session n'existait qu'après ce clic.
* Passé le quart d'heure un mail lu le soir, un lien ouvert deux fois, un filtre
* anti-spam qui le déclenche à vide il ne restait aucun chemin : ni compte, ni
* appartenance, donc aucun destinataire de rappel, sur un foyer payé 19,99 .
*
* Le paiement est la contrepartie ; il ne peut pas dépendre d'un clic dans un quart
* d'heure. Le lien magique redevient ce qu'il aurait toujours être : un raccourci de
* confort, dont l'expiration ne coûte plus qu'un aller-retour par /connexion.
*/
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";
/**
* On passe par l'adaptateur interne de better-auth plutôt que par un `insert` maison :
* c'est lui qui fabrique l'identifiant, joue les hooks et respecte le schéma. Le compte
* est marqué vérifié parce que l'adresse vient de Stripe, qui vient d'y envoyer un reçu.
*
* Contrepartie assumée : Stripe ne prouve pas la possession de la boîte. Quelqu'un qui
* paierait 19,99 en donnant l'adresse d'un tiers rattacherait un foyer au compte de ce
* tiers un ajout indésirable, jamais une prise de contrôle, et facturé à celui qui le
* tente. En face, l'ancien comportement perdait un client payant sur un simple délai.
*/
const ctx = await auth.$context;
const compte =
(await ctx.internalAdapter.findUserByEmail(email))?.user ??
(await ctx.internalAdapter.createUser({ email, emailVerified: true, name: "" }));
if (compte) {
const [dejaMembre] = await database
.select({ id: membershipsTable.id })
.from(membershipsTable)
.where(and(eq(membershipsTable.householdId, householdId), eq(membershipsTable.userId, compte.id)));
if (!dejaMembre) {
await database.insert(membershipsTable).values({ householdId, userId: compte.id });
}
}
/**
* Le lien magique part quand même : un tap reste un tap. L'URL durable du foyer voyage
* avec lui (audit mail § 4.4) elle, ne périme pas.
*/
await auth.api.signInMagicLink({
body: {
email,

View file

@ -108,6 +108,90 @@ describe("renderRappelEmail", () => {
});
expect(text).toContain("Parce que c'est obligatoire.");
});
/**
* Le sujet a une page publique la bicéphalie de l'invariant n° 6 ne servait que le SEO, alors
* que c'est au destinataire d'un rappel que l'explication en entier manque le plus.
*/
it("renvoie au guide de la fiche quand elle en a un", async () => {
const { html, text } = await renderRappelEmail({
rappel: { urgent: false, items: [ligne({ templateId: "x", guideSlug: "entretien-chaudiere-gaz-obligatoire" })] },
urlFoyer: "https://fokan.fr/foyer/abc",
objet: "Objet",
});
expect(html).toContain("/guide/entretien-chaudiere-gaz-obligatoire");
expect(text).toContain("Lire le guide");
});
it("ne fabrique aucun lien de guide pour une fiche qui n'a pas de page", async () => {
const { html } = await renderRappelEmail({
rappel: { urgent: false, items: [ligne({ templateId: "x" })] },
urlFoyer: "https://fokan.fr/foyer/abc",
objet: "Objet",
});
expect(html).not.toContain("/guide/");
});
/**
* Le paragraphe d'accompagnement se démentait au moment même on le lisait : « pour que tu
* n'aies pas à y penser toi-même », dans le seul message qui demande justement d'y penser.
*/
it("n'ajoute plus de phrase d'accompagnement entre le titre et les échéances", async () => {
const { text } = await renderRappelEmail({
rappel: { urgent: false, items: [ligne({ templateId: "a" }), ligne({ templateId: "b" })] },
urlFoyer: "https://fokan.fr/foyer/abc",
objet: "2 choses ce mois-ci",
});
expect(text).not.toContain("Rien d'urgent");
expect(text).not.toContain("y penser toi-même");
});
});
/**
* Ce qui rend un message lisible partout, et qui ne se voit qu'à l'ouverture chez le destinataire.
* Le fond crème vivait sur `<body>` que Gmail supprime en réécrivant le message : la carte
* blanche se retrouvait posée sur le fond du client, blanc sur blanc ou bloc éblouissant en mode
* sombre. On teste le mécanisme, jamais les valeurs de mise en page.
*/
describe("châssis commun", () => {
const rendus = async () => [
(await renderRappelEmail({
rappel: { urgent: false, items: [ligne({ templateId: "x" })] },
urlFoyer: "https://fokan.fr/foyer/abc",
objet: "Objet",
})).html,
(await renderMagicLinkEmail({ url: "https://fokan.fr/x", context: "connexion" })).html,
(await renderGarderLienEmail({ url: "https://fokan.fr/foyer/abc", total: 3 })).html,
];
it("déclare les deux modes plutôt que de laisser le client inverser au jugé", async () => {
for (const html of await rendus()) {
expect(html).toContain('name="color-scheme"');
expect(html).toContain('name="supported-color-schemes"');
expect(html).toContain("prefers-color-scheme: dark");
// Outlook.com ne connaît pas la requête média et préfixe les classes à la volée.
expect(html).toContain("[data-ogsc]");
}
});
/**
* Plus aucun fond de page : autour de la carte, c'est le fond du lecteur qui passe. Un aplat
* crème faisait une tache le client est blanc et un cadre clair il est sombre
* la bordure dit la même chose et reste juste sur n'importe quel fond.
*/
it("ne pose aucune couleur de fond, et laisse la bordure délimiter", async () => {
for (const html of await rendus()) {
expect(html).not.toContain("#fbfaf7");
expect(html).toContain("border:1px solid #e6e4de");
}
});
it("montre le logo, et le nomme pour qui ne charge pas les images", async () => {
for (const html of await rendus()) {
expect(html).toContain("/email/fokan-creme.png");
expect(html).toContain('alt="fokan"');
}
});
});
describe("renderMagicLinkEmail", () => {
@ -124,7 +208,8 @@ describe("renderMagicLinkEmail", () => {
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");
expect(html).not.toContain("ne périme pas");
expect(html).not.toContain("/foyer/");
});
it("nomme l'invitant dans une invitation", async () => {

View file

@ -1,15 +1,5 @@
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Preview,
Section,
Text,
render,
} from "@react-email/components";
import { Heading, Section, Text, render } from "@react-email/components";
import { Bouton, MailLayout, styleTexte, styleTitre } from "./layout";
type Props = {
url: string;
@ -26,42 +16,20 @@ type Props = {
*/
function GarderLienEmail({ url, total }: Props) {
return (
<Html lang="fr">
<Head />
<Preview>Le lien de ton foyer fokan</Preview>
<Body style={{ backgroundColor: "#fbfaf7", fontFamily: "sans-serif", padding: "2rem 0" }}>
<Container style={{ backgroundColor: "#ffffff", borderRadius: 14, padding: "2rem", maxWidth: 480 }}>
<Text style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 1, color: "#617a6c", fontWeight: 700 }}>
fokan
</Text>
<Heading style={{ fontSize: 20, color: "#1e2a2b" }}>Comme promis, voici ton lien</Heading>
<Text style={{ color: "#596161", fontSize: 15, lineHeight: 1.6 }}>
Ton foyer compte {total} échéance{total > 1 ? "s" : ""} sous contrôle. Garde ce mail
c&apos;est le moyen le plus simple d&apos;y revenir.
</Text>
<Section style={{ margin: "1.5rem 0" }}>
<Link
href={url}
style={{
background: "#617a6c",
color: "#ffffff",
padding: "0.85rem 1.5rem",
borderRadius: 10,
fontWeight: 600,
textDecoration: "none",
display: "inline-block",
}}
>
Retrouver mon foyer
</Link>
</Section>
<Text style={{ color: "#596161", fontSize: 13 }}>
Si tu n&apos;es pas à l&apos;origine de cette demande, ignore simplement ce mail
rien ne se passera.
</Text>
</Container>
</Body>
</Html>
<MailLayout apercu="Le lien de ton foyer fokan">
<Heading className="encre" style={styleTitre}>Comme promis, voici ton lien</Heading>
<Text className="sourdine" style={styleTexte}>
Ton foyer compte {total} échéance{total > 1 ? "s" : ""} sous contrôle. Garde ce mail
c&apos;est le moyen le plus simple d&apos;y revenir.
</Text>
<Section style={{ margin: "22px 0 4px" }}>
<Bouton href={url}>Retrouver mon foyer</Bouton>
</Section>
<Text className="sourdine" style={{ ...styleTexte, fontSize: 13, margin: "20px 0 4px" }}>
Si tu n&apos;es pas à l&apos;origine de cette demande, ignore simplement ce mail rien ne
se passera.
</Text>
</MailLayout>
);
}

252
src/emails/layout.tsx Normal file
View file

@ -0,0 +1,252 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from "@react-email/components";
/**
* Le châssis commun aux trois messages (05/08/2026) un seul endroit qui sait à quoi ressemble
* un mail fokan, chaque gabarit redessinait le sien.
*
* Il corrige un défaut visible chez le destinataire, pas une préférence : le fond crème était posé
* sur `<body>`, et **Gmail supprime `<body>` et ses styles** en réécrivant le message. La carte
* blanche se retrouvait donc collée sur le fond du client blanc sur blanc chez les uns, bloc
* blanc éblouissant sur fond noir chez les autres. Le fond vit désormais sur une `<table>` pleine
* largeur, seul conteneur qu'aucun client ne retire, et la carte porte une bordure qui la définit
* même si le fond saute quand même.
*
* Le mode sombre est déclaré plutôt que subi (`color-scheme`) : sans cette déclaration, Gmail et
* Apple Mail inversent eux-mêmes les couleurs claires, au jugé, et le vert de marque en ressort
* délavé. Avec elle, ils nous laissent la main et appliquent les règles ci-dessous. Les sélecteurs
* `[data-ogsc]` couvrent Outlook.com, qui ne connaît pas `prefers-color-scheme` et préfixe les
* classes à la volée.
*/
export const ENCRE = "#1e2a2b";
export const SOURDINE = "#596161";
export const ACCENT = "#617a6c";
/**
* Aucun fond de page (05/08/2026). Le message n'habille plus que sa propre carte : autour d'elle,
* c'est le fond du client qui passe, quel qu'il soit. Un aplat crème ne s'accordait avec aucun
* lecteur il faisait une tache le client est blanc, un cadre clair il est sombre
* et il n'apportait rien qu'une bordure ne dise mieux. C'est la bordure qui délimite désormais,
* seule, et elle a l'avantage de rester juste sur n'importe quel fond.
*/
const CARTE = "#ffffff";
const BORDURE = "#e6e4de";
const FILET = "#e9e9e7";
/** Georgia est la police de repli déclarée pour Newsreader (globals.css) la marque tient debout
* sans embarquer de fonte, ce qu'aucun client mail ne charge de façon fiable. */
const SERIF = "Georgia, 'Times New Roman', Times, serif";
const SANS = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
const CSS = `
:root { color-scheme: light dark; supported-color-schemes: light dark; }
body { margin: 0 !important; padding: 0 !important; width: 100% !important; }
img { border: 0; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
table { border-collapse: collapse !important; }
a { text-decoration: none; }
@media (max-width: 600px) {
.bord { padding-left: 20px !important; padding-right: 20px !important; }
}
@media (prefers-color-scheme: dark) {
/* Seule la carte se repeint : hors d'elle, c'est le fond du lecteur, et il est déjà sombre. */
.carte, .carte > tbody > tr > td { background-color: #232c2d !important; border-color: #33403f !important; }
.bandeau, .bandeau > tbody > tr > td { background-color: #151d1e !important; }
.encre, .encre a { color: #f1efe9 !important; }
.sourdine, .sourdine a { color: #b4b7b6 !important; }
.accent, .accent a { color: #a7c0b1 !important; }
.filet, .filet > tbody > tr > td { border-color: #33403f !important; }
}
[data-ogsc] .carte, [data-ogsc] .carte > tbody > tr > td { background-color: #232c2d !important; border-color: #33403f !important; }
[data-ogsc] .bandeau, [data-ogsc] .bandeau > tbody > tr > td { background-color: #151d1e !important; }
[data-ogsc] .encre, [data-ogsc] .encre a { color: #f1efe9 !important; }
[data-ogsc] .sourdine, [data-ogsc] .sourdine a { color: #b4b7b6 !important; }
[data-ogsc] .accent, [data-ogsc] .accent a { color: #a7c0b1 !important; }
[data-ogsc] .filet, [data-ogsc] .filet > tbody > tr > td { border-color: #33403f !important; }
`.trim();
/**
* L'adresse publique du site, seule origine possible pour l'image du logo : un client mail ne
* résout ni chemin relatif ni `data:` (Gmail et Outlook refusent les deux). La valeur de repli est
* celle des autres appelants du produit.
*/
export function baseUrl(): string {
return (process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr").replace(/\/+$/, "");
}
/**
* Une bande horizontale et sa marge intérieure.
*
* La marge est portée par le `<td>`, jamais par la `<table>` : en modèle de bordures fusionnées
* celui que tout mail impose, et que le moteur Word d'Outlook applique de toute façon la marge
* d'un élément `table` est ignorée par la spécification elle-même. Posée sur la table, elle
* disparaissait partout, et tout le message se collait aux bords de la carte.
*/
function Bande({
className,
classeCellule,
fond,
padding,
style,
children,
}: {
className?: string;
classeCellule?: string;
fond?: string;
padding: string;
style?: React.CSSProperties;
children: React.ReactNode;
}) {
return (
<Section className={className} style={{ backgroundColor: fond, ...style }}>
<Row>
<Column className={classeCellule} style={{ padding }}>{children}</Column>
</Row>
</Section>
);
}
/**
* Le bandeau est toujours sombre, dans les deux modes c'est ce qui rend le logo sûr partout.
* Un logo encre sur carte blanche redeviendrait invisible chez le premier client qui bascule la
* carte en sombre sans lire nos règles ; posé sur un aplat qui ne change jamais, il n'a qu'une
* seule version à exister, et elle est juste dans tous les cas.
*/
function Bandeau({ base }: { base: string }) {
return (
<Bande className="bandeau" classeCellule="bord" fond={ENCRE} padding="22px 28px" style={{ borderRadius: "16px 16px 0 0" }}>
<Img
src={`${base}/email/fokan-creme.png`}
width="101"
height="50"
alt="fokan"
style={{ display: "block", border: 0, outline: "none" }}
/>
</Bande>
);
}
type Props = {
/** Le texte d'aperçu des listes de mails — l'objet du message, jamais une phrase de plus. */
apercu: string;
children: React.ReactNode;
/** Le pied de page propre au message : pourquoi il arrive, et comment le faire cesser. */
pied?: React.ReactNode;
};
export function MailLayout({ apercu, children, pied }: Props) {
const base = baseUrl();
return (
<Html lang="fr">
<Head>
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<style dangerouslySetInnerHTML={{ __html: CSS }} />
</Head>
<Preview>{apercu}</Preview>
<Body style={{ margin: 0, padding: 0, fontFamily: SANS }}>
{/*
Aucune couleur ici : la marge respire sur le fond du lecteur, et la carte se suffit.
C'est aussi ce qui rend le message insensible à la réécriture de `<body>` par Gmail
il n'y a plus rien à y perdre.
*/}
<Bande padding="32px 12px">
<Container
className="carte"
style={{
backgroundColor: CARTE,
border: `1px solid ${BORDURE}`,
borderRadius: 16,
maxWidth: 560,
padding: 0,
}}
>
<Bandeau base={base} />
<Bande classeCellule="bord" padding="28px 28px 6px">{children}</Bande>
{/* Un filet, pas un aplat : le pied se distingue par une ligne, jamais par une teinte. */}
<Bande
className="filet"
classeCellule="bord"
padding="18px 28px"
style={{ borderTop: `1px solid ${FILET}` }}
>
{pied ?? <MentionPied />}
</Bande>
</Container>
</Bande>
</Body>
</Html>
);
}
/** Le pied minimal : qui écrit, et pourquoi ce message est arrivé. */
export function MentionPied({ children }: { children?: React.ReactNode }) {
return (
<>
{children}
<Text className="sourdine" style={{ color: SOURDINE, fontSize: 12, lineHeight: 1.6, margin: children ? "12px 0 0" : 0 }}>
fokan la veille du foyer.{" "}
<Link className="sourdine" href={`${baseUrl()}/`} style={{ color: SOURDINE, textDecoration: "underline" }}>
fokan.fr
</Link>
</Text>
</>
);
}
/**
* Un bouton qui tient dans Outlook : `msoPaddingAlt` remplace le padding que le moteur Word
* ignore, faute de quoi le libellé colle aux bords de l'aplat.
*/
export function Bouton({ href, children, ton = "plein" }: { href: string; children: React.ReactNode; ton?: "plein" | "discret" }) {
const plein = ton === "plein";
return (
<Link
href={href}
className={plein ? undefined : "accent"}
style={{
backgroundColor: plein ? ACCENT : "transparent",
color: plein ? "#ffffff" : ACCENT,
border: plein ? `1px solid ${ACCENT}` : `1px solid ${ACCENT}`,
padding: "13px 22px",
borderRadius: 10,
fontSize: 15,
fontWeight: 600,
fontFamily: SANS,
textDecoration: "none",
display: "inline-block",
msoPaddingAlt: "0px",
}}
>
{children}
</Link>
);
}
export const styleTitre = {
fontFamily: SERIF,
fontSize: 23,
lineHeight: 1.25,
fontWeight: 600,
color: ENCRE,
margin: "0 0 12px",
} as const;
export const styleTexte = {
fontFamily: SANS,
fontSize: 15,
lineHeight: 1.65,
color: SOURDINE,
margin: "0 0 14px",
} as const;

View file

@ -1,15 +1,5 @@
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Preview,
Section,
Text,
render,
} from "@react-email/components";
import { Heading, Link, Section, Text, render } from "@react-email/components";
import { ACCENT, Bouton, MailLayout, SOURDINE, baseUrl, styleTexte, styleTitre } from "./layout";
type Props = {
url: string;
@ -19,15 +9,11 @@ type Props = {
/**
* 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.
* Le lien magique expire en 15 minutes, et cette expiration a longtemps é un précipice :
* jusqu'à D-049, la vérification du lien était la seule chose au monde qui créait le compte de
* l'acheteur donc son appartenance, donc son droit à recevoir quoi que ce soit. Le compte naît
* désormais du paiement lui-même, dans le webhook Stripe. Ce mail n'est plus une bouée : c'est
* un raccourci, et son expiration ne coûte qu'un aller-retour par /connexion.
*/
urlFoyer?: string;
};
@ -43,52 +29,49 @@ function MagicLinkEmail({ url, context, invitedByEmail, urlFoyer }: Props) {
context === "invitation"
? "Un tap et tu deviens membre à part entière du foyer : tu recevras directement tes propres rappels, sans jamais passer par quelqu'un d'autre."
: context === "activation"
? "Le paiement est passé — il ne reste qu'un tap pour voir ton calendrier complet et recevoir tes rappels par mail, au bon moment. Sans mot de passe, ce lien te connecte directement."
? "Le paiement est passé, ton compte existe et ton calendrier est complet. Un tap et tu y es, sans mot de passe."
: "Ce lien te connecte directement, sans mot de passe. Il expire dans quelques minutes et ne sert qu'une fois.";
return (
<Html lang="fr">
<Head />
<Preview>{titre}</Preview>
<Body style={{ backgroundColor: "#fbfaf7", fontFamily: "sans-serif", padding: "2rem 0" }}>
<Container style={{ backgroundColor: "#ffffff", borderRadius: 14, padding: "2rem", maxWidth: 480 }}>
<Text style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 1, color: "#617a6c", fontWeight: 700 }}>
fokan
</Text>
<Heading style={{ fontSize: 20, color: "#1e2a2b" }}>{titre}</Heading>
<Text style={{ color: "#596161", fontSize: 15, lineHeight: 1.6 }}>{corps}</Text>
<Section style={{ margin: "1.5rem 0" }}>
<Link
href={url}
style={{
background: "#617a6c",
color: "#ffffff",
padding: "0.85rem 1.5rem",
borderRadius: 10,
fontWeight: 600,
textDecoration: "none",
display: "inline-block",
}}
>
{context === "invitation" ? "Rejoindre le foyer" : context === "activation" ? "Voir mon foyer" : "Me connecter"}
</Link>
</Section>
{urlFoyer ? (
<Text style={{ color: "#596161", 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: "#617a6c", wordBreak: "break-all" }}>
{urlFoyer}
</Link>
</Text>
) : null}
<Text style={{ color: "#596161", fontSize: 13 }}>
Si tu n&apos;es pas à l&apos;origine de cette demande, ignore simplement ce mail
rien ne se passera.
</Text>
</Container>
</Body>
</Html>
<MailLayout apercu={titre}>
<Heading className="encre" style={styleTitre}>{titre}</Heading>
<Text className="sourdine" style={styleTexte}>{corps}</Text>
{/*
Un seul bloc d'accès (05/08/2026) : le bouton et l'adresse durable formaient deux étages
séparés par un paragraphe d'explication de trois lignes, pour deux liens qui mènent au
même écran. Le bouton ouvre la session, la ligne dessous dit ce qui reste après.
*/}
<Section style={{ margin: "22px 0 0" }}>
<Bouton href={url}>
{context === "invitation" ? "Rejoindre le foyer" : context === "activation" ? "Voir mon foyer" : "Me connecter"}
</Bouton>
</Section>
{urlFoyer ? (
<Text className="sourdine" style={{ color: SOURDINE, fontSize: 13, lineHeight: 1.6, margin: "12px 0 0" }}>
{/*
Ce que cette ligne peut enfin dire depuis D-049 : rien ne se perd à l'expiration. Elle
promettait auparavant une adresse durable sur un foyer dont le compte, lui, n'existait
pas encore la seule chose vraiment périssable était passée sous silence.
*/}
Ce bouton te connecte pendant 15 minutes. Passé ce délai rien n&apos;est perdu : ton
foyer reste à cette adresse {" "}
<Link className="accent" href={urlFoyer} style={{ color: ACCENT, wordBreak: "break-all", textDecoration: "underline" }}>
{urlFoyer.replace(/^https?:\/\//, "")}
</Link>{" "}
et un nouveau lien de connexion s&apos;obtient à tout moment sur{" "}
<Link className="accent" href={`${baseUrl()}/connexion`} style={{ color: ACCENT, textDecoration: "underline" }}>
{baseUrl().replace(/^https?:\/\//, "")}/connexion
</Link>
.
</Text>
) : null}
<Text className="sourdine" style={{ ...styleTexte, fontSize: 13, margin: "20px 0 4px" }}>
Si tu n&apos;es pas à l&apos;origine de cette demande, ignore simplement ce mail rien ne
se passera.
</Text>
</MailLayout>
);
}

View file

@ -1,25 +1,22 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Text,
render,
} from "@react-email/components";
import { Heading, Hr, Link, Section, Text, render } from "@react-email/components";
import type { Rappel } from "@/engine/notifications";
import {
ACCENT,
Bouton,
ENCRE,
MentionPied,
SOURDINE,
MailLayout,
baseUrl,
styleTitre,
} from "./layout";
/**
* Le mail de rappel l'objet de valeur de l'abonnement (§ 10.2, § 12).
*
* Le ton est celui du majordome (§ 4.6) : le pourquoi en une ligne, l'imprécision assumée par le
* service et jamais reprochée, et une porte de sortie visible à chaque rappel (« si c'est déjà
* fait, dis-le-moi et je me tais »). On n'écrit ni « en retard », ni « à faire », ni un décompte
* de ce qui reste il n'y a pas de comptabilité morale ici non plus (§ 4.5).
* Le ton est celui du majordome (§ 4.6) : le pourquoi en une ligne et l'imprécision assumée par le
* service, jamais reprochée. On n'écrit ni « en retard », ni « à faire », ni un décompte de ce qui
* reste il n'y a pas de comptabilité morale ici non plus (§ 4.5).
*
* Le texte des échéances vient des fiches, jamais d'ici : ce composant met en page, il ne rédige
* pas (invariant n° 6 le contenu multi-canal est dans le template).
@ -43,82 +40,47 @@ type Props = {
urlCouper?: string | null;
};
const ENCRE = "#1e2a2b";
const SOURDINE = "#596161";
const ACCENT = "#617a6c";
function Echeance({ titre, sujetLabel, assetLabel, fenetreLabel, corps }: {
function Echeance({ titre, sujetLabel, assetLabel, fenetreLabel, corps, urlGuide }: {
titre: string; sujetLabel?: string; assetLabel: string; fenetreLabel: string; corps: string;
urlGuide?: string;
}) {
return (
<Section style={{ marginBottom: "1.25rem" }}>
<Text style={{ fontSize: 16, fontWeight: 600, color: ENCRE, margin: "0 0 0.15rem" }}>
<Section style={{ marginBottom: 22 }}>
<Text className="encre" style={{ fontSize: 16.5, fontWeight: 600, color: ENCRE, lineHeight: 1.35, margin: "0 0 3px" }}>
{titre}
{sujetLabel ? <span style={{ fontWeight: 400, color: SOURDINE }}> · {sujetLabel}</span> : null}
</Text>
<Text style={{ fontSize: 13, color: ACCENT, fontWeight: 600, margin: "0 0 0.4rem" }}>
<Text className="accent" style={{ fontSize: 13, color: ACCENT, fontWeight: 600, margin: "0 0 7px" }}>
{fenetreLabel}
<span style={{ color: SOURDINE, fontWeight: 400 }}> · {assetLabel}</span>
<span className="sourdine" style={{ color: SOURDINE, fontWeight: 400 }}> · {assetLabel}</span>
</Text>
<Text style={{ fontSize: 14.5, color: SOURDINE, lineHeight: 1.6, margin: 0 }}>{corps}</Text>
<Text className="sourdine" style={{ fontSize: 14.5, color: SOURDINE, lineHeight: 1.65, margin: 0 }}>{corps}</Text>
{/*
Le lien vers le sujet, pas seulement vers le hub (05/08/2026) : la fiche a déjà écrit la
page publique qui explique la règle en entier la bicéphalie de l'invariant n° 6 ne servait
jusqu'ici qu'au SEO, alors que c'est au destinataire d'un rappel qu'elle manque le plus.
Absent quand la fiche n'a pas de page : on ne fabrique pas un lien pour la symétrie.
*/}
{urlGuide ? (
<Text style={{ margin: "8px 0 0" }}>
<Link className="accent" href={urlGuide} style={{ color: ACCENT, fontSize: 13.5, fontWeight: 600, textDecoration: "none" }}>
Lire le guide
</Link>
</Text>
) : null}
</Section>
);
}
function RappelEmail({ rappel, urlFoyer, objet, urlCouper }: Props) {
const multiple = rappel.items.length > 1;
const base = baseUrl();
return (
<Html lang="fr">
<Head />
<Preview>{objet}</Preview>
<Body style={{ backgroundColor: "#fbfaf7", 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 }}>
fokan
</Text>
{multiple ? (
<>
<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.
</Text>
<Hr style={{ borderColor: "#e9e9e7", margin: "1.25rem 0" }} />
</>
) : null}
{rappel.items.map((item) => (
<Echeance
key={item.occurrences[0].deadlineId}
titre={item.titre}
sujetLabel={item.sujetLabel}
assetLabel={item.assetLabels.join(", ")}
fenetreLabel={item.fenetreLabel}
// Le corps rédigé de la fiche s'il existe ; sinon l'enjeu, qui tient déjà en une ligne.
corps={item.mailCorps ?? item.pourquoi}
/>
))}
<Section style={{ margin: "1.75rem 0 1rem" }}>
<Link
href={urlFoyer}
style={{
background: ACCENT,
color: "#ffffff",
padding: "0.85rem 1.5rem",
borderRadius: 10,
fontWeight: 600,
textDecoration: "none",
display: "inline-block",
}}
>
Voir mon foyer
</Link>
</Section>
<Hr style={{ borderColor: "#e9e9e7", margin: "1.25rem 0" }} />
<MailLayout
apercu={objet}
pied={
<MentionPied>
{/*
La porte de sortie, à chaque envoi et sans culpabilisation (§ 4.4, § 4.6).
@ -127,16 +89,12 @@ function RappelEmail({ rappel, urlFoyer, objet, urlCouper }: Props) {
arbitrage § 6.2). Elle ne promet plus que ce qui est vrai depuis le 05/08/2026 : le
silence, en un clic et sans se connecter. Les deux autres gestes reviendront dans
cette phrase le jour ils existeront (§ 7.1), pas avant.
Formulée pour ne pas répéter les fiches, dont plusieurs finissent déjà par « si c'est
déjà fait, dis-le-moi et je me tais » le pied de page dit ce que la fiche ne peut
pas dire, parce que ça ne dépend pas d'elle.
*/}
<Text style={{ color: SOURDINE, fontSize: 13, lineHeight: 1.6, margin: 0 }}>
<Text className="sourdine" style={{ color: SOURDINE, fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{urlCouper ? (
<>
Ces rappels ne te servent à rien ?{" "}
<Link href={urlCouper} style={{ color: SOURDINE, textDecoration: "underline" }}>
<Link className="sourdine" href={urlCouper} style={{ color: SOURDINE, textDecoration: "underline" }}>
Coupe-les d&apos;un clic
</Link>{" "}
ton calendrier reste complet, on cesse simplement de t&apos;écrire.
@ -148,9 +106,38 @@ function RappelEmail({ rappel, urlFoyer, objet, urlCouper }: Props) {
</>
)}
</Text>
</Container>
</Body>
</Html>
</MentionPied>
}
>
{/*
Le titre, et rien entre lui et la liste (05/08/2026).
Un paragraphe d'accompagnement disait ici « rien d'urgent, rien à faire tout de suite
juste ce qui arrive, pour que tu n'aies pas à y penser toi-même ». Il n'ajoutait aucune
information à ce que les échéances disent déjà, et sa dernière proposition était fausse
au moment précis on la lisait : ce mail est justement le moment l'on demande au
foyer d'y penser. Le silence vaut mieux qu'une phrase qui se dément elle-même (§ 4.4).
*/}
<Heading className="encre" style={styleTitre}>{objet}</Heading>
{multiple ? <Hr className="filet" style={{ borderColor: "#e9e9e7", borderTop: "1px solid #e9e9e7", margin: "0 0 20px" }} /> : null}
{rappel.items.map((item) => (
<Echeance
key={item.occurrences[0].deadlineId}
titre={item.titre}
sujetLabel={item.sujetLabel}
assetLabel={item.assetLabels.join(", ")}
fenetreLabel={item.fenetreLabel}
// Le corps rédigé de la fiche s'il existe ; sinon l'enjeu, qui tient déjà en une ligne.
corps={item.mailCorps ?? item.pourquoi}
urlGuide={item.guideSlug ? `${base}/guide/${item.guideSlug}` : undefined}
/>
))}
<Section style={{ margin: "24px 0 8px" }}>
<Bouton href={urlFoyer}>Voir mon foyer</Bouton>
</Section>
</MailLayout>
);
}

View file

@ -43,6 +43,12 @@ export type CandidatRappel = {
pourquoi: string;
mailObjet?: string;
mailCorps?: string;
/**
* Le slug de la page publique de la fiche, quand elle en a une de quoi renvoyer le lecteur
* vers le sujet lui-même et pas seulement vers son hub. Transporté comme le reste des contenus :
* le moteur ne fabrique aucune URL, il n'en connaît pas le domaine (invariant n° 5).
*/
guideSlug?: string;
assetLabel: string;
sujetLabel?: string;
};

View file

@ -21,6 +21,7 @@ import {
type CandidatRappel,
type Confiance,
type EnjeuType,
type Rappel,
} from "@/engine/notifications";
/**
@ -126,6 +127,9 @@ async function candidatsFoyer(householdId: string): Promise<CandidatRappel[]> {
pourquoi: template.enjeu.resume,
mailObjet: template.contenus.mail?.objet,
mailCorps: template.contenus.mail?.corps,
// Toutes les fiches n'ont pas de page publique : le lien « lire le guide » n'apparaît que
// pour celles qui en ont une, jamais un lien fabriqué au cas où.
guideSlug: template.contenus.seo?.slug,
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
@ -450,3 +454,34 @@ export async function simulerRappelFoyer(householdId: string, aujourdhui = new D
return { destinataires: destinataires.length, candidats: tous.length, plans };
}
/**
* Le rappel d'un foyer AVEC son contenu, pour relire un vrai message avant qu'il ne parte
* `simulerRappelFoyer` ne rend qu'un résumé de ce qui serait retenu.
*
* Deux règles de l'envoi sont volontairement mises de côté : l'outbox et le budget mensuel. Elles
* empêchent de RÉÉMETTRE, elles ne changent pas une ligne de ce qui serait écrit les respecter
* ici rendrait « rien à montrer » sur un foyer qui vient justement de recevoir son message.
*
* Quand rien n'est encore dû, on ne fabrique pas un message : on avance la date jusqu'à la
* première échéance datée et on laisse le moteur travailler. Ce qui en sort est donc exactement
* le mail que ce foyer recevra, le jour il le recevra `quand` dit lequel.
*/
export async function apercuRappelFoyer(
householdId: string,
aujourdhui = new Date(),
): Promise<{ rappel: Rappel; objet: string; quand: Date; urlFoyer: string; urlCouper: string | null } | null> {
const tous = await candidatsFoyer(householdId);
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
const commun = { urlFoyer: `${base}/foyer/${householdId}`, urlCouper: urlOptOut(householdId, base) };
const maintenant = planRappel({ candidats: tous, aujourdhui, envoisCeMois: 0 });
if (maintenant) return { rappel: maintenant, objet: objetRappel(maintenant, aujourdhui), quand: aujourdhui, ...commun };
const prochaine = [...tous].sort((a, b) => a.fenetreDue.getTime() - b.fenetreDue.getTime())[0];
if (!prochaine) return null;
const quand = prochaine.fenetreDue;
const plus_tard = planRappel({ candidats: tous, aujourdhui: quand, envoisCeMois: 0 });
return plus_tard ? { rappel: plus_tard, objet: objetRappel(plus_tard, quand), quand, ...commun } : null;
}

7
tsconfig.scripts.json Normal file
View file

@ -0,0 +1,7 @@
{
"//": "Les scripts hors Next.js s'exécutent par tsx, qui compile lui-même le JSX. Le tsconfig du projet dit `preserve` — c'est Next.js qui transforme, et tsx ne le peut pas. Cette variante n'existe que pour les scripts qui rendent des gabarits React Email (scripts/mails-echantillon.ts).",
"extends": "./tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}