
# E-Mail-Anbieter ersetzen

`PUT /v1/email-provider` · Berechtigung `email-provider:write` · alle Tarife

Ersetzt, wie die Umgebung E-Mails versendet. Sie können fünf Modi festlegen:

- `platform_default`: KeenAuth sendet von einer eigenen Adresse. Nur `reply_to` darf gesetzt werden.
- `platform_managed_domain`: KeenAuth sendet von Ihrer eigenen Domain, signiert mit DKIM für diese Domain. Fügen Sie die Domain zuerst über [E-Mail-Absenderdomains](../email-sender-domains/create.md) hinzu und veröffentlichen Sie vorher ihre DNS-Einträge; die Domain muss `verified` sein. Setzen Sie `sender_domain_id` und eine `from_address` genau in dieser Domain (nicht in einer Subdomain davon). Nur für Produktionsumgebungen. Wenn die Domain später nicht mehr verifiziert ist, wird E-Mail als `platform_default` gesendet, bis die Domain erneut verifiziert ist; die Konfiguration selbst ändert sich nicht.
- `customer_smtp`: Ihr SMTP-Server auf Port `465` (implizites TLS) oder `587` (STARTTLS ist erforderlich). Das Zertifikat muss für den Hostnamen gültig sein.
- `customer_api`: Ihr Konto bei `ses`, `sendgrid`, `postmark`, `mailgun` oder `brevo`.
- `customer_webhook`: KeenAuth rendert die Nachricht und übergibt sie an einen Ihrer [Webhook-Endpunkte](../webhook-endpoints/create.md); Ihre Anwendung versendet sie (siehe [Webhook-Zustellung](#webhook-zustellung)). Setzen Sie `webhook_endpoint_id` auf einen aktivierten Endpunkt dieser Umgebung.

`If-Match` ist erforderlich und enthält die Version, die Sie gelesen haben (das `ETag` von [get](get.md)). Für eine Umgebung, die noch nie konfiguriert wurde, senden Sie `If-Match: "0"`; `*` passt darauf nicht. Bei einer Abweichung wird `412` zurückgegeben.

Die Zugangsdaten sind nur beim Schreiben möglich. Senden Sie sie in `credentials`; keine Antwort gibt sie zurück. Wenn Sie das Feld weglassen, bleiben die gespeicherten Zugangsdaten nur erhalten, wenn Modus und Ziel (SMTP-Host, Port und Benutzername oder Anbieter) unverändert sind. Andernfalls schlägt die Anfrage mit `/credentials` `required` fehl.

Eine Änderung löscht das letzte Ergebnis des Verbindungstests und schreibt einen Audit-Eintrag (`email_provider.updated`) nur mit Modus, Anbieter und ID der Absenderdomain, niemals mit einer Adresse oder den Zugangsdaten. Führen Sie nach einer Änderung einen [Verbindungstest](test.md) aus.

## Anfrage

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `mode` | string | ja | `platform_default`, `platform_managed_domain`, `customer_smtp`, `customer_api` oder `customer_webhook`. |
| `sender_domain_id` | string | `platform_managed_domain` | Eine `verified` Absenderdomain dieser Umgebung (`sdom_…`). In anderen Modi nicht erlaubt. |
| `webhook_endpoint_id` | string | `customer_webhook` | Ein aktivierter [Webhook-Endpunkt](../webhook-endpoints/get.md) dieser Umgebung (`whe_…`). In anderen Modi nicht erlaubt. |
| `from_address` | string | alle außer `platform_default` | Reine Absenderadresse, etwa `noreply@example.com`. Bei `platform_managed_domain` muss ihre Domain genau die Absenderdomain sein. Bei `platform_default` nicht erlaubt. |
| `reply_to` | string | nein | Reine Antwortadresse. |
| `provider` | string | `customer_api` | `ses`, `sendgrid`, `postmark`, `mailgun` oder `brevo`. |
| `smtp_host` | string | `customer_smtp` | DNS-Name mit mindestens zwei Labels (keine IP-Adresse). |
| `smtp_port` | integer | `customer_smtp` | `465` oder `587`. |
| `smtp_username` | string | `customer_smtp` | Benutzername für SMTP AUTH, 1 bis 256 Zeichen. |
| `credentials` | string | siehe oben | Nur beim Schreiben, 1 bis 1024 Byte. `customer_smtp`: das SMTP-Passwort. `customer_api`: `<region>:<access key id>:<secret access key>` für `ses`; der API-Schlüssel für `sendgrid` und `mailgun` (Präfix `eu:` für den EU-Endpunkt des Anbieters); das Server-Token für `postmark`; der API-Schlüssel für `brevo`. |

## Antwort

`200 OK` mit der gespeicherten Konfiguration (gleiche Struktur wie bei [get](get.md)) und `ETag` auf die neue Version gesetzt.

## Fehler

| HTTP | code | Wann |
|---|---|---|
| 400 | `unknown_field` | Der Body enthält ein unbekanntes Feld oder ist kein JSON-Objekt. |
| 412 | `precondition_failed` | `If-Match` ist nicht die gespeicherte Version (zwischenzeitlich wurde gespeichert) oder ist `*` vor dem ersten Schreiben. |
| 413 | `payload_too_large` | Der Body ist größer als 16 KiB. |
| 428 | `precondition_required` | `If-Match` fehlt. |
| 422 | `validation_failed` | Ein Feld besteht die Validierung nicht; `errors[]` nennt das Feld und die Regel (siehe unten). |
| 401 | `unauthenticated` | Die Zugangsdaten fehlen, sind ungültig oder widerrufen. |
| 403 | `insufficient_permissions` | Die Zugangsdaten haben nicht die erforderliche Berechtigung. |
| 429 | `rate_limited` | Das Ratenlimit wurde überschritten; wiederholen Sie die Anfrage nach der Zeit aus `Retry-After`. |
| 503 | `service_unavailable` | Eine Abhängigkeit ist vorübergehend nicht verfügbar; wiederholen Sie die Anfrage nach der Zeit aus `Retry-After`. |

Regeln in `errors[]`:

| Pointer | Regel | Bedeutung |
|---|---|---|
| `/mode` | `required`, `invalid` | Der Modus fehlt oder ist unbekannt. |
| `/mode` | `delivery_mode_unavailable` | Der Modus kann in dieser Bereitstellung nicht senden: `customer_webhook` benötigt eine aktivierte Webhook-Zustellverbindung, `platform_managed_domain` benötigt aktivierte DKIM-Signierung, und die Kundenmodi benötigen aktivierten ausgehenden Versand. |
| `/mode` | `production_only` | `platform_managed_domain` ist nur in Produktionsumgebungen erlaubt. |
| `/mode` | `fields_not_allowed` | Ein Feld, das der Modus nicht akzeptiert, wurde gesendet. `customer_webhook` akzeptiert kein `provider`, keine SMTP-Felder, keine `credentials` und keine `sender_domain_id`. |
| `/sender_domain_id` | `required`, `invalid` | `platform_managed_domain` wurde ohne ID der Absenderdomain gesendet oder die ID ist keine `sdom_…`-ID. |
| `/sender_domain_id` | `not_found` | Diese Umgebung hat keine Absenderdomain mit dieser ID. Eine Domain einer anderen Umgebung erhält dieselbe Antwort. |
| `/sender_domain_id` | `not_verified` | Die Absenderdomain existiert, ist aber nicht `verified` (DNS-Einträge ausstehend, beeinträchtigt oder deaktiviert). |
| `/from_address` | `not_in_sender_domain` | Die Absenderadresse liegt nicht genau in der Absenderdomain. |
| `/webhook_endpoint_id` | `required`, `invalid` | `customer_webhook` wurde ohne Endpunkt-ID gesendet oder die ID ist keine `whe_…`-ID. |
| `/webhook_endpoint_id` | `not_found` | Diese Umgebung hat keinen Webhook-Endpunkt mit dieser ID. Ein Endpunkt einer anderen Umgebung erhält dieselbe Antwort. |
| `/webhook_endpoint_id` | `not_enabled` | Der Endpunkt existiert, ist aber nicht aktiviert. |
| `/credentials` | `required` | Modus oder Ziel wurden geändert, und es wurden keine neuen Zugangsdaten gesendet. |
| `/credentials` | `invalid` | Die Zugangsdaten sind leer, zu lang, enthalten Steuerzeichen oder entsprechen nicht dem Format des Anbieters. |
| `/from_address`, `/reply_to`, `/provider`, `/smtp_host`, `/smtp_port`, `/smtp_username` | `required`, `invalid` | Das Feld fehlt oder ist für den Modus ungültig. |

## Webhook-Zustellung

Im Modus `customer_webhook` versendet KeenAuth die Nachricht nicht selbst und weicht niemals auf den eigenen Versand aus. Für jede Nachricht, die gerendert wird (Betreff, HTML und Text), sendet KeenAuth ein signiertes Ereignis per HTTPS an den Endpunkt:

- Die Anfrage ist ein `POST` mit `Content-Type: application/json` und den Standard-Webhooks-v1-Headern `webhook-id`, `webhook-timestamp` und `webhook-signature`. `webhook-id` ist die Nachrichten-ID (`msg_…`) und bleibt bei jedem Versuch derselben Nachricht gleich, deduplizieren Sie daher anhand dieses Werts. Der Ereignistyp muss nicht in der Ereignisliste des Endpunkts enthalten sein: Der Endpunkt erhält ihn, weil er der Zustellendpunkt ist.
- Erfolg ist jede Antwort mit `2xx`. Weiterleitungen werden nicht verfolgt und gelten als Fehler. Ein Versuch läuft nach 10 Sekunden ab.
- Ein fehlgeschlagener Versuch wird 15 Sekunden später wiederholt, insgesamt höchstens 3 Versuche. Nach dem dritten Fehlschlag wird die Nachricht als fehlgeschlagen markiert und keine E-Mail gesendet. Ein Endpunkt, der nach dem Speichern der Konfiguration gelöscht oder deaktiviert wurde, lässt die Nachricht sofort ohne Versuch fehlschlagen.
- Der Nachrichteninhalt wird vom Webhook-Dienst nicht gespeichert. Senden Sie nur an Endpunkte, denen Sie vertrauen: Der Body enthält die Empfängeradresse sowie etwaige Anmelde-Codes oder Links.

Body (`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"
  }
}
```

| Feld in `data` | Beschreibung |
|---|---|
| `message_id`, `family`, `locale` | Die Nachrichten-ID (identisch mit der `id` des Envelopes), die Vorlagenfamilie und die Sprache, in der die Nachricht gerendert wurde. |
| `to` | Empfängeradresse. |
| `from`, `from_name`, `reply_to` | Nur vorhanden, wenn gesetzt: `from` ist die `from_address` dieser Konfiguration. |
| `subject` | Gerenderter Betreff. |
| `html`, `text` | Gerenderter Inhalt; mindestens einer ist vorhanden. |
| `link`, `code` | Nur vorhanden, wenn die Nachricht einen Anmeldelink oder einen Einmalcode enthält. |

Die Signatur ist `v1,` plus die Base64-Kodierung von `HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")`. Der Schlüssel ist der 32-Byte-Wert, der durch die 43 Base62-Zeichen nach dem Präfix `idp_whsec_` des Signiergeheimnisses des Endpunkts kodiert ist. Prüfen Sie den rohen Request-Body, bevor Sie ihn parsen, und weisen Sie einen Zeitstempel ab, der mehr als 300 Sekunden von Ihrer Uhr abweicht. Während einer Geheimnis-Rotation enthält `webhook-signature` zwei durch Leerzeichen getrennte Signaturen.

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);
  });
}
```

## Beispiel

```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>"
}
```

Um Nachrichten an einen Webhook-Endpunkt zu übergeben:

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

In diesem Modus gibt es keine Verbindung zu [test](test.md); testen Sie den Endpunkt mit [Einen Webhook-Endpunkt testen](../webhook-endpoints/test.md).

Um von Ihrer verifizierten Absenderdomain zu senden:

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

In diesem Modus gibt es keine Verbindung zu [test](test.md); prüfen Sie stattdessen die Domain mit [verify](../email-sender-domains/verify.md).

CLI: `keenauth email-provider put --if-match '"0"' --data @email-provider.json`. Bewahren Sie die Zugangsdaten in einer Datei auf oder übergeben Sie sie über die Standardeingabe (`--data -`), damit sie nicht in Ihrem Shell-Verlauf landen.
