# Capture d'email vers Listmonk — design

**Date** : 2026-07-31 · **Branche** : `feature/listmonk-capture`
**Statut** : en attente de relecture

---

## Objectif

Inscrire dans Listmonk les adresses laissées sur les trois formulaires du site, avec assez
d'attributs pour segmenter les campagnes ensuite. Sans jamais compromettre la capture du lead
commercial, qui reste la fonction première de ces formulaires.

## Décisions actées

| Sujet | Décision | Conséquence |
|---|---|---|
| Consentement | Délégué à Listmonk (double opt-in) | Envoi en `status: "unconfirmed"`, **aucune modification d'interface** |
| Listes | Une seule, **ID 3** | L'origine et les données outil vivent dans `attribs` |
| Doublon (HTTP 409) | Fusion des attributs | Les clés non envoyées sont préservées |
| Périmètre | Les 3 server actions | Mini-simulateur de la home exclu : il ne collecte pas d'email |
| Client HTTP | `fetch` natif | Le SDK `listmonk` a été retiré (voir `f360242`) |
| Champ `name` | La société | Les outils ne collectent pas de nom de personne |

### Pourquoi pas le SDK `listmonk`

Inspection du paquet 1.0.2 :

- `create_subscriber()` n'envoie pas le champ de statut (`index.js:61-68`) → impossible d'obtenir
  `unconfirmed`, dont dépend tout le modèle de consentement retenu.
- `add_subscribers_to_list()` force `"confirmed"` en dur (`index.js:75`) → court-circuiterait le
  double opt-in.
- Ni `GET /api/subscribers?query=`, ni `PUT /api/subscribers/{id}` → la fusion sur 409 est
  irréalisable.
- axios 0.27.2 (2022, CommonJS) là où `fetch` est natif, et aucun timeout.

---

## Architecture

Un module isolé. Les server actions ne connaissent qu'une fonction ; le module ne connaît aucune
d'elles.

```
app/actions/contact.ts      ─┐
app/actions/comparateur.ts  ─┼─→  lib/listmonk.ts  ─→  Listmonk HTTP API
app/actions/simulateur.ts   ─┘
```

### Surface publique

```ts
export type SubscriberSource = "contact" | "comparateur" | "simulateur";

export function captureSubscriber(input: {
  email: string;
  name: string;                        // la société, ou l'email en repli
  source: SubscriberSource;
  attribs?: Record<string, string | number | boolean | null>;
}): void;                              // synchrone, ne rend jamais la main en erreur
```

`captureSubscriber` ne retourne pas de promesse et ne jette jamais. Elle planifie le travail via
`after()` de Next.js et rend la main immédiatement.

### Ce dont le module dépend

Quatre variables d'environnement. Permissions API requises côté Listmonk : `subscribers:get` et `subscribers:manage` (PAS `subscribers:sql_query`). **Si l'une manque, le module ne fait rien** et le site
fonctionne exactement comme aujourd'hui — c'est le comportement voulu, pas une dégradation.

```
LISTMONK_URL        ex. https://listmonk.exemple.fr
LISTMONK_API_USER
LISTMONK_API_KEY
LISTMONK_LIST_ID    3   ← À AJOUTER, absent de .env.local
```

---

## Flux nominal

```
POST {LISTMONK_URL}/api/subscribers
Authorization: token {LISTMONK_API_USER}:{LISTMONK_API_KEY}
Content-Type: application/json

{
  "email":  "contact@exemple.fr",
  "name":   "Boulangerie Durand",
  "status": "enabled",
  "lists":  [3],
  "preconfirm_subscriptions": false,
  "attribs": { "source": "simulateur", "naf": "10.71C", "conso_mwh": 850, ... }
}
```

**Deux statuts se superposent dans Listmonk, et les confondre coûte un 500** (corrigé en cours
d'implémentation, la première version envoyait `"unconfirmed"` ici) :

| Niveau | Champ | Valeurs admises |
|---|---|---|
| Abonné | `status` | `enabled` \| `blocklisted` **uniquement** |
| Souscription à une liste | `subscription_status` | `unconfirmed` \| `confirmed` \| `unsubscribed` |

C'est le second qui porte le consentement, et on ne l'écrit pas directement :
`preconfirm_subscriptions: false` le place en `unconfirmed`. Listmonk envoie alors son email de
confirmation ; sans clic, aucune campagne ne part. C'est ce clic qui vaut consentement.

Vérifié en conditions réelles : `status=enabled`, `listes=3:unconfirmed`.

### Attributs par origine

| Clé | contact | comparateur | simulateur |
|---|:-:|:-:|:-:|
| `source` | ✅ | ✅ | ✅ |
| `company`, `phone` | ✅ | ✅ | ✅ |
| `profil` (C5/C4/C3) | — | ✅ | — |
| `type_entreprise` | — | ✅ | — |
| `conso_mwh` | — | ✅ | ✅ |
| `budget_min`, `budget_max` | — | ✅ | — |
| `economies_max` | — | ✅ | ✅ |
| `contrat_recommande` | — | ✅ | — |
| `top_fournisseur` | — | ✅ | — |
| `naf` | — | — | ✅ |
| `palier` | — | — | ✅ |

Le **message** du formulaire de contact n'est pas envoyé : il part déjà par email à l'équipe, et
c'est une donnée personnelle sans usage pour la segmentation.

---

## Flux doublon (HTTP 409)

```
POST /api/subscribers  ──→ 409 Conflict   (corps : "Cet e-mail existe déjà.", sans id)
   └─→ GET /api/subscribers?search=<email>       → récupère id + attribs actuels
        └─→ PUT /api/subscribers/{id}
            attribs = { ...existants, ...nouveaux }
```

La fusion est unidirectionnelle : on écrase les clés qu'on envoie, on préserve toutes les autres.
Une donnée saisie à la main dans l'admin Listmonk survit, sauf si elle porte exactement un de nos
noms de clés.

Le `PUT` **ne renvoie pas `status`** : rien ne doit pouvoir faire passer une souscription de
`unconfirmed` à `confirmed` dans le dos de l'abonné. Vérifié : après fusion, la souscription reste
`unconfirmed`.

### `search=` et non `query=`

Le paramètre `query=` de Listmonk est injecté tel quel dans une clause SQL. Il exige la permission
**`subscribers:sql_query`** — accorder l'exécution de SQL arbitraire pour retrouver un email serait
disproportionné — et imposerait d'échapper nous-mêmes l'adresse.

`search=` est une recherche texte, couverte par le simple `subscribers:get`, et ne construit
aucune requête. En contrepartie elle est approximative : elle peut renvoyer des fiches dont seul le
nom contient la chaîne. **On ne prend donc jamais le premier résultat**, on cherche l'égalité
stricte sur l'email.

*(La première implémentation utilisait `query=` et se heurtait à un 403 ; `search=` a été retenu
après avoir constaté qu'il répond 200 avec les droits déjà accordés.)*

---

## Robustesse — le point non négociable

**Un échec Listmonk ne doit jamais être visible par le visiteur ni retarder sa réponse.** Perdre
un lead commercial à cause d'une newsletter serait absurde.

| Garde-fou | Mise en œuvre |
|---|---|
| Non bloquant | `after()` de Next.js — la réponse part, l'inscription suit |
| Timeout | `AbortSignal.timeout(3000)` sur chaque requête |
| Erreurs | Attrapées, loguées, **jamais propagées** |
| Config absente | Sortie silencieuse, aucun appel réseau |
| Aucun secret dans les logs | On logue le statut HTTP et l'origine, jamais l'URL complète ni les identifiants |

Aucun `await` de `captureSubscriber` dans les actions : un appel, et l'action poursuit son cours.

---

## Découpage des fichiers

| Fichier | Rôle | Modification |
|---|---|---|
| `lib/listmonk.ts` | tout l'accès HTTP, l'auth, la fusion, les garde-fous | **création**, ~130 lignes |
| `app/actions/contact.ts` | appel avec `source: "contact"` | +3 lignes |
| `app/actions/comparateur.ts` | appel avec `source: "comparateur"` | +3 lignes |
| `app/actions/simulateur.ts` | appel avec `source: "simulateur"` | +3 lignes |

Les actions ne gagnent aucune logique conditionnelle : elles appellent, point.

---

## Tests

Le module est testable isolément car il ne dépend que de `fetch` et de l'environnement.

| Cas | Attendu |
|---|---|
| Config incomplète | aucun appel réseau |
| 200 | inscription en `unconfirmed` sur la liste 3 |
| 409 puis PUT | attributs fusionnés, clés préexistantes préservées |
| 409 puis GET en échec | logué, aucune exception |
| Timeout | logué, aucune exception |
| 500 Listmonk | logué, aucune exception |
| Recherche 409 approximative | seule l'égalité stricte sur l'email est retenue |

Vérification de bout en bout : soumettre les trois formulaires en local et constater l'apparition
des inscrits en `unconfirmed` dans l'admin Listmonk, avec leurs attributs.

---

## Hors périmètre

- Le mini-simulateur de la home — pas d'email collecté.
- Toute modification d'interface : le consentement étant délégué au double opt-in Listmonk,
  aucune case à cocher n'est ajoutée.
- La désinscription : gérée par Listmonk.
- L'envoi de campagnes : hors sujet, on ne fait qu'alimenter la liste.

## Prérequis avant implémentation

- [ ] Ajouter `LISTMONK_LIST_ID=3` à `.env.local` **et** aux variables Vercel
- [x] Next.js 16.2.11 (deux failles HIGH portaient sur les Server Actions) — `f360242`
- [x] SDK `listmonk` retiré — `f360242`
