이메일 제공자 교체

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

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

If-Match는 필수이며, 읽은 버전(get의 ETag)을 담아야 합니다. 한 번도 설정되지 않은 환경에는 If-Match: "0"을 보내십시오. *는 일치하지 않습니다. 불일치하면 412가 반환됩니다.

자격 증명은 쓰기 전용입니다. credentials로 보내십시오. 어떤 응답에도 포함되지 않습니다. 생략하면 모드와 대상(SMTP 호스트, 포트, 사용자 이름 또는 제공자)이 변경되지 않은 경우에만 저장된 자격 증명이 유지됩니다. 그렇지 않으면 요청이 /credentials required와 함께 실패합니다.

변경하면 마지막 연결 테스트 결과가 지워지고, 모드, 제공자, 발신 도메인 ID만 기록된 감사 레코드(email_provider.updated)가 작성됩니다. 주소나 자격 증명은 기록되지 않습니다. 변경 후에는 연결 테스트를 실행하십시오.

요청

필드 타입 필수 설명
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 이 환경의 활성화된 웹훅 엔드포인트(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과 같은 형태)과 새 버전을 나타내는 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로 엔드포인트에 전송합니다.

본문(schema_version 1):

{
  "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:

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:

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

예시

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:

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

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

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

이 모드에서는 테스트를 연결할 수 없습니다. 웹훅 엔드포인트 테스트로 엔드포인트를 테스트하십시오.

검증된 발신 도메인에서 발송하려면:

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

이 모드에서는 테스트를 연결할 수 없습니다. 대신 검증으로 도메인을 확인하십시오.

CLI: keenauth email-provider put --if-match '"0"' --data @email-provider.json. 셸 기록에 남지 않도록 자격 증명은 파일에 보관하거나 표준 입력(--data -)으로 전달하십시오.