
# 이메일 제공자 교체

`PUT /v1/email-provider` · 권한 `email-provider:write` · 모든 요금제

환경이 이메일을 발송하는 방식을 교체합니다. 다음 다섯 가지 모드를 설정할 수 있습니다.

- `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-endpoints/create.md) 중 하나로 전달하며, 귀하의 애플리케이션이 이를 발송합니다([웹훅 전달](#웹훅-전달) 참조). `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-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` | 레이블이 두 개 이상인 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 키. |

## 응답

저장된 설정([get](get.md)과 같은 형태)과 새 버전을 나타내는 `ETag`를 담은 `200 OK`가 반환됩니다.

## 오류

| 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`에는 웹훅 전달 연결이 활성화되어야 하고, `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가 없거나, ID가 `sdom_…` 형식이 아닙니다. |
| `/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가 없거나, ID가 `whe_…` 형식이 아닙니다. |
| `/webhook_endpoint_id` | `not_found` | 이 환경에 해당 ID의 웹훅 엔드포인트가 없습니다. 다른 환경의 엔드포인트도 같은 응답을 받습니다. |
| `/webhook_endpoint_id` | `not_enabled` | 엔드포인트는 존재하지만 활성화되어 있지 않습니다. |
| `/credentials` | `required` | 모드 또는 대상이 변경되었는데 새 자격 증명을 보내지 않았습니다. |
| `/credentials` | `invalid` | 자격 증명이 비어 있거나, 너무 길거나, 제어 문자를 포함하거나, 제공자의 형식에 맞지 않습니다. |
| `/from_address`, `/reply_to`, `/provider`, `/smtp_host`, `/smtp_port`, `/smtp_username` | `required`, `invalid` | 해당 모드에서 필드가 없거나 유효하지 않습니다. |

## 웹훅 전달

`customer_webhook` 모드에서 KeenAuth는 메시지를 직접 발송하지 않으며, 자체 발송으로 대체하지 않습니다. KeenAuth는 렌더링한 각 메시지(제목, HTML, 텍스트)에 대해 서명된 이벤트 하나를 HTTPS로 엔드포인트에 전송합니다.

- 요청은 `Content-Type: application/json`을 사용하는 `POST`이며, Standard Webhooks v1 헤더인 `webhook-id`, `webhook-timestamp`, `webhook-signature`를 포함합니다. `webhook-id`는 메시지 ID(`msg_…`)이며 같은 메시지의 모든 시도에서 동일하므로, 이 값으로 중복을 제거하십시오. 이벤트 타입이 엔드포인트의 이벤트 구독에 포함되어 있을 필요는 없습니다. 전달 엔드포인트이기 때문에 이벤트를 받습니다.
- `2xx` 응답은 성공으로 간주합니다. 리디렉션은 따라가지 않으며 실패로 간주합니다. 한 번의 시도는 10초 후에 시간 초과됩니다.
- 실패한 시도는 15초 후에 재시도되며, 총 시도 횟수는 최대 3회입니다. 세 번째 실패 후 메시지는 실패로 표시되고 이메일은 발송되지 않습니다. 설정을 저장한 후 엔드포인트가 삭제되거나 비활성화되면 시도 없이 즉시 메시지가 실패합니다.
- 메시지 내용은 웹훅 서비스에 저장되지 않습니다. 신뢰할 수 있는 엔드포인트로만 전송하십시오. 본문에는 수신자 주소와 로그인 코드 또는 링크가 포함됩니다.

본문(`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(envelope의 `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`에 공백으로 구분된 서명 두 개가 포함됩니다.

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

웹훅 엔드포인트로 메시지를 전달하려면:

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

이 모드에서는 [테스트](test.md)를 연결할 수 없습니다. [웹훅 엔드포인트 테스트](../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 -`)으로 전달하십시오.
