Files
AriWeb-Suite/modules/arimail/server/provision.mjs

210 lines
24 KiB
JavaScript

/**
* 메일 서비스 자동 설정(1.0.5, 요구사항 2·4·5) — "계획(plan)" 모델.
*
* 생각의 틀
* 사용자가 수·발신 방식과 도메인을 정하면, 메일을 실제로 쓰기까지 필요한 일이 여러 곳에 흩어져 있습니다: Resend 에 도메인 등록·기능 켜기·검증·웹훅,
* DNS 에 MX/SPF/DKIM/CNAME 레코드, 내장 Haraka 설치·DKIM 키·PTR·방화벽, 외부 SMTP 중계 서버 접속 확인 … 이 모듈은 그 일들을 **단계(step)** 로 나누어
* 현재 상태를 진단하고(buildPlan), 자동으로 할 수 있는 단계는 실행하며(runPlan), 사람이 해야 하는 단계는 무엇을·어떻게 해야 하는지와 입력 칸을 돌려줍니다.
* 화면(web/setup.js)은 이 계획을 점검표로 그리고 "자동 설정 실행" 단추 하나로 돌립니다. 실제 변경은 각 모듈(providers/resend.mjs, dns/*.mjs, config.mjs)이 합니다.
*
* 단계 상태(state)
* ok 끝남 todo 자동으로 할 수 있고 아직 안 함(runPlan 이 실행)
* wait 반영·검증을 기다리는 중(DNS 전파, Resend 검증) action 사용자가 해야 함(입력 칸 input 또는 단추 actions, 안내 detail)
* error 실패(원인 detail) info 참고(준비 판정에 영향 없음) skip 이번 구성에서는 필요 없음(화면에 안 보임)
*
* 계획 모양: { domain, inbound, outbound, dns:{provider, configured, zone?, error?}, records:[레코드+check], steps:[{id, group, title, state, detail, auto, input?, actions?}], ready, readyMessage }
*/
import { inboundMode, outboundMode, usesResend, usesHaraka, relaySettings } from './providers/index.mjs';
import * as resend from './providers/resend.mjs';
import { smtpProbe } from './providers/smtp-client.mjs';
import { requiredRecords, checkRecords, applyRecords, dnsProviderName, dnsConfigured, probeDns } from './dns/index.mjs';
import { lookup, reverseCheck, bare } from './dns/common.mjs';
import { saveConfig, generateDkim, mailDomain, harakaHostname, harakaStatus, harakaDir, CONFIG_SCHEMA } from './config.mjs';
/** Resend 상태·오류 코드를 사람이 읽는 말로. 화면 문구와 겹치지 않게 서버가 한 번만 만듭니다. */
const RESEND_STATUS = { not_started: '검증을 시작하지 않음', pending: '검증 진행 중(DNS 반영 뒤 보통 몇 분, 최대 72시간)', verified: '검증 완료', failed: '검증 실패(Resend 가 레코드를 확인하지 못해 멈춤 — 고친 뒤 다시 요청해야 함)', temporary_failure: '일시 실패 — DNS 반영을 기다린 뒤 다시 검증' };
const ERR = { mail_not_configured: 'API 키가 없습니다', mail_auth_failed: 'API 키가 거부되었습니다', mail_forbidden: '권한이 없습니다(키 종류·도메인 소유 확인)', mail_rate_limited: 'Resend 가 요청을 제한합니다. 잠시 뒤 다시', mail_rejected: 'Resend 가 요청을 거부했습니다', mail_provider_unavailable: 'Resend 에 연결할 수 없습니다', dns_not_configured: 'DNS 모듈 인증 정보가 없습니다', dns_bad_credentials: 'DNS 인증 정보 형식이 틀립니다', dns_auth_failed: 'DNS 서비스가 인증을 거부했습니다', dns_forbidden: 'DNS 서비스 권한이 없습니다', dns_zone_not_found: 'DNS 존을 찾지 못했습니다', dns_unavailable: 'DNS 서비스에 연결할 수 없습니다' };
const say = (e) => (ERR[e?.message] || e?.message || '알 수 없는 오류') + (e?.detail || e?.providerMessage ? ` (${e.detail || e.providerMessage})` : '');
/**
* 진단에 필요한 외부 상태를 한 번씩만 읽어 모아 둡니다(Resend 도메인·웹훅, DNS 조회). 실패는 오류로 남겨 단계가 설명합니다.
* @param {object} o resolver: 시험용 DNS 리졸버
*/
async function gather(env, { resolver = null } = {}) {
const ctx = { domain: mailDomain(env), inbound: inboundMode(env), outbound: outboundMode(env), host: bare(harakaHostname(env)), resendDomain: null, resendError: null, webhooks: null, webhookError: null, existingSpf: null, resolver };
if (usesResend(env) && env.RESEND_API_KEY) {
try {
let d = null;
if (env.RESEND_DOMAIN_ID) { try { d = await resend.getDomain(env, env.RESEND_DOMAIN_ID); if (String(d.name || '').toLowerCase() !== ctx.domain) d = null; } catch { d = null; } }
if (!d) { const found = await resend.findDomain(env, ctx.domain); if (found) d = await resend.getDomain(env, found.id); }
ctx.resendDomain = d;
} catch (e) { ctx.resendError = e; }
if (ctx.inbound === 'resend') { try { ctx.webhooks = await resend.listWebhooks(env); } catch (e) { ctx.webhookError = e; } }
}
if (ctx.outbound === 'haraka') { const r = await lookup({ name: ctx.domain, type: 'TXT', values: [] }, { resolver }); ctx.existingSpf = (r.found || []).find((v) => /^v=spf1\b/i.test(v)) || null; }
return ctx;
}
const step = (id, group, title, state, detail = '', extra = {}) => ({ id, group, title, state, detail, auto: false, ...extra });
// 경로 접두사(1.0.8): site.example.com/mail 처럼 등록한 도메인은 웹훅도 그 경로 아래(https://site.example.com/mail/api/webhooks/resend)로 받습니다.
const webhookEndpoint = (env) => `https://${env.PUBLIC_HOST || 'mail.arihakdang.com'}${env.PUBLIC_PATH || ''}/api/webhooks/resend`;
const inputs = (...keys) => keys.map((key) => { const item = CONFIG_SCHEMA.find((i) => i.key === key); return { key, type: item?.type || 'text', ...(item?.options ? { options: item.options } : {}), ...(item?.multiline ? { multiline: true } : {}) }; });
/**
* 현재 상태를 진단해 계획을 만듭니다. 외부 호출(Resend·DNS 조회)을 하므로 1~3초 걸릴 수 있습니다.
* @param {object} [o] resolver: 시험용 DNS 리졸버, ctx: gather 결과 재사용
*/
export async function buildPlan(env, { resolver = null, ctx = null } = {}) {
ctx = ctx || await gather(env, { resolver });
const steps = [];
const { domain, inbound, outbound, host } = ctx;
// ---- Resend
if (usesResend(env)) {
if (!env.RESEND_API_KEY) steps.push(step('resend.key', 'resend', 'Resend API 키', 'action', 'Resend → API Keys 에서 전체 접근(Full access) 키를 만들어 넣으세요. 도메인 등록·검증·웹훅을 이 키로 자동 처리합니다.', { input: inputs('RESEND_API_KEY', 'RESEND_REGION') }));
else {
steps.push(step('resend.key', 'resend', 'Resend API 키', 'ok', '설정됨'));
const d = ctx.resendDomain, wantRecv = inbound === 'resend', wantSend = outbound === 'resend';
if (ctx.resendError) steps.push(step('resend.domain', 'resend', `Resend 에 도메인 ${domain} 등록`, 'error', say(ctx.resendError)));
else if (!d) steps.push(step('resend.domain', 'resend', `Resend 에 도메인 ${domain} 등록`, 'todo', `지역 ${env.RESEND_REGION || 'ap-northeast-1'} 로 등록하고 ${[wantSend && '발송', wantRecv && '수신'].filter(Boolean).join('·')} 기능을 켭니다.`, { auto: true }));
else {
const cap = d.capabilities || {}, recvOn = cap.receiving === 'enabled', sendOn = cap.sending !== 'disabled';
if (recvOn !== wantRecv || sendOn !== wantSend) steps.push(step('resend.domain', 'resend', `Resend 도메인 기능 맞추기`, 'todo', `지금: 발송 ${sendOn ? '켬' : '끔'} · 수신 ${recvOn ? '켬' : '끔'} → 필요: 발송 ${wantSend ? '켬' : '끔'} · 수신 ${wantRecv ? '켬' : '끔'}`, { auto: true }));
else steps.push(step('resend.domain', 'resend', `Resend 도메인 ${domain}`, 'ok', `등록됨 (지역 ${d.region || '-'}, 발송 ${sendOn ? '켬' : '끔'} · 수신 ${recvOn ? '켬' : '끔'})`));
}
}
}
// ---- Haraka 준비(레코드 계산 전에 DKIM 이 있어야 하므로 먼저)
const hs = usesHaraka(env) ? harakaStatus(env) : null;
if (usesHaraka(env)) {
const svc = hs.status && !hs.stale;
if (!hs.installed) steps.push(step('haraka.install', 'haraka', '내장 Haraka 설치', 'action', `서버에서 root 로 한 번 실행하세요: sudo bash ${harakaDir(env).replace(/\/haraka$/, '')}/repo/deploy/haraka-apply.sh (npm 설치 + 서비스 등록, 인터넷 필요)`, { actions: [{ id: 'recheck', label: '설치 확인' }] }));
else steps.push(step('haraka.install', 'haraka', '내장 Haraka 설치', 'ok', `Haraka ${hs.status?.version || ''} 설치됨`));
if (hs.installed && !svc) steps.push(step('haraka.service', 'haraka', 'Haraka 관리 서비스', 'action', '관리 서비스가 돌고 있지 않습니다. 서버에서: sudo systemctl start <서비스 이름>-haraka (deploy/haraka-apply.sh 를 다시 실행해도 됩니다)', { actions: [{ id: 'recheck', label: '다시 확인' }] }));
else if (hs.installed) steps.push(step('haraka.service', 'haraka', 'Haraka 관리 서비스', 'ok', '실행 중'));
if (outbound === 'haraka') steps.push(env.HARAKA_DKIM_PRIVATE ? step('haraka.dkim', 'haraka', 'DKIM 서명 키', 'ok', `셀렉터 ${env.HARAKA_DKIM_SELECTOR || 'arimail'}`) : step('haraka.dkim', 'haraka', 'DKIM 서명 키 만들기', 'todo', 'RSA 2048 키를 만들어 Haraka 가 보내는 메일에 서명합니다. 공개키는 DNS TXT 로 올립니다.', { auto: true }));
}
// ---- DNS
const records = await checkRecords(requiredRecords(env, { resendDomain: ctx.resendDomain, existingSpf: ctx.existingSpf }), { resolver: ctx.resolver });
const needed = records.filter((r) => !r.optional), missing = needed.filter((r) => !r.check.ok);
const provider = dnsProviderName(env), configured = dnsConfigured(env);
let dnsInfo = { provider, configured };
if (records.length) {
if (!missing.length) steps.push(step('dns.apply', 'dns', 'DNS 레코드', 'ok', `${needed.length}개 레코드가 모두 반영되어 있습니다.`));
else if (provider === 'none' || !configured) {
// 1.0.11: 값이 다른 레코드(⚠️ 지금 값 표시)와 아직 없는 레코드를 구분해 알립니다.
const wrongCount = missing.filter((r) => r.check.problem === 'wrong').length;
const shape = wrongCount ? `값이 다른 레코드 ${wrongCount}개(표의 "지금 값" 참고)${missing.length - wrongCount ? `, 없는 레코드 ${missing.length - wrongCount}개` : ''}. ` : '';
steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 ${wrongCount ? '고치기' : '넣기'}`, 'action', shape + (provider === 'none' ? '자동 적용할 DNS 모듈이 없습니다. 아래 표의 레코드를 도메인의 DNS 서비스에 직접 넣고 "반영 확인" 을 누르세요. Google Cloud DNS 나 AWS Route 53 을 쓰면 "DNS 모듈" 카드에 인증 정보를 넣어 자동 적용할 수 있습니다.' : `DNS 모듈(${provider})의 인증 정보가 비어 있습니다. "DNS 모듈" 카드에서 채우거나 레코드를 직접 넣으세요.`), { actions: [{ id: 'recheck', label: '반영 확인' }] }));
}
else {
const probe = await probeDns(env); dnsInfo = { ...dnsInfo, zone: probe.zone, error: probe.ok ? null : probe.error, detail: probe.detail };
if (!probe.ok) steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 넣기`, 'error', `DNS 모듈(${provider}) 연결 실패: ${say({ message: probe.error, detail: probe.detail })}. 인증 정보·권한·존을 확인하세요. 또는 아래 레코드를 직접 넣으세요.`, { actions: [{ id: 'recheck', label: '반영 확인' }] }));
else steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 넣기`, env.PLAN_DNS_APPLIED_AT && Date.now() - Date.parse(env.PLAN_DNS_APPLIED_AT) < 1800000 ? 'wait' : 'todo', env.PLAN_DNS_APPLIED_AT && Date.now() - Date.parse(env.PLAN_DNS_APPLIED_AT) < 1800000 ? `${provider} 존 ${probe.zone} 에 적용했습니다. 전파를 기다리는 중(보통 1~10분): ${missing.map((r) => `${r.type} ${r.name}`).join(', ')}` : `${provider} 존 ${probe.zone} 에 자동으로 넣습니다: ${missing.map((r) => `${r.type} ${r.name}`).join(', ')}`, { auto: true, actions: [{ id: 'recheck', label: '반영 확인' }] }));
}
}
// ---- Resend 검증·웹훅
if (usesResend(env) && env.RESEND_API_KEY && ctx.resendDomain) {
const d = ctx.resendDomain, st = String(d.status || 'not_started');
// 1.0.11: 빠진 Resend 레코드를 "값이 다름(wrong)" 과 "없음(missing)" 으로 나눕니다. 값이 다른 것은 기다려도 해결되지 않으므로 사용자 작업(action)으로,
// 없는 것만 전파 대기(wait)로 안내합니다. 운영에서 DKIM TXT 가 다른 등록의 공개키로 들어가 있어 "검증 실패" 만 보이던 일에서 나온 구분입니다.
const resendMissing = missing.filter((r) => r.source === 'resend'), wrong = resendMissing.filter((r) => r.check.problem === 'wrong');
const wrongText = wrong.map((r) => `${r.type} ${r.name} (지금 ${resend.short(r.check.found[0] || '', r.values[0])} / 필요 ${resend.short(r.values[0], r.check.found[0] || '')})`).join(', ');
if (st === 'verified') steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증', 'ok', RESEND_STATUS.verified));
else if (st === 'pending') steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증', 'wait', RESEND_STATUS.pending, { actions: [{ id: 'recheck', label: '상태 확인' }] }));
else if (wrong.length) steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증 — DNS 레코드 값이 다릅니다', 'action', `${RESEND_STATUS[st] || st}. 값이 다른 레코드: ${wrongText}. 아래 표의 값(복사 단추)으로 바꾸세요 — 다른 Resend 계정이나 예전 등록에서 받은 값일 수 있습니다. 바꾼 뒤에는 1시간마다 자동으로 검증을 다시 요청하므로 기다리거나 "지금 검증 요청" 을 누르세요.`, { actions: [{ id: 'recheck', label: '반영 확인' }, { id: 'verify', label: '지금 검증 요청' }] }));
else steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증 요청', resendMissing.length ? 'wait' : 'todo', (RESEND_STATUS[st] || st) + (resendMissing.length ? ` — 아직 DNS 에 없는 Resend 레코드: ${resendMissing.map((r) => `${r.type} ${r.name}`).join(', ')}. 반영되면 자동으로 검증을 요청합니다(1시간마다).` : ' — 검증을 요청합니다.'), { auto: !resendMissing.length, actions: [{ id: 'verify', label: '지금 검증 요청' }] }));
if (inbound === 'resend') {
const ep = webhookEndpoint(env);
if (ctx.webhookError) steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'error', say(ctx.webhookError)));
else {
const mine = (ctx.webhooks || []).find((w) => String(w.endpoint || '').replace(/\/$/, '') === ep);
if (!mine) steps.push(step('resend.webhook', 'resend', '수신 웹훅 만들기', 'todo', `${ep} 로 email.received 이벤트를 보내는 웹훅을 만들고 서명 비밀키를 설정에 저장합니다.`, { auto: true }));
else if (mine.status !== 'enabled') steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'action', `웹훅이 꺼져 있습니다(${mine.status}). Resend 대시보드에서 켜거나 아래에서 새로 만드세요.`, { actions: [{ id: 'recreate', label: '웹훅 새로 만들기' }] }));
else if (!env.RESEND_WEBHOOK_SECRET) steps.push(step('resend.webhook', 'resend', '수신 웹훅 서명 비밀키', 'action', `웹훅은 있지만(${mine.id}) 서명 비밀키가 설정에 없습니다. Resend 는 비밀키를 만들 때만 보여 주므로, 대시보드의 Signing Secret 을 넣거나 웹훅을 새로 만드세요(기존 것은 지웁니다).`, { input: inputs('RESEND_WEBHOOK_SECRET'), actions: [{ id: 'recreate', label: '웹훅 새로 만들기' }] }));
else steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'ok', `${mine.id} (이벤트 ${(mine.events || []).length}개${(mine.events || []).length > 1 ? ' — email.received 외 이벤트는 무시됩니다' : ''})`));
}
}
}
// ---- Haraka 실행·발신 준비
if (usesHaraka(env) && hs.installed && hs.status && !hs.stale) {
const st = hs.status;
if (st.running) steps.push(step('haraka.running', 'haraka', 'Haraka 실행', 'ok', `대기 중: ${(st.listen || []).join(', ')}`));
else steps.push(step('haraka.running', 'haraka', 'Haraka 실행', 'wait', st.lastExit ? `마지막 종료 코드 ${st.lastExit.code} (${st.lastExit.at}). 로그: journalctl -u <서비스 이름>-haraka` : '관리 서비스가 곧 띄웁니다(설정 저장 뒤 수 초).', { actions: [{ id: 'recheck', label: '다시 확인' }] }));
if (!st.tls) steps.push(step('haraka.tls', 'haraka', 'STARTTLS 인증서', 'info', '메일 포트 인증서(POP_TLS_CERT)가 없어 Haraka 가 STARTTLS 를 제공하지 않습니다. 인증서가 생기면 자동으로 켭니다.'));
if (inbound === 'haraka') steps.push(step('haraka.port', 'haraka', `수신 포트 ${env.HARAKA_PORT || 25} 개방`, 'info', `방화벽(클라우드 보안 그룹 포함)에서 TCP ${env.HARAKA_PORT || 25} 를 열어야 외부 메일 서버가 접속할 수 있습니다. 서버 안에서는 확인할 수 없으니 외부에서 telnet ${host} ${env.HARAKA_PORT || 25} 로 확인하세요.`));
if (outbound === 'haraka') {
const ptr = await reverseCheck(host, { resolver: ctx.resolver });
steps.push(ptr.ok ? step('haraka.ptr', 'haraka', '역방향 DNS(PTR)', 'ok', `${ptr.ips.join(', ')} → ${host}`) : step('haraka.ptr', 'haraka', '역방향 DNS(PTR)', 'action', ptr.error ? `${host} 의 A 레코드를 찾지 못했습니다(${ptr.error}). 메일 서버 이름이 이 서버 IP 를 가리켜야 합니다.` : `IP ${ptr.ips.join(', ')} 의 PTR 이 ${host} 가 아닙니다(지금: ${ptr.ptr.join(', ') || '없음'}). 호스팅 업체에 PTR(역방향 DNS)을 ${host} 로 요청하세요. 없으면 Gmail 등에서 스팸으로 분류되기 쉽습니다.`, { actions: [{ id: 'recheck', label: '다시 확인' }] }));
}
}
// ---- 외부 SMTP 중계
if (outbound === 'smtp') {
const relay = relaySettings(env, 'smtp');
if (!relay.host) steps.push(step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'action', '보내는 메일을 넘길 중계 서버 접속 정보를 넣으세요.', { input: inputs('SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS') }));
else { const p = await smtpProbe({ ...relay, hostname: env.PUBLIC_HOST || 'localhost', timeoutMs: 8000 }); steps.push(p.ok ? step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'ok', `${relay.host}:${relay.port} 연결·${relay.user ? '인증 ' : ''}확인`) : step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'error', `${relay.host}:${relay.port} — ${ERR[p.error] || p.error}${p.detail ? ' (' + p.detail + ')' : ''}`, { input: inputs('SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS'), actions: [{ id: 'recheck', label: '다시 확인' }] })); }
}
const blocking = steps.filter((s) => !['ok', 'info', 'skip'].includes(s.state));
const ready = blocking.length === 0;
const readyMessage = ready ? `메일 사용 준비가 끝났습니다 — 수신 ${label(inbound)} · 발신 ${label(outbound)} (${domain}).` : blocking.some((s) => s.state === 'wait') && blocking.every((s) => s.state === 'wait') ? '반영을 기다리는 중입니다. 잠시 뒤 "다시 확인" 을 누르세요.' : `${blocking.length}개 단계가 남았습니다: ${blocking.map((s) => s.title).join(', ')}`;
return { domain, inbound, outbound, host, dns: dnsInfo, records, steps, ready, readyMessage, checkedAt: new Date().toISOString() };
}
const label = (m) => ({ resend: 'Resend', haraka: '내장 Haraka', smtp: '외부 SMTP 중계' }[m] || m);
/**
* 계획을 실행합니다. input 은 먼저 설정에 저장하고(허용된 키만), step 이 있으면 그 단계(또는 그 단계의 action)만, 없으면 자동 가능한 단계를 순서대로 실행합니다.
* 실행 뒤 다시 진단한 계획과 실행 기록(log: [{id, result, detail}])을 돌려줍니다. 한 단계가 실패해도 다음 단계로 넘어가며 기록에 남깁니다.
*/
export async function runPlan(env, { step: only = null, stepAction = null, input = {}, resolver = null } = {}) {
const log = [];
const patch = Object.fromEntries(Object.entries(input).filter(([k]) => CONFIG_SCHEMA.some((i) => i.key === k && !i.hidden)));
if (Object.keys(patch).length) { saveConfig(env, patch); log.push({ id: 'config', result: 'saved', detail: Object.keys(patch).join(', ') }); }
let plan = await buildPlan(env, { resolver });
const run = async (s) => {
try {
const r = await execute(env, s, stepAction, plan, resolver);
log.push({ id: s.id, result: r ? 'done' : 'noop', detail: r || '' });
} catch (e) { log.push({ id: s.id, result: 'error', detail: say(e) }); }
};
if (only) { const s = plan.steps.find((x) => x.id === only); if (s) await run(s); }
else {
// 순서가 중요: 도메인 등록 → DKIM → DNS 적용 → (다시 진단) → 검증·웹훅
// 한 단계가 끝나면 다시 진단합니다: DKIM 키가 생기면 DNS 레코드 목록이 바뀌고, 도메인이 등록되면 Resend 레코드가 생기기 때문입니다.
for (const id of ['resend.domain', 'haraka.dkim', 'dns.apply', 'resend.verify', 'resend.webhook']) {
const s = plan.steps.find((x) => x.id === id && x.auto && x.state === 'todo'); if (!s) continue;
await run(s); plan = await buildPlan(env, { resolver });
}
}
plan = await buildPlan(env, { resolver });
return { plan, log };
}
/** 단계 하나의 실제 동작. 돌려주는 문자열은 기록용 요약, null 이면 할 일이 없었음. */
async function execute(env, s, action, plan, resolver) {
const domain = plan.domain, wantRecv = plan.inbound === 'resend', wantSend = plan.outbound === 'resend';
switch (s.id) {
case 'resend.domain': {
const found = await resend.findDomain(env, domain);
if (!found) { const d = await resend.createDomain(env, { name: domain, region: env.RESEND_REGION || 'ap-northeast-1', sending: wantSend, receiving: wantRecv }); saveConfig(env, { RESEND_DOMAIN_ID: d.id }, { allowHidden: true }); return `도메인 등록 ${d.id}`; }
saveConfig(env, { RESEND_DOMAIN_ID: found.id }, { allowHidden: true });
await resend.updateDomainCapabilities(env, found.id, { sending: wantSend, receiving: wantRecv }); return `기능 갱신(발송 ${wantSend ? '켬' : '끔'} · 수신 ${wantRecv ? '켬' : '끔'})`;
}
case 'haraka.dkim': generateDkim(env); return 'DKIM 키 생성';
case 'dns.apply': {
if (action === 'recheck') return null;
const r = await applyRecords(env, plan.records.filter((x) => !x.check.ok));
env.PLAN_DNS_APPLIED_AT = new Date().toISOString(); // 적용 직후에는 "전파 대기" 로 표시(메모리에만, 재시작하면 다시 진단)
return `${r.provider} 존 ${r.zone}: 변경 ${r.changed.length}개${r.unchanged.length ? `, 그대로 ${r.unchanged.length}개` : ''}`;
}
case 'resend.verify': { if (action === 'recheck') return null; const id = env.RESEND_DOMAIN_ID || (await resend.findDomain(env, domain))?.id; if (!id) throw new Error('mail_rejected'); await resend.verifyDomain(env, id); return '검증 요청'; }
case 'resend.webhook': {
const ep = webhookEndpoint(env);
if (action === 'recreate') { for (const w of await resend.listWebhooks(env)) if (String(w.endpoint || '').replace(/\/$/, '') === ep) { try { await resend.deleteWebhook(env, w.id); } catch (e) { console.warn('웹훅 삭제 실패', w.id, e.message); } } }
else if ((await resend.listWebhooks(env)).some((w) => String(w.endpoint || '').replace(/\/$/, '') === ep)) return null;
const w = await resend.createWebhook(env, { endpoint: ep, events: ['email.received'] });
saveConfig(env, { RESEND_WEBHOOK_SECRET: w.signing_secret || '' }); saveConfig(env, { RESEND_WEBHOOK_ID: w.id || '' }, { allowHidden: true });
return `웹훅 생성 ${w.id}${w.signing_secret ? ' + 서명 비밀키 저장' : ''}`;
}
default: return null; // recheck 류: buildPlan 이 다시 진단하는 것으로 충분
}
}