API REST

difuzio expose une API REST sous /api/v1, servie par le même processus que l'interface web (serve-web). Elle partage la couche service et donc les mêmes règles d'autorisation : le comportement est identique quel que soit le point d'entrée.

Authentification

L'API accepte deux justificatifs, un seul à la fois par requête.

Clé d'API au format dfz_<prefix>_<secret>, transmise soit dans l'en-tête Authorization: Bearer dfz_..., soit dans X-API-Key: dfz_.... Une clé est :

  • liée à un utilisateur (ses droits bornent ceux de la clé) ;
  • attachée à un périmètre (scope_kind) : user, domain ou list ;
  • dotée d'un jeu de permissions (perms) : read, members:write, send, moderate, manage ;
  • éventuellement datée d'expiration, et révocable à tout moment ;
  • soumise à son quota rate_limit_per_min (dépassement : 429).

Cookie de session de l'interface web (difuzio_session), utilisé par la console d'administration : c'est la même session que les pages HTML, donc un rôle révoqué prend effet immédiatement. Dans ce mode, toute requête mutante (POST, PATCH, DELETE) doit renvoyer le secret synchroniseur de la session dans l'en-tête X-CSRF-Token, sans quoi elle est refusée par 403 csrf_failed. Une requête portant une clé d'API ne présente aucun justificatif ambiant : elle reste exemptée de CSRF. Le cookie et le secret s'obtiennent par POST /api/v1/auth/login.

Un justificatif absent, inconnu, révoqué, expiré, ou dont l'utilisateur n'est plus actif, est rejeté par un 401 uniforme ; le motif précis est journalisé côté serveur et jamais renvoyé au client, pour ne pas offrir un oracle d'énumération.

Création de clé

Une clé se frappe en ligne de commande avec create-api-key, ou par POST /api/v1/apikeys. Dans les deux cas le secret en clair dfz_<prefix>_<secret> n'est affiché qu'une seule fois (seul un hash est stocké) :

difuzio create-api-key --email u@example.org --scope user \
    --perms read,members:write,send,moderate,manage --name "ci"

--scope vaut user, domain ou list (avec --domain/--list selon le cas). --perms est une liste séparée par des virgules parmi read, members:write, send, moderate, manage. Une clé ne peut jamais être plus large que l'utilisateur qui la porte.

Isolation

L'API applique la même règle d'isolation que le reste du produit : une ressource hors du périmètre de la clé renvoie 404 Not Found (et non 403), pour ne pas révéler son existence.

Enveloppes de réponse

Toutes les réponses sont en JSON et enveloppées :

  • succès :

    { "data": ..., "meta": { "request_id": "...", "api_version": "v1" } }
  • erreur :

    { "error": { "code": "...", "message": "..." }, "meta": { ... } }

Chaque erreur est journalisée (avec request_id, route, statut, code) avant d'être écrite : aucune erreur n'est silencieuse.

Une collection ajoute ses compteurs de pagination à meta :

{ "data": [ ... ], "meta": { "request_id": "...", "api_version": "v1",
                             "total": 128, "limit": 50, "offset": 0 } }

Toutes les collections se paginent avec les deux mêmes paramètres, limit (1 à 500, défaut 50) et offset (défaut 0) ; total compte les lignes qui satisfont le filtre, indépendamment de la fenêtre. Une valeur hors bornes est refusée par un 422 explicite, jamais ramenée silencieusement dans les bornes : un limit rogné ferait croire au client qu'il a lu toute la page.

Statuts communs : 400 corps mal formé ou trop gros, 401 non authentifié, 403 permission absente ou CSRF, 404 inconnu ou hors périmètre, 409 conflit, 413 document trop volumineux, 422 valeur invalide, 429 quota, 500 erreur interne. Un identifiant de chemin qui n'est pas un entier positif est un 422 sur toutes les routes.

Points d'entrée

Sonde de santé

GET /api/v1/system/health

Non authentifiée. Renvoie {"data":{"status":"ok"}, ...}.

Identité du binaire en service

GET /api/v1/system/version

Authentifiée (clé d'API ou session), sans permission particulière : c'est la version du binaire qui répond, pas une donnée de périmètre. Elle n'est PAS publique, une version en clair servie à un anonyme étant une empreinte offerte à qui cherche une release vulnérable connue.

{
  "data": {
    "version": "0.5.0-dev",
    "commit": "26e2218...",
    "dirty": false,
    "built_at": "2026-08-11T10:16:38Z",
    "commit_at": "2026-08-11T10:00:33Z",
    "go_version": "go1.23.5",
    "platform": "linux/amd64",
    "backend": "MariaDB >= 10.6"
  }
}

built_at date l'édition de liens et n'est renseigné que si la compilation l'a injecté ; commit_at date le commit source et vient de l'empreinte que Go pose lui-même quand le binaire est compilé depuis un dépôt git. commit est vide si le binaire ne porte aucune de ces deux traces. C'est ce point d'entrée que la console interroge pour afficher la version du serveur en pied de barre latérale.

Lister les listes d'un domaine

GET /api/v1/domains/{domaine}/lists

Nécessite la permission read. Renvoie un tableau des listes du domaine visibles par la clé (id, name, display_name, type, status, send_suspended). Une clé au périmètre list ne voit que sa liste ; une clé au périmètre user voit les listes que son porteur gère (propriétaire, modérateur ou domain_admin du domaine). Un domaine hors du périmètre de la clé renvoie 404 (règle d'isolation).

Lister les abonnés d'une liste

GET /api/v1/domains/{domaine}/lists/{liste}/members

Nécessite la permission read et la capacité de voir les abonnés sur cette liste. Renvoie un tableau d'abonnés triés par id croissant (ordre stable, calculé par le serveur, donc la pagination ne remélange jamais les lignes). Chaque abonné porte : id, email, display_name, status, delivery_mode, bounce_score, subscribed_at, last_bounce_at, disabled_reason, disabled_at, consent_source, consent_at.

Les champs qui peuvent ne pas être renseignés valent null, jamais une chaîne vide ni une date à zéro : last_bounce_at: null signifie "jamais rebondi" et se distingue d'un score nul. Les preuves de consentement (consent_ip, identifiant du jeton, texte de consentement, consent_evidence) ne sont jamais exposées : seul le marqueur de base légale (consent_source, consent_at) est renvoyé, ce dont un administrateur a besoin à l'écran (minimisation RGPD).

Paramètres de requête, tous appliqués en SQL (le total de l'enveloppe est donc bien le nombre d'abonnés filtrés, pas la taille de la page) :

  • limit, offset : pagination (défaut 50, maximum 500) ;
  • status : pending, active, disabled, unsubscribed ou removed ;
  • delivery_mode : regular, digest ou nomail ;
  • min_bounce_score : ne garde que les abonnés dont le score de rebond atteint ce plancher (tri des adresses qui abîment la délivrabilité) ;
  • email_contains : sous-chaîne de l'adresse, insensible à la casse, 2 caractères minimum ; les jokers % et _ sont cherchés littéralement.

Une valeur invalide (statut inconnu, plancher non numérique, recherche d'un seul caractère) renvoie 422 avec la raison : un filtre ignoré silencieusement laisserait croire qu'aucun abonné ne correspond.

Consulter un abonné

GET /api/v1/domains/{domaine}/lists/{liste}/members/{membre}

Nécessite la permission read et la capacité de voir les abonnés. {membre} est l'identifiant numérique ou l'adresse encodée dans l'URL. Renvoie le même objet abonné que la liste. Un abonné d'une autre liste renvoie 404 (règle d'isolation).

Ajouter / abonner un membre

POST /api/v1/domains/{domaine}/lists/{liste}/members

Nécessite la permission members:write. Corps JSON :

{
  "email": "personne@example.org",
  "name": "Nom affiché",
  "delivery_mode": "regular",
  "force": false,
  "consent_source": "api",
  "consent_evidence": "trace de consentement"
}

Réponses :

  • 201 : abonné actif ({"status":"active","member_id":...}) ;
  • 202 : en attente (pending_confirmation ou pending_moderation) selon la politique d'abonnement de la liste ;
  • 409 : déjà abonné actif ;
  • 422 : validation (mode de livraison inconnu, ou preuve de consentement manquante sur un force=true) ;
  • 403 : liste fermée à l'abonnement, ou droits insuffisants.

Les modes de livraison acceptés sont regular, nomail et digest (voir Listes).

Le header Idempotency-Key (sur POST .../members et POST .../sends) rend le double-envoi sûr : la clé est réservée atomiquement avant l'exécution, donc deux requêtes concurrentes ne s'exécutent jamais deux fois (la seconde reçoit 409 si la première est en cours, ou rejoue la réponse stockée une fois finalisée). La même clé avec un corps différent renvoie 409 idempotency_conflict. La réponse est rejouée pendant 24 h (Idempotent-Replay: true), puis la clé expire. Le registre de rejeu est indexé par clé d'API : une requête authentifiée par cookie de session n'a nulle part où enregistrer sa réservation, un Idempotency-Key y est donc refusé explicitement (422 idempotency_unsupported) plutôt que d'être ignoré en silence, ce qui ferait croire à une déduplication inexistante.

Créer un webhook

POST /api/v1/webhooks

Nécessite la permission manage. Corps JSON :

{ "url": "https://...", "events": ["member.added"], "domain_id": 1, "list_id": 0 }

L'URL passe par une garde anti-SSRF (HTTPS obligatoire, résolution DNS et liste d'exclusion d'adresses internes) ; une URL refusée renvoie 422. En cas de succès, 201 avec {"id":..., "secret":...} (le secret de signature, à conserver).

Les événements reconnus (webhooks.events) sont : member.added, member.removed, member.bounced, subscription.pending, subscription.confirmed, send.submitted, send.completed, send.failed, message.held, list.suspended.

Les webhooks se listent (GET /api/v1/webhooks), se suppriment (DELETE /api/v1/webhooks/{id}) et se testent (POST /api/v1/webhooks/{id}/test, qui envoie un ping signé).

Livraison des webhooks

Le worker de livraison émet chaque événement par un POST signé X-Difuzio-Signature: sha256=hmac(secret, body) (plus X-Difuzio-Event), en at-least-once avec backoff, et journalise les échecs. La livraison passe par un dialer durci : HTTPS seulement, IP re-résolue à l'envoi et vérifiée contre la liste d'exclusion (RFC1918, loopback, link-local/metadata, CGNAT 100.64/10, ULA IPv6, NAT64 64:ff9b::/96), anti DNS-rebinding, sans suivi de redirection. Une livraison bloquée (worker mort en plein POST) est réenfilée automatiquement.

Les dix événements sont émis : les huit de cycle de vie (member.added, member.removed, member.bounced, subscription.pending, subscription.confirmed, send.submitted, message.held, list.suspended) et les deux d'agrégat d'envoi (send.completed, send.failed), ces derniers une fois que toutes les copies d'un envoi ont atteint un état terminal (tâche périodique du worker, émission exactement une fois via la table send_events).

Endpoints d'envoi et de modération

La permission send ouvre les endpoints d'envoi, moderate ceux de modération :

POST /api/v1/domains/{domaine}/lists/{liste}/sends          (send)   submit
GET  /api/v1/domains/{domaine}/lists/{liste}/sends/{send}   (read)   statut
POST .../sends/{send}/cancel                                (send)   annulation
GET/POST .../moderation/messages|subscriptions[...]         (moderate)

POST /sends soumet un message (corps {subject, text, html}) qui passe par le MÊME pipeline que les posts LMTP ; la réponse 202 porte le send_id et une estimation de destinataires. GET /sends/{id} renvoie les comptes par état ; POST /sends/{id}/cancel annule les copies encore en file (409 si rien à annuler). Voir Modération pour les endpoints de modération.

Endpoints d'administration

Ces points d'entrée couvrent tout ce que la ligne de commande sait faire en administration ; ils sont consommés par la console web, mais restent utilisables avec une clé d'API. La permission indiquée est le minimum exigé de la clé ; le rôle de l'utilisateur porteur borne toujours la portée réelle, et une ressource hors périmètre répond 404.

POST   /api/v1/auth/login                       (aucune)   ouvre une session
POST   /api/v1/auth/logout                      (aucune)   ferme la session
GET    /api/v1/auth/session                     (auth)     profil, rôle, portées, capacités

GET    /api/v1/dashboard                        (read)     synthèse adaptée au rôle
GET    /api/v1/system/version                   (auth)     version, révision et date du binaire
GET    /api/v1/system/queue                     (read)     profondeur de la file sortante
GET    /api/v1/audit                            (read)     journal d'audit, filtré et paginé

GET    /api/v1/domains                          (read)     domaines à portée, filtre ?status=
POST   /api/v1/domains                          (manage)   création (état pending_mta)
GET    /api/v1/domains/{domaine}                (read)     détail et valeurs par défaut
PATCH  /api/v1/domains/{domaine}                (manage)   mise à jour partielle (réglages et statut)
POST   /api/v1/domains/{domaine}/dns-check      (manage)   relance la vérification SPF / DKIM / _dmarc

POST   /api/v1/domains/{d}/lists                (manage)   création avec son propriétaire
GET    /api/v1/domains/{d}/lists/{l}            (read)     détail et politiques
PATCH  /api/v1/domains/{d}/lists/{l}            (manage)   mise à jour partielle des politiques
POST   /api/v1/domains/{d}/lists/{l}/suspend    (manage)   coupe-circuit de diffusion
POST   /api/v1/domains/{d}/lists/{l}/resume     (manage)   reprise de la diffusion

POST   /api/v1/domains/{d}/lists/{l}/members/import  (members:write)  import CSV
GET    /api/v1/domains/{d}/lists/{l}/members/export  (read)           export CSV

GET    /api/v1/domains/{d}/lists/{l}/credentials             (read)    comptes de messagerie (sans mot de passe)
PUT    /api/v1/domains/{d}/lists/{l}/credentials/{kind}      (manage)  enregistrement d'un compte
DELETE /api/v1/domains/{d}/lists/{l}/credentials/{kind}      (manage)  suppression d'un compte
POST   /api/v1/domains/{d}/lists/{l}/credentials/{kind}/test (manage)  tentative de connexion réelle

GET    /api/v1/users                            (read)     comptes à portée
POST   /api/v1/users                            (manage)   création (sans mot de passe = invitation)
GET    /api/v1/users/{id}                       (read)     compte et rôles détenus
PATCH  /api/v1/users/{id}                       (manage)   active / disabled
POST   /api/v1/users/{id}/password              (manage)   nouveau mot de passe (204)
GET    /api/v1/users/{id}/roles                 (read)     rôles visibles par l'appelant
POST   /api/v1/users/{id}/roles                 (manage)   attribution d'un rôle
DELETE /api/v1/users/{id}/roles                 (manage)   révocation (corps JSON)

GET    /api/v1/apikeys                          (manage)   clés administrables
POST   /api/v1/apikeys                          (manage)   frappe d'une clé (secret montré une fois)
DELETE /api/v1/apikeys/{id}                     (manage)   révocation, idempotente

GET /auth/session renvoie le profil, le rôle effectif, les domaines et listes à portée et les drapeaux de capacité : la console s'en sert pour masquer ce que le rôle ne permet pas, mais le serveur reste le seul juge et revérifie chaque opération.

PATCH .../lists/{l} est partiel : seuls les champs présents sont appliqués, les autres gardent leur valeur. Une chaîne vide sur subject_tag, footer_text ou footer_html efface la valeur.

Vérification DNS d'un domaine

POST /api/v1/domains/{domaine}/dns-check relance la vérification SPF, sélecteur DKIM et _dmarc du domaine, exactement celle de difuzio domain check-dns (même code partagé, donc jamais de divergence entre la ligne de commande et la console). Le verdict est enregistré sur le domaine (dns_ok, dns_checked_at) AVANT d'être renvoyé : une relecture de GET /api/v1/domains juste après montre donc le même état.

La réponse ne se limite pas à un booléen : elle porte un verdict par enregistrement (records), avec le nom interrogé, ce qui a été trouvé (found), ce qui était attendu (expected) et, quand la résolution elle-même a échoué, le message du résolveur (error) -- c'est ce qui distingue une zone injoignable d'un enregistrement simplement absent. Une ligne checked: false signale un contrôle qui n'a pas eu lieu (DKIM sur un domaine sans sélecteur configuré) : ce n'est pas un échec et ne doit pas être affiché comme tel.

L'autorisation est celle des autres mutations de domaine : qui administre le domaine. Hors de portée, 404 (jamais 403, qui révélerait l'existence du domaine) ; à portée mais sans l'autorité, 403. Les deux sont journalisés et audités.

Comme l'appel fait résoudre le serveur à la demande, il est borné : délai maximal par requête, et limitation de débit par domaine vérifié et par appelant. Au-delà, 429 rate_limited -- il ne s'agit pas d'une panne, mais du garde-fou qui empêche de marteler les serveurs de noms d'un tiers.

Comptes de messagerie d'une liste

{kind} vaut ingest (boîte IMAP relevée) ou submission (compte SMTP d'envoi) ; toute autre valeur répond 404. Ces routes ne concernent que les installations qui relaient par la boîte d'un hébergeur.

Le mot de passe est en écriture seule : aucune réponse ne le renvoie, quelle que soit la route. Sur un PUT visant un compte existant, omettre le champ password conserve celui déjà enregistré ; l'envoyer vide est refusé (422) plutôt qu'interprété comme un effacement.

POST .../test ouvre une vraie session (connexion, TLS, authentification, et ouverture du dossier pour l'IMAP) sans envoyer ni relever de message. Un refus de l'hébergeur répond 422 en reprenant son message : c'est ce qui distingue un mot de passe erroné d'un port bloqué. Le résultat est enregistré et relu par GET .../credentials (last_ok_at, last_error, last_error_at).

Une installation sans security.credential_keyring répond 503 credential_keyring_missing sur les écritures : elle ne peut pas chiffrer, et n'enregistrera pas un mot de passe en clair. Un hôte qui ne résout pas vers une adresse publique est refusé (422) : un administrateur de domaine ne doit pas pouvoir sonder le réseau interne depuis le bouton de test.

Import et export CSV des abonnés

L'import accepte deux formes de corps : le document seul (Content-Type: text/csv, en-tête email,display_name,delivery_mode), ou un envoi multipart/form-data portant le document dans une partie file. La preuve de consentement RGPD est obligatoire : soit le paramètre de requête consent_evidence, soit un champ consent_evidence placé AVANT la partie file (les parties sont lues dans l'ordre). Sans elle, 422.

La réponse est un rapport {total, added, skipped, errors, mode_coerced, error_samples} : chaque ligne est validée pour elle-même, donc une ligne fautive est comptée, pas fatale. Le document est plafonné à 25 Mio ; au-delà, 413 -- et le message le dit explicitement : les lignes lues avant la coupure ONT été importées, il faut découper le document et réimporter le reste.

L'export répond en text/csv (et non dans l'enveloppe JSON), en flux, avec les colonnes email,display_name,delivery_mode,status,subscribed_at. Les trois premières sont exactement celles que l'import relit : un export se réimporte tel quel. Les filtres status et delivery_mode sont acceptés.

Libre-service de l'abonné

Les routes /api/v1/me/... sont les seules dont le porteur n'est ni une clé d'API ni une session d'administration : elles acceptent uniquement la session d'abonné, ouverte par un lien à usage unique envoyé à l'adresse elle-même (cookie difuzio_subscriber, schéma de sécurité subscriberCookie dans le document OpenAPI). Cette session ne porte aucune autorité d'administration et n'est acceptée par aucune autre route ; réciproquement, une clé d'API ou une session d'administration est refusée ici (401).

POST   /api/v1/me/login-link                (aucune)  envoie le lien magique
POST   /api/v1/me/session                   (aucune)  consomme le jeton du lien
GET    /api/v1/me/session                   (abonné)  adresse, expiration, jeton CSRF
POST   /api/v1/me/logout                    (aucune)  ferme la session
GET    /api/v1/me/subscriptions             (abonné)  ses abonnements
PATCH  /api/v1/me/subscriptions/{list_id}   (abonné)  change le mode de réception
DELETE /api/v1/me/subscriptions/{list_id}   (abonné)  se désabonne

Points à connaître en exploitation :

  • POST /me/login-link répond toujours 202, adresse connue ou non. La réponse ne doit jamais varier : elle deviendrait un oracle listant qui est abonné où. Le résultat réel est journalisé côté serveur.
  • Cette même route est limitée en débit par adresse (3 par heure) et par source (5 par minute, 20 par heure), sur le compteur rate_counters action self_service, faute de quoi elle servirait de canon à courrier contre un tiers. Dépassement : 429.
  • Le jeton du lien est à usage unique et expire au bout d'une heure. La session qui en résulte glisse d'une heure d'inactivité, avec un plafond absolu de douze heures.
  • La portée d'une session d'abonné est une adresse, appliquée en SQL : le client ne transmet jamais que l'identifiant de liste, jamais l'adresse. Une liste où l'adresse n'est pas abonnée répond 404, comme une liste inconnue.
  • Les mutations exigent le jeton CSRF de la session dans l'en-tête X-CSRF-Token, lisible sur GET /me/session.

Les mêmes opérations existent en pages web sous /p/me (voir le guide de l'abonné) : les deux surfaces appellent la même couche service.

Document OpenAPI

Le contrat est servi en OpenAPI 3.1 sur GET /api/v1/openapi.json (généré depuis le registre de routes ; un test CI échoue si le document committé docs/openapi.json diverge du généré).

Chaque route y nomme un schéma de requête et un schéma de réponse : le document est la source des types TypeScript de la console d'administration, un schéma approximatif dégraderait tout le client. Les deux seules réponses hors enveloppe JSON sont le document lui-même et l'export CSV (text/csv).