mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
Documente le fil d'informations et la messagerie
CLAUDE.md gagne les deux sections et, surtout, les pièges qui coûteraient une soirée à retrouver : `db.execute` qui rend les colonnes brutes, les deux interrupteurs de boucle qu'il ne faut pas confondre, la rotation du jeton Microsoft, l'adresse d'expédition lue chez le fournisseur, et la raison pour laquelle les mots de passe temporaires ne passent jamais par la file. README et .env.example décrivent les trois transports, le piège des sept jours côté Google, et le repli SMTP par variables d'environnement qui permet un premier déploiement sans démarche préalable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
3 files changed
+167
-4
No files matched your search
@@ -72,3 +72,29 @@ FACEBOOK_PAGE_ACCESS_TOKEN=
|
|||||||
LINKEDIN_ORGANIZATION_URN=
|
LINKEDIN_ORGANIZATION_URN=
|
||||||
LINKEDIN_ORGANIZATION_ID=
|
LINKEDIN_ORGANIZATION_ID=
|
||||||
LINKEDIN_ACCESS_TOKEN=
|
LINKEDIN_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# ---- Boîte mail de l'association ----
|
||||||
|
# La configuration se fait dans Backend › Boîte mail : on y branche Google
|
||||||
|
# (API Gmail), Microsoft (API Graph) ou un serveur SMTP, et les secrets sont
|
||||||
|
# stockés chiffrés. Rien à mettre ici dans le cas normal.
|
||||||
|
#
|
||||||
|
# Les jetons et mots de passe utilisent la même clé que les réseaux sociaux
|
||||||
|
# (SOCIAL_TOKEN_KEY, avec repli sur AUTH_SECRET).
|
||||||
|
#
|
||||||
|
# `off` arrête la file d'envoi sans toucher au reste (le libérateur de
|
||||||
|
# publications programmées a son propre interrupteur, PROMO_SCHEDULER).
|
||||||
|
#MAIL_WORKER=off
|
||||||
|
# Débit de la file, en messages par passage (défaut : 20). Gmail plafonne
|
||||||
|
# autour de 500 destinataires par jour sur un compte gratuit.
|
||||||
|
#MAIL_RATE_PER_MINUTE=20
|
||||||
|
|
||||||
|
# --- Repli avant toute configuration depuis le backoffice ---
|
||||||
|
# Permet un premier déploiement sans passer par l'écran. Dès qu'un compte est
|
||||||
|
# enregistré dans Backend › Boîte mail, il prime sur ces variables.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_SECURE=true
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=
|
||||||
|
SMTP_FROM_NAME=
|
||||||
@@ -202,7 +202,11 @@ même raison.
|
|||||||
- Sessions JWT limitées à 7 jours (`auth.config.ts`), HSTS et suppression de
|
- 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`
|
`X-Powered-By` dans `next.config.mjs`. Le port Postgres de `docker-compose`
|
||||||
n'est publié que sur `127.0.0.1`.
|
n'est publié que sur `127.0.0.1`.
|
||||||
- `npm test` verrouille ces protections (`tests/security.test.ts`).
|
- `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, et rien rendu en HTML brut côté informations.
|
||||||
|
|
||||||
## Référencement (SEO)
|
## Référencement (SEO)
|
||||||
|
|
||||||
@@ -274,6 +278,96 @@ même raison.
|
|||||||
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
`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`.
|
`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`. Deux rendus, un seul
|
||||||
|
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
|
||||||
|
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message. L'aperçu
|
||||||
|
du formulaire passe par le premier, il est donc fidèle par construction. Un
|
||||||
|
lien hors `http`/`https` perd sa cible et ne garde que son libellé
|
||||||
|
(`safeHttpUrl`, partagé avec `email-templates.ts`).
|
||||||
|
- **Pas de WYSIWYG** : il produirait du HTML, qu'il faudrait stocker puis
|
||||||
|
assainir — seconde dépendance, seconde surface d'attaque. La saisie est un
|
||||||
|
`<textarea>` avec une barre qui encadre la sélection (`setRangeText`).
|
||||||
|
- **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.
|
||||||
|
- `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
|
## Docker notes
|
||||||
|
|
||||||
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
|
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
|
||||||
|
|||||||
@@ -114,8 +114,10 @@ Mise à jour : `git pull && docker compose up -d --build`.
|
|||||||
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
||||||
| Modération des promotions | ✅ | ✅ | — | — |
|
| Modération des promotions | ✅ | ✅ | — | — |
|
||||||
| Publication réseaux sociaux | ✅ | ✅ | — | — |
|
| Publication réseaux sociaux | ✅ | ✅ | — | — |
|
||||||
|
| Informations adhérents | ✅ | ✅ | — | — |
|
||||||
|
| E-mails & boîte mail | ✅ | — | — | — |
|
||||||
| Administrateurs | ✅ | — | — | — |
|
| Administrateurs | ✅ | — | — | — |
|
||||||
| Mon espace (publier une promo) | — | — | — | ✅ |
|
| Mon espace (promos, informations) | — | — | — | ✅ |
|
||||||
|
|
||||||
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
|
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
|
||||||
|
|
||||||
@@ -217,7 +219,7 @@ npm run dev # http://localhost:3000
|
|||||||
| `npm run db:generate` | Génère les migrations Drizzle |
|
| `npm run db:generate` | Génère les migrations Drizzle |
|
||||||
| `npm run db:migrate` | Applique les migrations |
|
| `npm run db:migrate` | Applique les migrations |
|
||||||
| `npm run db:seed` | Insère les données de démonstration |
|
| `npm run db:seed` | Insère les données de démonstration |
|
||||||
| `npm test` | Tests de sécurité (filtre XSS, limitation de connexion, chiffrement) |
|
| `npm test` | Tests unitaires et de sécurité (XSS, chiffrement, MIME, file d'envoi) |
|
||||||
|
|
||||||
## Variables d'environnement
|
## Variables d'environnement
|
||||||
|
|
||||||
@@ -230,10 +232,51 @@ Voir [`.env.example`](./.env.example). Les principales :
|
|||||||
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
||||||
- `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle
|
- `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle
|
||||||
normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise.
|
normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise.
|
||||||
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`)
|
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux **et de la boîte
|
||||||
|
mail** (défaut : `AUTH_SECRET`)
|
||||||
|
- `MAIL_WORKER=off` — arrête la file d'envoi ; `PROMO_SCHEDULER=off` arrête le
|
||||||
|
libérateur de publications programmées. Les deux sont indépendants.
|
||||||
|
- `MAIL_RATE_PER_MINUTE` — débit de la file d'envoi (défaut : 20)
|
||||||
|
- `SMTP_HOST` / `SMTP_USER` / `SMTP_PASSWORD` … — repli avant toute
|
||||||
|
configuration depuis **Backend › Boîte mail**
|
||||||
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
|
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
|
||||||
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
|
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
|
||||||
|
|
||||||
|
## Informations aux adhérents
|
||||||
|
|
||||||
|
L'association publie ses annonces dans **Backend › Informations** : titre, texte
|
||||||
|
avec une mise en forme simple (`**gras**`, `*italique*`, listes, liens), image de
|
||||||
|
couverture, brouillon puis publication, et une seule information épinglée en tête.
|
||||||
|
|
||||||
|
Les adhérents les retrouvent sous l'onglet **Informations** de leur espace, avec
|
||||||
|
une pastille « Nouveau » et un compteur de non-lues qui retombe à la lecture.
|
||||||
|
|
||||||
|
À la publication, une case permet d'**envoyer aussi le message par e-mail** : un
|
||||||
|
message par adhérent, jamais de copie partagée.
|
||||||
|
|
||||||
|
## Envoi d'e-mails
|
||||||
|
|
||||||
|
Le site expédie depuis la boîte de l'association, branchée dans
|
||||||
|
**Backend › Boîte mail**. Trois transports, un seul actif à la fois :
|
||||||
|
|
||||||
|
| Transport | Chemin | À savoir |
|
||||||
|
|---|---|---|
|
||||||
|
| Google | API Gmail (`gmail.send`) | Une application laissée en mode « Test » voit son autorisation expirer au bout de 7 jours : passez-la « En production », ou déclarez-la « Interne » avec un compte Workspace. |
|
||||||
|
| Microsoft | API Graph (`Mail.Send`) | Fonctionne pour `@outlook.com` et `@hotmail.com` comme pour une boîte 365 ; l'authentification SMTP par mot de passe y est désactivée depuis 2024. |
|
||||||
|
| Autre serveur | SMTP (`nodemailer`) | Couvre aussi un mot de passe d'application Gmail, Outlook professionnel, OVH, Ionos. Marche immédiatement, sans démarche préalable. |
|
||||||
|
|
||||||
|
Pour Google et Microsoft, l'adresse d'expédition est **lue chez le fournisseur**
|
||||||
|
et non saisie : expédier depuis un autre domaine ferait échouer SPF et DKIM.
|
||||||
|
|
||||||
|
Ce qui part du site : mots de passe temporaires (création, réinitialisation,
|
||||||
|
invitation), invitations aux rencontres, diffusion d'une information, et les
|
||||||
|
envois du studio de composition. Les diffusions passent par une file d'attente
|
||||||
|
vidée en tâche de fond ; les mots de passe partent en direct, sans jamais être
|
||||||
|
écrits en base.
|
||||||
|
|
||||||
|
Sans boîte configurée, rien ne casse : les mots de passe temporaires restent
|
||||||
|
affichés une fois à l'écran, comme auparavant.
|
||||||
|
|
||||||
## Note sur le logo
|
## Note sur le logo
|
||||||
|
|
||||||
Le logo (`public/assets/logo.svg`) est une recréation vectorielle aux couleurs de
|
Le logo (`public/assets/logo.svg`) est une recréation vectorielle aux couleurs de
|
||||||
|
|||||||
Reference in new issue
Block a user