Author SHA1 Message Date
Claude 7e7d307cdd Type la redirection de la boîte mail en « never »
`redirect` lève : le déclarer supprime les `!` posés après chaque appel
pour rassurer TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:26:35 +00:00
Claude 39eb8cfc13 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
2026-09-14 16:23:32 +00:00
Claude 0c8542f894 Tests : file d'envoi, destinataires, navigation et invariants
`tests/security.test.ts` gagne cinq blocs qui verrouillent ce qui compte
et qu'aucun type ne protège :

- les cinq actions qui émettent un mot de passe appellent `sendNow` et
  jamais `queueMail`, et la trace journalisée porte un corps vide ;
- l'écran Boîte mail ne déchiffre aucun secret, saisit en champ masqué et
  annonce qu'un champ vide conserve la valeur enregistrée ;
- les trois fonctions qui écrivent un secret en base le chiffrent ;
- ni copie ni copie cachée dans la chaîne d'envoi, et `to_address` reste
  une adresse unique et obligatoire ;
- aucune lecture publique ne touche aux informations, et rien de ce qui
  les rend n'emprunte `dangerouslySetInnerHTML` ;
- chaque boucle de fond garde son interrupteur et reste hors du bundle
  edge.

`tests/backend-nav.test.ts` attrape l'oubli qu'on ne voit qu'à l'écran :
ajouter une page demande de toucher aux titres, aux sections et au jeu
d'icônes, et rater l'un des trois donne un en-tête vide ou une icône
absente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:22:07 +00:00
Claude 158958a8f4 Le studio d'e-mails et les invitations partent vraiment
Les deux écrans de composition gagnent le choix des destinataires — tous
les adhérents actifs, une catégorie de métier, ou les inscrits à la
rencontre — et un bouton qui envoie pour de bon. « M'envoyer un test »
part en direct, pour un verdict immédiat ; une diffusion passe par la
file, une ligne par destinataire.

Copier, brouillon Outlook et export HTML restent en place : ils servent
encore aux cas particuliers, et au cas où aucune boîte n'est configurée.

L'aperçu comme l'envoi s'appuient désormais sur l'adresse publique
enregistrée plutôt que sur `window.location.origin` : c'est celle que
verront les destinataires, pas celle par laquelle l'administrateur est
arrivé.

Un modérateur gère les rencontres mais n'écrit pas à tous les adhérents :
le bouton d'envoi du composeur reste conditionné à `manageEmails`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:19:28 +00:00
Claude 877b0ece78 Diffuser une information par e-mail, à la publication
La case « Envoyer aussi par e-mail » du bouton Publier met une ligne par
adhérent dans la file : jamais de copie partagée, aucune adresse visible
des autres. `informations.email_sent_at` fait office de garde
anti-double-diffusion, comme `publishPromoShares` pour les réseaux — une
information dépubliée puis republiée ne repart pas.

La diffusion est réservée à `manageEmails` : un modérateur publie, il
n'écrit pas à tous les adhérents. Sans adresse publique enregistrée, rien
ne part et le bandeau le dit, plutôt que d'expédier un message dont le
logo et tous les liens seraient cassés.

L'image de couverture passe par `/api/informations/<id>/image` et non en
data-URI : Gmail et Outlook suppriment les `<img src="data:">`, et
l'incorporer gonflerait chaque ligne de la file à plusieurs méga-octets.
La route ne sert que les informations publiées.

Deux défauts corrigés en chemin, trouvés en jouant le parcours complet :

- `db.execute` rend les colonnes brutes, sans les rebaptiser comme le fait
  le constructeur de requêtes : `to_address` n'arrivait pas dans
  `toAddress`, et nodemailer refusait tous les messages faute de
  destinataire.
- `PROMO_SCHEDULER=off` empêchait le chargement du module de fond, donc
  coupait aussi l'envoi des e-mails. Chaque boucle a maintenant son propre
  interrupteur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:15:11 +00:00
Claude a7ac27e530 Les identifiants partent par e-mail, sans jamais passer par la file
Les cinq actions qui émettent un mot de passe temporaire — création d'un
adhérent, rattrapage des comptes manquants, réinitialisation, invitation
staff, approbation d'une demande — l'envoient désormais au destinataire.

L'envoi est **en ligne directe**, jamais mis en file : `mail_messages.html`
est stocké en base, et CLAUDE.md pose que le mot de passe temporaire n'est
jamais conservé. Le clair n'existe donc que dans la portée de l'action, et
seule une trace sans contenu est journalisée.

C'est un plus, jamais un point de rupture : `OneTimeCredentials` continue
d'afficher le mot de passe quoi qu'il arrive, et annonce « envoyé à … » ou
« envoi impossible, transmettez-le à la main ». Boîte non configurée,
l'écran se comporte exactement comme avant.

Au passage, le rattrapage des comptes manquants ne perd plus l'état d'envoi
de chaque ligne : la liste propage l'objet entier au lieu d'en recopier
trois champs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:05:41 +00:00
Claude 8d4443a1a1 Boîte mail de l'association : trois transports, une file d'attente
Le site savait composer de beaux messages et ne savait pas les envoyer :
`email-client.ts` ne proposait que la copie ou le téléchargement d'un
brouillon Outlook, complété à la main. `/backend/boite-mail` branche
désormais la boîte de l'association, sur le modèle exact de l'écran
Réseaux sociaux — secrets chiffrés, jamais renvoyés au navigateur, champ
laissé vide qui conserve la valeur enregistrée, contrôle de santé qui ne
lève jamais.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:00:11 +00:00
Claude d57e544a18 Fil d'informations dans l'espace adhérent
L'association pouvait publier des promotions et des rencontres, mais rien
à destination de ses seuls adhérents connectés. Un troisième onglet, placé
en tête de l'espace, porte désormais ce que publie le bureau : annonces,
convocations, rappels.

Côté rédaction, `/backend/informations` (capacité `manageInformations`,
administrateur et modérateur) : titre, texte balisé avec barre de mise en
forme et aperçu, image de couverture, brouillon puis publication,
épinglage — une seule information en tête à la fois, appliqué dans
l'action plutôt que par un index conditionnel.

Côté lecture, `information_reads` porte une ligne par lecture plutôt
qu'une date « vu jusqu'ici » sur l'utilisateur : 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 une action appelée après affichage :
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 seraient contredites sur la même page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 15:49:42 +00:00
Claude 1701fc2834 Analyseur de texte riche pour les informations adhérents
Sous-ensemble de Markdown — gras, italique, listes, liens, sous-titres —
analysé par `src/lib/rich-text.ts` puis rendu en éléments React, jamais en
HTML brut : même principe que le journal d'activité. Un éditeur WYSIWYG
aurait imposé de stocker du HTML tiers puis de l'assainir, soit une
seconde dépendance et une seconde surface d'attaque.

Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
`richTextToEmailHtml()` pour le message. L'aperçu du formulaire passera
par le premier, il sera donc fidèle par construction.

`safeHttpUrl()` remplace le `webUrl()` privé d'`email-templates.ts` : un
lien hors http/https perd sa cible et ne garde que son libellé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 15:28:37 +00:00
Claude 3df3e55974 Extrait le constructeur MIME dans un module pur
`downloadOutlookDraft` assemblait à la main un message RFC 822 complet
(sujet RFC 2047, base64 replié, partie `cid:` en ligne). Ce code a un
second appelant à venir : le champ `raw` de l'API Gmail attend exactement
ce format. Il part donc dans `src/lib/mime.ts`, pur et verrouillé par
`tests/mime.test.ts`, plutôt que d'importer plus tard l'entrée interne
`nodemailer/lib/mail-composer`.

Le constructeur gagne au passage ce que le brouillon Outlook n'avait pas
besoin de gérer : repli des mots encodés longs sans couper une séquence
UTF-8, `multipart/alternative` pour le repli texte, mise en forme des
adresses, et neutralisation des CR/LF dans les valeurs d'en-tête — un
retour à la ligne dans un objet permettrait sinon d'injecter un `Bcc:`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 15:25:22 +00:00
Claude 67cd9d6684 Affiche les coordonnées du référent aux visiteurs connectés
L'annuaire, les pages métier et la fiche adhérent montrent désormais la
personne à joindre chez l'adhérent — nom, prénom, ligne directe — mais
uniquement à un visiteur connecté.

Trois colonnes sur `members` (`contact_first_name`, `contact_last_name`,
`contact_phone`), saisies dans les deux formulaires de fiche via le
composant partagé `MemberContactFields`.

Le filtrage ne repose pas sur du CSS ni sur un rendu conditionnel tardif :
aucune requête publique ne lit ces colonnes. Seules `getMemberContacts()`
et `getMemberContact()` les rapatrient, et les pages ne les appellent
qu'après un `getSession()` positif. Un visiteur anonyme ne reçoit donc
rien — ni dans le HTML, ni dans le JSON-LD, ni dans les props du composant
client de l'annuaire. `tests/security.test.ts` verrouille cette séparation.

`src/lib/member-contact.ts` reste pur (composition du nom, lien `tel:`) et
est couvert par `tests/member-contact.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TAK3c4jAUqxVMzy746wWQ
2026-09-14 11:31:59 +00:00
Claude fb09bf0e3a Accueil : préciser « entreprises de service »
Sur la page d'accueil, les mentions d'« entreprises » deviennent
« entreprises de service » : texte alternatif du logo, sous-titre du
hero, phrase d'accroche et appel à adhésion (les deux variantes,
avec et sans compteur d'adhérents).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkXREQkpPAKHFLgtnFFkCM
2026-09-14 11:04:53 +00:00
69 changed files with 10759 additions and 112 deletions

No files matched your search

+26
View File
@@ -72,3 +72,29 @@ FACEBOOK_PAGE_ACCESS_TOKEN=
LINKEDIN_ORGANIZATION_URN=
LINKEDIN_ORGANIZATION_ID=
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=
+107 -1
View File
@@ -202,7 +202,11 @@ même raison.
- 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`).
- `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)
@@ -248,6 +252,18 @@ même raison.
(`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
@@ -262,6 +278,96 @@ même raison.
`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`. 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
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
+46 -3
View File
@@ -114,8 +114,10 @@ Mise à jour : `git pull && docker compose up -d --build`.
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
| Modération des promotions | ✅ | ✅ | — | — |
| Publication réseaux sociaux | ✅ | ✅ | — | — |
| Informations adhérents | ✅ | ✅ | — | — |
| E-mails & boîte mail | ✅ | — | — | — |
| 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.
@@ -217,7 +219,7 @@ npm run dev # http://localhost:3000
| `npm run db:generate` | Génère les migrations Drizzle |
| `npm run db:migrate` | Applique les migrations |
| `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
@@ -230,10 +232,51 @@ Voir [`.env.example`](./.env.example). Les principales :
- `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
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` /
`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
Le logo (`public/assets/logo.svg`) est une recréation vectorielle aux couleurs de
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "members" ADD COLUMN "contact_first_name" varchar(120);--> statement-breakpoint
ALTER TABLE "members" ADD COLUMN "contact_last_name" varchar(120);--> statement-breakpoint
ALTER TABLE "members" ADD COLUMN "contact_phone" varchar(40);
+27
View File
@@ -0,0 +1,27 @@
CREATE TYPE "public"."info_status" AS ENUM('draft', 'published');--> statement-breakpoint
CREATE TABLE "information_reads" (
"id" serial PRIMARY KEY NOT NULL,
"information_id" integer NOT NULL,
"user_id" integer NOT NULL,
"read_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "informations" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(200) NOT NULL,
"body" text NOT NULL,
"image_url" text,
"status" "info_status" DEFAULT 'draft' NOT NULL,
"pinned" boolean DEFAULT false NOT NULL,
"published_at" timestamp with time zone,
"author_id" integer,
"email_sent_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "informations" ADD CONSTRAINT "informations_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "information_reads_user_info_idx" ON "information_reads" USING btree ("user_id","information_id");--> statement-breakpoint
CREATE INDEX "informations_feed_idx" ON "informations" USING btree ("status","published_at");
+58
View File
@@ -0,0 +1,58 @@
CREATE TYPE "public"."mail_kind" AS ENUM('credentials', 'information', 'meeting', 'studio', 'test');--> statement-breakpoint
CREATE TYPE "public"."mail_provider" AS ENUM('google', 'microsoft', 'smtp');--> statement-breakpoint
CREATE TYPE "public"."mail_status" AS ENUM('queued', 'sending', 'sent', 'failed', 'cancelled');--> statement-breakpoint
CREATE TABLE "mail_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"provider" "mail_provider" NOT NULL,
"from_address" varchar(200),
"from_name" varchar(200),
"is_active" boolean DEFAULT false NOT NULL,
"app_id" varchar(200),
"app_secret" text,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"smtp_host" varchar(200),
"smtp_port" integer,
"smtp_secure" boolean DEFAULT true NOT NULL,
"smtp_user" varchar(200),
"smtp_password" text,
"connected_by_id" integer,
"connected_at" timestamp with time zone,
"last_check_at" timestamp with time zone,
"last_check_ok" boolean,
"last_check_error" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "mail_accounts_provider_unique" UNIQUE("provider")
);
--> statement-breakpoint
CREATE TABLE "mail_messages" (
"id" serial PRIMARY KEY NOT NULL,
"kind" "mail_kind" NOT NULL,
"status" "mail_status" DEFAULT 'queued' NOT NULL,
"to_address" varchar(200) NOT NULL,
"to_name" varchar(200),
"subject" varchar(300) NOT NULL,
"html" text NOT NULL,
"text" text,
"reply_to" varchar(200),
"information_id" integer,
"meeting_id" integer,
"member_id" integer,
"created_by_id" integer,
"provider" "mail_provider",
"attempts" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"locked_at" timestamp with time zone,
"sent_at" timestamp with time zone,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mail_accounts" ADD CONSTRAINT "mail_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mail_messages_due_idx" ON "mail_messages" USING btree ("status","next_attempt_at");--> statement-breakpoint
CREATE INDEX "mail_messages_information_idx" ON "mail_messages" USING btree ("information_id");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21
View File
@@ -120,6 +120,27 @@
"when": 1788692576315,
"tag": "0016_tiresome_korg",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789385157231,
"tag": "0017_regular_devos",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1789399747132,
"tag": "0018_lazy_big_bertha",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789401051538,
"tag": "0019_nostalgic_warstar",
"breakpoints": true
}
]
}
+3
View File
@@ -4,6 +4,9 @@ const nextConfig = {
reactStrictMode: true,
// Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile.
poweredByHeader: false,
// `nodemailer` charge net/tls/dns par des requires dynamiques : laissé au
// bundler, il se retrouve à moitié inliné et casse à l'exécution.
serverExternalPackages: ["nodemailer"],
experimental: {
// Les images d'entête/logo sont envoyées en data-URL via server action.
serverActions: { bodySizeLimit: "4mb" },
+21
View File
@@ -13,6 +13,7 @@
"drizzle-orm": "^0.45.2",
"next": "^15.5.25",
"next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"pg": "^8.23.0",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -21,6 +22,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.10",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
@@ -1220,6 +1222,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -1711,6 +1723,15 @@
}
}
},
"node_modules/nodemailer": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
+2
View File
@@ -20,6 +20,7 @@
"drizzle-orm": "^0.45.2",
"next": "^15.5.25",
"next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"pg": "^8.23.0",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -28,6 +29,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.10",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
+34 -2
View File
@@ -7,7 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter";
import { VitrineImage } from "@/components/VitrineImage";
import { PromoImage } from "@/components/PromoImage";
import { JsonLd } from "@/components/JsonLd";
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { getMemberContact, getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { getSession } from "@/lib/session";
import { telHref } from "@/lib/member-contact";
import {
NOINDEX,
breadcrumbJsonLd,
@@ -144,7 +146,16 @@ export default async function FicheAdherentPage({
const canonical = memberPath(member);
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
const [promos, baseUrl, session] = await Promise.all([
getMemberLivePromotions(memberId),
publicBaseUrl(),
getSession(),
]);
// Nom, prénom et ligne directe du référent : réservés aux visiteurs
// connectés. La requête n'est pas faite pour un visiteur anonyme, et ces
// champs restent hors du JSON-LD et des métadonnées de la fiche.
const contact = session?.user ? await getMemberContact(memberId) : null;
const contactTel = telHref(contact?.phone);
const accent = member.accent ?? "#E0A63C";
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
@@ -384,6 +395,27 @@ export default async function FicheAdherentPage({
<div className="practical-empty">Coordonnées non communiquées.</div>
)}
</div>
{contact && (
<div className="contact-referent">
<div className="contact-referent__label">
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
Contact adhérent
</div>
{contact.name && <div className="contact-referent__name">{contact.name}</div>}
{contact.phone &&
(contactTel ? (
<a className="contact-referent__phone" href={contactTel}>
{contact.phone}
</a>
) : (
<div className="contact-referent__phone">{contact.phone}</div>
))}
<div className="contact-referent__hint">
Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas
publiées sur le site.
</div>
</div>
)}
</section>
</div>
</section>
+4 -1
View File
@@ -2,10 +2,13 @@
import { useMemo, useState } from "react";
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
import type { MemberContact } from "@/lib/member-contact";
export type DirectoryMember = MemberCardData & {
hasPromo: boolean;
promoBadge: string | null;
/** Référent de l'adhérent, `null` tant que le visiteur n'est pas connecté. */
contact: MemberContact | null;
};
export function AnnuaireClient({
@@ -176,7 +179,7 @@ export function AnnuaireClient({
{list.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{list.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
))}
</div>
) : (
+8 -2
View File
@@ -13,7 +13,9 @@ import {
getCategoriesInUse,
getCategoryBySlug,
getLiveBadgesByMember,
getMemberContacts,
} from "@/lib/queries";
import { getSession } from "@/lib/session";
import {
NOINDEX,
breadcrumbJsonLd,
@@ -77,12 +79,16 @@ export default async function CategoriePage({ params }: { params: Params }) {
const category = await getCategoryBySlug(categorie);
if (!category) notFound();
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
const [rawMembers, badges, categoriesInUse, baseUrl, session] = await Promise.all([
getActiveMembersByCategory(category.id),
getLiveBadgesByMember(),
getCategoriesInUse(),
publicBaseUrl(),
getSession(),
]);
// Même règle que sur l'annuaire : le référent n'est lu que pour un visiteur
// connecté, et reste hors du JSON-LD (construit sur `members`).
const contacts = session?.user ? await getMemberContacts() : null;
const badgeByMember = new Map<number, string | null>();
for (const b of badges) {
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
@@ -148,7 +154,7 @@ export default async function CategoriePage({ params }: { params: Params }) {
{members.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{members.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
<MemberCard key={m.id} m={m} index={i} contact={contacts?.get(m.id) ?? null} />
))}
</div>
) : (
+17 -1
View File
@@ -8,7 +8,9 @@ import {
getAllCategories,
getCategoriesInUse,
getLiveBadgesByMember,
getMemberContacts,
} from "@/lib/queries";
import { getSession } from "@/lib/session";
import { CategoryLinks } from "@/components/CategoryLinks";
import { JsonLd } from "@/components/JsonLd";
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
@@ -32,14 +34,21 @@ export default async function AnnuairePage({
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([
getActiveMembersWithCategory(),
getAllCategories(),
getLiveBadgesByMember(),
publicBaseUrl(),
getCategoriesInUse(),
getSession(),
]);
// Coordonnées du référent : réservées aux visiteurs connectés. Pour un
// visiteur anonyme la requête n'est pas faite du tout, donc rien ne part
// dans le HTML ni vers le composant client.
const signedIn = Boolean(session?.user);
const contacts = signedIn ? await getMemberContacts() : null;
const badgeByMember = new Map<number, string | null>();
for (const b of badges) {
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
@@ -51,6 +60,7 @@ export default async function AnnuairePage({
...m,
hasPromo: badgeByMember.has(m.id),
promoBadge: badgeByMember.get(m.id) ?? null,
contact: contacts?.get(m.id) ?? null,
}));
return (
@@ -97,6 +107,12 @@ export default async function AnnuairePage({
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
</p>
{signedIn && (
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#9a8d72", maxWidth: 560 }}>
Vous êtes connecté : les coordonnées du référent de chaque adhérent (nom, prénom,
téléphone) sont affichées sur les fiches ci-dessous.
</p>
)}
</section>
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getInformation } from "@/lib/informations";
export const dynamic = "force-dynamic";
/**
* Sert l'image de couverture d'une information **publiée**, en octets.
*
* Elle est stockée en data-URI, ce qui convient au site mais pas aux e-mails :
* Gmail et Outlook suppriment les `<img src="data:">`. Cette route donne au
* gabarit une URL ordinaire, et évite au passage de recopier plusieurs
* méga-octets dans chaque ligne de la file d'attente.
*
* Seules les informations publiées répondent : un brouillon ne doit pas fuiter
* par une URL devinable.
*/
const DATA_URI = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/;
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: raw } = await params;
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return new NextResponse("Introuvable", { status: 404 });
const information = await getInformation(id);
if (!information || information.status !== "published" || !information.imageUrl) {
return new NextResponse("Introuvable", { status: 404 });
}
const match = DATA_URI.exec(information.imageUrl);
if (!match) return new NextResponse("Introuvable", { status: 404 });
const bytes = Buffer.from(match[2].replace(/\s+/g, ""), "base64");
return new NextResponse(bytes, {
headers: {
"Content-Type": match[1],
"Content-Length": String(bytes.length),
// L'image d'une information ne change pas : les clients mail peuvent la
// garder, et un message relu six mois plus tard l'affiche encore.
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800",
},
});
}
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
exchangeMailCode,
getActiveMailAccount,
getDecryptedAppSecret,
getMailAccount,
saveMailConnection,
setActiveMailProvider,
MAIL_PROVIDERS,
} from "@/lib/mail-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
import type { MailProvider } from "@/db/schema";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/boite-mail";
function back(request: Request, params: Record<string, string>) {
const url = new URL(SETTINGS, request.url);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = NextResponse.redirect(url);
// Le state a fait son office, quel que soit le résultat.
for (const provider of MAIL_PROVIDERS) response.cookies.delete(`plr_mail_oauth_${provider}`);
return response;
}
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { provider: raw } = await params;
const provider = raw as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") {
return back(request, { error: "Fournisseur inconnu." });
}
const url = new URL(request.url);
const failure = url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (failure) return back(request, { error: `Autorisation refusée : ${failure}` });
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = request.headers
.get("cookie")
?.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(`plr_mail_oauth_${provider}=`))
?.split("=")[1];
if (!code || !state || !expected || state !== expected) {
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
}
const account = await getMailAccount(provider);
const appSecret = await getDecryptedAppSecret(provider);
if (!account?.appId || !appSecret) {
return back(request, { error: "Identifiants d'application introuvables." });
}
const base = await publicBaseUrl();
if (!base) return back(request, { error: "Adresse publique du site introuvable." });
try {
const result = await exchangeMailCode(provider, account.appId, appSecret, code, base);
const userId = Number(session?.user.id);
await saveMailConnection({
provider,
...result,
connectedById: Number.isFinite(userId) ? userId : null,
});
// Première boîte connectée : elle devient l'expéditeur, sinon l'écran
// afficherait une connexion réussie sans qu'aucun message ne parte. Si un
// autre fournisseur expédie déjà, on ne le détrône pas dans son dos.
if (!(await getActiveMailAccount())) await setActiveMailProvider(provider);
return back(request, { connected: provider });
} catch (caught) {
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
return back(request, { error: message.slice(0, 400) });
}
}
@@ -0,0 +1,62 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
getDecryptedAppSecret,
getMailAccount,
mailAuthorizeUrl,
MAIL_PROVIDERS,
} from "@/lib/mail-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
import type { MailProvider } from "@/db/schema";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/boite-mail";
function back(request: Request, error: string) {
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
}
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { provider: raw } = await params;
const provider = raw as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") {
return back(request, "Fournisseur inconnu.");
}
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
// arrivé : c'est celle où le fournisseur nous renverra.
const base = await publicBaseUrl();
if (!base) {
return back(
request,
"Impossible de déterminer l'adresse publique du site : renseignez-la dans Backend › Réseaux sociaux."
);
}
const account = await getMailAccount(provider);
const appSecret = await getDecryptedAppSecret(provider);
if (!account?.appId || !appSecret) {
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
}
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
// de rappel forgée. Aucun jeton ne transite jamais par une URL.
const state = randomBytes(24).toString("hex");
const response = NextResponse.redirect(await mailAuthorizeUrl(provider, account.appId, state, base));
response.cookies.set(`plr_mail_oauth_${provider}`, state, {
httpOnly: true,
sameSite: "lax",
secure: new URL(base).protocol === "https:",
path: "/",
maxAge: 600,
});
return response;
}
+10 -2
View File
@@ -19,18 +19,21 @@ const TITLES: Record<string, [string, string]> = {
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
"/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"],
"/backend/informations": ["Informations", "Ce que l'association publie dans l'espace adhérent"],
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
"/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"],
"/backend/boite-mail": ["Boîte mail", "Brancher la boîte de l'association pour les envois du site"],
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
"/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"],
"/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"],
"/backend/espace/informations": ["Informations", "Les annonces de l'association"],
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
};
type Capability = Parameters<typeof can>[1];
type BadgeKey = "inbox" | "promos";
type BadgeKey = "inbox" | "promos" | "infos";
type NavItem = {
href: string;
@@ -72,6 +75,7 @@ const SECTIONS: NavSection[] = [
{
label: "Communication",
items: [
{ href: "/backend/informations", label: "Informations", icon: "news", capability: "manageInformations" },
{ href: "/backend/emails", label: "E-mails", icon: "emails", capability: "manageEmails" },
{ href: "/backend/reseaux", label: "Réseaux sociaux", icon: "social", capability: "manageSettings" },
],
@@ -80,6 +84,7 @@ const SECTIONS: NavSection[] = [
label: "Configuration",
items: [
{ href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" },
{ href: "/backend/boite-mail", label: "Boîte mail", icon: "mailbox", capability: "manageSettings" },
{ href: "/backend/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" },
{ href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" },
],
@@ -87,6 +92,7 @@ const SECTIONS: NavSection[] = [
{
label: "Côté adhérent",
items: [
{ href: "/backend/espace/informations", label: "Informations", icon: "news", badge: "infos" },
{ href: "/backend/espace", label: "Mon profil", icon: "space" },
{ href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" },
],
@@ -124,11 +130,13 @@ export function BackendShell({
user,
pendingCount,
inboxCount = 0,
infoCount = 0,
children,
}: {
user: { name: string; role: AppRole };
pendingCount: number;
inboxCount?: number;
infoCount?: number;
children: ReactNode;
}) {
const pathname = usePathname();
@@ -164,7 +172,7 @@ export function BackendShell({
return () => window.removeEventListener("keydown", onKey);
}, [menuOpen]);
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount };
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount, infos: infoCount };
const sections = SECTIONS.map((section) => ({
...section,
+632 -11
View File
@@ -16,6 +16,7 @@ import {
categories,
contactMessages,
imageConsents,
informations,
meetingRegistrations,
meetings,
members,
@@ -42,6 +43,32 @@ import { isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule";
import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish";
import { logActivity } from "@/lib/activity-log";
import {
MAIL_LABELS,
MAIL_PROVIDERS,
checkMailHealth,
disconnectMailAccount,
saveOAuthApp,
saveSmtpAccount,
setActiveMailProvider,
isMailConfigured,
} from "@/lib/mail-accounts";
import type { MailProvider } from "@/db/schema";
import { sendNow } from "@/lib/mailer";
import { cancelMailMessage, logSentMail, queueMails, retryMailMessage } from "@/lib/mail-outbox";
import { activeMemberRecipients, resolveAudience, selfRecipient, type Audience } from "@/lib/mail-recipients";
import {
buildCredentialsEmail,
buildGeneralEmail,
buildInformationEmail,
buildMeetingEmail,
type GeneralEmailContent,
type MeetingEmailTexts,
} from "@/lib/email-templates";
import { richTextToPlain } from "@/lib/rich-text";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { siteUrl } from "@/lib/social-accounts";
import { markInformationsRead as markRead } from "@/lib/informations";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
@@ -355,7 +382,55 @@ export async function publishPromo(formData: FormData) {
* qui a déclenché l'action. Un export de la base ne peut donc plus révéler de
* mot de passe utilisable.
*/
export type IssuedCredentials = { email: string; tempPassword: string };
export type IssuedCredentials = {
email: string;
tempPassword: string;
/** Faux si aucune boîte n'est configurée ou si le fournisseur a refusé. */
mailed: boolean;
mailError?: string;
};
/**
* Envoie les identifiants au destinataire, tout de suite.
*
* **Jamais par la file d'attente** : `mail_messages.html` est stocké en base,
* et le mot de passe temporaire ne doit exister que le temps de l'action. Seule
* une trace sans contenu est journalisée.
*
* L'envoi est un plus, jamais un point de rupture : en cas d'échec, le mot de
* passe reste affiché à l'écran comme avant, et l'appelant en est informé.
*/
async function deliverCredentials(
input: { name: string; email: string; tempPassword: string; intro: string },
actorId: number | null
): Promise<{ mailed: boolean; mailError?: string }> {
try {
const settings = await getSiteSettings();
const { subject, html } = buildCredentialsEmail(
{ name: input.name, email: input.email, tempPassword: input.tempPassword, intro: input.intro },
await siteUrl(),
emailBrand(settings)
);
const result = await sendNow({
to: input.email,
toName: input.name,
subject,
html,
replyTo: settings.association_email || null,
});
await logSentMail({
kind: "credentials",
toAddress: input.email,
subject,
createdById: actorId,
result: result.ok ? { ok: true } : { ok: false, reason: result.reason },
});
return result.ok ? { mailed: true } : { mailed: false, mailError: result.reason };
} catch (error) {
// Un envoi raté ne doit jamais faire échouer la création du compte.
return { mailed: false, mailError: error instanceof Error ? error.message : "Envoi impossible" };
}
}
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
@@ -394,7 +469,7 @@ async function resolveMemberTags(
export async function addMember(
formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole();
const { role, userId } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim();
@@ -430,10 +505,15 @@ export async function addMember(
await logActivity(`Nouvel adhérent ajouté : <strong>${name}</strong>`, "#2C6FB3");
const delivery = await deliverCredentials(
{ name, email, tempPassword, intro: "Votre compte adhérent Plein R est ouvert. Voici de quoi vous connecter à votre espace : fiche publique, promotions et informations de l'association." },
userId
);
revalidatePath("/backend/adherents");
revalidatePath("/backend");
revalidatePath("/");
return { memberId: newMember.id, email, tempPassword };
return { memberId: newMember.id, email, tempPassword, ...delivery };
}
// Crée des comptes de connexion pour les adhérents existants qui n'en ont pas
@@ -441,7 +521,7 @@ export async function addMember(
// reçoit un mot de passe temporaire à changer à la première connexion. Les
// identifiants sont renvoyés pour un affichage unique : rien n'est conservé.
export async function createMissingMemberAccounts(): Promise<(IssuedCredentials & { name: string })[]> {
const { role } = await requireRole();
const { role, userId: actorId } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const allMembers = await db
@@ -470,7 +550,11 @@ export async function createMissingMemberAccounts(): Promise<(IssuedCredentials
mustChangePassword: true,
});
takenEmails.add(email);
created.push({ name: m.name, email, tempPassword });
const delivery = await deliverCredentials(
{ name: m.name, email, tempPassword, intro: "Votre compte adhérent Plein R est ouvert. Voici de quoi vous connecter à votre espace : fiche publique, promotions et informations de l'association." },
actorId
);
created.push({ name: m.name, email, tempPassword, ...delivery });
}
if (created.length > 0) {
@@ -483,7 +567,7 @@ export async function createMissingMemberAccounts(): Promise<(IssuedCredentials
// Réinitialise le mot de passe d'un adhérent : le nouveau mot de passe
// temporaire est renvoyé pour un affichage unique, puis oublié.
export async function resetMemberPassword(formData: FormData): Promise<IssuedCredentials | undefined> {
const { role } = await requireRole();
const { role, userId } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const memberId = Number(formData.get("memberId"));
@@ -503,8 +587,13 @@ export async function resetMemberPassword(formData: FormData): Promise<IssuedCre
})
.where(eq(users.id, u.id));
const delivery = await deliverCredentials(
{ name: u.name, email: u.email, tempPassword, intro: "Le mot de passe de votre compte Plein R vient d'être réinitialisé par l'association. Vos sessions ouvertes ont été fermées." },
userId
);
revalidatePath(`/backend/adherents/${memberId}`);
return { email: u.email, tempPassword };
return { email: u.email, tempPassword, ...delivery };
}
export async function updateMember(formData: FormData) {
@@ -525,6 +614,9 @@ export async function updateMember(formData: FormData) {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
categoryId,
city,
address: String(formData.get("address") ?? "").trim() || null,
@@ -543,6 +635,7 @@ export async function updateMember(formData: FormData) {
revalidatePath("/backend/adherents");
revalidatePath("/adherents/[id]", "page");
revalidatePath("/annuaire");
revalidatePath("/");
}
@@ -566,7 +659,7 @@ export async function deleteMember(formData: FormData) {
export async function inviteAdmin(
formData: FormData
): Promise<IssuedCredentials | ActionError | undefined> {
const { role } = await requireRole();
const { role, userId } = await requireRole();
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim();
@@ -591,9 +684,14 @@ export async function inviteAdmin(
mustChangePassword: true,
});
const delivery = await deliverCredentials(
{ name, email, tempPassword, intro: "Un accès à l'administration du site Plein R vient d'être ouvert à votre nom. Voici de quoi vous connecter." },
userId
);
await logActivity(`<strong>${name}</strong> a été invité comme ${roleLabel}`, "#2C6FB3");
revalidatePath("/backend/administrateurs");
return { email, tempPassword };
return { email, tempPassword, ...delivery };
}
export async function removeAdmin(formData: FormData) {
@@ -1095,6 +1193,9 @@ export async function updateOwnProfile(formData: FormData) {
.set({
name,
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
description,
address: String(formData.get("address") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
@@ -1124,7 +1225,7 @@ export async function updateOwnProfile(formData: FormData) {
export async function approveMembershipRequest(
formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole();
const { role, userId } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
@@ -1175,11 +1276,16 @@ export async function approveMembershipRequest(
await db.update(membershipRequests).set({ status: "approved" }).where(eq(membershipRequests.id, id));
await logActivity(`Demande approuvée : adhérent <strong>${req.name}</strong> créé`, "#1f8a5b");
const delivery = await deliverCredentials(
{ name: req.name, email, tempPassword, intro: "Votre demande d'adhésion a été acceptée : bienvenue chez Plein R. Voici de quoi vous connecter à votre espace adhérent." },
userId
);
revalidatePath("/backend/demandes");
revalidatePath("/backend/adherents");
revalidatePath("/backend");
revalidatePath("/");
return { memberId: newMember.id, email, tempPassword };
return { memberId: newMember.id, email, tempPassword, ...delivery };
}
// ---- Inbox: membership requests + contact messages ----
@@ -1305,3 +1411,518 @@ export async function disconnectSocial(formData: FormData) {
export async function doSignOut() {
await signOut({ redirectTo: "/" });
}
// ---- Informations de l'association ----
function revalidateInformationPaths() {
revalidatePath("/backend/informations");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/informations");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend");
}
async function requireInformations() {
const access = await requireRole();
if (!can(access.role, "manageInformations")) throw new Error("Accès refusé");
return access;
}
/**
* Une seule information épinglée à la fois. Appliqué ici plutôt que par un
* index partiel : l'écriture est réservée au staff, il n'y a pas de course
* réelle, et drizzle-kit ne génère pas les index conditionnels.
*/
async function unpinOthers(keepId: number) {
await db
.update(informations)
.set({ pinned: false })
.where(and(eq(informations.pinned, true), ne(informations.id, keepId)));
}
export type SavedInformation = { id: number };
export async function saveInformation(
formData: FormData
): Promise<SavedInformation | ActionError> {
const { userId } = await requireInformations();
const id = Number(formData.get("id") ?? 0) || null;
const title = asString(formData, "title");
const body = asString(formData, "body");
if (!title) return { error: "Donnez un titre à cette information." };
if (!body) return { error: "Le message est vide." };
let imageUrl: string | null = null;
try {
imageUrl = asImageDataUri(formData, "imageUrl");
} catch (error) {
// Refus de saisie attendu : on le renvoie au formulaire, qui garde le reste.
return { error: error instanceof Error ? error.message : "Image refusée." };
}
const pinned = formData.get("pinned") === "on";
const now = new Date();
if (id) {
const [updated] = await db
.update(informations)
.set({ title, body, imageUrl, pinned, updatedAt: now })
.where(eq(informations.id, id))
.returning({ id: informations.id });
if (!updated) return { error: "Cette information n'existe plus." };
if (pinned) await unpinOthers(updated.id);
revalidateInformationPaths();
return { id: updated.id };
}
const [created] = await db
.insert(informations)
.values({ title, body, imageUrl, pinned, authorId: userId, createdAt: now, updatedAt: now })
.returning({ id: informations.id });
if (pinned) await unpinOthers(created.id);
await logActivity(`Information rédigée : <strong>${title}</strong>`, "#6FB0C6");
revalidateInformationPaths();
return { id: created.id };
}
/**
* Les refus de diffusion passent par une redirection avec message plutôt que
* par un retour : le bouton « Publier » est un `<form action>` de composant
* serveur, qui ne peut rien renvoyer au navigateur. Même idiome que
* `saveSitePublicUrl`.
*/
function informationRedirect(message: string, tone: "error" | "ok"): never {
redirect(`/backend/informations?${tone}=${encodeURIComponent(message)}`);
}
export async function publishInformation(formData: FormData) {
const { role, userId } = await requireInformations();
const id = Number(formData.get("id") ?? 0);
if (!id) return;
const [published] = await db
.update(informations)
.set({ status: "published", publishedAt: new Date(), updatedAt: new Date() })
.where(and(eq(informations.id, id), eq(informations.status, "draft")))
.returning({
id: informations.id,
title: informations.title,
body: informations.body,
imageUrl: informations.imageUrl,
emailSentAt: informations.emailSentAt,
});
if (!published) return;
await logActivity(`Information publiée : <strong>${published.title}</strong>`, "#1F8A5B");
revalidateInformationPaths();
// La diffusion est un choix, pas une conséquence de la publication. Elle est
// en outre réservée à qui peut déjà écrire aux adhérents : un modérateur
// publie, il ne diffuse pas.
if (formData.get("sendEmail") !== "on" || !can(role, "manageEmails")) return;
// Garde anti-double-diffusion, sur le modèle de `publishPromoShares` : une
// information dépubliée puis republiée ne repart pas une seconde fois.
if (published.emailSentAt) return;
const base = await siteUrl();
if (!base) {
// Sans adresse publique, le logo et tous les liens du message seraient
// cassés : mieux vaut ne rien envoyer et le dire.
informationRedirect(
"Information publiée, mais non diffusée : l'adresse publique du site n'est pas renseignée (Backend › Réseaux sociaux).",
"error"
);
}
const settings = await getSiteSettings();
const { subject, html } = buildInformationEmail(
{ id: published.id, title: published.title, body: published.body, hasImage: Boolean(published.imageUrl) },
base,
emailBrand(settings)
);
const text = `${published.title}\n\n${richTextToPlain(published.body)}\n\n${base}/backend/espace/informations`;
const recipients = await activeMemberRecipients();
if (recipients.length === 0) {
informationRedirect("Information publiée, mais aucun adhérent actif n'a d'adresse e-mail utilisable.", "error");
}
// Une ligne par destinataire : aucune adresse n'est visible des autres.
await queueMails(
recipients.map((recipient) => ({
kind: "information" as const,
toAddress: recipient.email,
toName: recipient.name,
subject,
html,
text,
replyTo: settings.association_email || null,
informationId: published.id,
memberId: recipient.memberId,
createdById: userId,
}))
);
await db.update(informations).set({ emailSentAt: new Date() }).where(eq(informations.id, published.id));
await logActivity(
`Information <strong>${published.title}</strong> diffusée à ${recipients.length} adhérent(s)`,
"#2C6FB3"
);
revalidateInformationPaths();
informationRedirect(
`Information publiée et mise en file pour ${recipients.length} adhérent(s).`,
"ok"
);
}
export async function unpublishInformation(formData: FormData) {
await requireInformations();
const id = Number(formData.get("id") ?? 0);
if (!id) return;
// Le retrait dépingle : une information invisible ne doit pas garder la
// place de tête au retour en ligne.
const [hidden] = await db
.update(informations)
.set({ status: "draft", pinned: false, updatedAt: new Date() })
.where(eq(informations.id, id))
.returning({ title: informations.title });
if (!hidden) return;
await logActivity(`Information retirée : <strong>${hidden.title}</strong>`, "#9A6638");
revalidateInformationPaths();
}
export async function toggleInformationPin(formData: FormData) {
await requireInformations();
const id = Number(formData.get("id") ?? 0);
if (!id) return;
const [current] = await db
.select({ pinned: informations.pinned })
.from(informations)
.where(eq(informations.id, id));
if (!current) return;
const pinned = !current.pinned;
await db.update(informations).set({ pinned, updatedAt: new Date() }).where(eq(informations.id, id));
if (pinned) await unpinOthers(id);
revalidateInformationPaths();
}
export async function deleteInformation(formData: FormData) {
await requireInformations();
const id = Number(formData.get("id") ?? 0);
if (!id) return;
// Les lignes de lecture partent en cascade avec l'information.
await db.delete(informations).where(eq(informations.id, id));
revalidateInformationPaths();
}
/**
* Marque des informations comme lues pour l'utilisateur courant.
*
* Appelée par un petit composant client après affichage, plutôt qu'au rendu de
* la page : l'écriture et le compteur de la barre latérale vivent dans deux
* composants serveur distincts, dont l'ordre de rendu n'est pas garanti. En
* passant par une action, les deux pastilles retombent ensemble.
*/
export async function markInformationsRead(ids: number[]) {
const session = await getSession();
const userId = Number(session?.user.id);
if (!Number.isFinite(userId)) return;
const clean = [...new Set(ids.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0))];
if (clean.length === 0) return;
await markRead(userId, clean);
revalidatePath("/backend/espace/informations");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend");
}
// ---- Boîte mail de l'association ----
async function requireMailSettings() {
const access = await requireRole();
if (!can(access.role, "manageSettings")) throw new Error("Accès refusé");
return access;
}
/** `redirect` lève : le type `never` évite d'avoir à rassurer TypeScript après. */
function mailRedirect(message: string, tone: "error" | "ok"): never {
redirect(`/backend/boite-mail?${tone}=${encodeURIComponent(message)}`);
}
export async function saveMailApp(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") return;
const appId = asString(formData, "appId");
if (!appId) mailRedirect("L'identifiant de l'application est requis.", "error");
// Champ secret laissé vide = on garde celui déjà enregistré.
const appSecret = asString(formData, "appSecret") || null;
try {
await saveOAuthApp(provider, appId, appSecret);
} catch (error) {
mailRedirect(error instanceof Error ? error.message : "Enregistrement impossible.", "error");
}
revalidatePath("/backend/boite-mail");
}
export async function saveMailSmtp(formData: FormData) {
await requireMailSettings();
const host = asString(formData, "smtpHost");
const user = asString(formData, "smtpUser");
const port = Number(formData.get("smtpPort") ?? 465) || 465;
if (!host || !user) mailRedirect("Le serveur et l'identifiant sont requis.", "error");
try {
await saveSmtpAccount({
host,
port: Math.min(65535, Math.max(1, port)),
secure: formData.get("smtpSecure") === "on",
user,
password: asString(formData, "smtpPassword") || null,
fromAddress: asString(formData, "fromAddress") || user,
fromName: asString(formData, "fromName"),
});
} catch (error) {
mailRedirect(error instanceof Error ? error.message : "Enregistrement impossible.", "error");
}
revalidatePath("/backend/boite-mail");
mailRedirect("Réglages SMTP enregistrés.", "ok");
}
export async function setMailProvider(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider)) return;
await setActiveMailProvider(provider);
await logActivity(`Boîte mail : expédition via <strong>${MAIL_LABELS[provider]}</strong>`, "#2C6FB3");
revalidatePath("/backend/boite-mail");
}
export async function disconnectMail(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider)) return;
await disconnectMailAccount(provider);
await logActivity(`Boîte mail déconnectée : <strong>${MAIL_LABELS[provider]}</strong>`, "#d8472b");
revalidatePath("/backend/boite-mail");
}
/**
* Envoi de contrôle vers sa propre adresse. Direct et non mis en file : c'est
* précisément le verdict immédiat qu'on cherche.
*/
export async function sendMailTest() {
const { userId, name } = await requireMailSettings();
const recipient = userId ? await selfRecipient(userId) : null;
if (!recipient) mailRedirect("Votre compte n'a pas d'adresse e-mail utilisable.", "error");
const settings = await getSiteSettings();
const base = await siteUrl();
const { subject, html } = buildGeneralEmail(
{
subject: `Test d'envoi — ${settings.association_name}`,
kicker: settings.association_name,
title: "La boîte mail répond",
body: `Bonjour ${name},\n\nCe message confirme que le site sait expédier depuis la boîte de l'association.\n\nSi vous le recevez, les mots de passe temporaires, les invitations aux rencontres et les informations diffusées partiront de la même façon.`,
buttonLabel: "",
buttonUrl: "",
signature: `L'équipe de ${settings.association_name}`,
},
base,
emailBrand(settings)
);
const result = await sendNow({
to: recipient.email,
toName: recipient.name,
subject,
html,
replyTo: settings.association_email || null,
});
await logSentMail({
kind: "test",
toAddress: recipient.email,
subject,
createdById: userId,
result: result.ok ? { ok: true } : { ok: false, reason: result.reason },
});
revalidatePath("/backend/boite-mail");
mailRedirect(
result.ok ? `Message de test envoyé à ${recipient.email}.` : `Échec de l'envoi : ${result.reason}`,
result.ok ? "ok" : "error"
);
}
/** Contrôle la santé des trois fournisseurs à la demande. */
export async function checkMailProvider(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider)) return;
await checkMailHealth(provider);
revalidatePath("/backend/boite-mail");
}
export async function retryMail(formData: FormData) {
await requireMailSettings();
const id = Number(formData.get("id") ?? 0);
if (id) await retryMailMessage(id);
revalidatePath("/backend/boite-mail");
}
export async function cancelMail(formData: FormData) {
await requireMailSettings();
const id = Number(formData.get("id") ?? 0);
if (id) await cancelMailMessage(id);
revalidatePath("/backend/boite-mail");
}
// ---- Envois groupés ----
async function requireEmails() {
const access = await requireRole();
if (!can(access.role, "manageEmails")) throw new Error("Accès refusé");
return access;
}
export type QueuedBroadcast = { queued: number; audience: string };
/**
* Prépare un envoi groupé.
*
* Le message est construit **une seule fois** puis recopié par destinataire :
* une ligne chacun, jamais de copie partagée. Rien ne part dans la requête —
* la file est vidée par la boucle de fond, sinon la page attendrait autant
* d'allers-retours que d'adhérents.
*/
async function queueBroadcast(
message: { subject: string; html: string; text?: string | null },
audience: Audience,
meta: { kind: "studio" | "meeting"; meetingId?: number | null; userId: number | null }
): Promise<QueuedBroadcast | ActionError> {
const recipients = await resolveAudience(audience);
if (recipients.length === 0) {
return { error: "Aucun destinataire n'a d'adresse e-mail utilisable pour cette sélection." };
}
if (!(await isMailConfigured())) {
return { error: "Aucune boîte mail n'est configurée : voir Configuration › Boîte mail." };
}
const settings = await getSiteSettings();
await queueMails(
recipients.map((recipient) => ({
kind: meta.kind,
toAddress: recipient.email,
toName: recipient.name,
subject: message.subject,
html: message.html,
text: message.text ?? null,
replyTo: settings.association_email || null,
meetingId: meta.meetingId ?? null,
memberId: recipient.memberId,
createdById: meta.userId,
}))
);
revalidatePath("/backend/boite-mail");
return { queued: recipients.length, audience: describeAudience(audience) };
}
function describeAudience(audience: Audience): string {
if (audience.kind === "category") return "une catégorie de métier";
if (audience.kind === "members") return "une sélection d'adhérents";
if (audience.kind === "meeting") return "les inscrits à la rencontre";
return "tous les adhérents actifs";
}
/** Envoi de contrôle vers sa propre adresse : direct, pour un verdict immédiat. */
export async function sendSelfTest(
message: { subject: string; html: string; text?: string | null }
): Promise<QueuedBroadcast | ActionError> {
const { userId } = await requireEmails();
const recipient = userId ? await selfRecipient(userId) : null;
if (!recipient) return { error: "Votre compte n'a pas d'adresse e-mail utilisable." };
const settings = await getSiteSettings();
const result = await sendNow({
to: recipient.email,
toName: recipient.name,
subject: message.subject,
html: message.html,
text: message.text ?? null,
replyTo: settings.association_email || null,
});
await logSentMail({
kind: "test",
toAddress: recipient.email,
subject: message.subject,
createdById: userId,
result: result.ok ? { ok: true } : { ok: false, reason: result.reason },
});
if (!result.ok) return { error: result.reason };
return { queued: 1, audience: `votre adresse (${recipient.email})` };
}
export async function sendStudioEmail(input: {
content: GeneralEmailContent;
audience: Audience;
}): Promise<QueuedBroadcast | ActionError> {
const { userId } = await requireEmails();
const settings = await getSiteSettings();
const base = await siteUrl();
if (!base) {
return { error: "Renseignez d'abord l'adresse publique du site (Backend › Réseaux sociaux)." };
}
const { subject, html } = buildGeneralEmail(input.content, base, emailBrand(settings));
return queueBroadcast({ subject, html, text: input.content.body }, input.audience, { kind: "studio", userId });
}
export async function sendMeetingInvitations(input: {
meetingId: number;
texts: MeetingEmailTexts;
audience: Audience;
}): Promise<QueuedBroadcast | ActionError> {
const { userId } = await requireEmails();
const settings = await getSiteSettings();
const base = await siteUrl();
if (!base) {
return { error: "Renseignez d'abord l'adresse publique du site (Backend › Réseaux sociaux)." };
}
const [meeting] = await db.select().from(meetings).where(eq(meetings.id, input.meetingId));
if (!meeting) return { error: "Cette rencontre n'existe plus." };
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(meetingRegistrations)
.where(eq(meetingRegistrations.meetingId, meeting.id));
const { subject, html, registrationUrl } = buildMeetingEmail(
{
id: meeting.id,
title: meeting.title,
startsAt: meeting.startsAt.toISOString(),
location: meeting.location,
description: meeting.description,
capacity: meeting.capacity,
registered: count?.total ?? 0,
},
input.texts,
base,
emailBrand(settings)
);
return queueBroadcast(
{ subject, html, text: `${input.texts.intro}\n\n${registrationUrl}` },
input.audience,
{ kind: "meeting", meetingId: meeting.id, userId }
);
}
@@ -13,7 +13,7 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
if (issued) {
return (
<OneTimeCredentials
items={issued.map((i) => ({ label: i.label, email: i.email, tempPassword: i.tempPassword }))}
items={issued}
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
/>
);
@@ -25,7 +25,8 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
setPending(true);
try {
const created = await createMissingMemberAccounts();
setIssued(created.map((c) => ({ label: c.name, email: c.email, tempPassword: c.tempPassword })));
// On propage tout : l'état d'envoi accompagne chaque ligne.
setIssued(created.map((c) => ({ ...c, label: c.name })));
router.refresh();
} finally {
setPending(false);
+7
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { ImageField } from "@/components/ImageField";
import { MemberContactFields } from "@/components/MemberContactFields";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { communeOptions } from "@/lib/communes";
@@ -132,6 +133,12 @@ export default async function EditMemberPage({
</div>
</div>
<MemberContactFields
firstName={member.contactFirstName}
lastName={member.contactLastName}
phone={member.contactPhone}
/>
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
+399
View File
@@ -0,0 +1,399 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
MAIL_LABELS,
MAIL_PROVIDERS,
getMailAccounts,
mailHealthCached,
type MailHealth,
} from "@/lib/mail-accounts";
import { recentMails } from "@/lib/mail-outbox";
import { publicBaseUrl } from "@/lib/social-accounts";
import { getSiteSettings } from "@/lib/site-settings";
import type { MailAccount, MailProvider } from "@/db/schema";
import {
cancelMail,
checkMailProvider,
disconnectMail,
retryMail,
saveMailApp,
saveMailSmtp,
sendMailTest,
setMailProvider,
} from "../actions";
export const dynamic = "force-dynamic";
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "18px 20px", marginBottom: 16 };
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const ghost: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12.5, padding: "8px 13px", borderRadius: 9, cursor: "pointer", textDecoration: "none", display: "inline-block" };
const primary: CSSProperties = { ...ghost, border: "none", background: "#13324F", color: "#fff", fontWeight: 800 };
const danger: CSSProperties = { ...ghost, color: "#d8472b", borderColor: "#e0c3bb" };
const pill: CSSProperties = { borderRadius: 999, padding: "5px 11px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
const BRAND: Record<MailProvider, { color: string; mark: string }> = {
google: { color: "#1a73e8", mark: "G" },
microsoft: { color: "#0f6cbd", mark: "M" },
smtp: { color: "#6c6150", mark: "@" },
};
const HELP: Record<"google" | "microsoft", { portal: string; steps: string[]; caution: string }> = {
google: {
portal: "https://console.cloud.google.com/apis/credentials",
steps: [
"Créez un projet, puis un identifiant OAuth de type « Application Web ».",
"Activez l'API Gmail dans la bibliothèque d'API du projet.",
"Sur l'écran de consentement, ajoutez la portée .../auth/gmail.send.",
"Déclarez l'adresse de retour ci-dessous comme URI de redirection autorisé.",
"Collez l'ID client et le code secret du client ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Tant que l'application reste en mode « Test », Google fait expirer l'autorisation au bout de 7 jours et l'envoi s'arrête sans prévenir. Passez-la « En production » (un écran d'avertissement subsiste, sans conséquence), ou déclarez-la « Interne » si l'association a un compte Google Workspace.",
},
microsoft: {
portal: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
steps: [
"Inscrivez une application, en autorisant « comptes dans un annuaire quelconque et comptes Microsoft personnels ».",
"Ajoutez l'adresse de retour ci-dessous comme URI de redirection de type « Web ».",
"Dans les autorisations d'API, ajoutez les permissions déléguées Mail.Send, User.Read et offline_access.",
"Créez un secret client et notez sa valeur : elle n'est affichée qu'une fois.",
"Collez l'ID d'application et le secret ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Microsoft a désactivé l'authentification par simple mot de passe (SMTP AUTH) sur les boîtes outlook.com et hotmail.com : cette connexion par bouton est le seul chemin fiable pour ces adresses.",
},
};
function Banner({ tone, children }: { tone: "error" | "ok" | "warn"; children: ReactNode }) {
const palette = {
error: { background: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
ok: { background: "#e6f4ec", border: "#c4e2d1", color: "#1f8a5b" },
warn: { background: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
}[tone];
return (
<div role="status" style={{ ...palette, border: `1px solid ${palette.border}`, borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{children}
</div>
);
}
function StatusChip({ account, health }: { account: MailAccount | undefined; health: MailHealth | null }) {
if (!account) return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
if (health && !health.ok) return <span style={{ ...pill, background: "#fbe9e6", color: "#d8472b" }}>Ne répond plus</span>;
if (account.provider !== "smtp" && !account.fromAddress) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non connectée</span>;
}
if (account.provider === "smtp" && !account.smtpHost) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
}
return <span style={{ ...pill, background: "#e6f4ec", color: "#1f8a5b" }}>Connectée</span>;
}
function formatMoment(value: Date) {
return new Date(value).toLocaleString("fr-FR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
const MAIL_STATUS: Record<string, { label: string; background: string; color: string }> = {
queued: { label: "En file", background: "#eaf0f6", color: "#2C6FB3" },
sending: { label: "En cours", background: "#eaf0f6", color: "#2C6FB3" },
sent: { label: "Remis", background: "#e6f4ec", color: "#1f8a5b" },
failed: { label: "Échec", background: "#fbe9e6", color: "#d8472b" },
cancelled: { label: "Annulé", background: "#f1efe7", color: "#a99c82" },
};
/**
* Boîte mail de l'association.
*
* Même structure que Backend › Réseaux sociaux, et mêmes règles : aucun secret
* ne descend vers le navigateur — le champ mot de passe part vide et un champ
* vide conserve la valeur enregistrée.
*/
export default async function BoiteMailPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; ok?: string; connected?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const { error, ok, connected } = await searchParams;
const [accounts, settings, base, log] = await Promise.all([
getMailAccounts(),
getSiteSettings(),
publicBaseUrl(),
recentMails(20),
]);
const byProvider = new Map(accounts.map((account) => [account.provider, account]));
const health = Object.fromEntries(
await Promise.all(MAIL_PROVIDERS.map(async (provider) => [provider, await mailHealthCached(provider)] as const)),
) as Record<MailProvider, MailHealth | null>;
const active = accounts.find((account) => account.isActive) ?? null;
return (
<div>
{error && <Banner tone="error">{error}</Banner>}
{ok && <Banner tone="ok">{ok}</Banner>}
{connected && <Banner tone="ok">Boîte {MAIL_LABELS[connected as MailProvider] ?? connected} connectée.</Banner>}
{!active && (
<Banner tone="warn">
Aucune boîte n&apos;expédie pour l&apos;instant : les mots de passe temporaires restent affichés à
l&apos;écran, et aucune information ne part par e-mail.
</Banner>
)}
{MAIL_PROVIDERS.map((provider) => {
const account = byProvider.get(provider);
const verdict = health[provider];
const brand = BRAND[provider];
const isActive = account?.isActive ?? false;
const connectable = provider !== "smtp";
const usable = provider === "smtp" ? Boolean(account?.smtpHost) : Boolean(account?.fromAddress);
return (
<section
key={provider}
style={{ ...panel, ...(isActive ? { borderColor: "#b9d3c6", boxShadow: "inset 4px 0 0 #1f8a5b" } : {}) }}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: 13, alignItems: "center" }}>
<span
aria-hidden="true"
className="font-display"
style={{ width: 40, height: 40, borderRadius: 10, background: brand.color, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}
>
{brand.mark}
</span>
<div>
<div className="font-display" style={{ fontWeight: 700, fontSize: 16, color: "#26201a" }}>{MAIL_LABELS[provider]}</div>
<div style={{ color: "#9a8d72", fontSize: 12.5 }}>
{provider === "smtp"
? account?.smtpHost
? `${account.smtpHost} : ${account.smtpPort ?? 465} · ${account.smtpUser}`
: "Aucun serveur enregistré"
: account?.fromAddress
? `${account.fromAddress}${account.connectedAt ? ` · connectée le ${formatMoment(account.connectedAt)}` : ""}`
: "Aucun compte relié"}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 9, alignItems: "center" }}>
<StatusChip account={account} health={verdict} />
{usable && (
<form action={setMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" disabled={isActive} style={{ ...(isActive ? { ...ghost, borderColor: "#b9d3c6", color: "#1f8a5b", cursor: "default" } : ghost) }}>
{isActive ? "✓ Expédie les messages" : "Expédier par ici"}
</button>
</form>
)}
</div>
</div>
{verdict && !verdict.ok && (
<div style={{ marginTop: 13 }}>
<Banner tone="error">{verdict.reason.slice(0, 220)}</Banner>
</div>
)}
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
{provider === "smtp" ? (
<form action={saveMailSmtp}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
<label className="field-label">
Serveur
<input className="field" name="smtpHost" defaultValue={account?.smtpHost ?? ""} placeholder="smtp.ionos.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Port
<input className="field" name="smtpPort" type="number" min={1} max={65535} defaultValue={account?.smtpPort ?? 465} style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Identifiant
<input className="field" name="smtpUser" defaultValue={account?.smtpUser ?? ""} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 12 }}>
<label className="field-label">
Mot de passe
<input
className="field"
name="smtpPassword"
type="password"
autoComplete="new-password"
placeholder={account?.smtpPassword ? "Enregistré — laissez vide pour le conserver" : "Mot de passe de la boîte"}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Adresse d&apos;expédition
<input className="field" name="fromAddress" defaultValue={account?.fromAddress ?? ""} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Nom affiché
<input className="field" name="fromName" defaultValue={account?.fromName ?? settings.association_name} style={{ marginTop: 6 }} />
</label>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
<input type="checkbox" name="smtpSecure" defaultChecked={account?.smtpSecure ?? true} />
Connexion chiffrée dès l&apos;ouverture (port 465). Décochez pour STARTTLS (port 587).
</label>
<p className="field-hint" style={{ marginTop: 8 }}>
L&apos;adresse d&apos;expédition doit appartenir au domaine qui authentifie la connexion, sinon
SPF et DKIM échouent et les messages partent en indésirable.
</p>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
<button type="submit" style={primary}>Enregistrer</button>
</div>
</form>
) : (
<>
<form action={saveMailApp}>
<input type="hidden" name="provider" value={provider} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 12 }}>
<label className="field-label">
Identifiant de l&apos;application
<input className="field" name="appId" required defaultValue={account?.appId ?? ""} style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Secret de l&apos;application
<input
className="field"
name="appSecret"
type="password"
autoComplete="new-password"
placeholder={account?.appSecret ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
style={{ marginTop: 6 }}
/>
</label>
</div>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
<button type="submit" style={primary}>Enregistrer</button>
<Link
href={account?.appId ? `/api/mail/${provider}/connect` : "#"}
aria-disabled={!account?.appId}
style={{ ...ghost, ...(account?.appId ? {} : { opacity: 0.5, pointerEvents: "none" }) }}
>
{account?.fromAddress ? "Reconnecter la boîte" : "Connecter la boîte"}
</Link>
</div>
</form>
<details style={{ marginTop: 14 }}>
<summary style={{ cursor: "pointer", fontSize: 13, fontWeight: 700, color: "#9a6638" }}>
Comment obtenir ces identifiants ?
</summary>
<ol style={{ margin: "11px 0 0", paddingLeft: 20, color: "#6c6150", fontSize: 13, lineHeight: 1.7 }}>
{HELP[provider].steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<p style={{ margin: "11px 0 0", color: "#6c6150", fontSize: 13 }}>
Adresse de retour à déclarer :{" "}
<code style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 5, padding: "1px 6px", fontSize: 12.5 }}>
{base ? `${base}/api/mail/${provider}/callback` : "définissez d'abord l'URL publique du site"}
</code>
</p>
<p style={{ margin: "11px 0 0" }}>
<Link href={HELP[provider].portal} target="_blank" style={{ color: "#2C6FB3", fontSize: 13, fontWeight: 700 }}>
Ouvrir la console {provider === "google" ? "Google Cloud" : "Microsoft Entra"} ↗
</Link>
</p>
<div style={{ marginTop: 12 }}>
<Banner tone="warn">{HELP[provider].caution}</Banner>
</div>
</details>
</>
)}
{usable && (
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
<form action={checkMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={ghost}>Vérifier la connexion</button>
</form>
{connectable && account?.fromAddress && (
<form action={disconnectMail}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={danger}>Déconnecter</button>
</form>
)}
{verdict?.ok && <span style={{ alignSelf: "center", color: "#1f8a5b", fontSize: 12.5 }}>✓ {verdict.detail}</span>}
</div>
)}
</section>
);
})}
<section style={panel}>
<div style={eyebrow}>Envoi de contrôle</div>
<form action={sendMailTest} style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<button type="submit" disabled={!active} style={{ ...primary, ...(active ? {} : { opacity: 0.5 }) }}>
M&apos;envoyer un message de test
</button>
<span className="field-hint">
Part immédiatement vers votre propre adresse et affiche le verdict, sans passer par la file.
</span>
</form>
</section>
<section style={panel}>
<div style={eyebrow}>Journal d&apos;envoi</div>
{log.length === 0 ? (
<div style={{ color: "#a99c82", fontSize: 13 }}>Aucun message expédié pour l&apos;instant.</div>
) : (
<div className="table-scroll">
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12.8 }}>
<thead>
<tr>
{["Quand", "Destinataire", "Objet", "État", ""].map((header) => (
<th key={header} style={{ textAlign: "left", fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, padding: "0 10px 8px 0", whiteSpace: "nowrap" }}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{log.map((message) => {
const status = MAIL_STATUS[message.status] ?? MAIL_STATUS.queued;
return (
<tr key={message.id}>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068", whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums" }}>
{formatMoment(message.createdAt)}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#26201a", fontWeight: 600 }}>{message.toAddress}</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068" }}>
{message.subject}
{message.error && <div style={{ color: "#d8472b", fontSize: 11.5, marginTop: 3 }}>{message.error.slice(0, 160)}</div>}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6" }}>
<span style={{ ...pill, background: status.background, color: status.color }}>{status.label}</span>
</td>
<td style={{ padding: "9px 0", borderTop: "1px solid #f0e8d6", whiteSpace: "nowrap" }}>
{message.status === "failed" && (
<form action={retryMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Réessayer</button>
</form>
)}
{message.status === "queued" && (
<form action={cancelMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Annuler</button>
</form>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+19 -10
View File
@@ -2,21 +2,30 @@ import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { EmailCreator } from "@/components/EmailCreator";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { audienceOptions } from "@/lib/mail-recipients";
import { isMailConfigured } from "@/lib/mail-accounts";
import { siteUrl } from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
export default async function EmailsPage() {
const session = await getSession();
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
const settings = await getSiteSettings();
const brand = {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
return <EmailCreator brand={brand} />;
const [settings, choices, mailReady, base] = await Promise.all([
getSiteSettings(),
audienceOptions(),
isMailConfigured(),
siteUrl(),
]);
return (
<EmailCreator
brand={emailBrand(settings)}
choices={choices}
mailReady={mailReady}
siteUrl={base}
/>
);
}
+12 -4
View File
@@ -5,6 +5,7 @@ function initialsOf(name: string) {
}
const TABS = [
{ key: "informations", href: "/backend/espace/informations", label: "Informations" },
{ key: "profil", href: "/backend/espace", label: "Mon profil" },
{ key: "promotions", href: "/backend/espace/promotions", label: "Mes promotions" },
] as const;
@@ -12,9 +13,12 @@ const TABS = [
export type EspaceTab = (typeof TABS)[number]["key"];
/**
* Bandeau de l'espace adhérent et onglets Profil / Promotions : deux pages
* distinctes pour que la fiche publique et la vie des promotions ne se
* mélangent pas dans une seule longue page.
* Bandeau de l'espace adhérent et ses onglets. Trois pages distinctes pour que
* les annonces de l'association, la fiche publique et la vie des promotions ne
* se mélangent pas dans une seule longue page.
*
* Les compteurs sont passés par chaque page : sans cela ils disparaîtraient
* en changeant d'onglet.
*/
export function EspaceHeader({
memberName,
@@ -22,6 +26,7 @@ export function EspaceHeader({
active,
publicPath,
promoBadge,
infoBadge,
}: {
memberName: string;
subtitle: string;
@@ -29,6 +34,8 @@ export function EspaceHeader({
publicPath?: string | null;
/** Nombre de promotions en ligne, affiché sur l'onglet. */
promoBadge?: number;
/** Nombre d'informations non lues. */
infoBadge?: number;
}) {
return (
<div style={{ marginBottom: 22 }}>
@@ -49,10 +56,11 @@ export function EspaceHeader({
<nav className="espace-tabs" aria-label="Sections de l'espace adhérent">
{TABS.map((tab) => {
const isActive = tab.key === active;
const badge = tab.key === "promotions" ? promoBadge : tab.key === "informations" ? infoBadge : 0;
return (
<Link key={tab.key} href={tab.href} className={`espace-tab${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
{tab.label}
{tab.key === "promotions" && promoBadge ? <span className="espace-tab__badge">{promoBadge}</span> : null}
{badge ? <span className="espace-tab__badge">{badge}</span> : null}
</Link>
);
})}
@@ -0,0 +1,102 @@
import { and, eq, sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
import { memberPath } from "@/lib/seo";
import { getMemberFeed } from "@/lib/informations";
import { InformationCard } from "@/components/InformationCard";
import { MarkInformationsRead } from "@/components/MarkInformationsRead";
import { EspaceHeader } from "../EspaceHeader";
export const dynamic = "force-dynamic";
/**
* Espace adhérent, onglet Informations : ce que publie l'association.
*
* Les non-lues sont calculées **avant** le marquage, qui a lieu après le rendu
* (`MarkInformationsRead`) : les pastilles « Nouveau » sont donc visibles sur
* la visite où elles disparaissent.
*/
export default async function EspaceInformationsPage() {
const session = await getSession();
if (!session?.user) redirect("/login");
const userId = Number(session.user.id);
const memberId = session.user.memberId ?? null;
let memberName = session.user.name ?? "Adhérent";
let subtitle = "Espace adhérent";
let publicPath: string | null = null;
let livePromoCount = 0;
if (memberId) {
const [profile] = await db.select().from(members).where(eq(members.id, memberId));
if (profile) {
memberName = profile.name;
const [category] = profile.categoryId
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, profile.categoryId))
: [];
subtitle = ["Espace adhérent", category?.label, profile.city].filter(Boolean).join(" · ");
if (profile.status === "active") publicPath = memberPath(profile);
}
const [live] = await db
.select({ total: sql<number>`count(*)::int` })
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
livePromoCount = live?.total ?? 0;
}
const feed = await getMemberFeed(userId);
const unreadIds = feed.filter((item) => item.unread).map((item) => item.id);
return (
<div>
<EspaceHeader
memberName={memberName}
subtitle={subtitle}
active="informations"
publicPath={publicPath}
promoBadge={livePromoCount}
infoBadge={unreadIds.length}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 12, flexWrap: "wrap", marginBottom: 16 }}>
<div>
<h2 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 19, color: "#26201a" }}>
Informations de l&apos;association
</h2>
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>
Annonces, convocations et rappels publiés par le bureau.
</div>
</div>
{unreadIds.length > 0 && (
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "6px 13px", fontSize: 12.5, fontWeight: 800 }}>
{unreadIds.length} non lue{unreadIds.length > 1 ? "s" : ""}
</span>
)}
</div>
{feed.length === 0 ? (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 28, color: "#a99c82", fontSize: 13.5 }}>
L&apos;association n&apos;a encore rien publié. Les annonces du bureau apparaîtront ici.
</div>
) : (
feed.map((item) => (
<InformationCard
key={item.id}
title={item.title}
body={item.body}
imageUrl={item.imageUrl}
publishedAt={item.publishedAt}
authorName={item.authorName}
pinned={item.pinned}
unread={item.unread}
/>
))
)}
<MarkInformationsRead ids={unreadIds} />
</div>
);
}
+11
View File
@@ -9,7 +9,9 @@ import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { MemberContactFields } from "@/components/MemberContactFields";
import { communeOptions } from "@/lib/communes";
import { unreadInformationCount } from "@/lib/informations";
import { saveImageConsent, updateOwnProfile } from "../actions";
import { EspaceHeader } from "./EspaceHeader";
@@ -20,6 +22,8 @@ export default async function EspacePage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
const fallbackName = session?.user.name ?? "Adhérent";
const userId = Number(session?.user.id);
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let memberName = fallbackName;
let subtitle = "Espace adhérent";
@@ -87,6 +91,7 @@ export default async function EspacePage() {
active="profil"
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
promoBadge={livePromoCount}
infoBadge={infoBadge}
/>
{memberId && (
@@ -167,6 +172,12 @@ export default async function EspacePage() {
</div>
</div>
<MemberContactFields
firstName={profile.contactFirstName}
lastName={profile.contactLastName}
phone={profile.contactPhone}
/>
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
+6 -1
View File
@@ -10,6 +10,7 @@ import { memberPath } from "@/lib/seo";
import { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
import { scheduleLabel } from "@/lib/promo-schedule";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { unreadInformationCount } from "@/lib/informations";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
@@ -51,6 +52,10 @@ function fmtDate(d: Date) {
export default async function EspacePromotionsPage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
// Le compteur suit l'adhérent d'onglet en onglet : sans cela il
// disparaîtrait en quittant le fil.
const userId = Number(session?.user.id);
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let memberName = session?.user.name ?? "Adhérent";
let memberLogoUrl: string | null = null;
let subtitle = "Espace adhérent · Promotions";
@@ -139,7 +144,7 @@ export default async function EspacePromotionsPage() {
</div>
)}
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} />
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} infoBadge={infoBadge} />
{/* Résumé : ce qui est visible par le public aujourd'hui */}
<div className="grid grid-3" style={{ gap: 12, marginBottom: 24 }}>
@@ -0,0 +1,195 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ImageField } from "@/components/ImageField";
import { richTextNodes } from "@/lib/rich-text";
import { saveInformation } from "../actions";
/**
* Rédaction d'une information.
*
* Pas d'éditeur WYSIWYG : un `<textarea>` et des boutons qui encadrent la
* sélection, plus un aperçu rendu par **le même** analyseur que le site
* (`richTextNodes`). L'aperçu est donc fidèle par construction, et rien de ce
* qui est saisi ne peut devenir du HTML.
*/
type Draft = {
id: number | null;
title: string;
body: string;
imageUrl: string | null;
pinned: boolean;
};
const TOOLS = [
{ label: "B", title: "Gras", before: "**", after: "**", sample: "texte en gras", strong: true },
{ label: "I", title: "Italique", before: "*", after: "*", sample: "texte en italique", italic: true },
{ label: "Titre", title: "Sous-titre", before: "## ", after: "", sample: "Sous-titre", line: true },
{ label: "• liste", title: "Liste à puces", before: "- ", after: "", sample: "premier point", line: true },
{ label: "1. liste", title: "Liste numérotée", before: "1. ", after: "", sample: "première étape", line: true },
{ label: "Lien", title: "Lien", before: "[", after: "](https://)", sample: "libellé du lien" },
] as const;
export function InformationForm({ draft }: { draft: Draft }) {
const router = useRouter();
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [title, setTitle] = useState(draft.title);
const [body, setBody] = useState(draft.body);
const [pinned, setPinned] = useState(draft.pinned);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Remonte le formulaire après une création : `ImageField` n'est pas contrôlé,
// seul un changement de clé le remet à vide.
const [round, setRound] = useState(0);
function applyTool(tool: (typeof TOOLS)[number]) {
const field = bodyRef.current;
if (!field) return;
const { selectionStart, selectionEnd } = field;
const selected = field.value.slice(selectionStart, selectionEnd) || tool.sample;
// `setRangeText` conserve la pile d'annulation du navigateur, ce qu'une
// réécriture complète de la valeur ferait perdre.
const insert = "line" in tool && tool.line && selectionStart > 0 && field.value[selectionStart - 1] !== "\n";
field.setRangeText(`${insert ? "\n" : ""}${tool.before}${selected}${tool.after}`, selectionStart, selectionEnd, "select");
field.focus();
setBody(field.value);
}
async function submit(formData: FormData) {
setSaving(true);
setError(null);
const result = await saveInformation(formData);
setSaving(false);
if (result && "error" in result) {
// Refus de saisie : le formulaire reste ouvert avec ce qui a été écrit.
setError(result.error);
return;
}
if (draft.id) {
router.push("/backend/informations");
} else {
setTitle("");
setBody("");
setPinned(false);
setRound((value) => value + 1);
}
router.refresh();
}
return (
<form key={round} action={submit} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "20px 22px" }}>
{draft.id && <input type="hidden" name="id" value={draft.id} />}
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 14 }}>
{draft.id ? "Modifier l'information" : "Nouvelle information"}
</div>
<label className="field-label" htmlFor="information-title">Titre</label>
<input
id="information-title"
className="field"
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={200}
placeholder="Assemblée générale — mardi 14 octobre, 19 h"
style={{ marginBottom: 15, fontWeight: 700 }}
/>
<label className="field-label" htmlFor="information-body">Message</label>
<div
style={{
display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap",
border: "1px solid #ddd2bb", borderBottom: "none", borderRadius: "10px 10px 0 0",
background: "#fff", padding: "6px 7px",
}}
>
{TOOLS.map((tool) => (
<button
key={tool.label}
type="button"
title={tool.title}
onClick={() => applyTool(tool)}
style={{
minWidth: 30, height: 28, padding: "0 9px", borderRadius: 7, border: "none", background: "transparent",
color: "#6c6150", fontSize: 12.5, fontWeight: 700, cursor: "pointer",
fontStyle: "italic" in tool && tool.italic ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
</div>
<textarea
id="information-body"
ref={bodyRef}
className="field"
name="body"
rows={10}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={"Rédigez ici. Laissez une ligne vide entre deux paragraphes.\n\n- une puce\n**du gras**, [un lien](https://pleinr.fr)"}
style={{ borderRadius: "0 0 10px 10px", resize: "vertical", lineHeight: 1.7 }}
/>
<p className="field-hint" style={{ marginTop: 6 }}>
Mise en forme simple : <strong>**gras**</strong>, <em>*italique*</em>, <code>- puce</code>,{" "}
<code>[libellé](https://…)</code>. Les liens s&apos;ouvrent dans un nouvel onglet.
</p>
<div style={{ marginTop: 16 }}>
<ImageField name="imageUrl" label="Image (facultative)" defaultValue={draft.imageUrl ?? ""} height={150} />
</div>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: 16, cursor: "pointer" }}>
<input type="checkbox" name="pinned" checked={pinned} onChange={(event) => setPinned(event.target.checked)} style={{ marginTop: 3 }} />
<span>
<strong style={{ fontSize: 13.5, color: "#26201a" }}>Épingler en tête de l&apos;espace adhérent</strong>
<span className="field-hint" style={{ display: "block" }}>
Une seule information à la fois : celle déjà épinglée redescend dans le fil.
</span>
</span>
</label>
{body.trim() && (
<div style={{ marginTop: 18, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 10 }}>
Aperçu
</div>
<div style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 12, padding: "14px 16px" }}>
<h3 className="font-display" style={{ margin: "0 0 8px", fontWeight: 700, fontSize: 17, color: "#26201a" }}>
{title || "Sans titre"}
</h3>
<div className="rich-text">{richTextNodes(body)}</div>
</div>
</div>
)}
{error && (
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
{error}
</div>
)}
<div style={{ display: "flex", gap: 9, marginTop: 18, flexWrap: "wrap" }}>
<button
type="submit"
disabled={saving}
style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "12px 20px", borderRadius: 10, cursor: saving ? "progress" : "pointer", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Enregistrement…" : draft.id ? "Enregistrer les modifications" : "Enregistrer le brouillon"}
</button>
{draft.id && (
<Link
href="/backend/informations"
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 13.5, padding: "12px 18px", borderRadius: 10, textDecoration: "none" }}
>
Annuler
</Link>
)}
</div>
</form>
);
}
+191
View File
@@ -0,0 +1,191 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import { countMemberAccounts, getAdminInformations, getInformation } from "@/lib/informations";
import { richTextExcerpt } from "@/lib/rich-text";
import { isMailConfigured } from "@/lib/mail-accounts";
import { mailCountsFor } from "@/lib/mail-outbox";
import { InformationForm } from "./InformationForm";
import {
deleteInformation,
publishInformation,
toggleInformationPin,
unpublishInformation,
} from "../actions";
export const dynamic = "force-dynamic";
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "17px 18px" };
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const rowButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 11px", borderRadius: 8, cursor: "pointer" };
const pill: CSSProperties = { borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
function formatDay(value: Date) {
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
}
/**
* Rédaction et suivi des informations diffusées dans l'espace adhérent.
*
* L'information en cours d'édition est désignée par `?modifier=<id>` plutôt
* que par un état client : la page reste un composant serveur, et un lien
* suffit à passer d'une information à l'autre.
*/
export default async function InformationsPage({
searchParams,
}: {
searchParams: Promise<{ modifier?: string; error?: string; ok?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageInformations")) redirect("/backend");
// Un modérateur publie ; écrire à tous les adhérents reste réservé à qui
// tient déjà le studio d'e-mails.
const canBroadcast = can(session?.user.role, "manageEmails");
const mailReady = canBroadcast && (await isMailConfigured());
const { modifier, error, ok } = await searchParams;
const editId = Number(modifier ?? 0) || null;
const editing = editId ? await getInformation(editId) : null;
const [items, memberCount] = await Promise.all([getAdminInformations(), countMemberAccounts()]);
const published = items.filter((item) => item.status === "published");
const drafts = items.filter((item) => item.status === "draft");
const mailCounts = Object.fromEntries(
await Promise.all(
published
.filter((item) => item.emailSentAt)
.map(async (item) => [item.id, await mailCountsFor(item.id)] as const)
)
);
return (
<div>
{error && (
<div role="status" style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{error}
</div>
)}
{ok && (
<div role="status" style={{ background: "#e6f4ec", border: "1px solid #c4e2d1", color: "#1f8a5b", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{ok}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(0, 1fr)", gap: 18, alignItems: "start" }} className="informations-split">
<InformationForm
key={editing?.id ?? "nouvelle"}
draft={{
id: editing?.id ?? null,
title: editing?.title ?? "",
body: editing?.body ?? "",
imageUrl: editing?.imageUrl ?? null,
pinned: editing?.pinned ?? false,
}}
/>
<div style={{ display: "grid", gap: 16 }}>
<section style={panel}>
<div style={eyebrow}>Publiées · {published.length}</div>
{published.length === 0 && (
<div style={{ color: "#a99c82", fontSize: 13 }}>
Rien n&apos;est encore visible dans l&apos;espace adhérent.
</div>
)}
<div style={{ display: "grid", gap: 12 }}>
{published.map((item, index) => (
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
{item.pinned && <span style={{ ...pill, background: "#fbeede", color: "#9a6638" }}>Épinglée</span>}
</div>
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
{item.publishedAt ? formatDay(item.publishedAt) : "—"}
{" · "}
lue par {item.readCount} / {memberCount}
{item.authorName ? ` · ${item.authorName}` : ""}
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
{item.emailSentAt && (
<div style={{ color: "#1f8a5b", fontSize: 12, marginTop: 5, fontWeight: 700 }}>
Diffusée par e-mail · {mailCounts[item.id]?.sent ?? 0} remis
{mailCounts[item.id]?.queued ? ` · ${mailCounts[item.id].queued} en file` : ""}
{mailCounts[item.id]?.failed ? ` · ${mailCounts[item.id].failed} en échec` : ""}
</div>
)}
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
Modifier
</Link>
<form action={toggleInformationPin}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={rowButton}>{item.pinned ? "Décrocher" : "Épingler"}</button>
</form>
<form action={unpublishInformation}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={rowButton}>Retirer</button>
</form>
</div>
</div>
))}
</div>
</section>
<section style={panel}>
<div style={eyebrow}>Brouillons · {drafts.length}</div>
{drafts.length === 0 && <div style={{ color: "#a99c82", fontSize: 13 }}>Aucun brouillon en attente.</div>}
<div style={{ display: "grid", gap: 12 }}>
{drafts.map((item, index) => (
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
<span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Brouillon</span>
</div>
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
Modifié le {formatDay(item.updatedAt)}
{item.authorName ? ` · ${item.authorName}` : ""}
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<form action={publishInformation} style={{ display: "grid", gap: 7 }}>
<input type="hidden" name="id" value={item.id} />
{canBroadcast && (
<label style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 12.5, color: "#6c6150", cursor: mailReady ? "pointer" : "not-allowed" }}>
<input type="checkbox" name="sendEmail" disabled={!mailReady} style={{ marginTop: 2 }} />
<span>
Envoyer aussi par e-mail <strong>aux {memberCount} adhérent(s)</strong>
{!mailReady && (
<span style={{ display: "block", color: "#a99c82", fontSize: 11.5 }}>
Aucune boîte mail configurée — voir Configuration › Boîte mail.
</span>
)}
</span>
</label>
)}
<button
type="submit"
style={{ ...rowButton, border: "none", background: "#13324F", color: "#fff", fontWeight: 800, justifySelf: "start" }}
>
Publier
</button>
</form>
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
Modifier
</Link>
<form action={deleteInformation}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={{ ...rowButton, color: "#d8472b", borderColor: "#e0c3bb" }}>
Supprimer
</button>
</form>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</div>
);
}
+7
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { isStaff } from "@/lib/rbac";
import { unreadInformationCount } from "@/lib/informations";
import { saveImageConsent } from "./actions";
import { BackendShell } from "./BackendShell";
import { NOINDEX } from "@/lib/seo";
@@ -70,6 +71,11 @@ export default async function BackendLayout({
}
}
// Le compteur d'informations non lues suit le compte, pas le rôle : un
// membre du bureau a lui aussi un espace adhérent.
const userId = Number(session.user.id);
const infoCount = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let pendingCount = 0;
let inboxCount = 0;
if (isStaff(session.user.role)) {
@@ -87,6 +93,7 @@ export default async function BackendLayout({
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
pendingCount={pendingCount}
inboxCount={inboxCount}
infoCount={infoCount}
>
{children}
</BackendShell>
+19 -10
View File
@@ -8,7 +8,10 @@ import { meetingRegistrations, meetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { audienceOptions } from "@/lib/mail-recipients";
import { isMailConfigured } from "@/lib/mail-accounts";
import { siteUrl } from "@/lib/social-accounts";
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
export const dynamic = "force-dynamic";
@@ -23,7 +26,8 @@ export default async function RencontresAdminPage() {
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const [rows, settings] = await Promise.all([
const canSend = can(session?.user.role, "manageEmails");
const [rows, settings, choices, mailReady, base] = await Promise.all([
db
.select({
id: meetings.id,
@@ -41,14 +45,11 @@ export default async function RencontresAdminPage() {
.groupBy(meetings.id)
.orderBy(desc(meetings.startsAt)),
getSiteSettings(),
audienceOptions(),
isMailConfigured(),
siteUrl(),
]);
const emailBrand = {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
const brand = emailBrand(settings);
return (
<div style={{ display: "grid", gap: 28 }}>
@@ -139,7 +140,15 @@ export default async function RencontresAdminPage() {
capacity: meeting.capacity,
registered: Number(meeting.registered),
}}
brand={emailBrand}
brand={brand}
choices={{
...choices,
meeting: { id: meeting.id, title: meeting.title, count: Number(meeting.registered) },
}}
// Un modérateur gère les rencontres mais n'écrit pas à tous les
// adhérents : l'envoi reste réservé au studio d'e-mails.
mailReady={canSend && mailReady}
siteUrl={base}
/>
<form action={deleteMeeting} style={{ marginTop: 8 }}>
<input type="hidden" name="id" value={meeting.id} />
+90
View File
@@ -998,6 +998,54 @@ img {
background: #33291D;
color: #fff;
}
/* Corps d'une information : rendu par `richTextNodes`, donc uniquement des
p / ul / ol / li / strong / em / a / br. Le rythme vertical vit ici plutôt
qu'en styles en ligne, parce qu'il porte sur des balises engendrées. */
.rich-text {
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.rich-text > *:first-child {
margin-top: 0;
}
.rich-text > *:last-child {
margin-bottom: 0;
}
.rich-text p {
margin: 0 0 11px;
}
.rich-text h4 {
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
font-size: 15.5px;
font-weight: 700;
color: var(--ink-strong);
margin: 16px 0 8px;
}
.rich-text ul,
.rich-text ol {
margin: 0 0 11px;
padding-left: 21px;
}
.rich-text li {
margin-bottom: 3px;
}
.rich-text strong {
color: var(--ink-strong);
}
.rich-text a {
color: var(--blue);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Rédaction d'une information : éditeur et listes côte à côte, empilés dès
que la colonne de droite n'a plus la place d'être lisible. */
@media (max-width: 1100px) {
.informations-split {
grid-template-columns: minmax(0, 1fr) !important;
}
}
/* Suggestions de tags : pastilles cliquables sous le champ. */
.tag-suggestions {
display: flex;
@@ -1403,6 +1451,48 @@ img {
color: var(--muted-4);
font-size: 13px;
}
/* Référent de l'adhérent : rendu seulement quand le visiteur est connecté —
le HTML public ne contient jamais ce bloc, rien n'est masqué en CSS. */
.contact-referent {
margin-top: 12px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 12px;
background: #fff;
}
.contact-referent__label {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted-2);
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.contact-referent__name {
margin-top: 7px;
color: var(--ink-strong);
font-size: 14px;
font-weight: 700;
}
.contact-referent__phone {
display: inline-block;
margin-top: 3px;
color: var(--blue);
font-size: 13.5px;
font-weight: 700;
text-decoration: none;
}
.contact-referent__phone:hover {
text-decoration: underline;
}
.contact-referent__hint {
margin-top: 7px;
color: var(--muted-4);
font-size: 11.5px;
line-height: 1.45;
}
/* ---- Éditeur hebdomadaire des horaires ---- */
.hours-editor {
+5 -5
View File
@@ -58,7 +58,7 @@ export default async function AccueilPage() {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/assets/logo.png"
alt="Plein R — Association des commerçants et entreprises du Bassin de Pompey"
alt="Plein R — Association des commerçants et entreprises de service du Bassin de Pompey"
className="floatlogo"
style={{
width: "100%",
@@ -107,10 +107,10 @@ export default async function AccueilPage() {
className="font-display"
style={{ fontWeight: 600, fontSize: 17, color: "#9a6638", margin: "0 0 16px" }}
>
Association des commerçants &amp; entreprises du Bassin de Pompey
Association des commerçants &amp; entreprises de service du Bassin de Pompey
</div>
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 24px", maxWidth: 520 }}>
Boulangers, artisans, restaurateurs, entreprises… retrouvez tous nos adhérents, profitez de
Boulangers, artisans, restaurateurs, entreprises de service… retrouvez tous nos adhérents, profitez de
leurs bons plans et faites vivre le commerce de proximité.
</p>
<form
@@ -359,8 +359,8 @@ export default async function AccueilPage() {
</h2>
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
{memberCount > 0
? `Rejoignez les ${memberCount} commerçants et entreprises adhérents qui font réseau, se rencontrent et réussissent ensemble.`
: "Rejoignez les commerçants et entreprises qui font réseau, se rencontrent et réussissent ensemble."}
? `Rejoignez les ${memberCount} commerçants et entreprises de service adhérents qui font réseau, se rencontrent et réussissent ensemble.`
: "Rejoignez les commerçants et entreprises de service qui font réseau, se rencontrent et réussissent ensemble."}
</p>
<MembershipModalButton
label="Devenir adhérent"
+14
View File
@@ -13,7 +13,9 @@ export type BackendIconName =
| "meetings"
| "registrations"
| "gallery"
| "news"
| "emails"
| "mailbox"
| "social"
| "categories"
| "settings"
@@ -70,6 +72,18 @@ const PATHS: Record<BackendIconName, ReactNode> = {
<path d="m4 17 4.8-4.4a1.8 1.8 0 0 1 2.4 0L16 17M14 14.4l1.6-1.5a1.8 1.8 0 0 1 2.4 0L21 15.6" />
</>
),
news: (
<>
<path d="M17.5 4.5H4.2A1.2 1.2 0 0 0 3 5.7v12a1.8 1.8 0 0 0 1.8 1.8h13.4a2.8 2.8 0 0 0 2.8-2.8V9.2h-3.5V4.5Z" />
<path d="M6.2 8.3h8.1M6.2 11.6h8.1M6.2 14.9h5.2" />
</>
),
mailbox: (
<>
<rect x="2.6" y="5" width="18.8" height="14" rx="2.4" />
<path d="m3.4 7.2 7.4 5.3a2 2 0 0 0 2.4 0l7.4-5.3" />
</>
),
emails: (
<>
<path d="M20.5 3.5 10.8 13.2M20.5 3.5 14.3 21l-3.5-7.8L3 9.7l17.5-6.2Z" />
+80 -7
View File
@@ -3,9 +3,32 @@
import { useEffect, useMemo, useState } from "react";
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
import {
MailRecipientPicker,
audienceCount,
audienceFrom,
type AudienceState,
type RecipientChoices,
} from "@/components/MailRecipientPicker";
import { sendSelfTest, sendStudioEmail } from "@/app/backend/actions";
export function EmailCreator({ brand }: { brand: EmailBrand }) {
const [baseUrl, setBaseUrl] = useState("");
export function EmailCreator({
brand,
choices,
mailReady,
siteUrl,
}: {
brand: EmailBrand;
choices: RecipientChoices;
/** Faux si aucune boîte n'expédie : l'envoi réel est alors désactivé. */
mailReady: boolean;
/** Adresse publique enregistrée, utilisée par l'aperçu comme par l'envoi. */
siteUrl: string;
}) {
const [baseUrl, setBaseUrl] = useState(siteUrl);
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
const [sending, setSending] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const [form, setForm] = useState<GeneralEmailContent>({
subject: `Actualités de ${brand.associationName}`,
kicker: brand.associationName,
@@ -19,15 +42,35 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
const [error, setError] = useState("");
useEffect(() => {
setBaseUrl(window.location.origin);
setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` }));
}, []);
// L'adresse enregistrée prime : c'est elle que verront les destinataires,
// et non celle par laquelle l'administrateur est arrivé.
const origin = siteUrl || window.location.origin;
setBaseUrl(origin);
setForm((current) => ({ ...current, buttonUrl: `${origin}/association` }));
}, [siteUrl]);
const email = useMemo(
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
[baseUrl, brand, form],
);
const recipientCount = audienceCount(audience, choices);
async function deliver(task: () => Promise<{ queued: number; audience: string } | { error: string }>) {
setSending(true);
setError("");
setSent(null);
try {
const result = await task();
if ("error" in result) setError(result.error);
else setSent(`✓ ${result.queued} message(s) mis en file pour ${result.audience}.`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
} finally {
setSending(false);
}
}
function change(name: keyof GeneralEmailContent, value: string) {
setForm((current) => ({ ...current, [name]: value }));
}
@@ -69,8 +112,37 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
</div>
<div style={{ marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
<div style={{ display: "grid", gap: 9, marginTop: 14 }}>
<button
type="button"
className="email-primary-button"
disabled={!email || sending || !mailReady || recipientCount === 0}
onClick={() => email && deliver(() => sendStudioEmail({ content: form, audience: audienceFrom(audience) }))}
>
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} adhérent${recipientCount > 1 ? "s" : ""}`}
</button>
<button
type="button"
className="email-secondary-button"
disabled={!email || sending || !mailReady}
onClick={() => email && deliver(() => sendSelfTest({ subject: email.subject, html: email.html, text: form.body }))}
>
M&apos;envoyer un test
</button>
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
{!mailReady && (
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Aucune boîte mail n&apos;expédie pour l&apos;instant : branchez-la dans Configuration › Boîte mail.
Les boutons ci-dessous restent utilisables.
</p>
)}
</div>
</div>
{error && <div style={{ marginTop: 13, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
<div style={{ display: "grid", gap: 9, marginTop: 20 }}>
<div style={{ display: "grid", gap: 9, marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html))}>
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger le brouillon Outlook"}
</button>
@@ -87,7 +159,8 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
</div>
</div>
<p style={{ margin: "13px 0 0", color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Ouvrez fichier .eml pour obtenir un brouillon Outlook prêt à compléter avec destinataires.
Ces trois boutons restent là pour les cas particuliers : le .eml donne un brouillon Outlook
à compléter à la main.
</p>
</section>
+92
View File
@@ -0,0 +1,92 @@
import { richTextNodes } from "@/lib/rich-text";
/**
* Une information de l'association. Rendu unique, partagé par le fil de
* l'espace adhérent et l'aperçu du back-office : une seule carte à maintenir.
*
* Le corps passe par `richTextNodes()` : des éléments React, jamais du HTML
* injecté.
*/
export type InformationCardProps = {
title: string;
body: string;
imageUrl?: string | null;
publishedAt?: Date | null;
authorName?: string | null;
pinned?: boolean;
unread?: boolean;
/** Mention d'état supplémentaire (« Brouillon », « Lue par 12 / 40 »…). */
note?: string | null;
};
function formatDay(value: Date) {
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
export function InformationCard({
title,
body,
imageUrl,
publishedAt,
authorName,
pinned,
unread,
note,
}: InformationCardProps) {
// Une seule accentuation à la fois : l'épinglage prime sur la nouveauté,
// sinon les deux liserés se disputeraient le même bord.
const accent = pinned ? "#E0A63C" : unread ? "#1F8A5B" : null;
return (
<article
style={{
background: unread || pinned ? "#fff" : "#fdfcf8",
border: `1px solid ${pinned ? "#e5cf9a" : "#e6dcc6"}`,
borderRadius: 14,
padding: "18px 20px",
marginBottom: 14,
boxShadow: accent ? `inset 4px 0 0 ${accent}` : undefined,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap", marginBottom: 9 }}>
{pinned && (
<span style={{ background: "#fbeede", color: "#9a6638", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
Épinglée
</span>
)}
{unread && (
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
Nouveau
</span>
)}
<span style={{ color: "#9a8d72", fontSize: 12.5 }}>
{publishedAt ? `Publié le ${formatDay(publishedAt)}` : "Non publiée"}
{authorName ? ` par ${authorName}` : ""}
</span>
{note && (
<span style={{ background: "#f1efe7", color: "#a99c82", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
{note}
</span>
)}
</div>
<h3 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 18, color: "#26201a" }}>
{title}
</h3>
{imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt=""
style={{ display: "block", width: "100%", maxHeight: 280, objectFit: "cover", borderRadius: 10, border: "1px solid #e6dcc6", margin: "13px 0 4px" }}
/>
)}
<div className="rich-text" style={{ marginTop: 9 }}>
{richTextNodes(body)}
</div>
</article>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import type { Audience } from "@/lib/mail-recipients";
/**
* Choix des destinataires, partagé par le studio de composition et le
* composeur d'invitations.
*
* Les effectifs sont calculés côté serveur et passés en props : le navigateur
* n'a jamais besoin de la liste des adresses pour afficher un compteur.
*/
export type RecipientChoices = {
all: number;
categories: { value: string; label: string; count: number }[];
meeting?: { id: number; title: string; count: number } | null;
};
export type AudienceState = { kind: Audience["kind"]; categoryId: string };
export function audienceFrom(state: AudienceState, meetingId?: number | null): Audience {
if (state.kind === "category" && state.categoryId) {
return { kind: "category", categoryId: Number(state.categoryId) };
}
if (state.kind === "meeting" && meetingId) return { kind: "meeting", meetingId };
return { kind: "all" };
}
export function audienceCount(state: AudienceState, choices: RecipientChoices): number {
if (state.kind === "category") {
return choices.categories.find((option) => option.value === state.categoryId)?.count ?? 0;
}
if (state.kind === "meeting") return choices.meeting?.count ?? 0;
return choices.all;
}
export function MailRecipientPicker({
choices,
value,
onChange,
}: {
choices: RecipientChoices;
value: AudienceState;
onChange: (next: AudienceState) => void;
}) {
const options: { kind: Audience["kind"]; label: string; hint: string; count: number }[] = [
{ kind: "all", label: "Tous les adhérents actifs", hint: "", count: choices.all },
];
if (choices.categories.length > 0) {
options.push({ kind: "category", label: "Une catégorie de métier", hint: "", count: 0 });
}
if (choices.meeting) {
options.push({
kind: "meeting",
label: "Les inscrits à cette rencontre",
hint: choices.meeting.title,
count: choices.meeting.count,
});
}
return (
<fieldset style={{ border: "none", margin: 0, padding: 0 }}>
<legend className="field-label" style={{ marginBottom: 8 }}>Destinataires</legend>
<div style={{ display: "grid", gap: 9 }}>
{options.map((option) => (
<div key={option.kind}>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", cursor: "pointer", fontSize: 13.5, color: "#3c3322" }}>
<input
type="radio"
name="audience"
checked={value.kind === option.kind}
onChange={() => onChange({ ...value, kind: option.kind })}
style={{ marginTop: 3 }}
/>
<span>
<strong style={{ color: "#26201a" }}>{option.label}</strong>
{option.kind !== "category" && (
<span style={{ color: "#9a8d72" }}> · {option.count} adresse{option.count > 1 ? "s" : ""}</span>
)}
{option.hint && <span style={{ display: "block", color: "#9a8d72", fontSize: 12.5 }}>{option.hint}</span>}
</span>
</label>
{option.kind === "category" && value.kind === "category" && (
<select
className="field"
value={value.categoryId}
onChange={(event) => onChange({ ...value, categoryId: event.target.value })}
style={{ marginTop: 8, marginLeft: 27, width: "calc(100% - 27px)" }}
>
<option value="">Choisir un métier…</option>
{choices.categories.map((category) => (
<option key={category.value} value={category.value}>
{category.label} ({category.count})
</option>
))}
</select>
)}
</div>
))}
</div>
</fieldset>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { markInformationsRead } from "@/app/backend/actions";
/**
* Marque les informations affichées comme lues, une fois la page rendue.
*
* Le marquage ne peut pas se faire pendant le rendu de la page : le compteur
* de la barre latérale est calculé par le gabarit, un composant serveur
* distinct dont l'ordre de rendu n'est pas garanti — la pastille de l'onglet
* et celle du menu auraient pu se contredire sur la même page. Ici les deux
* retombent ensemble au rafraîchissement.
*/
export function MarkInformationsRead({ ids }: { ids: number[] }) {
const router = useRouter();
const handled = useRef("");
const key = ids.join(",");
useEffect(() => {
if (!key || handled.current === key) return;
handled.current = key;
void markInformationsRead(key.split(",").map(Number)).then(() => router.refresh());
}, [key, router]);
return null;
}
+66 -4
View File
@@ -4,20 +4,64 @@ import { useMemo, useState } from "react";
import { Modal } from "@/components/Modal";
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates";
import {
MailRecipientPicker,
audienceCount,
audienceFrom,
type AudienceState,
type RecipientChoices,
} from "@/components/MailRecipientPicker";
import { sendMeetingInvitations } from "@/app/backend/actions";
export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmailData; brand: EmailBrand }) {
export function MeetingEmailComposer({
meeting,
brand,
choices,
mailReady,
siteUrl,
}: {
meeting: MeetingEmailData;
brand: EmailBrand;
choices: RecipientChoices;
mailReady: boolean;
siteUrl: string;
}) {
const [open, setOpen] = useState(false);
const [baseUrl, setBaseUrl] = useState("");
const [baseUrl, setBaseUrl] = useState(siteUrl);
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
const [flash, setFlash] = useState("");
const [error, setError] = useState("");
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
const [sending, setSending] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
const recipientCount = audienceCount(audience, choices);
function show() {
setBaseUrl(window.location.origin);
// L'adresse enregistrée prime : c'est celle que verront les destinataires.
setBaseUrl(siteUrl || window.location.origin);
setOpen(true);
}
async function deliver() {
setSending(true);
setError("");
setSent(null);
try {
const result = await sendMeetingInvitations({
meetingId: meeting.id,
texts,
audience: audienceFrom(audience, meeting.id),
});
if ("error" in result) setError(result.error);
else setSent(`✓ ${result.queued} invitation(s) mises en file pour ${result.audience}.`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
} finally {
setSending(false);
}
}
function change(name: keyof MeetingEmailTexts, value: string) {
setTexts((current) => ({ ...current, [name]: value }));
}
@@ -46,8 +90,26 @@ export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmail
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
Date, lieu, description, places et lien d'inscription repris automatiquement.
</p>
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
</div>
{error && <div style={{ color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
<button
type="button"
className="email-primary-button"
disabled={!email || sending || !mailReady || recipientCount === 0}
onClick={deliver}
>
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} destinataire${recipientCount > 1 ? "s" : ""}`}
</button>
{!mailReady && (
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Aucune boîte mail ne répond : branchez-la dans Configuration › Boîte mail. Les boutons
ci-dessous restent utilisables.
</p>
)}
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger brouillon Outlook"}
</button>
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n${email.registrationUrl}`))}>
+30 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { memberPath } from "@/lib/seo";
import type { MemberContact } from "@/lib/member-contact";
export type MemberCardData = {
id: number;
@@ -44,8 +45,21 @@ const clampLines = (lines: number) =>
/**
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
* état : il se rend aussi bien côté serveur que dans le filtre client.
*
* `contact` (nom du référent, ligne directe) n'est passé qu'aux visiteurs
* connectés : la page ne le rapatrie pas du tout pour un visiteur anonyme, il
* n'y a donc rien à masquer en CSS. Le numéro reste en texte simple — un
* `tel:` imbriqué dans le lien de la carte serait un lien dans un lien.
*/
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
export function MemberCard({
m,
index = 0,
contact = null,
}: {
m: MemberCardData;
index?: number;
contact?: MemberContact | null;
}) {
const location = [m.address, m.city].filter(Boolean).join(" · ");
return (
<Link
@@ -87,6 +101,21 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
</div>
{contact && (
<div style={{ marginTop: 10, borderTop: "1px dashed #e6dcc6", paddingTop: 10 }}>
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 800, color: "#9a8d72", marginBottom: 4 }}>
Contact adhérent
</div>
{contact.name && (
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{contact.name}
</div>
)}
{contact.phone && (
<div style={{ fontSize: 12.5, color: "#6c6150", marginTop: 2 }}>{contact.phone}</div>
)}
</div>
)}
</div>
</Link>
);
+52
View File
@@ -0,0 +1,52 @@
/**
* Coordonnées du référent de l'adhérent (nom, prénom, ligne directe), dans les
* deux formulaires de fiche : l'espace adhérent et l'écran staff.
*
* Ces champs ne sont jamais publiés : l'annuaire et la fiche publique ne les
* affichent qu'à un visiteur connecté (`src/lib/member-contact.ts`).
*/
export function MemberContactFields({
firstName,
lastName,
phone,
}: {
firstName: string | null;
lastName: string | null;
phone: string | null;
}) {
return (
<fieldset
style={{
marginTop: 16,
border: "1px solid #f0e8d6",
borderRadius: 12,
background: "#faf7ef",
padding: 16,
minWidth: 0,
}}
>
<legend className="field-label" style={{ padding: "0 6px" }}>
Contact adhérent
</legend>
<p className="field-hint" style={{ margin: "0 0 12px" }}>
La personne à joindre et sa ligne directe. Affiché sur l&apos;annuaire et sur la fiche
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n&apos;apparaît sur
le site public ni dans les moteurs de recherche.
</p>
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Prénom</label>
<input name="contactFirstName" defaultValue={firstName ?? ""} className="field" />
</div>
<div>
<label className="field-label">Nom</label>
<input name="contactLastName" defaultValue={lastName ?? ""} className="field" />
</div>
<div>
<label className="field-label">Téléphone direct</label>
<input name="contactPhone" defaultValue={phone ?? ""} className="field" placeholder="06 12 ..." />
</div>
</div>
</fieldset>
);
}
+34 -3
View File
@@ -7,8 +7,34 @@
* l'état de ce composant, le temps que le staff le relève. Un rechargement de
* la page le fait disparaître définitivement (il reste possible de
* réinitialiser le mot de passe pour en obtenir un nouveau).
*
* L'envoi par e-mail est un **plus**, jamais un remplacement : le mot de passe
* reste affiché même quand le message est parti, et surtout quand il n'a pas
* pu partir. Boîte mail non configurée, le panneau se comporte exactement
* comme avant.
*/
export type IssuedCredentialsItem = { label?: string; email: string; tempPassword: string };
export type IssuedCredentialsItem = {
label?: string;
email: string;
tempPassword: string;
mailed?: boolean;
mailError?: string;
};
function Delivery({ item }: { item: IssuedCredentialsItem }) {
if (item.mailed === undefined) return null;
if (item.mailed) {
return (
<span style={{ color: "#1f8a5b", fontWeight: 700 }}>✓ Envoyé à {item.email}</span>
);
}
return (
<span style={{ color: "#a8503c", fontWeight: 700 }}>
Envoi impossible — transmettez-le à la main
{item.mailError ? ` (${item.mailError.slice(0, 120)})` : ""}
</span>
);
}
export function OneTimeCredentials({
items,
@@ -18,6 +44,8 @@ export function OneTimeCredentials({
title?: string;
}) {
if (items.length === 0) return null;
const allMailed = items.every((item) => item.mailed);
return (
<div
role="status"
@@ -26,7 +54,7 @@ export function OneTimeCredentials({
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
{title}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{items.map((item) => (
<div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}>
{item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>}
@@ -37,11 +65,14 @@ export function OneTimeCredentials({
Mot de passe temporaire :{" "}
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
</span>
<Delivery item={item} />
</div>
))}
</div>
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
Notez-le maintenant : il n&apos;est affiché qu&apos;une seule fois et n&apos;est conservé nulle part.
{allMailed
? "Le message est parti, mais notez-le quand même : il n'est affiché qu'une seule fois et n'est conservé nulle part."
: "Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part."}{" "}
Un changement de mot de passe sera exigé à la première connexion.
</div>
</div>
+9
View File
@@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
address: "12 rue de la République",
postalCode: "54390",
phone: "03 83 49 00 00",
contactFirstName: "Julien",
contactLastName: "Marchal",
contactPhone: "06 12 34 56 78",
website: "https://www.aubonpain-frouard.fr",
memberSince: 2021,
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
@@ -36,6 +39,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
address: "3 place Stanislas",
postalCode: "54340",
phone: "03 83 24 00 00",
contactFirstName: "Sophie",
contactLastName: "Renard",
contactPhone: "06 98 76 54 32",
memberSince: 2019,
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
@@ -49,6 +55,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
categoryId: catId("mode-beaute"),
city: "Champigneulles",
address: "7 av. des Tilleuls",
contactFirstName: "Émilie",
contactLastName: "Bertrand",
contactPhone: "06 45 67 89 01",
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
status: "active" as const,
highlighted: true,
+152
View File
@@ -42,6 +42,22 @@ export const contactStatusEnum = pgEnum("contact_status", [
"read",
"archived",
]);
export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]);
export const mailProviderEnum = pgEnum("mail_provider", ["google", "microsoft", "smtp"]);
export const mailStatusEnum = pgEnum("mail_status", [
"queued",
"sending",
"sent",
"failed",
"cancelled",
]);
export const mailKindEnum = pgEnum("mail_kind", [
"credentials",
"information",
"meeting",
"studio",
"test",
]);
// ---- Categories (métiers) ----
export const categories = pgTable("categories", {
@@ -62,6 +78,13 @@ export const members = pgTable("members", {
email: varchar("email", { length: 200 }).notNull(),
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
contactEmail: varchar("contact_email", { length: 200 }),
// Référent de l'adhérent : la personne à joindre au sein du commerce, avec
// sa ligne directe. Contrairement aux coordonnées de l'établissement, ces
// trois champs ne sont montrés qu'aux visiteurs connectés (cf.
// `src/lib/member-contact.ts`), jamais dans le HTML public ni le JSON-LD.
contactFirstName: varchar("contact_first_name", { length: 120 }),
contactLastName: varchar("contact_last_name", { length: 120 }),
contactPhone: varchar("contact_phone", { length: 40 }),
categoryId: integer("category_id").references(() => categories.id),
city: varchar("city", { length: 120 }),
address: varchar("address", { length: 240 }),
@@ -285,6 +308,114 @@ export const imageConsents = pgTable("image_consents", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// ---- Informations de l'association (fil de l'espace adhérent) ----
// Le corps est du texte balisé (`src/lib/rich-text.ts`), jamais du HTML :
// il est analysé puis rendu en éléments React.
export const informations = pgTable(
"informations",
{
id: serial("id").primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
body: text("body").notNull(),
imageUrl: text("image_url"),
status: infoStatusEnum("status").notNull().default("draft"),
pinned: boolean("pinned").notNull().default(false),
publishedAt: timestamp("published_at", { withTimezone: true }),
authorId: integer("author_id").references(() => users.id, { onDelete: "set null" }),
// Garde anti-double-diffusion, sur le modèle de `publishPromoShares` :
// une information déjà envoyée ne repart pas à la republication.
emailSentAt: timestamp("email_sent_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
feedIdx: index("informations_feed_idx").on(table.status, table.publishedAt),
})
);
// Une ligne par lecture. Une simple date « vu jusqu'ici » sur l'utilisateur ne
// permettrait ni la pastille « nouveau » par information, ni le « lue par
// 12 / 40 » du back-office.
export const informationReads = pgTable(
"information_reads",
{
id: serial("id").primaryKey(),
informationId: integer("information_id")
.notNull()
.references(() => informations.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
readAt: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
uniq: uniqueIndex("information_reads_user_info_idx").on(table.userId, table.informationId),
})
);
// ---- Boîte mail de l'association ----
// Une ligne par fournisseur, `is_active` désigne celui qui expédie. Les secrets
// sont chiffrés (`src/lib/crypto.ts`) et ne ressortent jamais vers le
// navigateur — même règle que `social_accounts`.
export const mailAccounts = pgTable("mail_accounts", {
id: serial("id").primaryKey(),
provider: mailProviderEnum("provider").notNull().unique(),
// Lue chez le fournisseur pour Google et Microsoft : on expédie comme la
// boîte authentifiée, sinon SPF et DKIM tombent.
fromAddress: varchar("from_address", { length: 200 }),
fromName: varchar("from_name", { length: 200 }),
isActive: boolean("is_active").notNull().default(false),
appId: varchar("app_id", { length: 200 }),
appSecret: text("app_secret"),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
smtpHost: varchar("smtp_host", { length: 200 }),
smtpPort: integer("smtp_port"),
smtpSecure: boolean("smtp_secure").notNull().default(true),
smtpUser: varchar("smtp_user", { length: 200 }),
smtpPassword: text("smtp_password"),
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
connectedAt: timestamp("connected_at", { withTimezone: true }),
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
lastCheckOk: boolean("last_check_ok"),
lastCheckError: text("last_check_error"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// File d'attente d'envoi. `to_address` est un destinataire **unique** : une
// diffusion produit autant de lignes que d'adhérents, et aucune adresse ne peut
// donc apparaître aux yeux des autres.
export const mailMessages = pgTable(
"mail_messages",
{
id: serial("id").primaryKey(),
kind: mailKindEnum("kind").notNull(),
status: mailStatusEnum("status").notNull().default("queued"),
toAddress: varchar("to_address", { length: 200 }).notNull(),
toName: varchar("to_name", { length: 200 }),
subject: varchar("subject", { length: 300 }).notNull(),
html: text("html").notNull(),
text: text("text"),
replyTo: varchar("reply_to", { length: 200 }),
informationId: integer("information_id").references(() => informations.id, { onDelete: "set null" }),
meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }),
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
createdById: integer("created_by_id").references(() => users.id, { onDelete: "set null" }),
provider: mailProviderEnum("provider"),
attempts: integer("attempts").notNull().default(0),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
lockedAt: timestamp("locked_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
dueIdx: index("mail_messages_due_idx").on(table.status, table.nextAttemptAt),
informationIdx: index("mail_messages_information_idx").on(table.informationId),
})
);
// ---- Relations ----
export const membersRelations = relations(members, ({ one, many }) => ({
category: one(categories, {
@@ -355,6 +486,21 @@ export const imageConsentsRelations = relations(imageConsents, ({ one }) => ({
}),
}));
export const informationsRelations = relations(informations, ({ one, many }) => ({
author: one(users, {
fields: [informations.authorId],
references: [users.id],
}),
reads: many(informationReads),
}));
export const informationReadsRelations = relations(informationReads, ({ one }) => ({
information: one(informations, {
fields: [informationReads.informationId],
references: [informations.id],
}),
}));
export type Category = typeof categories.$inferSelect;
export type Member = typeof members.$inferSelect;
export type User = typeof users.$inferSelect;
@@ -371,3 +517,9 @@ export type MeetingRegistration = typeof meetingRegistrations.$inferSelect;
export type PastMeeting = typeof pastMeetings.$inferSelect;
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
export type ImageConsent = typeof imageConsents.$inferSelect;
export type Information = typeof informations.$inferSelect;
export type MailAccount = typeof mailAccounts.$inferSelect;
export type MailProvider = (typeof mailProviderEnum.enumValues)[number];
export type MailMessage = typeof mailMessages.$inferSelect;
export type MailKind = (typeof mailKindEnum.enumValues)[number];
export type InformationRead = typeof informationReads.$inferSelect;
+29 -7
View File
@@ -1,13 +1,19 @@
/**
* Boucle de libération des publications programmées. Fichier séparé de
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans
* le bundle edge du middleware.
* Boucles de fond : libération des publications programmées et vidage de la
* file d'envoi. Fichier séparé de `instrumentation.ts` : il touche la base et
* `nodemailer`, et ne doit donc jamais entrer dans le bundle edge du
* middleware — c'est cet import-là que l'`IgnorePlugin` de `next.config.mjs`
* coupe.
*/
import { releaseDuePromotions } from "@/lib/promo-publish";
import { processOutbox } from "@/lib/mail-outbox";
const INTERVAL_MS = 60_000;
const PROMO_INTERVAL_MS = 60_000;
// Plus court que la minute des promotions : un mot de passe ou une invitation
// mis en file ne doit pas attendre une minute pleine.
const MAIL_INTERVAL_MS = 20_000;
async function tick() {
async function promoTick() {
try {
const n = await releaseDuePromotions();
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
@@ -18,7 +24,23 @@ async function tick() {
}
}
async function mailTick() {
try {
const { sent, failed } = await processOutbox();
if (sent || failed) console.log(`[mail] ${sent} envoyé(s), ${failed} en échec`);
} catch (error) {
console.error("[mail] file d'attente :", error);
}
}
// Un premier passage au démarrage rattrape les échéances tombées pendant un
// redéploiement ou une coupure.
void tick();
setInterval(tick, INTERVAL_MS).unref();
if (process.env.PROMO_SCHEDULER !== "off") {
void promoTick();
setInterval(promoTick, PROMO_INTERVAL_MS).unref();
}
if (process.env.MAIL_WORKER !== "off") {
void mailTick();
setInterval(mailTick, MAIL_INTERVAL_MS).unref();
}
+5 -2
View File
@@ -1,5 +1,5 @@
/**
* Démarrage du serveur : libérateur des publications programmées.
* Démarrage du serveur : boucles de fond.
*
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
@@ -16,6 +16,9 @@ export async function register() {
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
// pas entrer dans ce bundle.
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.PROMO_SCHEDULER === "off") return;
// Chaque boucle a son propre interrupteur, appliqué à l'intérieur du module :
// couper le libérateur de promotions ici couperait aussi l'envoi des
// e-mails, qui n'a rien à voir avec lui.
if (process.env.PROMO_SCHEDULER === "off" && process.env.MAIL_WORKER === "off") return;
await import("./instrumentation-node");
}
+25 -23
View File
@@ -1,23 +1,20 @@
function bytesBase64(bytes: Uint8Array) {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return globalThis.btoa(binary);
}
/**
* Sorties navigateur du studio de composition : copie enrichie, brouillon
* Outlook, export HTML. Rien ici ne part sur le réseau — l'envoi réel vit
* côté serveur dans `src/lib/mailer.ts`.
*
* L'assemblage du message est délégué à `src/lib/mime.ts`, partagé avec le
* transport Gmail : un seul constructeur RFC 822, verrouillé par ses tests.
*/
import { buildMimeMessage, bytesToBase64, type MimeAttachment } from "@/lib/mime";
function utf8Base64(value: string) {
return bytesBase64(new TextEncoder().encode(value));
}
function wrapBase64(value: string) {
return value.match(/.{1,76}/g)?.join("\r\n") ?? "";
}
const LOGO_CONTENT_ID = "plein-r-logo";
const LOGO_SRC = /(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi;
function slug(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "email-plein-r";
@@ -59,20 +56,25 @@ export async function copyRichEmail(html: string, plainText: string) {
export async function downloadOutlookDraft(subject: string, html: string, prefix = "email") {
const logoUrl = html.match(/\bsrc=["']([^"']*\/assets\/logo\.png(?:\?[^"']*)?)["']/i)?.[1];
let renderedHtml = html;
let logoPart = "";
const boundary = "----=_PleinR_Outlook_Draft";
const inline: MimeAttachment[] = [];
// Le logo est embarqué plutôt que laissé en lien : un brouillon ouvert hors
// ligne, ou avant la mise en ligne du site, afficherait sinon un cadre vide.
if (logoUrl) {
const response = await fetch(logoUrl, { cache: "force-cache" });
if (!response.ok) throw new Error("Logo Plein R introuvable.");
const base64 = bytesBase64(new Uint8Array(await response.arrayBuffer()));
renderedHtml = html.replace(/(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi, "$1$2cid:plein-r-logo$2");
logoPart = [`--${boundary}`, "Content-Type: image/png; name=\"logo.png\"", "Content-Transfer-Encoding: base64", "Content-ID: <plein-r-logo>", "Content-Disposition: inline; filename=\"logo.png\"", "", wrapBase64(base64)].join("\r\n");
renderedHtml = html.replace(LOGO_SRC, `$1$2cid:${LOGO_CONTENT_ID}$2`);
inline.push({
filename: "logo.png",
contentType: "image/png",
contentId: LOGO_CONTENT_ID,
base64: bytesToBase64(new Uint8Array(await response.arrayBuffer())),
});
}
const mime = logoPart
? ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", `Content-Type: multipart/related; type=\"text/html\"; boundary=\"${boundary}\"`, "", `--${boundary}`, "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(renderedHtml)), logoPart, `--${boundary}--`, ""].join("\r\n")
: ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(html)), ""].join("\r\n");
// `X-Unsent: 1` : Outlook ouvre le fichier comme un brouillon modifiable et
// non comme un message reçu.
const mime = buildMimeMessage({ subject, html: renderedHtml, inline, headers: { "X-Unsent": "1" } });
downloadBlob(new Blob([mime], { type: "message/rfc822" }), `${prefix}-${slug(subject)}-outlook.eml`);
}
+88 -10
View File
@@ -1,3 +1,5 @@
import { richTextToEmailHtml, safeHttpUrl } from "@/lib/rich-text";
export type EmailBrand = {
associationName: string;
address?: string;
@@ -45,15 +47,6 @@ function lines(value: unknown) {
return esc(value).replace(/\r?\n/g, "<br>");
}
function webUrl(value: string) {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
} catch {
return "";
}
}
function paragraphs(value: string) {
return value
.split(/\r?\n\s*\r?\n/)
@@ -100,7 +93,7 @@ function emailShell({ title, content, baseUrl, brand }: { title: string; content
}
function cta(url: string, label: string) {
const safe = webUrl(url);
const safe = safeHttpUrl(url);
if (!safe || !label) return "";
return `<table role="presentation" width="300" cellpadding="0" cellspacing="0" border="0" style="width:300px;border-collapse:collapse;"><tr><td align="center" bgcolor="#2C6FB3" style="background:#2C6FB3;padding:16px 0;border-radius:8px;font-family:Arial,Helvetica,sans-serif;font-size:17px;font-weight:bold;"><a href="${esc(safe)}" target="_blank" style="display:block;color:#FFFFFF;text-decoration:none;">${esc(label)}</a></td></tr></table>`;
}
@@ -168,3 +161,88 @@ export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmail
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si bouton bloqué, copiez ce lien :<br><a href="${esc(registrationUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(registrationUrl)}</a></td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }), registrationUrl };
}
export type CredentialsEmailData = {
name: string;
email: string;
tempPassword: string;
/** « Votre compte adhérent », « Votre accès à l'administration »… */
intro: string;
};
/**
* Identifiants d'un compte qui vient d'être créé ou réinitialisé.
*
* Construit dans la portée de l'action qui émet le mot de passe et expédié
* aussitôt : ce message ne passe **jamais** par la file d'attente, dont le
* corps est stocké en base.
*/
export function buildCredentialsEmail(data: CredentialsEmailData, baseUrl: string, brand: EmailBrand) {
const loginUrl = `${baseUrl.replace(/\/$/, "")}/login`;
const subject = `Vos identifiants ${brand.associationName}`;
const body = `
<tr><td style="padding:42px 40px 16px;">
<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">${esc(brand.associationName)}</div>
<div style="padding-bottom:23px;font-family:Georgia,'Times New Roman',serif;font-size:30px;line-height:36px;color:#26201A;">Bonjour ${esc(data.name)},</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5D5447;">${lines(data.intro)}</div>
</td></tr>
<tr><td style="padding:8px 40px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FAF7EF" style="width:100%;background:#FAF7EF;border:1px solid #E6DCC6;border-left:5px solid #E0A63C;border-collapse:collapse;">
<tr><td style="padding:24px 28px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:24px;color:#5D5447;">
Identifiant<br><strong style="font-size:16px;color:#26201A;">${esc(data.email)}</strong>
<div style="padding-top:14px;">Mot de passe temporaire<br>
<strong style="font-family:'Courier New',Courier,monospace;font-size:19px;letter-spacing:1.5px;color:#26201A;">${esc(data.tempPassword)}</strong>
</div>
</td></tr>
</table>
</td></tr>
<tr><td align="center" style="padding:26px 40px 10px;">${cta(loginUrl, "Me connecter")}</td></tr>
<tr><td style="padding:12px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:23px;color:#5D5447;">
Ce mot de passe est <strong style="color:#26201A;">provisoire</strong> : il vous sera demandé d'en choisir un
nouveau dès votre première connexion. Si vous n'êtes pas à l'origine de cette demande, prévenez
l'association.
</td></tr>
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si le bouton ne fonctionne pas, copiez ce lien :<br><a href="${esc(loginUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(loginUrl)}</a></td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) };
}
export type InformationEmailData = {
id: number;
title: string;
/** Texte balisé, analysé par `src/lib/rich-text.ts`. */
body: string;
hasImage: boolean;
};
/**
* Une information de l'association, mise en e-mail.
*
* Un constructeur à part plutôt qu'un élargissement de `GeneralEmailContent` :
* le contrat du studio de composition reste intact.
*
* L'image de couverture part par **URL** (`/api/informations/<id>/image`) et
* non en data-URI : Gmail et Outlook suppriment les `<img src="data:">`, et
* l'incorporer gonflerait chaque ligne de la file d'attente à plusieurs méga-octets.
*/
export function buildInformationEmail(data: InformationEmailData, baseUrl: string, brand: EmailBrand) {
const base = baseUrl.replace(/\/$/, "");
const spaceUrl = `${base}/backend/espace/informations`;
const subject = `${brand.associationName} — ${data.title}`;
const body = `
<tr><td style="padding:42px 40px 8px;">
<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">Information &middot; ${esc(brand.associationName)}</div>
<div style="padding-bottom:20px;font-family:Georgia,'Times New Roman',serif;font-size:29px;line-height:36px;color:#26201A;">${esc(data.title)}</div>
</td></tr>
${
data.hasImage
? `<tr><td style="padding:0 40px 18px;"><img src="${esc(`${base}/api/informations/${data.id}/image`)}" alt="" width="540" style="display:block;width:100%;max-width:540px;height:auto;border:1px solid #E6DCC6;"></td></tr>`
: ""
}
<tr><td style="padding:0 40px 10px;">${richTextToEmailHtml(data.body)}</td></tr>
<tr><td align="center" style="padding:18px 40px 10px;">${cta(spaceUrl, "Lire dans mon espace")}</td></tr>
<tr><td style="padding:14px 40px 34px;font-family:Arial,Helvetica,sans-serif;font-size:11.5px;line-height:18px;color:#8C8068;">
Vous recevez ce message en tant qu'adhérent de ${esc(brand.associationName)}.<br>
Si le bouton ne fonctionne pas, copiez ce lien : <a href="${esc(spaceUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(spaceUrl)}</a>
</td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) };
}
+142
View File
@@ -0,0 +1,142 @@
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
import { informationReads, informations, users } from "@/db/schema";
/**
* Lectures du fil d'informations.
*
* Ce module vit hors de `backend/actions.ts` parce qu'il a plusieurs appelants
* qui ne sont pas des actions : le gabarit du back-office (pour le compteur de
* non-lues de la barre latérale) et les trois pages de l'espace adhérent.
*/
export type FeedItem = {
id: number;
title: string;
body: string;
imageUrl: string | null;
pinned: boolean;
publishedAt: Date | null;
authorName: string | null;
unread: boolean;
};
export type AdminItem = {
id: number;
title: string;
body: string;
imageUrl: string | null;
status: "draft" | "published";
pinned: boolean;
publishedAt: Date | null;
emailSentAt: Date | null;
updatedAt: Date;
authorName: string | null;
readCount: number;
};
/** Épinglée d'abord, puis la plus récente : l'ordre est le même partout. */
const FEED_ORDER = [desc(informations.pinned), desc(informations.publishedAt), desc(informations.id)];
/**
* Le fil tel que le voit un adhérent, avec l'état de lecture **avant** la
* visite : c'est ce qui permet d'afficher les pastilles « Nouveau » sur le
* rendu même où elles sont marquées comme lues.
*/
export async function getMemberFeed(userId: number, limit?: number): Promise<FeedItem[]> {
const query = db
.select({
id: informations.id,
title: informations.title,
body: informations.body,
imageUrl: informations.imageUrl,
pinned: informations.pinned,
publishedAt: informations.publishedAt,
authorName: users.name,
readId: informationReads.id,
})
.from(informations)
.leftJoin(users, eq(users.id, informations.authorId))
.leftJoin(
informationReads,
and(eq(informationReads.informationId, informations.id), eq(informationReads.userId, userId)),
)
.where(eq(informations.status, "published"))
.orderBy(...FEED_ORDER);
const rows = await (limit ? query.limit(limit) : query);
return rows.map(({ readId, ...row }) => ({ ...row, unread: readId === null }));
}
export async function countPublishedInformations(): Promise<number> {
const [row] = await db
.select({ total: sql<number>`count(*)::int` })
.from(informations)
.where(eq(informations.status, "published"));
return row?.total ?? 0;
}
/** Compteur du badge : publiées moins déjà lues par cet utilisateur. */
export async function unreadInformationCount(userId: number): Promise<number> {
const [row] = await db
.select({ total: sql<number>`count(*)::int` })
.from(informations)
.leftJoin(
informationReads,
and(eq(informationReads.informationId, informations.id), eq(informationReads.userId, userId)),
)
.where(and(eq(informations.status, "published"), isNull(informationReads.id)));
return row?.total ?? 0;
}
/**
* Marque comme lues les informations passées. Idempotent : la contrainte
* d'unicité `(user_id, information_id)` absorbe les visites répétées, un
* double clic ou deux onglets ouverts.
*/
export async function markInformationsRead(userId: number, ids: number[]): Promise<void> {
if (ids.length === 0) return;
await db
.insert(informationReads)
.values(ids.map((informationId) => ({ informationId, userId })))
.onConflictDoNothing();
}
/** Liste du back-office : brouillons compris, avec le nombre de lectures. */
export async function getAdminInformations(): Promise<AdminItem[]> {
const rows = await db
.select({
id: informations.id,
title: informations.title,
body: informations.body,
imageUrl: informations.imageUrl,
status: informations.status,
pinned: informations.pinned,
publishedAt: informations.publishedAt,
emailSentAt: informations.emailSentAt,
updatedAt: informations.updatedAt,
authorName: users.name,
readCount: sql<number>`(
select count(*)::int from ${informationReads}
where ${informationReads.informationId} = ${informations.id}
)`,
})
.from(informations)
.leftJoin(users, eq(users.id, informations.authorId))
.orderBy(desc(informations.status), ...FEED_ORDER, desc(informations.updatedAt));
return rows;
}
export async function getInformation(id: number) {
const [row] = await db.select().from(informations).where(eq(informations.id, id));
return row ?? null;
}
/** Nombre de comptes adhérents destinataires : le dénominateur du « lue par ». */
export async function countMemberAccounts(): Promise<number> {
const [row] = await db
.select({ total: sql<number>`count(*)::int` })
.from(users)
.where(eq(users.role, "member"));
return row?.total ?? 0;
}
+532
View File
@@ -0,0 +1,532 @@
import { eq, ne } from "drizzle-orm";
import { db } from "@/db";
import { mailAccounts } from "@/db/schema";
import type { MailAccount, MailProvider } from "@/db/schema";
import { decryptSecret, encryptSecret } from "@/lib/crypto";
/**
* Boîte mail de l'association : configuration, connexion, santé.
*
* Décalque volontaire de `src/lib/social-accounts.ts` — mêmes règles, mêmes
* garanties : les secrets sont chiffrés en base, ne repartent jamais vers le
* navigateur, un champ laissé vide conserve celui déjà enregistré, et le
* contrôle de santé ne lève jamais.
*
* Trois transports, un seul actif à la fois. Pas de cascade automatique : si
* Google se bloque, c'est l'administrateur qui bascule, et il le voit.
*/
export const MAIL_PROVIDERS: MailProvider[] = ["google", "microsoft", "smtp"];
export const MAIL_LABELS: Record<MailProvider, string> = {
google: "Google — Gmail / Workspace",
microsoft: "Microsoft — Outlook / Hotmail / 365",
smtp: "Autre serveur (SMTP)",
};
/** Le jeton d'accès est renouvelé un peu avant l'échéance annoncée. */
const REFRESH_MARGIN_MS = 5 * 60_000;
function env(key: string): string {
return (process.env[key] ?? "").trim();
}
// ---- Lecture ----
export async function getMailAccount(provider: MailProvider): Promise<MailAccount | null> {
const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.provider, provider));
return row ?? null;
}
export async function getMailAccounts(): Promise<MailAccount[]> {
return db.select().from(mailAccounts);
}
export async function getActiveMailAccount(): Promise<MailAccount | null> {
const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.isActive, true));
return row ?? null;
}
export type MailSender = {
provider: MailProvider;
fromAddress: string;
fromName: string;
/** Google / Microsoft : jeton valide. SMTP : chaîne vide. */
accessToken: string;
smtp?: { host: string; port: number; secure: boolean; user: string; password: string };
};
/**
* Le transport prêt à expédier, ou `null` si rien n'est configuré.
*
* Aucun repli sur l'environnement pour les jetons OAuth — ils ne peuvent
* venir que du parcours de connexion — mais les réglages SMTP restent lisibles
* depuis l'environnement, ce qui permet un premier déploiement sans passer par
* l'écran.
*/
export async function resolveMailSender(): Promise<MailSender | null> {
const account = await getActiveMailAccount();
if (account?.provider === "smtp") {
const password = safeDecrypt(account.smtpPassword);
if (account.smtpHost && account.smtpUser && password) {
return {
provider: "smtp",
fromAddress: account.fromAddress || account.smtpUser,
fromName: account.fromName ?? "",
accessToken: "",
smtp: {
host: account.smtpHost,
port: account.smtpPort ?? 465,
secure: account.smtpSecure,
user: account.smtpUser,
password,
},
};
}
}
if (account && (account.provider === "google" || account.provider === "microsoft")) {
const accessToken = await ensureAccessToken(account.provider);
if (accessToken && account.fromAddress) {
return {
provider: account.provider,
fromAddress: account.fromAddress,
fromName: account.fromName ?? "",
accessToken,
};
}
}
// Repli d'environnement : utile avant toute configuration depuis l'écran.
const host = env("SMTP_HOST");
const user = env("SMTP_USER");
const password = env("SMTP_PASSWORD");
if (host && user && password) {
return {
provider: "smtp",
fromAddress: env("SMTP_FROM") || user,
fromName: env("SMTP_FROM_NAME"),
accessToken: "",
smtp: { host, port: Number(env("SMTP_PORT") || 465), secure: env("SMTP_SECURE") !== "false", user, password },
};
}
return null;
}
export async function isMailConfigured(): Promise<boolean> {
return (await resolveMailSender()) !== null;
}
function safeDecrypt(value: string | null): string | null {
if (!value) return null;
try {
return decryptSecret(value);
} catch {
// Clé de chiffrement changée : on préfère « non configuré » à une erreur.
return null;
}
}
// ---- Écriture ----
export async function saveOAuthApp(provider: MailProvider, appId: string, appSecret: string | null) {
const existing = await getMailAccount(provider);
if (!existing) {
if (!appSecret) throw new Error("Le secret de l'application est requis à la première saisie.");
await db.insert(mailAccounts).values({ provider, appId, appSecret: encryptSecret(appSecret), updatedAt: new Date() });
return;
}
await db
.update(mailAccounts)
.set({
appId,
// Champ laissé vide = on conserve le secret déjà enregistré.
...(appSecret ? { appSecret: encryptSecret(appSecret) } : {}),
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
}
export type SmtpSettings = {
host: string;
port: number;
secure: boolean;
user: string;
password: string | null;
fromAddress: string;
fromName: string;
};
export async function saveSmtpAccount(settings: SmtpSettings) {
const existing = await getMailAccount("smtp");
const shared = {
smtpHost: settings.host,
smtpPort: settings.port,
smtpSecure: settings.secure,
smtpUser: settings.user,
fromAddress: settings.fromAddress || settings.user,
fromName: settings.fromName,
updatedAt: new Date(),
// Les réglages changent : le dernier verdict ne vaut plus rien.
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
};
if (!existing) {
if (!settings.password) throw new Error("Le mot de passe est requis à la première saisie.");
await db.insert(mailAccounts).values({ provider: "smtp", ...shared, smtpPassword: encryptSecret(settings.password) });
return;
}
await db
.update(mailAccounts)
.set({ ...shared, ...(settings.password ? { smtpPassword: encryptSecret(settings.password) } : {}) })
.where(eq(mailAccounts.provider, "smtp"));
}
export async function saveMailConnection(input: {
provider: MailProvider;
accessToken: string;
refreshToken: string | null;
expiresAt: Date | null;
fromAddress: string;
fromName?: string | null;
connectedById: number | null;
}) {
const existing = await getMailAccount(input.provider);
const values = {
accessToken: encryptSecret(input.accessToken),
// Microsoft fait tourner le jeton de rafraîchissement à chaque
// renouvellement ; un `null` ici ne doit pas effacer celui en place.
...(input.refreshToken ? { refreshToken: encryptSecret(input.refreshToken) } : {}),
expiresAt: input.expiresAt,
fromAddress: input.fromAddress,
...(input.fromName ? { fromName: input.fromName } : {}),
connectedById: input.connectedById,
connectedAt: new Date(),
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
updatedAt: new Date(),
};
if (!existing) {
await db.insert(mailAccounts).values({ provider: input.provider, ...values });
return;
}
await db.update(mailAccounts).set(values).where(eq(mailAccounts.provider, input.provider));
}
/** Désigne le fournisseur expéditeur ; les autres restent configurés. */
export async function setActiveMailProvider(provider: MailProvider) {
await db.update(mailAccounts).set({ isActive: false }).where(ne(mailAccounts.provider, provider));
await db.update(mailAccounts).set({ isActive: true, updatedAt: new Date() }).where(eq(mailAccounts.provider, provider));
}
/**
* Coupe la connexion sans effacer les identifiants d'application : une
* reconnexion ne redemande pas de recopier l'identifiant et le secret.
*/
export async function disconnectMailAccount(provider: MailProvider) {
await db
.update(mailAccounts)
.set({
accessToken: null,
refreshToken: null,
expiresAt: null,
fromAddress: null,
isActive: false,
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
}
export async function getDecryptedAppSecret(provider: MailProvider): Promise<string | null> {
const account = await getMailAccount(provider);
return safeDecrypt(account?.appSecret ?? null);
}
// ---- Jetons OAuth ----
const TOKEN_ENDPOINT: Record<"google" | "microsoft", string> = {
google: "https://oauth2.googleapis.com/token",
microsoft: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
};
/**
* Jeton d'accès valide, renouvelé si besoin. Appelée par l'expéditeur, jamais
* par le rendu d'une page : un renouvellement est une écriture.
*/
export async function ensureAccessToken(provider: "google" | "microsoft"): Promise<string | null> {
const account = await getMailAccount(provider);
if (!account) return null;
const current = safeDecrypt(account.accessToken);
const stillValid =
current && account.expiresAt && new Date(account.expiresAt).getTime() - Date.now() > REFRESH_MARGIN_MS;
if (stillValid) return current;
const refreshToken = safeDecrypt(account.refreshToken);
const appSecret = safeDecrypt(account.appSecret);
if (!refreshToken || !account.appId || !appSecret) return current;
try {
const res = await fetch(TOKEN_ENDPOINT[provider], {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: account.appId,
client_secret: appSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) return current;
const payload = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!payload.access_token) return current;
await db
.update(mailAccounts)
.set({
accessToken: encryptSecret(payload.access_token),
// Microsoft renvoie un nouveau jeton de rafraîchissement à chaque
// appel et invalide l'ancien : ne pas l'enregistrer condamnerait
// l'envoi au bout d'une heure.
...(payload.refresh_token ? { refreshToken: encryptSecret(payload.refresh_token) } : {}),
expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null,
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
return payload.access_token;
} catch {
return current;
}
}
// ---- Santé ----
export type MailHealth = { ok: true; detail: string } | { ok: false; reason: string };
async function rememberCheck(provider: MailProvider, health: MailHealth) {
await db
.update(mailAccounts)
.set({
lastCheckAt: new Date(),
lastCheckOk: health.ok,
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
})
.where(eq(mailAccounts.provider, provider));
}
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
}
/**
* Interroge le fournisseur. Ne lève jamais : un échec est un verdict, pas une
* exception — l'écran doit pouvoir l'afficher.
*/
export async function checkMailHealth(provider: MailProvider): Promise<MailHealth | null> {
const account = await getMailAccount(provider);
if (!account) return null;
try {
if (provider === "smtp") {
const password = safeDecrypt(account.smtpPassword);
if (!account.smtpHost || !account.smtpUser || !password) return null;
const { verifySmtp } = await import("@/lib/mailer");
const health = await verifySmtp({
host: account.smtpHost,
port: account.smtpPort ?? 465,
secure: account.smtpSecure,
user: account.smtpUser,
password,
});
await rememberCheck(provider, health);
return health;
}
const token = await ensureAccessToken(provider);
if (!token) return null;
const url =
provider === "google"
? "https://gmail.googleapis.com/gmail/v1/users/me/profile"
: "https://graph.microsoft.com/v1.0/me";
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const health: MailHealth = res.ok
? { ok: true, detail: `Boîte accessible (${account.fromAddress ?? "compte connecté"})` }
: { ok: false, reason: await readError(res) };
await rememberCheck(provider, health);
return health;
} catch (error) {
const health: MailHealth = {
ok: false,
reason: error instanceof Error ? error.message : "Fournisseur injoignable",
};
await rememberCheck(provider, health);
return health;
}
}
/** Verdict mis en cache : évite un appel réseau à chaque affichage. */
export async function mailHealthCached(
provider: MailProvider,
maxAgeMs = 6 * 3_600_000
): Promise<MailHealth | null> {
const account = await getMailAccount(provider);
if (!account) return null;
const fresh = account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs;
if (fresh && account.lastCheckOk !== null) {
return account.lastCheckOk
? { ok: true, detail: "Vérifiée récemment" }
: { ok: false, reason: account.lastCheckError ?? "Boîte refusée" };
}
return checkMailHealth(provider);
}
// ---- Parcours OAuth ----
export class MailAuthError extends Error {}
/**
* Portées demandées.
*
* Google : `gmail.send` est classée *sensible*. `https://mail.google.com/`,
* qu'exigerait SMTP avec XOAUTH2, est *restreinte* et déclenche un audit de
* sécurité — d'où le choix de l'API Gmail.
*
* Microsoft : délégué, sur l'autorité `/common`, pour accepter aussi bien une
* boîte professionnelle qu'un compte outlook.com ou hotmail.com personnel.
*/
const SCOPES: Record<"google" | "microsoft", string> = {
google: "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/userinfo.email",
microsoft: "offline_access Mail.Send User.Read",
};
export async function mailRedirectUri(provider: MailProvider, base: string): Promise<string> {
return `${base.replace(/\/+$/, "")}/api/mail/${provider}/callback`;
}
export async function mailAuthorizeUrl(
provider: "google" | "microsoft",
appId: string,
state: string,
base: string
): Promise<string> {
const redirectUri = await mailRedirectUri(provider, base);
if (provider === "google") {
return `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri,
response_type: "code",
scope: SCOPES.google,
// Sans `offline` et `consent`, Google ne délivre pas de jeton de
// rafraîchissement au deuxième passage : l'envoi mourrait au bout d'une
// heure sans raison visible.
access_type: "offline",
prompt: "consent",
include_granted_scopes: "true",
state,
})}`;
}
return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri,
response_type: "code",
response_mode: "query",
scope: SCOPES.microsoft,
state,
})}`;
}
export type MailExchange = {
accessToken: string;
refreshToken: string | null;
expiresAt: Date | null;
fromAddress: string;
fromName: string | null;
};
/**
* Échange le code contre des jetons, puis **lit l'adresse chez le
* fournisseur** : Gmail expédie comme l'utilisateur authentifié et Graph comme
* la boîte. Laisser l'administrateur saisir une autre adresse ferait tomber
* SPF et DKIM, et tous les messages partiraient en indésirable.
*/
export async function exchangeMailCode(
provider: "google" | "microsoft",
appId: string,
appSecret: string,
code: string,
base: string
): Promise<MailExchange> {
const res = await fetch(TOKEN_ENDPOINT[provider], {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: appId,
client_secret: appSecret,
code,
redirect_uri: await mailRedirectUri(provider, base),
grant_type: "authorization_code",
}),
});
if (!res.ok) throw new MailAuthError(await readError(res));
const payload = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!payload.access_token) throw new MailAuthError("Aucun jeton d'accès renvoyé.");
const identity = await readIdentity(provider, payload.access_token);
if (!identity.address) {
throw new MailAuthError("Impossible de lire l'adresse de la boîte connectée.");
}
return {
accessToken: payload.access_token,
refreshToken: payload.refresh_token ?? null,
expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null,
fromAddress: identity.address,
fromName: identity.name,
};
}
async function readIdentity(
provider: "google" | "microsoft",
accessToken: string
): Promise<{ address: string; name: string | null }> {
if (provider === "google") {
const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new MailAuthError(await readError(res));
const profile = (await res.json()) as { emailAddress?: string };
return { address: profile.emailAddress ?? "", name: null };
}
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new MailAuthError(await readError(res));
const profile = (await res.json()) as { mail?: string; userPrincipalName?: string; displayName?: string };
return { address: profile.mail ?? profile.userPrincipalName ?? "", name: profile.displayName ?? null };
}
+243
View File
@@ -0,0 +1,243 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { mailMessages } from "@/db/schema";
import type { MailKind } from "@/db/schema";
import { sendNow } from "@/lib/mailer";
/**
* File d'attente d'envoi.
*
* Une diffusion à quatre-vingts adhérents ne peut pas partir dans la requête
* qui l'a déclenchée : la page attendrait quatre-vingts allers-retours. Les
* messages sont donc écrits en base et vidés par la boucle de fond, sur le
* modèle de `releaseDuePromotions`.
*
* Une ligne par destinataire, jamais de copie partagée : `to_address` est un
* `varchar` unique, la confidentialité est structurelle.
*
* **Les mots de passe temporaires ne passent pas par ici.** Le clair n'existe
* que dans la portée de l'action qui les émet, et `mail_messages.html` est
* stocké en base : ils partent en ligne directe (`sendNow`), et seule une
* trace sans contenu est journalisée.
*/
/** Débit par passage. Gmail bride autour de quelques centaines par jour. */
const DEFAULT_BATCH = Number(process.env.MAIL_RATE_PER_MINUTE ?? 20);
/** Au-delà, le message est abandonné : cinq échecs ne sont pas un incident réseau. */
export const MAX_ATTEMPTS = 5;
/** Un verrou plus vieux que cela vient d'un conteneur mort en plein envoi. */
const STALE_LOCK_MS = 10 * 60_000;
export type QueuedMail = {
kind: MailKind;
toAddress: string;
toName?: string | null;
subject: string;
html: string;
text?: string | null;
replyTo?: string | null;
informationId?: number | null;
meetingId?: number | null;
memberId?: number | null;
createdById?: number | null;
};
/** Attente avant la prochaine tentative : 2, 4, 8, 16 minutes. */
export function backoffDelayMs(attempts: number): number {
const capped = Math.min(Math.max(attempts, 1), MAX_ATTEMPTS);
return 2 ** capped * 60_000;
}
export function shouldGiveUp(attempts: number): boolean {
return attempts >= MAX_ATTEMPTS;
}
export async function queueMails(mails: QueuedMail[]): Promise<number> {
if (mails.length === 0) return 0;
const rows = await db.insert(mailMessages).values(mails).returning({ id: mailMessages.id });
return rows.length;
}
export async function queueMail(mail: QueuedMail): Promise<number> {
return queueMails([mail]);
}
/**
* Journalise un envoi déjà parti, sans son contenu.
*
* Sert aux mots de passe temporaires : le journal doit montrer qu'un message
* est parti, à qui et quand, sans que le corps — donc le mot de passe — ne
* touche la base.
*/
export async function logSentMail(entry: {
kind: MailKind;
toAddress: string;
subject: string;
memberId?: number | null;
createdById?: number | null;
result: { ok: true } | { ok: false; reason: string };
}) {
await db.insert(mailMessages).values({
kind: entry.kind,
toAddress: entry.toAddress,
subject: entry.subject,
html: "",
memberId: entry.memberId ?? null,
createdById: entry.createdById ?? null,
status: entry.result.ok ? "sent" : "failed",
attempts: 1,
sentAt: entry.result.ok ? new Date() : null,
error: entry.result.ok ? null : entry.result.reason.slice(0, 2000),
});
}
type ClaimedMail = {
id: number;
toAddress: string;
toName: string | null;
subject: string;
html: string;
text: string | null;
replyTo: string | null;
attempts: number;
};
/** Ligne telle que la renvoie le pilote : noms de colonnes bruts. */
type ClaimedRow = {
id: number;
to_address: string;
to_name: string | null;
subject: string;
html: string;
text: string | null;
reply_to: string | null;
attempts: number;
};
/**
* Réclame un lot de messages à envoyer.
*
* `FOR UPDATE SKIP LOCKED` : deux instances peuvent vider la file en parallèle
* sans jamais expédier deux fois le même message. `releaseDuePromotions` s'en
* passe parce que la transition de statut y fait office de verrou ; ici le
* passage en `sending` doit être exclusif avant l'appel réseau.
*/
async function claimDueMails(limit: number): Promise<ClaimedMail[]> {
const stale = new Date(Date.now() - STALE_LOCK_MS);
// Un conteneur arrêté en plein envoi laisse des lignes en `sending` :
// on les remet en file avant de servir le lot suivant.
await db
.update(mailMessages)
.set({ status: "queued", lockedAt: null })
.where(and(eq(mailMessages.status, "sending"), sql`${mailMessages.lockedAt} < ${stale}`));
const { rows } = await db.execute<ClaimedRow>(sql`
update ${mailMessages}
set status = 'sending', locked_at = now()
where id in (
select id from ${mailMessages}
where status = 'queued' and next_attempt_at <= now()
order by id
limit ${limit}
for update skip locked
)
returning id, to_address, to_name, subject, html, text, reply_to, attempts
`);
// `db.execute` rend les colonnes telles quelles : contrairement au
// constructeur de requêtes, il ne rebaptise pas `to_address` en `toAddress`.
// Sans cette conversion le destinataire arrivait vide chez nodemailer.
return rows.map((row) => ({
id: row.id,
toAddress: row.to_address,
toName: row.to_name,
subject: row.subject,
html: row.html,
text: row.text,
replyTo: row.reply_to,
attempts: Number(row.attempts),
}));
}
export type OutboxReport = { sent: number; failed: number };
/** Vide un lot de la file. Ne lève jamais : c'est une boucle de fond. */
export async function processOutbox(limit = DEFAULT_BATCH): Promise<OutboxReport> {
const report: OutboxReport = { sent: 0, failed: 0 };
let claimed: ClaimedMail[] = [];
try {
claimed = await claimDueMails(limit);
} catch (error) {
console.error("[mail] réclamation :", error);
return report;
}
for (const message of claimed) {
const result = await sendNow({
to: message.toAddress,
toName: message.toName,
subject: message.subject,
html: message.html,
text: message.text,
replyTo: message.replyTo,
});
if (result.ok) {
report.sent += 1;
await db
.update(mailMessages)
.set({ status: "sent", sentAt: new Date(), provider: result.provider, lockedAt: null, error: null })
.where(eq(mailMessages.id, message.id));
continue;
}
report.failed += 1;
const attempts = message.attempts + 1;
const exhausted = shouldGiveUp(attempts);
await db
.update(mailMessages)
.set({
status: exhausted ? "failed" : "queued",
attempts,
lockedAt: null,
nextAttemptAt: new Date(Date.now() + backoffDelayMs(attempts)),
error: result.reason.slice(0, 2000),
})
.where(eq(mailMessages.id, message.id));
}
return report;
}
// ---- Journal ----
export async function recentMails(limit = 40) {
return db.select().from(mailMessages).orderBy(desc(mailMessages.id)).limit(limit);
}
export async function mailCountsFor(informationId: number) {
const rows = await db
.select({ status: mailMessages.status, total: sql<number>`count(*)::int` })
.from(mailMessages)
.where(eq(mailMessages.informationId, informationId))
.groupBy(mailMessages.status);
return rows.reduce<Record<string, number>>((acc, row) => ({ ...acc, [row.status]: row.total }), {});
}
/** Remet un message échoué en file, immédiatement. */
export async function retryMailMessage(id: number) {
await db
.update(mailMessages)
.set({ status: "queued", attempts: 0, nextAttemptAt: new Date(), lockedAt: null, error: null })
.where(and(eq(mailMessages.id, id), eq(mailMessages.status, "failed")));
}
/** Annule un message encore en attente ; un message parti ne s'annule pas. */
export async function cancelMailMessage(id: number) {
await db
.update(mailMessages)
.set({ status: "cancelled", lockedAt: null })
.where(and(eq(mailMessages.id, id), eq(mailMessages.status, "queued")));
}
+122
View File
@@ -0,0 +1,122 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { categories, meetingRegistrations, members, users } from "@/db/schema";
/**
* Qui reçoit quoi.
*
* L'adresse retenue est **`members.email`**, l'adresse administrative qui sert
* aussi d'identifiant de connexion — jamais `members.contact_email`, qui est
* l'adresse publique de la fiche et peut pointer vers un standard.
*
* La partie pure (`dedupeRecipients`) est exportée pour être testée sans base :
* c'est elle qui garantit qu'un adhérent ne reçoit pas deux fois le même
* message, et que N destinataires donnent bien N envois.
*/
export type Recipient = { email: string; name: string; memberId: number | null };
export type AudienceKind = "all" | "category" | "members" | "meeting";
export type Audience =
| { kind: "all" }
| { kind: "category"; categoryId: number }
| { kind: "members"; memberIds: number[] }
| { kind: "meeting"; meetingId: number };
// Volontairement permissif : le rôle de ce contrôle est d'écarter les champs
// vides ou manifestement cassés, pas de réinventer la RFC 5322.
const PLAUSIBLE_EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]{2,}$/;
export function isPlausibleEmail(value: string): boolean {
return PLAUSIBLE_EMAIL.test(String(value ?? "").trim());
}
/**
* Écarte les adresses vides ou invalides et les doublons, insensiblement à la
* casse. L'ordre d'origine est conservé : la première occurrence gagne, avec
* son nom.
*/
export function dedupeRecipients(rows: { email: string | null; name?: string | null; memberId?: number | null }[]): Recipient[] {
const seen = new Set<string>();
const out: Recipient[] = [];
for (const row of rows) {
const email = String(row.email ?? "").trim();
if (!isPlausibleEmail(email)) continue;
const key = email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push({ email, name: String(row.name ?? "").trim(), memberId: row.memberId ?? null });
}
return out;
}
export async function resolveAudience(audience: Audience): Promise<Recipient[]> {
if (audience.kind === "meeting") {
const rows = await db
.select({ email: meetingRegistrations.attendeeEmail, name: meetingRegistrations.attendeeName, memberId: meetingRegistrations.memberId })
.from(meetingRegistrations)
.where(eq(meetingRegistrations.meetingId, audience.meetingId))
.orderBy(asc(meetingRegistrations.id));
return dedupeRecipients(rows);
}
const base = db
.select({ email: members.email, name: members.name, memberId: members.id })
.from(members)
.$dynamic();
if (audience.kind === "category") {
return dedupeRecipients(
await base
.where(and(eq(members.status, "active"), eq(members.categoryId, audience.categoryId)))
.orderBy(asc(members.name)),
);
}
if (audience.kind === "members") {
if (audience.memberIds.length === 0) return [];
return dedupeRecipients(await base.where(inArray(members.id, audience.memberIds)).orderBy(asc(members.name)));
}
return dedupeRecipients(await base.where(eq(members.status, "active")).orderBy(asc(members.name)));
}
/** Les adhérents actifs, pour la diffusion d'une information. */
export async function activeMemberRecipients(): Promise<Recipient[]> {
return resolveAudience({ kind: "all" });
}
export type AudienceOption = { value: string; label: string; count: number };
/** Options proposées par le sélecteur de destinataires. */
export async function audienceOptions(): Promise<{ all: number; categories: AudienceOption[] }> {
const all = (await activeMemberRecipients()).length;
const rows = await db
.select({ id: categories.id, label: categories.label, email: members.email, memberId: members.id })
.from(categories)
.innerJoin(members, and(eq(members.categoryId, categories.id), eq(members.status, "active")))
.orderBy(asc(categories.sort), asc(categories.label));
const grouped = new Map<number, { label: string; rows: { email: string | null; memberId: number }[] }>();
for (const row of rows) {
const entry = grouped.get(row.id) ?? { label: row.label, rows: [] };
entry.rows.push({ email: row.email, memberId: row.memberId });
grouped.set(row.id, entry);
}
return {
all,
categories: [...grouped.entries()].map(([id, entry]) => ({
value: String(id),
label: entry.label,
count: dedupeRecipients(entry.rows).length,
})),
};
}
/** L'adresse de l'utilisateur courant, pour « m'envoyer un test ». */
export async function selfRecipient(userId: number): Promise<Recipient | null> {
const [row] = await db.select({ email: users.email, name: users.name }).from(users).where(eq(users.id, userId));
return dedupeRecipients(row ? [{ ...row, memberId: null }] : [])[0] ?? null;
}
+141
View File
@@ -0,0 +1,141 @@
import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime";
import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts";
/**
* Expédition d'un message.
*
* Trois transports, deux familles :
*
* - **Google** passe par l'API Gmail (`users.messages.send`) et non par SMTP
* avec XOAUTH2 : celui-ci exigerait la portée `https://mail.google.com/`,
* classée « restreinte », donc un audit de sécurité. `gmail.send` est une
* portée simplement « sensible ».
* - **Microsoft** passe par Graph (`/me/sendMail`) : l'authentification
* basique SMTP est désactivée par défaut depuis 2024, y compris sur les
* boîtes outlook.com et hotmail.com.
* - **SMTP** couvre tout le reste (mot de passe d'application Gmail, Outlook
* professionnel, OVH, Ionos…) via `nodemailer`.
*
* `sendNow` ne lève jamais : elle renvoie un verdict, que la file d'attente ou
* l'action appelante affiche. Un envoi raté ne doit pas faire tomber la page
* qui l'a déclenché.
*/
export type OutgoingMail = {
to: string;
toName?: string | null;
subject: string;
html: string;
text?: string | null;
replyTo?: string | null;
};
export type SendResult =
| { ok: true; provider: MailSender["provider"] }
| { ok: false; reason: string };
export type SmtpConfig = { host: string; port: number; secure: boolean; user: string; password: string };
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
}
export async function sendNow(mail: OutgoingMail): Promise<SendResult> {
const sender = await resolveMailSender();
if (!sender) {
return { ok: false, reason: "Aucune boîte mail n'est configurée (Backend › Boîte mail)." };
}
try {
if (sender.provider === "google") return await sendViaGmail(sender, mail);
if (sender.provider === "microsoft") return await sendViaGraph(sender, mail);
return await sendViaSmtp(sender, mail);
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "Envoi impossible" };
}
}
async function sendViaGmail(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
// Le champ `raw` de l'API Gmail attend le message RFC 822 en base64url.
const raw = toBase64Url(
utf8ToBase64(
buildMimeMessage({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
}),
),
);
const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", {
method: "POST",
headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ raw }),
});
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, provider: "google" };
}
async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
// Graph prend du JSON : aucun MIME à construire de ce côté.
const res = await fetch("https://graph.microsoft.com/v1.0/me/sendMail", {
method: "POST",
headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
message: {
subject: mail.subject,
body: { contentType: "HTML", content: mail.html },
toRecipients: [{ emailAddress: { address: mail.to, name: mail.toName ?? undefined } }],
...(mail.replyTo ? { replyTo: [{ emailAddress: { address: mail.replyTo } }] } : {}),
},
saveToSentItems: true,
}),
});
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, provider: "microsoft" };
}
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." };
const transport = await smtpTransport(sender.smtp);
await transport.sendMail({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
});
return { ok: true, provider: "smtp" };
}
/**
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
* jamais entrer dans un bundle qui ne les a pas.
*/
async function smtpTransport(config: SmtpConfig) {
const nodemailer = (await import("nodemailer")).default;
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: { user: config.user, pass: config.password },
});
}
/** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */
export async function verifySmtp(config: SmtpConfig): Promise<MailHealth> {
try {
const transport = await smtpTransport(config);
await transport.verify();
transport.close();
return { ok: true, detail: `Connexion établie avec ${config.host}` };
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "Serveur SMTP injoignable" };
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Référent d'un adhérent : la personne à joindre, sa ligne directe.
*
* Ces informations sont **réservées aux visiteurs connectés** : elles ne
* doivent jamais partir dans le HTML public, le JSON-LD ou les métadonnées.
* Le module est pur — le filtrage par session se fait chez l'appelant, qui
* n'appelle `memberContact()` qu'après avoir vérifié la session, et verrouillé
* par `tests/member-contact.test.ts`.
*/
export type MemberContactSource = {
contactFirstName?: string | null;
contactLastName?: string | null;
contactPhone?: string | null;
};
export type MemberContact = {
/** « Prénom Nom », ou l'un des deux seulement. */
name: string | null;
phone: string | null;
};
function clean(value: string | null | undefined) {
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
return trimmed || null;
}
/** « Prénom Nom » à partir des deux champs, `null` si les deux sont vides. */
export function contactName(source: MemberContactSource): string | null {
return clean([clean(source.contactFirstName), clean(source.contactLastName)].filter(Boolean).join(" "));
}
/**
* Le référent affichable, ou `null` s'il n'y a rien à montrer : le bloc
* « Contact adhérent » disparaît alors plutôt que d'afficher un cadre vide.
*/
export function memberContact(source: MemberContactSource): MemberContact | null {
const name = contactName(source);
const phone = clean(source.contactPhone);
if (!name && !phone) return null;
return { name, phone };
}
/**
* `tel:` à partir d'un numéro saisi librement (« 03 83 24 .. », « +33 3 83 … »).
* Seuls les chiffres sont conservés, avec l'indicatif international s'il ouvre
* le numéro ; une saisie sans aucun chiffre ne produit pas de lien.
*/
export function telHref(phone: string | null | undefined): string | null {
const raw = clean(phone);
if (!raw) return null;
const international = raw.startsWith("+");
const digits = raw.replace(/\D/g, "");
if (!digits) return null;
return `tel:${international ? "+" : ""}${digits}`;
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Construction d'un message RFC 822.
*
* Module **pur** (verrouillé par `tests/mime.test.ts`) : il n'utilise que
* `TextEncoder` et `btoa`, disponibles aussi bien dans Node que dans le
* navigateur. Il sert donc deux appelants qui n'ont rien à voir :
*
* - `src/lib/email-client.ts`, qui fabrique le brouillon `.eml` téléchargé
* depuis le studio de composition ;
* - le transport Gmail (`src/lib/mailer.ts`), dont le champ `raw` attend
* exactement ce format, encodé en base64url.
*
* Tout corps est encodé en base64 : une frontière ne peut donc jamais
* apparaître dans la charge utile, et les sauts de ligne restent en CRLF —
* un LF isolé casse le « dot-stuffing » SMTP et se fait rejeter par Gmail.
*/
const CRLF = "\r\n";
/** Longueur maximale d'une ligne base64 (RFC 2045). */
const BASE64_LINE = 76;
/**
* Un mot encodé RFC 2047 ne doit pas dépasser 75 caractères. L'enveloppe
* `=?UTF-8?B?` + `?=` en consomme 12, il reste 63 caractères de base64, soit
* 45 octets de source (4 caractères base64 pour 3 octets).
*/
const HEADER_CHUNK_BYTES = 45;
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
export type MimeAttachment = {
filename: string;
contentType: string;
/** Contenu déjà encodé en base64, sans repli de ligne. */
base64: string;
/** Présent = pièce jointe en ligne, référencée par `cid:<contentId>`. */
contentId?: string;
};
export type MimeMessage = {
from?: string;
to?: string;
replyTo?: string;
subject: string;
html: string;
text?: string;
/** En-têtes libres ajoutés en tête (`X-Unsent`, `List-Unsubscribe`…). */
headers?: Record<string, string>;
inline?: MimeAttachment[];
/**
* Frontières imposées. Injectable pour que les tests décrivent une sortie
* stable ; en production elles sont tirées au hasard.
*/
boundaries?: string[];
};
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
// Par tranches : `String.fromCharCode(...bytes)` dépasse la pile d'appels
// au-delà de quelques dizaines de milliers d'octets.
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return globalThis.btoa(binary);
}
export function utf8ToBase64(value: string): string {
return bytesToBase64(new TextEncoder().encode(value));
}
export function wrapBase64(value: string): string {
return value.match(new RegExp(`.{1,${BASE64_LINE}}`, "g"))?.join(CRLF) ?? "";
}
/** Encodage attendu par le champ `raw` de l'API Gmail. */
export function toBase64Url(value: string): string {
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* Valeur d'en-tête sûre. Les caractères hors ASCII imprimable passent en mots
* encodés RFC 2047, repliés si le texte est long ; un accent dans un objet ne
* doit jamais partir tel quel.
*/
export function encodeHeaderValue(value: string): string {
// Un CR ou un LF dans une valeur d'en-tête permettrait d'en injecter
// d'autres (un `Bcc:` par exemple) : on les réduit à une espace.
const clean = String(value ?? "").replace(/[\r\n]+/g, " ").trim();
if (PRINTABLE_ASCII.test(clean)) return clean;
const encoder = new TextEncoder();
const words: string[] = [];
let chunk: number[] = [];
// Itération par point de code : découper le tableau d'octets au ras des 45
// couperait une séquence UTF-8 en deux.
for (const char of clean) {
const bytes = encoder.encode(char);
if (chunk.length > 0 && chunk.length + bytes.length > HEADER_CHUNK_BYTES) {
words.push(`=?UTF-8?B?${bytesToBase64(Uint8Array.from(chunk))}?=`);
chunk = [];
}
chunk.push(...bytes);
}
if (chunk.length > 0) words.push(`=?UTF-8?B?${bytesToBase64(Uint8Array.from(chunk))}?=`);
return words.join(`${CRLF} `);
}
/** « Nom affiché » <adresse>, avec le nom encodé ou échappé selon les cas. */
export function formatAddress(email: string, name?: string | null): string {
const address = String(email ?? "").replace(/[\r\n<>,;]/g, "").trim();
const display = String(name ?? "").replace(/[\r\n]+/g, " ").trim();
if (!address) return "";
if (!display) return address;
if (PRINTABLE_ASCII.test(display)) {
return `"${display.replace(/(["\\])/g, "\\$1")}" <${address}>`;
}
return `${encodeHeaderValue(display)} <${address}>`;
}
function randomBoundary(index: number): string {
const noise = Math.random().toString(36).slice(2, 12);
return `----=_PleinR_${index}_${noise}`;
}
type Part = { headers: string[]; body: string };
function textPart(contentType: string, value: string): Part {
return {
headers: [`Content-Type: ${contentType}; charset=UTF-8`, "Content-Transfer-Encoding: base64"],
body: wrapBase64(utf8ToBase64(value)),
};
}
function attachmentPart(attachment: MimeAttachment): Part {
const disposition = attachment.contentId ? "inline" : "attachment";
const headers = [
`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`,
"Content-Transfer-Encoding: base64",
`Content-Disposition: ${disposition}; filename="${attachment.filename}"`,
];
if (attachment.contentId) headers.splice(2, 0, `Content-ID: <${attachment.contentId}>`);
return { headers, body: wrapBase64(attachment.base64) };
}
function multipart(subtype: string, boundary: string, parts: Part[], extra = ""): Part {
const body = [
...parts.flatMap((part) => [`--${boundary}`, ...part.headers, "", part.body]),
`--${boundary}--`,
].join(CRLF);
return {
headers: [`Content-Type: multipart/${subtype}; ${extra}boundary="${boundary}"`],
body,
};
}
/**
* Assemble le message complet. Structure choisie selon ce qui est fourni :
*
* html seul → text/html
* html + texte → multipart/alternative
* … + images en ligne → multipart/related enveloppant le précédent
*/
export function buildMimeMessage(message: MimeMessage): string {
const boundaryAt = (index: number) => message.boundaries?.[index] ?? randomBoundary(index);
const inline = message.inline ?? [];
let root: Part = textPart("text/html", message.html);
if (message.text) {
// L'ordre compte : le repli texte d'abord, la version préférée en dernier.
root = multipart("alternative", boundaryAt(0), [
textPart("text/plain", message.text),
root,
]);
}
if (inline.length > 0) {
root = multipart(
"related",
boundaryAt(1),
[root, ...inline.map(attachmentPart)],
'type="text/html"; ',
);
}
const headers: string[] = [];
for (const [key, value] of Object.entries(message.headers ?? {})) {
headers.push(`${key}: ${encodeHeaderValue(value)}`);
}
if (message.from) headers.push(`From: ${message.from}`);
if (message.to) headers.push(`To: ${message.to}`);
if (message.replyTo) headers.push(`Reply-To: ${message.replyTo}`);
headers.push(`Subject: ${encodeHeaderValue(message.subject)}`);
headers.push("MIME-Version: 1.0");
return [...headers, ...root.headers, "", root.body, ""].join(CRLF);
}
+41
View File
@@ -1,6 +1,7 @@
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
import { memberContact, type MemberContact } from "@/lib/member-contact";
/**
* Promotion réellement visible par le public : validée **et** dans sa période
@@ -135,6 +136,46 @@ export async function getPublicMember(id: number) {
return m ?? null;
}
/**
* Référents des adhérents actifs, indexés par fiche.
*
* Volontairement séparé des lectures publiques (`getActiveMembersWithCategory`,
* `getPublicMember`) : nom, prénom et ligne directe ne sont réservés aux
* visiteurs connectés que parce qu'aucune requête publique ne les rapatrie.
* L'appelant vérifie la session **avant** d'appeler.
*/
export async function getMemberContacts(): Promise<Map<number, MemberContact>> {
const rows = await db
.select({
id: members.id,
contactFirstName: members.contactFirstName,
contactLastName: members.contactLastName,
contactPhone: members.contactPhone,
})
.from(members)
.where(eq(members.status, "active"));
const byMember = new Map<number, MemberContact>();
for (const row of rows) {
const contact = memberContact(row);
if (contact) byMember.set(row.id, contact);
}
return byMember;
}
/** Référent d'une fiche. Même règle que `getMemberContacts()` : session vérifiée d'abord. */
export async function getMemberContact(id: number): Promise<MemberContact | null> {
const [row] = await db
.select({
contactFirstName: members.contactFirstName,
contactLastName: members.contactLastName,
contactPhone: members.contactPhone,
})
.from(members)
.where(eq(members.id, id));
return row ? memberContact(row) : null;
}
export async function getMemberLivePromotions(memberId: number) {
return db
.select({
+3
View File
@@ -22,6 +22,7 @@ type Capability =
| "manageAdmins"
| "manageCategories"
| "manageMeetings"
| "manageInformations"
| "manageEmails"
| "manageSettings"
| "memberSpace";
@@ -35,6 +36,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
"manageAdmins",
"manageCategories",
"manageMeetings",
"manageInformations",
"manageEmails",
"manageSettings",
"memberSpace",
@@ -45,6 +47,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
"moderatePromos",
"publishSocial",
"manageMeetings",
"manageInformations",
],
editor: ["viewDashboard", "manageMembers"],
member: ["memberSpace"],
+318
View File
@@ -0,0 +1,318 @@
import type { ReactNode } from "react";
import { createElement, Fragment } from "react";
/**
* Sous-ensemble de Markdown pour les informations de l'association.
*
* Module **pur**, verrouillé par `tests/rich-text.test.ts`, bâti sur le même
* principe que `src/lib/activity.ts` : on ne stocke jamais de HTML et on ne
* rend jamais de HTML brut. Le texte saisi est analysé ici, puis rendu en
* éléments React côté site — ce que React échappe. Un éditeur WYSIWYG
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
* dépendance, deuxième surface d'attaque.
*
* Grammaire : `**gras**`, `*italique*`, `- puce`, `1. numéro`,
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
* Un saut de ligne simple reste un saut de ligne.
*
* Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
* `richTextNodes()`, il est donc fidèle par construction.
*/
export type Inline =
| { kind: "text"; value: string }
| { kind: "break" }
| { kind: "strong"; children: Inline[] }
| { kind: "em"; children: Inline[] }
| { kind: "link"; href: string; children: Inline[] };
export type Block =
| { kind: "paragraph"; children: Inline[] }
| { kind: "heading"; children: Inline[] }
| { kind: "bullets"; items: Inline[][] }
| { kind: "ordered"; items: Inline[][] };
/**
* Seuls `http:` et `https:` passent. Un `javascript:` ou un `data:` rendrait
* exécutable un lien écrit par un tiers ; il est rendu comme du texte.
*/
export function safeHttpUrl(value: string): string {
try {
const url = new URL(String(value ?? "").trim());
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
} catch {
return "";
}
}
function parseInline(text: string): Inline[] {
const nodes: Inline[] = [];
let buffer = "";
let index = 0;
const flush = () => {
if (buffer) nodes.push({ kind: "text", value: buffer });
buffer = "";
};
while (index < text.length) {
// `**` avant `*` : sans cet ordre, « **gras** » serait lu comme un
// italique vide suivi du mot.
if (text.startsWith("**", index)) {
const end = text.indexOf("**", index + 2);
if (end > index + 2) {
flush();
nodes.push({ kind: "strong", children: parseInline(text.slice(index + 2, end)) });
index = end + 2;
continue;
}
}
if (text[index] === "*") {
const end = text.indexOf("*", index + 1);
if (end > index + 1) {
flush();
nodes.push({ kind: "em", children: parseInline(text.slice(index + 1, end)) });
index = end + 1;
continue;
}
}
if (text[index] === "[") {
const close = text.indexOf("]", index + 1);
if (close > index && text[close + 1] === "(") {
const paren = text.indexOf(")", close + 2);
if (paren > close + 1) {
const label = text.slice(index + 1, close);
const href = safeHttpUrl(text.slice(close + 2, paren));
flush();
// Lien refusé : le libellé reste, la cible disparaît.
if (href) nodes.push({ kind: "link", href, children: parseInline(label) });
else nodes.push(...parseInline(label));
index = paren + 1;
continue;
}
}
}
buffer += text[index];
index += 1;
}
flush();
return nodes;
}
function parseInlineLines(lines: string[]): Inline[] {
const nodes: Inline[] = [];
lines.forEach((line, index) => {
if (index > 0) nodes.push({ kind: "break" });
nodes.push(...parseInline(line.trim()));
});
return nodes;
}
export function parseRichText(source: string): Block[] {
const lines = String(source ?? "").replace(/\r\n?/g, "\n").split("\n");
const blocks: Block[] = [];
let paragraph: string[] = [];
const flushParagraph = () => {
if (paragraph.length === 0) return;
blocks.push({ kind: "paragraph", children: parseInlineLines(paragraph) });
paragraph = [];
};
const pushItem = (kind: "bullets" | "ordered", item: Inline[]) => {
flushParagraph();
const last = blocks[blocks.length - 1];
if (last && last.kind === kind) last.items.push(item);
else blocks.push({ kind, items: [item] } as Block);
};
for (const raw of lines) {
const line = raw.trimEnd();
if (!line.trim()) {
flushParagraph();
continue;
}
const heading = /^##\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
blocks.push({ kind: "heading", children: parseInline(heading[1].trim()) });
continue;
}
// L'espace après le tiret est obligatoire : « *italique* » en début de
// ligne ne doit pas devenir une puce.
const bullet = /^[-*]\s+(.*)$/.exec(line);
if (bullet) {
pushItem("bullets", parseInline(bullet[1].trim()));
continue;
}
const ordered = /^\d+[.)]\s+(.*)$/.exec(line);
if (ordered) {
pushItem("ordered", parseInline(ordered[1].trim()));
continue;
}
paragraph.push(line);
}
flushParagraph();
return blocks;
}
// ---- Rendu écran ----
function inlineNodes(nodes: Inline[]): ReactNode[] {
return nodes.map((node, index) => {
switch (node.kind) {
case "break":
return createElement("br", { key: index });
case "strong":
return createElement("strong", { key: index }, inlineNodes(node.children));
case "em":
return createElement("em", { key: index }, inlineNodes(node.children));
case "link":
return createElement(
"a",
{ key: index, href: node.href, target: "_blank", rel: "noopener noreferrer" },
inlineNodes(node.children),
);
default:
return createElement(Fragment, { key: index }, node.value);
}
});
}
/**
* Rend le texte en nœuds React. Rien n'est jamais passé à
* `dangerouslySetInnerHTML` : une balise écrite dans la saisie s'affiche
* littéralement au lieu d'être interprétée.
*/
export function richTextNodes(source: string): ReactNode {
return parseRichText(source).map((block, index) => {
switch (block.kind) {
case "heading":
return createElement("h4", { key: index }, inlineNodes(block.children));
case "bullets":
return createElement(
"ul",
{ key: index },
block.items.map((item, position) => createElement("li", { key: position }, inlineNodes(item))),
);
case "ordered":
return createElement(
"ol",
{ key: index },
block.items.map((item, position) => createElement("li", { key: position }, inlineNodes(item))),
);
default:
return createElement("p", { key: index }, inlineNodes(block.children));
}
});
}
// ---- Rendu e-mail ----
const BODY_STYLE = "font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5d5447;";
const HEADING_STYLE = "font-family:Georgia,'Times New Roman',serif;font-size:19px;line-height:26px;color:#26201a;";
function esc(value: string) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function inlineHtml(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "<br>";
case "strong":
return `<strong style="color:#26201a;">${inlineHtml(node.children)}</strong>`;
case "em":
return `<em>${inlineHtml(node.children)}</em>`;
case "link":
return `<a href="${esc(node.href)}" target="_blank" style="color:#2C6FB3;">${inlineHtml(node.children)}</a>`;
default:
return esc(node.value);
}
})
.join("");
}
/**
* HTML à styles en ligne pour le gabarit d'e-mail. Sûr parce que le seul
* producteur est l'analyseur ci-dessus : aucune balise de la saisie n'arrive
* jusqu'ici, tout passe par `esc()`.
*/
export function richTextToEmailHtml(source: string): string {
return parseRichText(source)
.map((block) => {
switch (block.kind) {
case "heading":
return `<div style="${HEADING_STYLE}margin:0 0 12px;">${inlineHtml(block.children)}</div>`;
case "bullets":
case "ordered": {
const tag = block.kind === "bullets" ? "ul" : "ol";
const items = block.items.map((item) => `<li style="margin-bottom:4px;">${inlineHtml(item)}</li>`).join("");
return `<${tag} style="${BODY_STYLE}margin:0 0 18px;padding-left:22px;">${items}</${tag}>`;
}
default:
return `<div style="${BODY_STYLE}margin:0 0 18px;">${inlineHtml(block.children)}</div>`;
}
})
.join("");
}
// ---- Rendus texte ----
function inlinePlain(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "\n";
case "link": {
const label = inlinePlain(node.children);
return label === node.href ? node.href : `${label} (${node.href})`;
}
case "strong":
case "em":
return inlinePlain(node.children);
default:
return node.value;
}
})
.join("");
}
/** Repli `text/plain` du message : même contenu, sans balisage. */
export function richTextToPlain(source: string): string {
return parseRichText(source)
.map((block) => {
switch (block.kind) {
case "bullets":
return block.items.map((item) => `- ${inlinePlain(item)}`).join("\n");
case "ordered":
return block.items.map((item, index) => `${index + 1}. ${inlinePlain(item)}`).join("\n");
default:
return inlinePlain(block.children);
}
})
.join("\n\n");
}
/** Résumé d'une ligne, pour les listes du back-office. */
export function richTextExcerpt(source: string, max = 160): string {
const plain = richTextToPlain(source).replace(/\s+/g, " ").trim();
if (plain.length <= max) return plain;
const cut = plain.slice(0, max);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}
+16
View File
@@ -1,3 +1,4 @@
import type { EmailBrand } from "@/lib/email-templates";
import { asc } from "drizzle-orm";
import { db } from "@/db";
import { siteSettings } from "@/db/schema";
@@ -122,3 +123,18 @@ export function parseBoardMembers(value: string) {
return { name, role: role || "Membre du directoire" };
});
}
/**
* Identité de l'association telle qu'elle apparaît en pied de tous les
* e-mails. Elle sert au studio de composition comme aux envois automatiques :
* un seul endroit à changer.
*/
export function emailBrand(settings: SiteSettings): EmailBrand {
return {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
/**
* La barre latérale d'administration tient dans deux structures littérales de
* `BackendShell.tsx` — les titres d'en-tête et les sections — plus le jeu
* d'icônes. Ajouter un écran demande de toucher aux trois : ce test attrape
* l'oubli, qui se voit sinon par une icône absente ou un en-tête vide.
*/
const shell = readFileSync(new URL("../src/app/backend/BackendShell.tsx", import.meta.url), "utf8");
const icons = readFileSync(new URL("../src/components/BackendIcons.tsx", import.meta.url), "utf8");
/** Entrées de navigation déclarées dans `SECTIONS`. */
const navItems = [...shell.matchAll(/\{\s*href:\s*"([^"]+)",\s*label:\s*"([^"]*)",\s*icon:\s*"([^"]+)"/g)].map(
(match) => ({ href: match[1], label: match[2], icon: match[3] })
);
/** Clés du dictionnaire `TITLES`. */
const titled = new Set([...shell.matchAll(/^\s*"(\/backend[^"]*)":\s*\[/gm)].map((match) => match[1]));
/** Noms d'icônes réellement dessinés. */
const drawn = new Set([...icons.matchAll(/^\s{2}([a-z]+):\s*(?:\(|<)/gm)].map((match) => match[1]));
test("la navigation n'est pas vide", () => {
assert.ok(navItems.length >= 10, `seulement ${navItems.length} entrées trouvées`);
assert.ok(drawn.size >= 10, `seulement ${drawn.size} icônes trouvées`);
});
for (const item of navItems) {
test(`« ${item.label} » a son icône et son en-tête`, () => {
assert.ok(drawn.has(item.icon), `l'icône « ${item.icon} » n'est dessinée nulle part`);
assert.ok(titled.has(item.href), `${item.href} n'a pas d'entrée dans TITLES`);
});
}
test("chaque icône déclarée dans le type est dessinée", () => {
const declared = [...icons.matchAll(/^\s*\|\s*"([a-z]+)"/gm)].map((match) => match[1]);
assert.ok(declared.length > 0, "aucune icône déclarée : le test ne vérifie plus rien");
for (const name of declared) {
assert.ok(drawn.has(name), `l'icône « ${name} » est déclarée mais pas dessinée`);
}
});
+28
View File
@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { MAX_ATTEMPTS, backoffDelayMs, shouldGiveUp } from "../src/lib/mail-outbox";
test("l'attente entre deux tentatives croît et reste plafonnée", () => {
const delays = [1, 2, 3, 4, 5].map(backoffDelayMs);
for (let i = 1; i < delays.length; i += 1) {
assert.ok(delays[i] > delays[i - 1], `la tentative ${i + 1} devrait attendre plus longtemps`);
}
assert.equal(backoffDelayMs(1), 2 * 60_000);
assert.equal(backoffDelayMs(4), 16 * 60_000);
// Au-delà du plafond, l'attente ne double plus indéfiniment.
assert.equal(backoffDelayMs(50), backoffDelayMs(MAX_ATTEMPTS));
});
test("une valeur aberrante ne produit pas une attente absurde", () => {
assert.equal(backoffDelayMs(0), backoffDelayMs(1));
assert.equal(backoffDelayMs(-3), backoffDelayMs(1));
assert.ok(Number.isFinite(backoffDelayMs(1000)));
});
test("on abandonne après cinq tentatives, pas avant", () => {
assert.equal(shouldGiveUp(1), false);
assert.equal(shouldGiveUp(MAX_ATTEMPTS - 1), false);
assert.equal(shouldGiveUp(MAX_ATTEMPTS), true);
assert.equal(shouldGiveUp(MAX_ATTEMPTS + 1), true);
});
+53
View File
@@ -0,0 +1,53 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { dedupeRecipients, isPlausibleEmail } from "../src/lib/mail-recipients";
test("une adresse manifestement cassée est écartée", () => {
assert.equal(isPlausibleEmail("contact@pleinr.fr"), true);
assert.equal(isPlausibleEmail(" contact@pleinr.fr "), true);
assert.equal(isPlausibleEmail(""), false);
assert.equal(isPlausibleEmail("pas-une-adresse"), false);
assert.equal(isPlausibleEmail("contact@pleinr"), false);
assert.equal(isPlausibleEmail("a@b.fr, c@d.fr"), false);
assert.equal(isPlausibleEmail("victime@pleinr.fr>\r\nBcc: espion@x.fr"), false);
});
test("N destinataires donnent N entrées, jamais une seule fusionnée", () => {
const rows = [
{ email: "a@pleinr.fr", name: "A", memberId: 1 },
{ email: "b@pleinr.fr", name: "B", memberId: 2 },
{ email: "c@pleinr.fr", name: "C", memberId: 3 },
];
const out = dedupeRecipients(rows);
assert.equal(out.length, 3);
assert.deepEqual(out.map((r) => r.email), ["a@pleinr.fr", "b@pleinr.fr", "c@pleinr.fr"]);
});
test("un doublon ne reçoit pas deux fois, quelle que soit la casse", () => {
const out = dedupeRecipients([
{ email: "Contact@PleinR.fr", name: "Premier", memberId: 1 },
{ email: "contact@pleinr.fr", name: "Second", memberId: 2 },
{ email: " CONTACT@pleinr.FR ", name: "Troisième", memberId: 3 },
]);
assert.equal(out.length, 1);
// La première occurrence gagne, avec son nom et son rattachement.
assert.equal(out[0].name, "Premier");
assert.equal(out[0].memberId, 1);
assert.equal(out[0].email, "Contact@PleinR.fr");
});
test("les lignes sans adresse utilisable disparaissent", () => {
const out = dedupeRecipients([
{ email: null, name: "Sans adresse", memberId: 1 },
{ email: " ", name: "Vide", memberId: 2 },
{ email: "cassée", name: "Invalide", memberId: 3 },
{ email: "bon@pleinr.fr", name: "Bon", memberId: 4 },
]);
assert.deepEqual(out, [{ email: "bon@pleinr.fr", name: "Bon", memberId: 4 }]);
});
test("un nom absent devient une chaîne vide, pas « null »", () => {
const out = dedupeRecipients([{ email: "bon@pleinr.fr", name: null, memberId: null }]);
assert.deepEqual(out, [{ email: "bon@pleinr.fr", name: "", memberId: null }]);
});
+35
View File
@@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { contactName, memberContact, telHref } from "../src/lib/member-contact";
test("le référent joint prénom et nom", () => {
assert.equal(contactName({ contactFirstName: "Marie", contactLastName: "Durand" }), "Marie Durand");
assert.equal(contactName({ contactFirstName: " Marie ", contactLastName: " Durand " }), "Marie Durand");
assert.equal(contactName({ contactFirstName: "Marie" }), "Marie");
assert.equal(contactName({ contactLastName: "Durand" }), "Durand");
assert.equal(contactName({}), null);
assert.equal(contactName({ contactFirstName: " ", contactLastName: null }), null);
});
test("un référent sans nom ni téléphone n'existe pas", () => {
assert.equal(memberContact({}), null);
assert.equal(memberContact({ contactFirstName: " ", contactPhone: "" }), null);
assert.deepEqual(memberContact({ contactPhone: "03 83 24 10 10" }), {
name: null,
phone: "03 83 24 10 10",
});
assert.deepEqual(memberContact({ contactLastName: "Durand", contactPhone: null }), {
name: "Durand",
phone: null,
});
});
test("le lien tel: ne garde que les chiffres et l'indicatif", () => {
assert.equal(telHref("03 83 24 10 10"), "tel:0383241010");
assert.equal(telHref("+33 3 83 24 10 10"), "tel:+33383241010");
assert.equal(telHref("03.83.24.10.10"), "tel:0383241010");
assert.equal(telHref("sur rendez-vous"), null);
assert.equal(telHref(null), null);
assert.equal(telHref(" "), null);
});
+179
View File
@@ -0,0 +1,179 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildMimeMessage,
bytesToBase64,
encodeHeaderValue,
formatAddress,
toBase64Url,
utf8ToBase64,
wrapBase64,
} from "../src/lib/mime";
const BOUNDARIES = ["ALT", "REL"];
test("un objet purement ASCII part tel quel", () => {
assert.equal(encodeHeaderValue("Invitation Plein R"), "Invitation Plein R");
});
test("un objet accentué est encodé en mot RFC 2047", () => {
const encoded = encodeHeaderValue("Rencontre à Pompey");
assert.match(encoded, /^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
assert.equal(Buffer.from(encoded.slice(10, -2), "base64").toString("utf8"), "Rencontre à Pompey");
});
test("un objet accentué long est replié en plusieurs mots d'au plus 75 caractères", () => {
const encoded = encodeHeaderValue(
"Assemblée générale de l'association Plein R du Bassin de Pompey — mardi 14 octobre à 19 h, salle Jean-Jaurès",
);
const words = encoded.split("\r\n ");
assert.ok(words.length > 1, "le texte long doit être replié");
for (const word of words) {
assert.ok(word.length <= 75, `mot trop long : ${word.length}`);
assert.match(word, /^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
}
const decoded = words.map((w) => Buffer.from(w.slice(10, -2), "base64").toString("utf8")).join("");
assert.equal(decoded, "Assemblée générale de l'association Plein R du Bassin de Pompey — mardi 14 octobre à 19 h, salle Jean-Jaurès");
});
test("le repli ne coupe jamais une séquence UTF-8 en deux", () => {
// Que des caractères à 3 octets : 45 / 3 = 15 par mot, sans reste.
const encoded = encodeHeaderValue("€".repeat(40));
const decoded = encoded
.split("\r\n ")
.map((w) => Buffer.from(w.slice(10, -2), "base64").toString("utf8"))
.join("");
assert.equal(decoded, "€".repeat(40));
});
test("un retour à la ligne dans un en-tête ne permet pas d'en injecter un autre", () => {
const encoded = encodeHeaderValue("Bonjour\r\nBcc: espion@example.com");
assert.ok(!encoded.includes("\r"));
assert.ok(!encoded.includes("\n"));
assert.equal(encoded, "Bonjour Bcc: espion@example.com");
});
test("une adresse avec nom affiché est mise en forme, une adresse nue reste nue", () => {
assert.equal(formatAddress("contact@pleinr.fr"), "contact@pleinr.fr");
assert.equal(formatAddress("contact@pleinr.fr", "Plein R"), '"Plein R" <contact@pleinr.fr>');
assert.equal(
formatAddress("contact@pleinr.fr", 'Plein "R"'),
'"Plein \\"R\\"" <contact@pleinr.fr>',
);
});
test("un nom affiché accentué est encodé, pas mis entre guillemets", () => {
const address = formatAddress("contact@pleinr.fr", "Association Plein R — Élan");
assert.match(address, /^=\?UTF-8\?B\?.+\?= <contact@pleinr\.fr>$/);
});
test("une adresse malformée ne peut pas glisser d'en-tête supplémentaire", () => {
assert.equal(
formatAddress("victime@pleinr.fr>\r\nBcc: espion@example.com", "Plein R"),
'"Plein R" <victime@pleinr.frBcc: espion@example.com>',
);
});
test("le base64 est replié à 76 colonnes", () => {
const lines = wrapBase64(utf8ToBase64("x".repeat(400))).split("\r\n");
for (const line of lines) assert.ok(line.length <= 76, `ligne de ${line.length} caractères`);
assert.ok(lines.length > 1);
});
test("toBase64Url retire le remplissage et remplace + et /", () => {
const raw = bytesToBase64(Uint8Array.from([251, 255, 190, 0]));
assert.ok(raw.includes("+") || raw.includes("/"));
const url = toBase64Url(raw);
assert.ok(!url.includes("+") && !url.includes("/") && !url.includes("="));
assert.equal(url, raw.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
});
test("un message HTML seul tient en une seule partie", () => {
const mime = buildMimeMessage({ subject: "Bonjour", html: "<p>Salut</p>" });
assert.match(mime, /^Subject: Bonjour\r\nMIME-Version: 1\.0\r\nContent-Type: text\/html; charset=UTF-8\r\n/);
assert.ok(!mime.includes("multipart"));
assert.ok(mime.includes(utf8ToBase64("<p>Salut</p>")));
});
test("html + texte donnent un multipart/alternative, le texte d'abord", () => {
const mime = buildMimeMessage({
subject: "Bonjour",
html: "<p>Salut</p>",
text: "Salut",
boundaries: BOUNDARIES,
});
assert.ok(mime.includes('Content-Type: multipart/alternative; boundary="ALT"'));
assert.ok(mime.includes("--ALT--"));
assert.ok(
mime.indexOf("text/plain") < mime.indexOf("text/html"),
"le repli texte doit précéder la version préférée",
);
});
test("une image en ligne enveloppe le tout dans un multipart/related", () => {
const mime = buildMimeMessage({
subject: "Bonjour",
html: '<img src="cid:logo">',
text: "Bonjour",
boundaries: BOUNDARIES,
inline: [{ filename: "logo.png", contentType: "image/png", contentId: "logo", base64: "AAAA" }],
});
assert.ok(mime.includes('Content-Type: multipart/related; type="text/html"; boundary="REL"'));
assert.ok(mime.includes("Content-ID: <logo>"));
assert.ok(mime.includes('Content-Disposition: inline; filename="logo.png"'));
// L'alternative reste imbriquée dans le related, pas l'inverse.
assert.ok(mime.indexOf("--REL") < mime.indexOf("multipart/alternative"));
assert.ok(mime.includes("--ALT--"));
assert.ok(mime.includes("--REL--"));
});
test("une pièce jointe sans identifiant de contenu est attachée, pas incorporée", () => {
const mime = buildMimeMessage({
subject: "Compte rendu",
html: "<p>Ci-joint</p>",
boundaries: BOUNDARIES,
inline: [{ filename: "cr.pdf", contentType: "application/pdf", base64: "AAAA" }],
});
assert.ok(mime.includes('Content-Disposition: attachment; filename="cr.pdf"'));
assert.ok(!mime.includes("Content-ID"));
});
test("le message n'utilise que des CRLF, jamais un LF isolé", () => {
const mime = buildMimeMessage({
subject: "Rencontre à Pompey — programme complet de la soirée du 14 octobre",
html: "<p>Ligne une</p>\n<p>Ligne deux</p>",
text: "Ligne une\nLigne deux",
boundaries: BOUNDARIES,
inline: [{ filename: "logo.png", contentType: "image/png", contentId: "logo", base64: "AAAA" }],
});
// Les corps sont encodés en base64 : aucun LF de la source ne survit brut.
assert.equal(mime.replace(/\r\n/g, ""), mime.replace(/\r\n/g, "").replace(/[\r\n]/g, ""));
assert.ok(!/(?<!\r)\n/.test(mime), "un LF sans CR précède");
});
test("une frontière ne peut pas apparaître dans la charge utile", () => {
const mime = buildMimeMessage({
subject: "Essai",
html: "<p>--ALT et --REL dans le texte</p>",
text: "--ALT--",
boundaries: BOUNDARIES,
});
// Les corps étant en base64, les seules occurrences sont les délimiteurs.
assert.equal(mime.match(/--ALT/g)?.length, 3); // ouverture ×2 + fermeture
});
test("les en-têtes libres, l'expéditeur et le répondre-à sont posés", () => {
const mime = buildMimeMessage({
subject: "Essai",
html: "<p>a</p>",
from: '"Plein R" <contact@pleinr.fr>',
to: "adherent@example.com",
replyTo: "bureau@pleinr.fr",
headers: { "X-Unsent": "1" },
});
assert.ok(mime.startsWith("X-Unsent: 1\r\n"));
assert.ok(mime.includes('From: "Plein R" <contact@pleinr.fr>\r\n'));
assert.ok(mime.includes("To: adherent@example.com\r\n"));
assert.ok(mime.includes("Reply-To: bureau@pleinr.fr\r\n"));
});
+166
View File
@@ -0,0 +1,166 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
parseRichText,
richTextExcerpt,
richTextNodes,
richTextToEmailHtml,
richTextToPlain,
safeHttpUrl,
} from "../src/lib/rich-text";
/** Sérialise l'arbre React pour inspecter ce qui a réellement été construit. */
function serialize(source: string) {
return JSON.stringify(richTextNodes(source));
}
test("les paragraphes sont séparés par une ligne vide", () => {
const blocks = parseRichText("Premier.\n\nSecond.");
assert.equal(blocks.length, 2);
assert.equal(blocks[0].kind, "paragraph");
assert.equal(richTextToPlain("Premier.\n\nSecond."), "Premier.\n\nSecond.");
});
test("un saut de ligne simple reste un saut de ligne", () => {
const blocks = parseRichText("Une ligne\nune autre");
assert.equal(blocks.length, 1);
assert.deepEqual(
blocks[0].kind === "paragraph" ? blocks[0].children.map((c) => c.kind) : [],
["text", "break", "text"],
);
assert.ok(richTextToEmailHtml("Une ligne\nune autre").includes("<br>"));
});
test("gras et italique sont reconnus, et imbriqués", () => {
assert.ok(richTextToEmailHtml("**gras**").includes("<strong"));
assert.ok(richTextToEmailHtml("*doux*").includes("<em>doux</em>"));
assert.ok(richTextToEmailHtml("**du *doux* dedans**").includes("<em>doux</em>"));
});
test("une étoile non appariée reste littérale", () => {
assert.equal(richTextToPlain("**gras sans fin"), "**gras sans fin");
assert.equal(richTextToPlain("2 * 3 = 6"), "2 * 3 = 6");
assert.ok(!richTextToEmailHtml("**gras sans fin").includes("<strong"));
});
test("les listes à puces et numérotées regroupent leurs lignes", () => {
const blocks = parseRichText("- un\n- deux\n\n1. a\n2. b");
assert.equal(blocks.length, 2);
assert.equal(blocks[0].kind, "bullets");
assert.equal(blocks[0].kind === "bullets" ? blocks[0].items.length : 0, 2);
assert.equal(blocks[1].kind, "ordered");
const html = richTextToEmailHtml("- un\n- deux");
assert.equal(html.match(/<li/g)?.length, 2);
assert.ok(html.startsWith("<ul"));
});
test("un italique en début de ligne n'est pas confondu avec une puce", () => {
const blocks = parseRichText("*insistons* sur ce point");
assert.equal(blocks[0].kind, "paragraph");
});
test("un sous-titre devient un bloc à part", () => {
const blocks = parseRichText("## Ordre du jour\nPremier point");
assert.equal(blocks.length, 2);
assert.equal(blocks[0].kind, "heading");
assert.ok(richTextToEmailHtml("## Ordre du jour").includes("Georgia"));
});
test("un lien http(s) est rendu, en nouvel onglet et sans fuite d'ouvreur", () => {
const nodes = serialize("[le site](https://pleinr.fr/promotions)");
assert.ok(nodes.includes("https://pleinr.fr/promotions"));
assert.ok(nodes.includes("noopener noreferrer"));
assert.ok(nodes.includes('"target":"_blank"'));
assert.ok(richTextToEmailHtml("[le site](https://pleinr.fr)").includes('href="https://pleinr.fr/"'));
});
test("un lien javascript: ou data: perd sa cible et ne garde que son libellé", () => {
for (const hostile of [
"[clique ici](javascript:alert(1))",
"[clique ici](data:text/html,<script>alert(1)</script>)",
"[clique ici](vbscript:msgbox)",
"[clique ici]( JavaScript:alert(1) )",
]) {
const html = richTextToEmailHtml(hostile);
assert.ok(!html.includes("<a "), `cible conservée pour ${hostile}`);
assert.ok(!/javascript:/i.test(html), `protocole conservé pour ${hostile}`);
assert.ok(html.includes("clique ici"), "le libellé doit rester");
}
assert.equal(safeHttpUrl("javascript:alert(1)"), "");
assert.equal(safeHttpUrl("pas une url"), "");
assert.equal(safeHttpUrl("https://pleinr.fr"), "https://pleinr.fr/");
});
test("un crochet dans le libellé ne permet pas de sortir du lien", () => {
const html = richTextToEmailHtml("[a]b](https://pleinr.fr)");
assert.ok(!html.includes("<a "), "le lien malformé ne doit pas être construit");
assert.ok(html.includes("[a]b]"));
});
test("le HTML écrit dans la saisie est rendu littéralement", () => {
const payloads = [
"<script>alert(1)</script>",
'<img src=x onerror="alert(1)">',
"<svg/onload=alert(1)>",
'<iframe src="javascript:alert(1)"></iframe>',
"<ScRiPt>alert(1)</ScRiPt>",
'<a href="javascript:alert(1)">x</a>',
"<style>body{display:none}</style>",
];
// Les seules balises que le HTML d'e-mail a le droit de contenir sont
// celles que l'analyseur construit lui-même.
const OURS = /<\/?(?:div|strong|em|a|br|ul|ol|li)\b[^>]*>/g;
for (const payload of payloads) {
const html = richTextToEmailHtml(payload);
const leftovers = html.replace(OURS, "");
assert.ok(!leftovers.includes("<"), `balise conservée : ${payload}`);
assert.ok(!leftovers.includes(">"), `balise conservée : ${payload}`);
assert.ok(html.includes("&lt;"), `balise non échappée : ${payload}`);
// « onerror » survit comme texte échappé, et c'est sain : il n'est plus
// dans un contexte d'attribut, donc plus exécutable.
// Côté écran, le texte arrive tel quel dans un nœud React : c'est React
// qui échappe au rendu, la balise n'est jamais construite.
const tree = serialize(payload);
assert.ok(!tree.includes('"type":"script"'), `élément script construit : ${payload}`);
assert.ok(!tree.includes('"type":"img"'), `élément img construit : ${payload}`);
assert.ok(!tree.includes("onError"), `gestionnaire construit : ${payload}`);
}
});
test("le rendu n'emprunte jamais dangerouslySetInnerHTML", () => {
assert.ok(!serialize("**a** *b* [c](https://d.fr)\n- e\n\n## f").includes("dangerouslySetInnerHTML"));
});
test("les guillemets et esperluettes sont échappés dans le HTML d'e-mail", () => {
const html = richTextToEmailHtml('Marie & "Paul" <chez eux>');
assert.ok(html.includes("&amp;"));
assert.ok(html.includes("&quot;"));
assert.ok(html.includes("&lt;chez eux&gt;"));
});
test("le repli texte conserve le contenu et explicite les liens", () => {
assert.equal(
richTextToPlain("Voir **ici** : [les promos](https://pleinr.fr/promotions)"),
"Voir ici : les promos (https://pleinr.fr/promotions)",
);
assert.equal(richTextToPlain("- un\n- deux"), "- un\n- deux");
assert.equal(richTextToPlain("1. un\n2. deux"), "1. un\n2. deux");
});
test("le résumé coupe sur un mot et ajoute des points de suspension", () => {
assert.equal(richTextExcerpt("Court."), "Court.");
const long = richTextExcerpt("Vingt-deux chalets cette année, du 12 au 21 décembre prochain.", 30);
assert.ok(long.endsWith("…"));
assert.ok(long.length <= 31);
assert.ok(!long.includes(" "));
});
test("une saisie vide ne produit aucun bloc", () => {
assert.deepEqual(parseRichText(""), []);
assert.deepEqual(parseRichText(" \n\n "), []);
assert.equal(richTextToEmailHtml(""), "");
assert.equal(richTextExcerpt(""), "");
});
+211
View File
@@ -1,4 +1,5 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
@@ -109,3 +110,213 @@ describe("Chiffrement des secrets réseaux", () => {
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
});
});
describe("Coordonnées du référent — jamais dans les lectures publiques", () => {
const source = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
/** Corps d'une fonction exportée de `queries.ts`, jusqu'à la suivante. */
function bodyOf(name: string): string {
const start = source.indexOf(`export async function ${name}(`);
assert.notEqual(start, -1, `${name} introuvable dans src/lib/queries.ts`);
const next = source.indexOf("\nexport ", start + 1);
return source.slice(start, next === -1 ? source.length : next);
}
// Nom, prénom et ligne directe de l'adhérent ne sont montrés qu'à un visiteur
// connecté. La garantie tient à une seule chose : aucune requête servant une
// page publique ne les rapatrie — sinon ils partiraient dans le HTML, le
// JSON-LD ou les props du composant client de l'annuaire.
const publicReads = [
"getActiveMembersWithCategory",
"getActiveMembersByCategory",
"getRotatingActiveMembers",
"getPublicMember",
"getLivePromotions",
];
for (const name of publicReads) {
it(`${name} ne lit pas le référent`, () => {
const body = bodyOf(name);
for (const column of ["contactFirstName", "contactLastName", "contactPhone"]) {
assert.ok(
!body.includes(column),
`${name} sélectionne ${column} : ces coordonnées deviendraient publiques.`
);
}
});
}
it("les lectures réservées existent et sont séparées", () => {
for (const name of ["getMemberContacts", "getMemberContact"]) {
const body = bodyOf(name);
assert.ok(body.includes("contactPhone"), `${name} devrait lire le référent`);
}
});
});
describe("Mots de passe temporaires — jamais mis en file d'attente", () => {
const source = readFileSync(new URL("../src/app/backend/actions.ts", import.meta.url), "utf8");
/** Corps d'une fonction exportée, jusqu'à la suivante. */
function actionBody(name: string): string {
const start = source.indexOf(`export async function ${name}(`);
assert.notEqual(start, -1, `${name} introuvable dans src/app/backend/actions.ts`);
const next = source.indexOf("\nexport ", start + 1);
return source.slice(start, next === -1 ? source.length : next);
}
// `mail_messages.html` est stocké en base. Mettre un message d'identifiants
// dans la file y écrirait le mot de passe en clair, alors que tout le reste
// du produit s'emploie à ne jamais le conserver : il part donc en ligne
// directe, et seule une trace sans contenu est journalisée.
const issuers = [
"addMember",
"createMissingMemberAccounts",
"resetMemberPassword",
"inviteAdmin",
"approveMembershipRequest",
];
for (const name of issuers) {
it(`${name} envoie directement, sans passer par la file`, () => {
const body = actionBody(name);
assert.ok(body.includes("generateTempPassword()"), `${name} devrait émettre un mot de passe`);
assert.ok(body.includes("deliverCredentials("), `${name} devrait transmettre les identifiants`);
assert.ok(
!body.includes("queueMail"),
`${name} met un mot de passe temporaire dans la file : il finirait stocké en base.`
);
});
}
it("la trace journalisée ne porte aucun contenu", () => {
const outbox = readFileSync(new URL("../src/lib/mail-outbox.ts", import.meta.url), "utf8");
const start = outbox.indexOf("export async function logSentMail(");
assert.notEqual(start, -1, "logSentMail introuvable");
const body = outbox.slice(start, outbox.indexOf("\n/**", start + 1));
assert.ok(body.includes('html: ""'), "logSentMail devrait écrire un corps vide");
});
});
describe("Messagerie — aucun secret vers le navigateur", () => {
const page = readFileSync(new URL("../src/app/backend/boite-mail/page.tsx", import.meta.url), "utf8");
// Même règle que l'écran Réseaux sociaux : la page ne connaît que la
// *présence* d'un secret, jamais sa valeur.
for (const column of ["appSecret", "smtpPassword", "accessToken", "refreshToken"]) {
it(`l'écran ne lit jamais ${column} en clair`, () => {
// La présence du champ sert au libellé du champ de saisie ; ce qui est
// interdit, c'est de le déchiffrer.
assert.ok(
!page.includes(`decryptSecret(`) && !page.includes(`tryDecryptSecret(`),
`boite-mail/page.tsx déchiffre un secret : il partirait dans le HTML.`
);
});
}
it("le champ de saisie est masqué et un champ vide conserve la valeur", () => {
assert.ok(page.includes('type="password"'), "les secrets doivent être saisis en champ masqué");
assert.ok(
page.includes("laissez vide pour le conserver"),
"l'écran doit annoncer qu'un champ vide conserve le secret enregistré"
);
});
it("les secrets sont chiffrés à l'écriture", () => {
const accounts = readFileSync(new URL("../src/lib/mail-accounts.ts", import.meta.url), "utf8");
/** Corps d'une fonction exportée, jusqu'à la suivante. */
function writerBody(name: string): string {
const start = accounts.indexOf(`export async function ${name}(`);
assert.notEqual(start, -1, `${name} introuvable dans src/lib/mail-accounts.ts`);
const next = accounts.indexOf("\nexport ", start + 1);
return accounts.slice(start, next === -1 ? accounts.length : next);
}
// Seuls ces trois écrivent en base ; `exchangeMailCode` rend les jetons en
// mémoire à son appelant, qui les chiffre.
for (const name of ["saveOAuthApp", "saveSmtpAccount", "saveMailConnection"]) {
const body = writerBody(name);
const assignments = body.match(/\b(appSecret|smtpPassword|accessToken|refreshToken):\s*[^,\n]+/g) ?? [];
// On écarte les annotations de type de la signature et les valeurs
// littérales : seules les affectations réelles nous intéressent.
const writes = assignments.filter(
(assignment) => !/:\s*(null|""|string|number|boolean|Date)\b/.test(assignment)
);
assert.ok(writes.length > 0, `${name} n'écrit aucun secret : le test ne vérifie plus rien`);
for (const assignment of writes) {
assert.ok(
assignment.includes("encryptSecret("),
`${name} écrit un secret sans le chiffrer : ${assignment}`
);
}
}
});
});
describe("Diffusion groupée — aucune adresse partagée", () => {
it("ni copie ni copie cachée nulle part dans la chaîne d'envoi", () => {
for (const file of ["../src/lib/mailer.ts", "../src/lib/mail-outbox.ts"]) {
const source = readFileSync(new URL(file, import.meta.url), "utf8");
assert.ok(!/\bcc\s*:/i.test(source), `${file} pose une copie`);
assert.ok(!/\bbcc\s*:/i.test(source), `${file} pose une copie cachée`);
}
});
it("la file ne porte qu'un destinataire par ligne", () => {
const schema = readFileSync(new URL("../src/db/schema.ts", import.meta.url), "utf8");
assert.ok(
/toAddress: varchar\("to_address", \{ length: \d+ \}\)\.notNull\(\)/.test(schema),
"to_address devrait être une adresse unique et obligatoire"
);
});
});
describe("Informations — rien n'est rendu en HTML brut", () => {
const files = [
"../src/lib/rich-text.ts",
"../src/components/InformationCard.tsx",
"../src/app/backend/informations/InformationForm.tsx",
"../src/app/backend/espace/informations/page.tsx",
];
for (const file of files) {
it(`${file.split("/").pop()} n'emprunte pas dangerouslySetInnerHTML`, () => {
const source = readFileSync(new URL(file, import.meta.url), "utf8");
// L'usage réel, pas la mention : `rich-text.ts` explique en commentaire
// pourquoi il ne s'en sert pas.
assert.ok(
!/dangerouslySetInnerHTML\s*[={]/.test(source),
"le texte des informations vient de tiers : il doit rester des nœuds React"
);
});
}
it("les lectures publiques ignorent les informations", () => {
// Le fil est réservé aux adhérents connectés : aucune requête servant une
// page publique ne doit y toucher.
const queries = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
assert.ok(!queries.includes("informations"), "src/lib/queries.ts lit les informations");
});
});
describe("Boucles de fond — jamais dans le bundle edge", () => {
it("le module Node reste chargé à la demande et écarté du runtime edge", () => {
const entry = readFileSync(new URL("../src/instrumentation.ts", import.meta.url), "utf8");
assert.ok(entry.includes('NEXT_RUNTIME !== "nodejs"'), "la garde de runtime a disparu");
assert.ok(!entry.includes("mail-outbox"), "instrumentation.ts importe la file directement");
assert.ok(!entry.includes("nodemailer"), "instrumentation.ts importe nodemailer");
const config = readFileSync(new URL("../next.config.mjs", import.meta.url), "utf8");
assert.ok(config.includes("IgnorePlugin"), "l'exclusion du bundle edge a disparu");
assert.ok(config.includes('serverExternalPackages: ["nodemailer"]'), "nodemailer doit rester externe");
});
it("chaque boucle a son propre interrupteur", () => {
// Couper le libérateur de promotions ne doit pas couper l'envoi des
// e-mails : les deux n'ont rien à voir.
const worker = readFileSync(new URL("../src/instrumentation-node.ts", import.meta.url), "utf8");
assert.ok(worker.includes('process.env.PROMO_SCHEDULER !== "off"'));
assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"'));
});
});