
# メールプロバイダを置き換える

`PUT /v1/email-provider` · 権限 `email-provider:write` · すべてのプラン

環境がメールを送信する方法を置き換えます。次の5つのモードを指定できます:

- `platform_default`: KeenAuth が独自のアドレスから送信します。設定できるのは `reply_to` のみです。
- `platform_managed_domain`: KeenAuth が お客様のドメインから送信し、そのドメインの DKIM で署名します。先に [メール送信元ドメイン](../email-sender-domains/create.md) でドメインを追加し、DNS レコードを公開してください。ドメインは `verified` である必要があります。`sender_domain_id` と、そのドメイン内(サブドメインは不可)の `from_address` を設定してください。本番環境でのみ使用できます。後からドメインが検証済みでなくなった場合、ドメインが再び検証されるまでメールは `platform_default` として送信されます。設定自体は変わりません。
- `customer_smtp`: ポート `465`(暗黙的 TLS)または `587`(STARTTLS が必須)で、お客様の SMTP サーバーを使用します。証明書はホスト名に対して有効である必要があります。
- `customer_api`: `ses`、`sendgrid`、`postmark`、`mailgun` または `brevo` のお客様アカウントを使用します。
- `customer_webhook`: KeenAuth がメッセージをレンダリングし、お客様の [Webhook エンドポイント](../webhook-endpoints/create.md) のいずれかに渡します。送信はお客様のアプリケーションが行います([Webhook 配信](#webhook-配信)を参照)。`webhook_endpoint_id` には、この環境で有効になっているエンドポイントを設定してください。

`If-Match` は必須で、読み取ったバージョン([get](get.md) の `ETag`)を指定します。一度も設定されていない環境では `If-Match: "0"` を送信してください。`*` は一致しません。不一致の場合は `412` が返ります。

認証情報は書き込み専用です。`credentials` で送信してください。レスポンスには返されません。省略した場合、モードと送信先(SMTP ホスト、ポート、ユーザー名、またはプロバイダ)が変わらない場合にのみ保存済みの認証情報が保持されます。それ以外の場合、リクエストは `/credentials` の `required` で失敗します。

変更すると、最後の接続テストの結果はクリアされ、モード、プロバイダ、送信元ドメイン ID のみを含む監査レコード(`email_provider.updated`)が書き込まれます。アドレスや認証情報は記録されません。変更後は [接続テスト](test.md) を実行してください。

## リクエスト

| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| `mode` | string | はい | `platform_default`、`platform_managed_domain`、`customer_smtp`、`customer_api` または `customer_webhook`。 |
| `sender_domain_id` | string | `platform_managed_domain` | この環境の `verified` な送信元ドメイン(`sdom_…`)。他のモードでは使用できません。 |
| `webhook_endpoint_id` | string | `customer_webhook` | この環境で有効な [Webhook エンドポイント](../webhook-endpoints/get.md)(`whe_…`)。他のモードでは使用できません。 |
| `from_address` | string | `platform_default` 以外すべて | `noreply@example.com` のような送信元アドレス(表示名なし)。`platform_managed_domain` では、そのドメインが送信元ドメインと完全に一致する必要があります。`platform_default` では使用できません。 |
| `reply_to` | string | いいえ | 返信先アドレス(表示名なし)。 |
| `provider` | string | `customer_api` | `ses`、`sendgrid`、`postmark`、`mailgun` または `brevo`。 |
| `smtp_host` | string | `customer_smtp` | ラベルが 2 つ以上ある DNS 名(IP アドレスは不可)。 |
| `smtp_port` | integer | `customer_smtp` | `465` または `587`。 |
| `smtp_username` | string | `customer_smtp` | SMTP AUTH のユーザー名。1 から 256 文字。 |
| `credentials` | string | 上記を参照 | 書き込み専用。1 から 1024 バイト。`customer_smtp`: SMTP パスワード。`customer_api`: `ses` の場合は `<region>:<access key id>:<secret access key>`、`sendgrid` と `mailgun` の場合は API キー(プロバイダの EU エンドポイントを使う場合は `eu:` を接頭辞とする)、`postmark` の場合はサーバートークン、`brevo` の場合は API キー。 |

## レスポンス

`200 OK` と、保存された設定([get](get.md) と同じ形式)が返ります。`ETag` には新しいバージョンが設定されます。

## エラー

| HTTP | code | 発生条件 |
|---|---|---|
| 400 | `unknown_field` | ボディに未知のフィールドがあるか、JSON オブジェクトではありません。 |
| 412 | `precondition_failed` | `If-Match` が保存済みのバージョンと一致しない(その間に別の更新があった)か、最初の書き込み前に `*` が指定されています。 |
| 413 | `payload_too_large` | ボディが 16 KiB を超えています。 |
| 428 | `precondition_required` | `If-Match` がありません。 |
| 422 | `validation_failed` | フィールドが検証に失敗しました。`errors[]` にフィールドとルールが示されます(下記を参照)。 |
| 401 | `unauthenticated` | 認証情報がないか、無効または取り消されています。 |
| 403 | `insufficient_permissions` | 認証情報に必要な権限がありません。 |
| 429 | `rate_limited` | レート制限を超えました。`Retry-After` の期間を待ってから再試行してください。 |
| 503 | `service_unavailable` | 依存先が一時的に利用できません。`Retry-After` の期間を待ってから再試行してください。 |

`errors[]` のルール:

| ポインタ | ルール | 意味 |
|---|---|---|
| `/mode` | `required`、`invalid` | モードがないか、不明です。 |
| `/mode` | `delivery_mode_unavailable` | このデプロイメントではモードで送信できません。`customer_webhook` には Webhook 配信接続の有効化が必要で、`platform_managed_domain` には DKIM 署名の有効化が必要です。お客様のモードには送信配信の有効化が必要です。 |
| `/mode` | `production_only` | `platform_managed_domain` は本番環境でのみ使用できます。 |
| `/mode` | `fields_not_allowed` | モードが受け付けないフィールドが送信されました。`customer_webhook` では `provider`、SMTP フィールド、`credentials`、`sender_domain_id` を指定できません。 |
| `/sender_domain_id` | `required`、`invalid` | `platform_managed_domain` で送信元ドメイン ID が指定されていないか、`sdom_…` 形式の ID ではありません。 |
| `/sender_domain_id` | `not_found` | この環境にその ID の送信元ドメインがありません。別の環境のドメインを指定した場合も同じ応答になります。 |
| `/sender_domain_id` | `not_verified` | 送信元ドメインは存在しますが、`verified` ではありません(DNS レコードの確認待ち、劣化、または無効化)。 |
| `/from_address` | `not_in_sender_domain` | 送信元アドレスが、送信元ドメインと完全に一致するドメイン内にありません。 |
| `/webhook_endpoint_id` | `required`、`invalid` | `customer_webhook` でエンドポイント ID が指定されていないか、`whe_…` 形式の ID ではありません。 |
| `/webhook_endpoint_id` | `not_found` | この環境にその ID の Webhook エンドポイントがありません。別の環境のエンドポイントを指定した場合も同じ応答になります。 |
| `/webhook_endpoint_id` | `not_enabled` | エンドポイントは存在しますが、有効になっていません。 |
| `/credentials` | `required` | モードまたは送信先が変更されたのに、新しい認証情報が送信されていません。 |
| `/credentials` | `invalid` | 認証情報が空、長すぎる、制御文字を含む、またはプロバイダの形式に合っていません。 |
| `/from_address`、`/reply_to`、`/provider`、`/smtp_host`、`/smtp_port`、`/smtp_username` | `required`、`invalid` | フィールドがないか、モードに対して無効です。 |

## Webhook 配信

`customer_webhook` モードでは、KeenAuth はメッセージを送信せず、自身の送信に切り替えることもありません。レンダリングしたすべてのメッセージ(件名、HTML、テキスト)について、HTTPS で署名付きイベントを 1 件エンドポイントに送信します。

- リクエストは `POST` で、`Content-Type: application/json` と Standard Webhooks v1 のヘッダー `webhook-id`、`webhook-timestamp`、`webhook-signature` を含みます。`webhook-id` はメッセージ ID(`msg_…`)で、同じメッセージのすべての試行で同じ値になります。そのため、この値で重複を排除してください。イベントタイプがエンドポイントのイベント購読に含まれている必要はありません。配信先のエンドポイントであるため、イベントは受信されます。
- `2xx` のレスポンスを成功とみなします。リダイレクトは追跡されず、失敗として扱われます。1 回の試行は 10 秒でタイムアウトします。
- 失敗した試行は 15 秒後に再試行され、合計で最大 3 回試行されます。3 回目の失敗後、メッセージは失敗としてマークされ、メールは送信されません。設定を保存した後にエンドポイントが削除または無効化された場合、試行せずに即座にメッセージが失敗します。
- メッセージの内容は Webhook サービスに保存されません。信頼できるエンドポイントにのみ送信してください。ボディには受信者のアドレスと、サインイン用のコードやリンクが含まれます。

ボディ(`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"
  }
}
```

| `data` のフィールド | 説明 |
|---|---|
| `message_id`、`family`、`locale` | メッセージ ID(エンベロープの `id` と同じ)、テンプレートファミリー、およびメッセージのレンダリングに使用された言語。 |
| `to` | 受信者のアドレス。 |
| `from`、`from_name`、`reply_to` | 設定されている場合のみ含まれます。`from` はこの設定の `from_address` です。 |
| `subject` | レンダリングされた件名。 |
| `html`、`text` | レンダリングされた内容。少なくとも一方が含まれます。 |
| `link`、`code` | メッセージにサインイン用のリンクまたはワンタイムコードが含まれる場合のみ含まれます。 |

署名は `v1,` に続けて、`HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")` を base64 エンコードした値です。鍵は、エンドポイントの署名シークレットの `idp_whsec_` 接頭辞に続く 43 文字の base62 文字列でエンコードされた 32 バイトの値です。解析する前に生のリクエストボディを検証し、タイムスタンプがお客様の時計から 300 秒以上ずれている場合は拒否してください。シークレットのローテーション中は、`webhook-signature` に半角スペースで区切られた 2 つの署名が含まれます。

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

## 例

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

メッセージを Webhook エンドポイントに渡す場合:

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

このモードでは [テスト](test.md) への接続はありません。代わりに [Webhook エンドポイントをテストする](../webhook-endpoints/test.md) でエンドポイントをテストしてください。

検証済みの送信元ドメインから送信する場合:

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

このモードでは [テスト](test.md) への接続はありません。代わりに [検証](../email-sender-domains/verify.md) でドメインを確認してください。

CLI: `keenauth email-provider put --if-match '"0"' --data @email-provider.json`。認証情報はファイルに保存するか、標準入力(`--data -`)で渡してください。そうすることで、シェルの履歴に残ることを防げます。
