Newsletter
Inscription publique à la newsletter, sous double opt-in (RGPD). Une
adresse email seule suffit ; aucun compte n’est requis. La ligne est
persistée à l’état pending et ne devient confirmed qu’après que le
destinataire ait cliqué le lien envoyé par email.
Les confirmation et désinscription se font côté web uniquement
(GET /newsletter/confirm?token=… et GET /newsletter/unsubscribe?token=…) :
ce sont les URLs incluses dans les emails. Aucune API JSON équivalente
n’est exposée — le client web suffit, et limiter à un seul transport
réduit la surface d’attaque sur les tokens.
Modèle de données
Section titled “Modèle de données”- Table
newsletter_subscriber:id(UUID binaire),email(unique, toujours stocké en minuscules),status(pending/confirmed/unsubscribed),locale,source,user_id(FK nullable vers le compte si présent au moment de la signup),ip_address(VARBINARY(16)),user_agent, deux tokens (hash SHA-256 binaire) + leurs timestamps. - Table
newsletter_subscribe_attempt: bucket sliding-window IP utilisé par le throttle (même forme quelogin_attempt).
Tokens
Section titled “Tokens”Convention identique à EmailConfirmationService : 32 octets de CSPRNG,
transportés en base64url dans les liens, seule la SHA-256 binaire est
stockée. Le token de confirmation est à usage unique et a une
expiration (env NEWSLETTER_CONFIRM_TTL_HOURS, défaut 72 h). Le token
de désinscription est émis à la confirmation, n’expire pas et reste le
même dans chaque email envoyé à cette adresse.
Variables d’environnement
Section titled “Variables d’environnement”| Variable | Défaut | Rôle |
|---|---|---|
NEWSLETTER_CONFIRM_TTL_HOURS | 72 | Durée de vie du lien de confirmation (heures). |
NEWSLETTER_RATE_LIMIT_WINDOW_MINUTES | 60 | Fenêtre du throttle IP. |
NEWSLETTER_RATE_LIMIT_PER_HOUR | 5 | Nombre maximum d’appels acceptés par IP sur la fenêtre. |
Anti-spam
Section titled “Anti-spam”- Honeypot — un champ
websiteinvisible aux humains ; toute valeur non vide trip le piège, l’IP est throttlée silencieusement et l’erreur renvoyée ne révèle pas la nature du piège. - Rate-limit — sliding-window IP (envs ci-dessus).
- Validation format —
filter_var(..., FILTER_VALIDATE_EMAIL)+ longueur ≤ 254. - Blocklist — providers jetables (
yopmail.com,mailinator.com, etc.) rejetés au niveau du host + parent-zone walk pour bloquerinbox.yopmail.comégalement. Code statique (DisposableEmailBlocklist). - Déliverabilité — lookup DNS MX puis A/AAAA en repli. Fail-OPEN sur erreur DNS pour ne pas pénaliser un domaine valide quand le résolveur est lent.
Anti-énumération
Section titled “Anti-énumération”Soumettre une adresse déjà confirmée renvoie exactement la même
réponse 202 Accepted qu’une nouvelle inscription — aucun email n’est
ré-envoyé, mais le caller ne peut pas distinguer les deux cas. Cela
empêche d’utiliser le endpoint pour tester l’existence d’une adresse
dans la liste.
POST /api/newsletter/subscribe
Section titled “POST /api/newsletter/subscribe”Inscrit une adresse email à la newsletter (état pending) et déclenche
l’envoi de l’email de confirmation.
- Auth : optionnelle (Bearer). Si présent, le
userIddu viewer est stampé sur la ligne ; sinon la ligne est anonyme. - CSRF : non (route
/api/*). - Action : SubscribeAction
- Request body (forme plate ou JSON:API
data.attributes) :
{ "email": "alice@example.com", "locale": "fr-FR", "source": "landing-page", "website": ""}| Attribut | Type | Obligatoire | Sémantique |
|---|---|---|---|
email | string | ✓ | Adresse à inscrire (normalisée en minuscules côté serveur). |
locale | string | Locale pour rendre l’email de confirmation. Défaut : locale résolue de la requête. | |
source | string | Tag libre stocké tel quel (ex : footer, landing-page). Défaut : api. | |
website | string | Honeypot — doit rester vide. Toute valeur non vide trip le piège. |
- Réponse
202 Accepted— la persistance a eu lieu et l’email part. Forme identique pour une nouvelle inscription, un re-submit enpending(le token est renouvelé), un re-subscribe aprèsunsubscribed(soft-undo verspending), ou une adresse déjà confirmée (no-op silencieux, anti-énumération) :
{ "jsonapi": { "version": "1.1" }, "meta": { "confirmationRequired": true }}- Réponse
422—errors[]avecmeta.codeparmi :newsletter.invalidEmail— format invalide.newsletter.disposableEmail— provider jetable rejeté.newsletter.undeliverableDomain— pas de MX/A/AAAA pour le domaine.newsletter.honeypot— piège déclenché.
- Réponse
429—Retry-After: <seconds>+meta.code = newsletter.throttled+meta.retryAfterSeconds. Trop d’appels depuis cette IP sur la fenêtre.
Endpoints web associés
Section titled “Endpoints web associés”Pour mémoire, les retours utilisateur (links cliqués depuis l’email) :
GET /newsletter/confirm?token=<base64url>— landing du double opt-in. Rendtemplates/newsletter/confirmed.twigavec l’état (confirmed,expired,unknown). Idempotent : re-cliquer après une confirmation réussie rend l’étatunknown(le token a été consommé).GET /newsletter/unsubscribe?token=<base64url>— désabonnement un-clic, idempotent. Le token de désinscription reste valable tant qu’il n’a pas été ré-émis ; un même lien peut donc resservir.
Le widget Twig réutilisable (templates/partials/newsletter-form.twig)
poste vers POST /newsletter/subscribe (web, CSRF requis) et reçoit un
redirect 303 avec ?newsletter=<flash-code> que la page d’embedding
affiche via partials.newsletter.flash.*.