---
title: "Newsletter"
description: "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'éta…"
---

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

- 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`).

## 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

| 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

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 format** — `filter_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`](../src/Domain/Newsletter/DisposableEmailBlocklist.php)).
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.

## 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`

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](../src/Http/Action/Api/Newsletter/SubscribeAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "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 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) :

```json
{
  "jsonapi": { "version": "1.1" },
  "meta":    { "confirmationRequired": true }
}
```

- **Réponse `422`** — `errors[]` 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 `429`** — `Retry-After: <seconds>` + `meta.code = newsletter.throttled` +
  `meta.retryAfterSeconds`. Trop d'appels depuis cette IP sur la fenêtre.

## 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. 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.*`.

---
