이메일 제공자 교체
PUT /v1/email-provider · 권한 email-provider:write · 모든 요금제
환경이 이메일을 발송하는 방식을 교체합니다. 다음 다섯 가지 모드를 설정할 수 있습니다.
platform_default: KeenAuth가 자체 주소에서 발송합니다.reply_to만 설정할 수 있습니다.platform_managed_domain: KeenAuth가 귀하의 도메인에서 발송하며, 해당 도메인으로 DKIM 서명됩니다. 먼저 이메일 발신 도메인을 추가하고 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_endpoint_id에는 이 환경의 활성화된 엔드포인트를 설정하십시오.
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로 엔드포인트에 전송합니다.
- 요청은
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):
{
"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 -)으로 전달하십시오.