Files
PleinR/CLAUDE.md
T
Claude 0e340dde58 Boîte mail : accorder le port SMTP et le chiffrement
Le port et la case « connexion chiffrée » étaient deux champs indépendants
qu'il fallait accorder de tête. Passer en 587 sans décocher fait tenter une
poignée de main TLS sur un port qui répond en clair : la connexion meurt sur
un « wrong version number » que rien ne traduisait.

- `src/lib/smtp-config.ts` (pur, testé) : `impliedSecure()`, `tlsMismatch()`
  et `describeSmtpError()`, qui traduit les échecs courants — mauvaise case,
  port filtré, identifiants refusés, certificat, expéditeur rejeté — en
  consigne actionnable, l'explication avant la trace puisque l'écran tronque.
- `SmtpAccountForm` (client) réaligne la case sur le port saisi, propose les
  deux ports courants en pastilles et affiche le désaccord s'il subsiste. Le
  mot de passe enregistré ne descend pas en prop, seul un booléen le fait.
- Transport : `requireTLS` hors TLS implicite — sans lui STARTTLS reste
  opportuniste et l'identifiant partirait en clair chez un serveur qui ne
  l'annonce pas — SNI explicite, TLS 1.2 minimum, et délais ramenés à
  quelques secondes pour qu'un port bloqué en sortie rende son verdict au
  lieu de figer l'action serveur.
- `verifySmtp` referme la socket même en échec et dit sur quel port et dans
  quel mode la connexion a abouti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RCUSintYfXnBtwFKQezFSt
2026-09-15 13:51:51 +00:00

28 KiB
Raw Blame History

CLAUDE.md

Guidance for working in this repository.

What this is

Plein R — website for an association of merchants/businesses in the Bassin de Pompey. A single Next.js application serving a public Accueil page and an authenticated, role-based Backend (admin + member space). Postgres is an external container.

Stack

  • Next.js 15 (App Router, TypeScript, output: "standalone")
  • PostgreSQL 16 + Drizzle ORM (src/db/schema.ts) with drizzle-kit migrations
  • Auth.js v5 (credentials provider, JWT sessions) — RBAC in src/lib/rbac.ts
  • One app container + one Postgres container (docker-compose.yml)

Commands

npm run dev            # dev server
npm run build          # production build
npm run db:generate    # regenerate SQL after editing src/db/schema.ts
npm run db:migrate     # apply migrations
npm run db:seed        # catégories + admin initial ; données de démo si SEED_DEMO=true
npm run db:purge-demo  # retire les données de démo d'une base qui les a reçues
docker compose up --build   # full stack

Conventions

  • Styling: faithful port of the original design as inline styles + a small design-system in src/app/globals.css (palette as CSS vars, fonts, twinkle/float animations, hover lifts, responsive grid helpers). No Tailwind.
  • Mutations: server actions in src/app/backend/actions.ts. Each action re-checks auth + capability via getSession() and can() before writing, then revalidatePath().
  • Access control: src/middleware.ts gates /backend/*; each page further guards by role (isStaff, can) and redirects.
  • Data reads for the public site live in src/lib/queries.ts.
  • After changing src/db/schema.ts, run npm run db:generate and commit the new file under drizzle/.
  • Catégories : le référentiel vit dans src/db/categories.ts (slugs stables, jamais renommés). Il est inséré par la migration 0012_referentiel_categories (ON CONFLICT (slug) : seul sort est réaligné, un libellé renommé depuis le backend est conservé) et rejoué par le seed. Pour ajouter une catégorie, on l'ajoute au référentiel et on génère une nouvelle migration --custom du même modèle ; tests/categories.test.ts vérifie la cohérence.

Promotions

Statuts : pending → (scheduled →) live → suspended ⇄ live (+ rejected / expired). promotions.suspended_by retient qui a suspendu (member ou staff) : une suspension par le staff ne peut être levée que par le staff. Les lectures publiques filtrent sur le statut live, donc une promo suspendue — ou encore programmée — disparaît du site sans traitement supplémentaire.

Période de validité : promotions.starts_on / ends_on (colonnes date, les deux facultatives et indépendantes). src/lib/promo-validity.ts est pur et verrouillé par tests/promo-validity.test.ts : formatValidity() rend « Valable du 1er au 15 mars 2027 » / « Valable jusqu'au … » / « Valable à partir du … », formatValidityShort() la variante sans « Valable » pour les lignes de méta, isRangeInvalid() garde le formulaire et l'action. Sans date, rien n'est affiché ; l'ancien texte libre valid_until (jamais écrit par l'application) ne sert plus que de repli d'affichage. La même phrase part dans le message Facebook / LinkedIn (buildPromoMessage) : un seul formateur pour le site, le backoffice et les réseaux.

La liste publique vit sur /promotions (toutes les offres en cours) ; l'accueil n'en montre que les six dernières et renvoie vers elle. Les deux listes partagent src/components/PromoCard.tsx : un seul rendu, pas deux cartes à maintenir. getLivePromotions(limit) accepte null pour « tout », la limite restant appliquée en SQL. Les liens « Promotions » de l'en-tête, du pied de page et des publications réseaux pointent sur cette page, plus sur l'ancre /#promotions.

Une promotion n'est affichée que pendant sa période : les lectures de src/lib/queries.ts passent par VISIBLE_PROMO (statut live et fenêtre de dates, journée calculée en Europe/Paris — un conteneur en UTC retirerait sinon une offre deux heures trop tôt). Le statut n'est pas modifié : hors période, le backoffice affiche visibilityNote() (« pas encore affichée » / « période terminée »), et l'offre revient d'elle-même si les dates changent.

Publication programmée : promotions.publish_at (timestamptz, vide = mise en ligne immédiate). L'adhérent propose une date dans son formulaire, le modérateur la garde, la déplace ou la vide dans « Valider » — comme les cases réseaux, c'est le dernier moment où elle est ajustable. Une promotion validée avec une échéance future passe au statut scheduled : invisible du site et rien n'est diffusé. releaseDuePromotions() (src/lib/promo-publish.ts) fait la bascule scheduled → live par un UPDATE … RETURNING filtré sur le statut — atomique, donc sans double publication même à plusieurs instances — puis appelle publishPromoShares(). Le bouton « Publier maintenant » de la modération court-circuite l'attente.

src/lib/promo-schedule.ts est pur (tests/promo-schedule.test.ts) : il convertit la saisie datetime-local depuis/vers l'heure de l'association (Europe/Paris, changements d'heure compris) et refuse une saisie illisible plutôt que de la transformer en publication immédiate.

Le déclencheur est src/instrumentation.ts : une boucle d'une minute démarrée avec le serveur, avec un premier passage au démarrage pour rattraper les échéances tombées pendant un redéploiement. PROMO_SCHEDULER=off la désactive. Le travail vit dans src/instrumentation-node.ts parce que Next compile aussi instrumentation.ts pour le runtime edge du middleware : next.config.mjs l'écarte de ce bundle (IgnorePlugin), sans quoi webpack tente d'y embarquer pg et ses dépendances Node.

src/lib/promo-publish.ts porte publishPromoShares() — et non plus backend/actions.ts — parce qu'il a deux appelants : une action serveur et la boucle de libération, qui n'a pas de requête et ne peut donc pas appeler revalidatePath(). src/lib/activity-log.ts porte logActivity() pour la même raison.

Réseaux sociaux

  • src/lib/social.ts publie une promo sur la page Facebook (Graph API) ou LinkedIn (Posts API). src/lib/social-accounts.ts gère la configuration : OAuth, jetons, cibles. Réseau non configuré = case masquée.
  • Les identifiants et jetons vivent en base (social_accounts), chiffrés via src/lib/crypto.ts (AES-256-GCM, clé SOCIAL_TOKEN_KEY ou AUTH_SECRET), posés depuis /backend/reseaux. Les variables d'environnement restent lues en repli. Aucun secret ne doit jamais repartir vers le navigateur.
  • isNetworkConfigured() / configuredNetworks() / siteUrl() / redirectUri() sont asynchrones (accès base).
  • L'URL publique est le réglage site_public_url, édité sur /backend/reseaux (pré-rempli depuis les en-têtes de la requête). saveSiteSettings saute cette clé : le formulaire Paramètres ne la contient pas et l'écraserait.
  • Routes OAuth : src/app/api/social/[network]/{connect,callback}. Le state anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
  • Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
  • « N'expire pas » ≠ « ne meurt jamais » : checkTokenHealth() interroge la plateforme et persiste le verdict dans social_accounts.last_check_*. L'écran Réseaux contrôle à chaque affichage ; le tableau de bord passe par tokenHealthCached() (6 h) et croise le résultat avec la dernière tentative de publication en échec. Une date d'expiration seule ne suffit pas à alerter.
  • Les images de promo sont stockées en data-URI : l'upload se fait donc en binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas par URL.
  • L'image part telle quelle : aucun recadrage ni redimensionnement côté application. Un visuel non carré est donc recadré — ou entouré de bandes de couleur — par la plateforme. src/lib/image-info.ts (pur, verrouillé par tests/image-info.test.ts) décrit le fichier déposé (dimensions, format, poids) et MemberSpaceForm l'affiche sous l'aperçu : on informe, on ne bloque pas.
  • La diffusion est déclenchée par la validation (ou par l'échéance d'une publication programmée), pas par un bouton : promotions.share_facebook / share_linkedin sont choisis par l'adhérent, ajustables par le modérateur dans le formulaire « Valider », puis figés (status !== 'pending').
  • publishPromoShares() dans src/lib/promo-publish.ts est le seul point de publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne social_posts en posted : c'est la garde anti-republication, qui couvre aussi le cycle suspension → remise en ligne.
  • retryPromoShare ne sert qu'au rattrapage d'un échec sur un réseau déjà choisi ; il ne peut pas élargir la diffusion.
  • L'URL publique du site : siteUrl() lit le réglage site_public_url puis NEXT_PUBLIC_SITE_URL / AUTH_URL ; publicBaseUrl() y ajoute un repli sur l'origine de la requête (src/lib/site-url.ts, en-têtes X-Forwarded-*). L'adresse de retour OAuth et les liens des publications passent par publicBaseUrl() : la connexion marche sans réglage tant que l'admin passe par l'adresse publique.
  • La CSP (src/middleware.ts) ne pose upgrade-insecure-requests qu'en HTTPS, sinon un test en HTTP par IP voit toutes ses navigations basculer vers un https:// inexistant.
  • Les URLs publiques des pages FB/LinkedIn sont des site_settings (association_facebook, association_linkedin), éditables dans Paramètres.

Sécurité

  • Erreurs des server actions : un throw dans une action est masqué par Next en production (message remplacé par un digest) et fait tomber la page sur « Application error ». Les échecs attendus — e-mail déjà pris, champ manquant — sont donc renvoyés (ActionError = { error: string }) et affichés par le formulaire, qui conserve la saisie. Le throw reste réservé aux violations d'accès, qui n'ont pas à s'expliquer à l'utilisateur.
  • Le journal d'activité agrège des saisies de tiers, dont le formulaire de contact public : il est filtré à l'écriture (sanitizeActivityMessage) et rendu en éléments React (activityNodes), jamais en HTML brut.
  • getSession() (src/lib/session.ts) remplace auth() partout : le rôle et le rattachement adhérent sont relus en base à chaque requête, et users.session_version invalide les jetons émis avant un changement de mot de passe. N'appelez plus auth() directement depuis une page ou une action.
  • Les images ne sont acceptées qu'en data-URI (asImageDataUri) : une URL ferait appeler par le serveur une cible choisie par l'utilisateur (SSRF).
  • La CSP à nonce est posée par src/middleware.ts. Elle impose un rendu dynamique : export const dynamic = "force-dynamic" est dans app/layout.tsx, un HTML pré-généré ne pouvant pas porter de nonce.
  • Les mots de passe temporaires (création d'adhérent, réinitialisation, invitation staff) ne sont jamais stockés : l'action les renvoie et le composant OneTimeCredentials les affiche une seule fois, sans redirection. users.must_change_password seul persiste.
  • Le seed ne crée en production que le référentiel des catégories et l'administrateur initial, avec un mot de passe aléatoire affiché une fois dans les journaux (ou SEED_ADMIN_PASSWORD) et un changement obligatoire à la première connexion. Toutes les données de démonstration (adhérents, promotions, demandes, journal, comptes changeme123) vivent dans src/db/demo-data.ts, exigent SEED_DEMO=true, et npm run db:purge-demo les retire d'une base existante.
  • Sessions JWT limitées à 7 jours (auth.config.ts), HSTS et suppression de X-Powered-By dans next.config.mjs. Le port Postgres de docker-compose n'est publié que sur 127.0.0.1.
  • npm test verrouille ces protections (tests/security.test.ts) : XSS du journal, limitation des connexions, chiffrement des secrets, coordonnées du référent, mots de passe temporaires jamais mis en file, secrets de messagerie jamais renvoyés au navigateur, aucune copie cachée dans la chaîne d'envoi, rien rendu en HTML brut côté informations, et le HTML de l'éditeur visuel qui ne quitte jamais la page (formulaire à champ caché, collage passé par DOMParser, réinjection limitée à richTextToEditorHtml).

Référencement (SEO)

  • src/lib/seo.ts est pur (constantes, pageMetadata(), générateurs JSON-LD, serializeJsonLd() qui échappe <>&) et verrouillé par tests/seo.test.ts. src/lib/seo-server.ts fournit publicBaseUrl() : réglage site_public_url, puis variables d'environnement, puis en-têtes de la requête.
  • app/layout.tsx pose metadataBase, le gabarit de titre %s · Plein R, Open Graph / Twitter, robots, le manifeste, et les JSON-LD Organization + WebSite. Chaque page publique appelle pageMetadata({ title, description, path }) : le path sert de canonique (l'annuaire ignore ainsi ?q=).
  • Données structurées par page via <JsonLd data={…} /> : BreadcrumbList partout, ItemList sur l'annuaire, LocalBusiness (+ horaires) sur la fiche adhérent, BusinessEvent pour chaque rencontre à venir.
  • app/robots.ts, app/sitemap.ts (pages statiques + adhérents actifs), app/manifest.ts et app/opengraph-image.tsx (vignette 1200×630 générée).
  • /backend, /login, /inscription/* et les fiches non actives sont en NOINDEX ; les pages publiques utilisent <main> et un seul <h1>.
  • Mesure d'audience : src/lib/analytics.ts est pur (tests/analytics.test.ts). normalizeMeasurementId() n'accepte qu'un identifiant GA4 (G-…, ni UA-… ni texte libre) : c'est la garde qui interdit d'injecter une chaîne arbitraire dans le script en ligne. Le réglage google_analytics_id (Paramètres) vide = aucune balise posée. <Analytics> (src/components/Analytics.tsx) rend les deux <script> avec le nonce de la CSP et n'agit que sur les pages publiques : isTrackedPath() écarte PRIVATE_PATHS, le chemin venant de l'en-tête x-pathname posé par le middleware (un layout ne connaît pas l'URL demandée). La CSP liste les hôtes *.google-analytics.com / *.analytics.google.com / *.googletagmanager.com en connect-src sans condition : elle est posée sur le runtime Edge, qui ne peut pas interroger la base. script-src n'a pas besoin d'eux — 'strict-dynamic' couvre les scripts que gtag.js charge ensuite.
  • Search Console : le réglage google_site_verification alimente verification.google dans app/layout.tsx (balise <meta>). normalizeSiteVerification() refuse la balise <meta> complète collée telle quelle. Le plan du site est déjà servi sur /sitemap.xml et annoncé dans /robots.txt : il n'y a aucun fichier à déposer, seulement l'URL à déclarer.
  • Tags adhérents : src/lib/tags.ts (pur) porte un vocabulaire par métier (CATEGORY_TAGS, un par slug du référentiel, vérifié par tests/tags.test.ts) et un vocabulaire transversal détecté dans la description. autoTags() ne remplit le champ qu'à vide : à l'enregistrement (resolveMemberTags dans backend/actions.ts) et à l'affichage de la fiche publique. Le composant TagsField propose les suggestions en pastilles cliquables dans les deux formulaires. Un nouveau métier dans categories.ts exige son entrée dans CATEGORY_TAGS.
  • URLs de fiche : memberPath({ id, name, city }) donne /adherents/12-au-bon-pain-frouard. L'identifiant en tête suffit (parseMemberParam), toute autre écriture est redirigée en 301 vers la forme canonique par la page : ne construisez jamais /adherents/${id} à la main.
  • Pages métier : /annuaire/[categorie] (slug de categories) rend une page indexable par activité (titre, intro, grille MemberCard, ItemList) ; sans adhérent elle passe en NOINDEX et sort du sitemap. L'annuaire et ces pages listent les métiers via CategoryLinks pour le maillage interne.

Adhérents

  • L'espace adhérent est en deux pages : /backend/espace (profil, inscriptions, droit à l'image) et /backend/espace/promotions (dépôt et suivi des promos, les offres en ligne en tête). EspaceHeader porte le bandeau et les onglets ; toute action qui touche l'espace revalide les deux chemins.

  • Pas de barre latérale hors staff : BackendShell branche sur isStaff(user.role). Chez un adhérent la barre ne porterait que la section « Côté adhérent », c'est-à-dire les trois entrées déjà rendues en onglets par EspaceHeader — deux menus pour la même chose. Il ne reste qu'un bandeau de marque, aligné sur la colonne de contenu (max-width 1040). Conséquence à ne pas perdre de vue : sous 1024px les actions du compte vivaient dans le tiroir ; sans tiroir, .backend-header--plain rétablit leur affichage dans l'entête, sinon la déconnexion deviendrait inatteignable sur téléphone. tests/backend-nav.test.ts verrouille les deux points. Le staff garde la barre : elle y est la navigation principale, et « Côté adhérent » son seul chemin vers son propre espace.

  • La catégorie d'une promotion est un type de produit ou de service (src/lib/promo-categories.ts, groupes pour <optgroup>), pas le métier de l'adhérent ; defaultPromoCategory(slug) pré-sélectionne depuis le métier.

  • Référent : members.contact_first_name / contact_last_name / contact_phone portent la personne à joindre et sa ligne directe. Ils sont réservés aux visiteurs connectés : aucune requête publique ne les lit (tests/security.test.ts le verrouille), seules getMemberContacts() / getMemberContact() les rapatrient, et uniquement après un getSession() positif. L'annuaire, les pages métier et la fiche affichent alors un bloc « Contact adhérent » ; hors session la requête n'est pas faite, donc rien n'est masqué en CSS, rien ne part dans le HTML ni dans le JSON-LD. src/lib/member-contact.ts est pur (tests/member-contact.test.ts) : memberContact() compose « Prénom Nom » et renvoie null s'il n'y a rien à montrer, telHref() fabrique le lien tel:. Les deux formulaires de fiche (espace adhérent et écran staff) partagent MemberContactFields.

  • members.email est l'e-mail administratif (identifiant de connexion à la création, échanges avec l'association) ; members.contact_email est l'e-mail public de la fiche, saisi par l'adhérent ou le staff. La fiche et le JSON-LD affichent contact_email || email : ne montrez jamais email seul.

  • VitrineImage rend la couverture en cover, sinon le logo en contain (~60 % d'un cadre à hauteur fixe) sur un fond du logo flouté, sinon un placeholder rayé. Ne pas l'entourer d'un conteneur sans hauteur : les max-height en % ne seraient plus résolus et le logo déborderait.

Roles

admin > moderator > editor are staff; member is an adhérent linked to a members row via users.memberId. Capability matrix is in src/lib/rbac.ts.

manageInformations (admin + modérateur) ouvre la rédaction des informations ; écrire à tous les adhérents reste sous manageEmails (admin seul), et la configuration de la boîte mail sous manageSettings. Un modérateur publie donc une information sans pouvoir la diffuser.

Informations adhérents

Le fil de l'espace adhérent (/backend/espace/informations, onglet en tête) porte ce que publie le bureau depuis /backend/informations. Deux tables : informations (brouillon → publiée, pinned, email_sent_at) et information_reads.

  • Texte riche : src/lib/rich-text.ts est pur (tests/rich-text.test.ts) et analyse un sous-ensemble de Markdown — **gras**, _italique_, - puce, 1. numéro, [texte](https://…), ## sous-titre. Trois rendus, un seul analyseur : richTextNodes() pour l'écran (des éléments React, jamais dangerouslySetInnerHTML), richTextToEmailHtml() pour le message, richTextToEditorHtml() pour remplir l'éditeur. Un lien hors http/https perd sa cible et ne garde que son libellé (safeHttpUrl, partagé avec email-templates.ts). L'italique s'écrit avec des tirets bas : ***x*** serait ambigu pour l'analyseur, **_x_** ne l'est pas ; *étoiles* reste accepté en lecture, et un tiret bas au milieu d'un mot (fichier_de_sauvegarde) n'ouvre rien.
  • La syntaxe ne se montre jamais : RichTextEditor est un contentEditable avec une barre d'outils (G, I, Sous-titre, listes, lien). L'adhérent qui rédige voit du gras, pas des étoiles. Le balisage voyage dans un <input type="hidden">.
  • Le WYSIWYG porte sur la saisie, pas sur le stockage. C'est l'invariant à ne pas casser : à chaque frappe, src/lib/rich-text-dom.ts (serializeToRichText, pur, tests/rich-text-dom.test.ts) retraverse le contenu édité et n'en garde que le gras, l'italique, les listes, les sous-titres et les liens. Le HTML de l'éditeur ne quitte jamais la page : la base ne reçoit que le format balisé, que le serveur ré-analyse avec le même analyseur qu'avant. Aucun assainisseur HTML n'est donc nécessaire. Le module lit aussi le gras/italique codés en style CSS (<span style="font-weight:700"> de Google Docs), le style explicite l'emportant sur la balise — sans quoi le <b style="font-weight:normal"> dont Docs enveloppe tout document mettrait le texte entier en gras.
  • Un collage est analysé par DOMParser, jamais par une affectation d'innerHTML : le document produit est inerte, donc un <img onerror> collé ne s'exécute pas. Ce qui est réinjecté dans l'éditeur est du HTML produit par nous (richTextToEditorHtml), jamais celui du presse-papier.
  • Les sauts de ligne sont normalisés à l'enregistrement : l'encodage des formulaires HTML convertit tout \n en \r\n, et le balisage stocké différerait sinon de celui que l'éditeur a produit (saveInformation).
  • Une seule image, en couverture, jamais dans le corps : une data-URI recopiée dans chaque ligne de la file pèserait 3 Mo par destinataire. Elle est servie aux clients mail par /api/informations/[id]/image, qui ne répond que pour une information publiée — Gmail et Outlook suppriment les <img src="data:">.
  • Non-lues : une ligne par lecture (information_reads) plutôt qu'une date « vu jusqu'ici ». C'est ce qui permet à la fois la pastille « Nouveau » par information et le « lue par 12 / 40 » du back-office. Le marquage passe par l'action markInformationsRead, appelée après affichage par MarkInformationsRead : la pastille de l'onglet et celle de la barre latérale sont calculées par deux composants serveur distincts, dont l'ordre de rendu n'est pas garanti, et se contrediraient sur la même page.
  • Une seule épinglée : appliqué dans l'action (unpinOthers), pas par un index conditionnel que drizzle-kit ne génère pas.
  • Les trois pages de l'espace passent infoBadge : sans cela le compteur disparaîtrait en changeant d'onglet.

Envoi d'e-mails

/backend/boite-mail est le décalque de /backend/reseaux : mêmes règles, mêmes garanties. Trois transports dans mail_accounts, un seul is_active — pas de cascade automatique, la bascule est un choix visible.

  • Google → API Gmail (gmail.send) et non SMTP+XOAUTH2 : celui-ci exigerait https://mail.google.com/, portée restreinte donc audit de sécurité. Microsoft → API Graph (/me/sendMail) : l'authentification basique SMTP est désactivée depuis 2024, y compris sur outlook.com et hotmail.com. SMTP générique via nodemailer, seule dépendance d'envoi.
  • Port et chiffrement SMTP sont un seul choix. src/lib/smtp-config.ts est pur (tests/smtp-config.test.ts) : impliedSecure() dit que seul le port 465 chiffre dès l'ouverture, les autres (587, 25, 2525) passent par STARTTLS, et tlsMismatch() prévient quand la case et le port se contredisent. Le formulaire (SmtpAccountForm, client) réaligne la case sur le port choisi : un TLS tenté sur le 587 échoue sur la bannière en clair (« wrong version number »), un dialogue en clair sur le 465 attend une réponse qui ne vient jamais. describeSmtpError() traduit ces échecs avant affichage — l'explication d'abord, la trace ensuite, parce que l'écran tronque.
  • Le transport pose requireTLS hors TLS implicite : sans lui, STARTTLS est opportuniste et l'identifiant partirait en clair chez un serveur qui ne l'annonce pas. Les délais (connectionTimeout, greetingTimeout) sont ramenés à quelques secondes : ces connexions s'ouvrent depuis une action serveur, et un port filtré en sortie — cas courant chez les hébergeurs — ne doit pas bloquer l'écran deux minutes.
  • mail_accounts.from_address est lu chez le fournisseur au retour OAuth, jamais saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la boîte, et une adresse d'un autre domaine ferait tomber SPF/DKIM.
  • Microsoft fait tourner le jeton de rafraîchissement à chaque renouvellement : ensureAccessToken réenregistre celui qui revient, sinon l'envoi meurt au bout d'une heure.
  • src/lib/mime.ts est pur (tests/mime.test.ts) : extrait de downloadOutlookDraft, il sert le brouillon .eml et le champ raw de l'API Gmail. CRLF stricts, mots encodés RFC 2047 repliés sans couper une séquence UTF-8, CR/LF neutralisés dans les valeurs d'en-tête — un retour à la ligne dans un objet permettrait sinon d'injecter un Bcc:.
  • src/lib/mailer.ts porte sendNow(), qui ne lève jamais : un envoi raté est un verdict, pas une exception.
  • src/lib/mail-outbox.ts porte la file. Réclamation en UPDATE … FOR UPDATE SKIP LOCKED — releaseDuePromotions s'en passe parce que la transition de statut y fait office de verrou, pas ici. Faucheur des verrous laissés par un conteneur arrêté en plein envoi, donc livraison au moins une fois. ⚠ db.execute rend les colonnes brutes : il faut reconvertir to_address en toAddress, le constructeur de requêtes le fait, pas lui.
  • Les mots de passe temporaires ne passent jamais par la file : mail_messages.html est stocké en base. Les cinq actions à identifiants appellent sendNow en ligne directe et journalisent une trace sans contenu (logSentMail). tests/security.test.ts le verrouille. L'envoi est un plus, jamais un point de rupture : OneTimeCredentials affiche le mot de passe quoi qu'il arrive.
  • Une ligne mail_messages par destinataire : to_address est un varchar unique, la confidentialité d'une diffusion est structurelle.
  • La configuration mail ne passe surtout pas par site_settings : saveSiteSettings boucle sur toutes les clés de SITE_SETTING_DEFAULTS et écrase d'une chaîne vide celles qu'aucun champ ne porte.
  • La boucle d'envoi vit dans src/instrumentation-node.ts, à côté du libérateur de promotions, et next.config.mjs déclare serverExternalPackages: ["nodemailer"]. Chaque boucle a son propre interrupteur (MAIL_WORKER, PROMO_SCHEDULER) : couper l'un dans instrumentation.ts couperait l'autre, puisque le module entier ne serait plus chargé.
  • emailBrand(settings) (src/lib/site-settings.ts) compose l'identité en pied de tous les messages : un seul endroit à changer.

Docker notes

  • Migrations + optional seed run on container start via docker-entrypoint.sh.
  • npm run build:scripts bundles migrate/seed into dist/*.cjs so the runtime image needs no dev dependencies.
  • The standalone server binds HOSTNAME=0.0.0.0, PORT=3000.

public/assets/logo.svg is a brand-colour recreation; swap in the official asset when available (referenced as /assets/logo.svg).