Skip to content

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.

  • 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 que login_attempt).

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.

VariableDéfautRôle
NEWSLETTER_CONFIRM_TTL_HOURS72Durée de vie du lien de confirmation (heures).
NEWSLETTER_RATE_LIMIT_WINDOW_MINUTES60Fenêtre du throttle IP.
NEWSLETTER_RATE_LIMIT_PER_HOUR5Nombre maximum d’appels acceptés par IP sur la fenêtre.
  1. Honeypot — un champ website invisible 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.
  2. Rate-limit — sliding-window IP (envs ci-dessus).
  3. Validation formatfilter_var(..., FILTER_VALIDATE_EMAIL) + longueur ≤ 254.
  4. Blocklist — providers jetables (yopmail.com, mailinator.com, etc.) rejetés au niveau du host + parent-zone walk pour bloquer inbox.yopmail.com également. Code statique (DisposableEmailBlocklist).
  5. 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.

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.

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 userId du 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": ""
}
AttributTypeObligatoireSémantique
emailstringAdresse à inscrire (normalisée en minuscules côté serveur).
localestringLocale pour rendre l’email de confirmation. Défaut : locale résolue de la requête.
sourcestringTag libre stocké tel quel (ex : footer, landing-page). Défaut : api.
websitestringHoneypot — 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 en pending (le token est renouvelé), un re-subscribe après unsubscribed (soft-undo vers pending), ou une adresse déjà confirmée (no-op silencieux, anti-énumération) :
{
"jsonapi": { "version": "1.1" },
"meta": { "confirmationRequired": true }
}
  • Réponse 422errors[] avec meta.code parmi :
    • 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 429Retry-After: <seconds> + meta.code = newsletter.throttled + meta.retryAfterSeconds. Trop d’appels depuis cette IP sur la fenêtre.

Pour mémoire, les retours utilisateur (links cliqués depuis l’email) :

  • GET /newsletter/confirm?token=<base64url> — landing du double opt-in. Rend templates/newsletter/confirmed.twig avec l’état (confirmed, expired, unknown). Idempotent : re-cliquer après une confirmation réussie rend l’état unknown (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.*.