
# Remplacer le fournisseur d'e-mail

`PUT /v1/email-provider` · autorisation `email-provider:write` · tous les forfaits

Remplace la manière dont l'environnement envoie les e-mails. Vous pouvez définir cinq modes :

- `platform_default` : KeenAuth envoie depuis sa propre adresse. Seul `reply_to` peut être défini.
- `platform_managed_domain` : KeenAuth envoie depuis votre propre domaine, signé avec DKIM pour ce domaine. Ajoutez le domaine avec [domaines d'expédition des e-mails](../email-sender-domains/create.md) et publiez d'abord ses enregistrements DNS ; le domaine doit être `verified`. Définissez `sender_domain_id` et un `from_address` appartenant exactement à ce domaine (et non à un sous-domaine). Environnements de production uniquement. Si le domaine cesse ultérieurement d'être vérifié, les e-mails sont envoyés en `platform_default` jusqu'à ce que le domaine soit à nouveau vérifié ; la configuration elle-même ne change pas.
- `customer_smtp` : votre serveur SMTP sur le port `465` (TLS implicite) ou `587` (STARTTLS obligatoire). Le certificat doit être valide pour le nom d'hôte.
- `customer_api` : votre compte chez `ses`, `sendgrid`, `postmark`, `mailgun` ou `brevo`.
- `customer_webhook` : KeenAuth génère le message et le transmet à l'un de vos [points de terminaison webhook](../webhook-endpoints/create.md) ; votre application l'envoie (voir [Livraison par webhook](#livraison-par-webhook)). Définissez `webhook_endpoint_id` sur un point de terminaison activé de cet environnement.

`If-Match` est obligatoire et contient la version que vous avez lue (le `ETag` de [get](get.md)). Pour un environnement qui n'a jamais été configuré, envoyez `If-Match: "0"` ; `*` ne correspond pas à cette valeur. Une non-correspondance renvoie `412`.

L'identifiant est écrit une seule fois. Envoyez-le dans `credentials` ; aucune réponse ne le renvoie. Si vous l'omettez, l'identifiant enregistré est conservé uniquement si le mode et la destination (hôte, port et nom d'utilisateur SMTP, ou fournisseur) sont inchangés. Sinon, la requête échoue avec `/credentials` `required`.

Une modification efface le dernier résultat de test de connexion et écrit un enregistrement d'audit (`email_provider.updated`) contenant uniquement le mode, le fournisseur et l'identifiant du domaine d'expédition, jamais une adresse ni l'identifiant. Après une modification, lancez un [test de connexion](test.md).

## Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `mode` | string | oui | `platform_default`, `platform_managed_domain`, `customer_smtp`, `customer_api` ou `customer_webhook`. |
| `sender_domain_id` | string | `platform_managed_domain` | Un domaine d'expédition `verified` de cet environnement (`sdom_…`). Non autorisé dans les autres modes. |
| `webhook_endpoint_id` | string | `customer_webhook` | Un [point de terminaison webhook](../webhook-endpoints/get.md) activé de cet environnement (`whe_…`). Non autorisé dans les autres modes. |
| `from_address` | string | tous sauf `platform_default` | Adresse d'expédition simple, par exemple `noreply@example.com`. En `platform_managed_domain`, son domaine doit être exactement le domaine d'expédition. Non autorisé en `platform_default`. |
| `reply_to` | string | non | Adresse de réponse simple. |
| `provider` | string | `customer_api` | `ses`, `sendgrid`, `postmark`, `mailgun` ou `brevo`. |
| `smtp_host` | string | `customer_smtp` | Nom DNS comportant au moins deux étiquettes (pas d'adresse IP). |
| `smtp_port` | integer | `customer_smtp` | `465` ou `587`. |
| `smtp_username` | string | `customer_smtp` | Nom d'utilisateur SMTP AUTH, de 1 à 256 caractères. |
| `credentials` | string | voir ci-dessus | Écriture seule, de 1 à 1024 octets. `customer_smtp` : le mot de passe SMTP. `customer_api` : `<region>:<access key id>:<secret access key>` pour `ses` ; la clé API pour `sendgrid` et `mailgun` (préfixe `eu:` pour le point de terminaison UE du fournisseur) ; le jeton serveur pour `postmark` ; la clé API pour `brevo`. |

## Réponse

`200 OK` avec la configuration enregistrée (même structure que [get](get.md)) et `ETag` défini sur la nouvelle version.

## Erreurs

| HTTP | code | Quand |
|---|---|---|
| 400 | `unknown_field` | Le corps contient un champ inconnu ou n'est pas un objet JSON. |
| 412 | `precondition_failed` | `If-Match` ne correspond pas à la version enregistrée (quelqu'un a enregistré entre-temps), ou vaut `*` avant la première écriture. |
| 413 | `payload_too_large` | Le corps dépasse 16 KiB. |
| 428 | `precondition_required` | `If-Match` est absent. |
| 422 | `validation_failed` | Un champ échoue à la validation ; `errors[]` indique le champ et la règle (voir ci-dessous). |
| 401 | `unauthenticated` | L'identifiant est absent, invalide ou révoqué. |
| 403 | `insufficient_permissions` | L'identifiant ne dispose pas de l'autorisation requise. |
| 429 | `rate_limited` | La limite de débit a été dépassée ; réessayez après la période indiquée par `Retry-After`. |
| 503 | `service_unavailable` | Une dépendance est temporairement indisponible ; réessayez après la période indiquée par `Retry-After`. |

Règles dans `errors[]` :

| Pointeur | Règle | Signification |
|---|---|---|
| `/mode` | `required`, `invalid` | Le mode est absent ou inconnu. |
| `/mode` | `delivery_mode_unavailable` | Le mode ne peut pas envoyer dans ce déploiement : `customer_webhook` nécessite que la connexion de livraison webhook soit activée, `platform_managed_domain` nécessite que la signature DKIM soit activée, et les modes client nécessitent que la livraison sortante soit activée. |
| `/mode` | `production_only` | `platform_managed_domain` n'est autorisé que dans les environnements de production. |
| `/mode` | `fields_not_allowed` | Un champ que le mode n'accepte pas a été envoyé. `customer_webhook` n'accepte ni `provider`, ni les champs SMTP, ni `credentials`, ni `sender_domain_id`. |
| `/sender_domain_id` | `required`, `invalid` | `platform_managed_domain` a été envoyé sans identifiant de domaine d'expédition, ou l'identifiant n'est pas un identifiant `sdom_…`. |
| `/sender_domain_id` | `not_found` | Cet environnement n'a aucun domaine d'expédition avec cet identifiant. Un domaine d'un autre environnement reçoit la même réponse. |
| `/sender_domain_id` | `not_verified` | Le domaine d'expédition existe mais n'est pas `verified` (enregistrements DNS en attente, dégradé ou désactivé). |
| `/from_address` | `not_in_sender_domain` | L'adresse d'expédition n'appartient pas exactement au domaine d'expédition. |
| `/webhook_endpoint_id` | `required`, `invalid` | `customer_webhook` a été envoyé sans identifiant de point de terminaison, ou l'identifiant n'est pas un identifiant `whe_…`. |
| `/webhook_endpoint_id` | `not_found` | Cet environnement n'a aucun point de terminaison webhook avec cet identifiant. Un point de terminaison d'un autre environnement reçoit la même réponse. |
| `/webhook_endpoint_id` | `not_enabled` | Le point de terminaison existe mais n'est pas activé. |
| `/credentials` | `required` | Le mode ou la destination a changé et aucun nouvel identifiant n'a été envoyé. |
| `/credentials` | `invalid` | L'identifiant est vide, trop long, contient des caractères de contrôle ou ne correspond pas au format du fournisseur. |
| `/from_address`, `/reply_to`, `/provider`, `/smtp_host`, `/smtp_port`, `/smtp_username` | `required`, `invalid` | Le champ est absent ou invalide pour le mode. |

## Livraison par webhook

Dans le mode `customer_webhook`, KeenAuth n'envoie pas le message et ne bascule jamais sur son propre envoi. Pour chaque message qu'il génère (objet, HTML et texte), il envoie un événement signé au point de terminaison en HTTPS :

- La requête est un `POST` avec `Content-Type: application/json` et les en-têtes Standard Webhooks v1 `webhook-id`, `webhook-timestamp` et `webhook-signature`. `webhook-id` est l'identifiant du message (`msg_…`) et il est identique à chaque tentative pour un même message ; dédupliquez donc à partir de cette valeur. Le type d'événement n'a pas besoin de figurer dans l'abonnement aux événements du point de terminaison : celui-ci le reçoit parce qu'il est le point de terminaison de livraison.
- Une réponse `2xx` est considérée comme un succès. Les redirections ne sont pas suivies et comptent comme des échecs. Une tentative expire après 10 secondes.
- Une tentative échouée est relancée 15 secondes plus tard, avec 3 tentatives au maximum au total. Après le troisième échec, le message est marqué comme échoué et aucun e-mail n'est envoyé. Un point de terminaison supprimé ou désactivé après l'enregistrement de la configuration fait échouer le message immédiatement, sans tentative.
- Le contenu du message n'est pas stocké par le service webhook. N'envoyez qu'à des points de terminaison de confiance : le corps contient l'adresse du destinataire ainsi que tout code de connexion ou lien.

Corps (`schema_version` 1) :

```json
{
  "id": "msg_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "type": "email.send_requested",
  "schema_version": 1,
  "environment_id": "env_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "occurred_at": "2026-10-27T09:00:00Z",
  "data": {
    "message_id": "msg_01k7xq3m5r8t9v0w1x2y3z4a5b",
    "family": "email_otp",
    "locale": "en",
    "to": "ada@example.com",
    "from": "auth@example.com",
    "from_name": "Example",
    "reply_to": "support@example.com",
    "subject": "Your sign-in code",
    "html": "<p>Your code is 123456</p>",
    "text": "Your code is 123456",
    "link": "https://example.keenauth.app/...",
    "code": "123456"
  }
}
```

| Champ `data` | Description |
|---|---|
| `message_id`, `family`, `locale` | L'identifiant du message (identique à `id` de l'enveloppe), la famille de modèles et la langue dans laquelle le message a été généré. |
| `to` | Adresse du destinataire. |
| `from`, `from_name`, `reply_to` | Présents uniquement lorsqu'ils sont définis : `from` correspond au `from_address` de cette configuration. |
| `subject` | Objet généré. |
| `html`, `text` | Contenu généré ; au moins l'un des deux est présent. |
| `link`, `code` | Présents uniquement lorsque le message contient un lien de connexion ou un code à usage unique. |

La signature est `v1,` suivi du base64 de `HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")`. La clé est la valeur de 32 octets encodée par les 43 caractères base62 qui suivent le préfixe `idp_whsec_` du secret de signature du point de terminaison. Vérifiez le corps brut de la requête avant de l'analyser, et rejetez tout horodatage qui s'écarte de plus de 300 secondes de votre horloge. Pendant une rotation de secret, `webhook-signature` contient deux signatures séparées par des espaces.

Python :

```python
import base64, hashlib, hmac, time

ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
PREFIX = "idp_whsec_"


def signing_key(secret: str) -> bytes:
    """The HMAC key is the 32-byte value behind the 43 base62 characters after the prefix."""
    body = secret[len(PREFIX):len(PREFIX) + 43]
    n = 0
    for ch in body:
        n = n * 62 + ALPHABET.index(ch)
    return n.to_bytes(32, "big")


def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
    msg_id = headers["webhook-id"]
    ts = headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > tolerance:
        return False
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    mac = hmac.new(signing_key(secret), signed, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(mac).decode()
    # webhook-signature holds one or more space-separated signatures.
    return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split(" "))
```

Node.js :

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const PREFIX = "idp_whsec_";

// The HMAC key is the 32-byte value behind the 43 base62 characters after the prefix.
function signingKey(secret) {
  let n = 0n;
  for (const ch of secret.slice(PREFIX.length, PREFIX.length + 43)) n = n * 62n + BigInt(ALPHABET.indexOf(ch));
  return Buffer.from(n.toString(16).padStart(64, "0"), "hex");
}

export function verify(secret, headers, rawBody, tolerance = 300) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
  const mac = createHmac("sha256", signingKey(secret)).update(`${id}.${ts}.`).update(rawBody).digest();
  const expected = Buffer.from(`v1,${mac.toString("base64")}`);
  // webhook-signature holds one or more space-separated signatures.
  return headers["webhook-signature"].split(" ").some((s) => {
    const got = Buffer.from(s);
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

## Exemple

```bash
curl -X PUT https://api.eu.keenauth.com/v1/email-provider \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "0"' \
  -d @email-provider.json
```

`email-provider.json` :

```json
{
  "mode": "customer_smtp",
  "from_address": "no-reply@example.com",
  "smtp_host": "smtp.example.com",
  "smtp_port": 587,
  "smtp_username": "mailer",
  "credentials": "<SMTP password>"
}
```

Pour transmettre les messages à un point de terminaison webhook :

```json
{
  "mode": "customer_webhook",
  "webhook_endpoint_id": "whe_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "from_address": "auth@example.com"
}
```

Ce mode ne comporte aucune connexion à [test](test.md) ; testez le point de terminaison avec [Tester un point de terminaison webhook](../webhook-endpoints/test.md).

Pour envoyer depuis votre domaine d'expédition vérifié :

```json
{
  "mode": "platform_managed_domain",
  "sender_domain_id": "sdom_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "from_address": "auth@mail.example.com",
  "reply_to": "support@example.com"
}
```

Ce mode ne comporte aucune connexion à [test](test.md) ; vérifiez le domaine avec [verify](../email-sender-domains/verify.md) à la place.

CLI : `keenauth email-provider put --if-match '"0"' --data @email-provider.json`. Conservez l'identifiant dans un fichier ou transmettez-le sur l'entrée standard (`--data -`) afin qu'il n'apparaisse pas dans l'historique de votre shell.
