/** * Resend 발송·수신 서비스 모듈(providers/resend.mjs). 1.0.0 에서 server/resend.mjs 를 이 디렉터리로 옮겼습니다(요구사항 2: 발송 모듈화). * 하는 일: Resend REST API 호출(직렬화·간격·오류 코드), 서명된 다운로드 주소에서 원본·첨부 받기, 도메인 상태 확인. * 발송 자체는 providers/index.mjs 의 sendMail() 이 설정(outbound)에 따라 이 모듈 또는 SMTP 중계 모듈을 고릅니다. * www 사이트(server/mailbox.mjs)와 같은 직렬화·간격·오류 코드 규칙을 씁니다. */ import { now } from '../util.mjs'; import { lookup } from '../dns/common.mjs'; let requestQueue = Promise.resolve(), nextRequestAt = 0; /** * Resend 응답 상태 코드 → 공통 오류 코드. * 1.0.11: 403 이라도 응답 문구가 "도메인이 검증되지 않았다"(The domain is not verified…)이면 mail_forbidden(키 권한 문제로 읽힘)이 아니라 * mail_domain_unverified 로 구분합니다. 운영에서 "Resend 가 요청을 거부했습니다 … API 키 권한을 확인" 문구가 떴지만 실제 원인은 DNS 레코드(DKIM) * 불일치로 도메인 검증이 실패한 것이어서, 사용자가 엉뚱한 곳(키 권한)을 살피게 했습니다. * @param {number} status HTTP 상태 * @param {string} [message] Resend 응답의 message(있으면 403 의 원인 구분에 씀) */ export function providerError(status, message = '') { if (status === 403 && /not verified|verify your domain/i.test(String(message || ''))) return 'mail_domain_unverified'; return status === 401 ? 'mail_auth_failed' : status === 403 ? 'mail_forbidden' : status === 429 ? 'mail_rate_limited' : status === 400 || status === 422 ? 'mail_rejected' : status === 404 ? 'not_found' : 'mail_provider_unavailable'; } export async function resend(env, path, method = 'GET', body, key) { if (!env.RESEND_API_KEY) throw new Error('mail_not_configured'); const execute = async () => { if (!env.RESEND_FETCH) { await new Promise((r) => setTimeout(r, Math.max(0, nextRequestAt - Date.now()))); nextRequestAt = Date.now() + 600; } let r; // 연결 단계 오류(ECONNRESET·연결 시간 초과·DNS)는 한 번 더 시도합니다(1.0.9). 운영 기록에 9일 동안 두 번, 모두 다음 주기에는 정상이던 일시 오류였습니다. // 응답을 받은 뒤의 오류(4xx·5xx)는 재시도하지 않습니다(발송 POST 는 Idempotency-Key 가 있어 재시도해도 두 번 보내지지 않지만, 거부된 요청을 되풀이할 이유가 없음). const attempt = () => (env.RESEND_FETCH || fetch)((env.RESEND_API_BASE || 'https://api.resend.com') + path, { method, headers: { Authorization: 'Bearer ' + env.RESEND_API_KEY, 'Content-Type': 'application/json', ...(key ? { 'Idempotency-Key': key } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000), }); // 재시도 조건: 연결 자체가 안 된 오류 코드(요청이 서버에 닿지 않았음이 확실)이고, 조회(GET)이거나 멱등 키가 있는 발송일 때만. 응답 대기 시간 초과(TimeoutError)는 // 요청이 이미 처리됐을 수 있고(웹훅·도메인 만들기 같은 관리 POST 가 두 번 될 수 있음) 재시도하지 않습니다. const RETRY_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'EPIPE', 'ETIMEDOUT']); const retryable = (e) => RETRY_CODES.has(String(e?.cause?.code || e?.code || '')) && (method === 'GET' || !!key); try { r = await attempt(); } catch (first) { const reason = String(first?.cause?.code || first?.name || first?.message || 'network').slice(0, 120); if (!retryable(first)) { const error = new Error('mail_provider_unavailable'); error.providerMessage = reason; console.error('Resend 연결 실패', method, path, reason); throw error; } console.warn('Resend 연결 실패 — 2초 뒤 한 번 더 시도', method, path, reason); await new Promise((res) => setTimeout(res, env.RESEND_RETRY_MS ?? 2000)); try { r = await attempt(); } catch (e) { const error = new Error('mail_provider_unavailable'); error.providerMessage = String(e?.cause?.code || e?.name || e?.message || 'network').slice(0, 120); console.error('Resend 연결 실패', method, path, error.providerMessage); throw error; } } const data = await r.json().catch(() => ({})); if (!r.ok) { const error = new Error(providerError(r.status, data.message)); error.providerStatus = r.status; error.providerMessage = String(data.message || data.name || '').slice(0, 300); console.error('Resend 실패', method, path, r.status, error.providerMessage); throw error; } return data; }; const result = requestQueue.then(execute, execute); requestQueue = result.catch(() => {}); return result; } /** 서명된 다운로드 주소(원본·첨부)에서 바이트를 받습니다. 크기 상한을 넘기면 중단합니다. */ export async function download(env, url, { maxBytes = 40 * 1024 * 1024 } = {}) { const u = new URL(url); if (u.protocol !== 'https:') throw new Error('invalid_download'); const r = await (env.DOWNLOAD_FETCH || env.RESEND_FETCH || fetch)(u, { signal: AbortSignal.timeout(120000) }); if (!r.ok) { const e = new Error('download_failed'); e.providerStatus = r.status; throw e; } const length = Number(r.headers.get('content-length') || 0); if (length > maxBytes) throw new Error('download_too_large'); const chunks = []; let size = 0; for await (const chunk of r.body) { size += chunk.length; if (size > maxBytes) throw new Error('download_too_large'); chunks.push(Buffer.from(chunk)); } return Buffer.concat(chunks); } // ------------------------------------------------------------------ 도메인 검증 상태 진단(1.0.11) /** Resend 도메인 상태 값을 사람이 읽는 말로(화면 문구는 서버가 한 번만 만듭니다). */ export const DOMAIN_STATUS_WORD = { not_started: '검증을 시작하지 않음', pending: '검증 진행 중', verified: '검증 완료', failed: '검증 실패', temporary_failure: '일시 실패' }; /** * 긴 값(DKIM 공개키 등)을 화면 문구용으로 줄입니다. 비교 상대(other)가 있으면 둘이 같은 앞부분은 건너뛰고 **달라지는 자리**부터 보여 줍니다 — * RSA 공개키는 앞 30자쯤("p=MIGfMA0GCSqGSIb3DQEBAQUAA4…")이 늘 같아 앞만 자르면 두 값이 똑같아 보였습니다(종단 시험에서 확인). */ export function short(v, other = null, n = 28) { const s = String(v ?? ''); if (s.length <= n) return s; const o = other === null ? null : String(other); let cp = 0; if (o !== null) while (cp < s.length && cp < o.length && s[cp] === o[cp]) cp++; if (o === null || cp < 12) return s.slice(0, n) + '…'; const from = Math.max(0, cp - 4); return `${s.slice(0, 8)}…${s.slice(from, from + 20)}${from + 20 < s.length ? '…' : ''}`; } /** * Resend 가 요구하는 DNS 레코드가 실제로 세상에 보이는지 레코드마다 확인합니다(공개 리졸버 8.8.8.8/1.1.1.1, dns/common.mjs lookup). * 운영에서 겪은 문제를 정확히 가리키기 위한 것입니다: "검증 실패" 만으로는 어느 레코드가 빠졌는지, 값이 다른지 알 수 없어 사용자가 API 키 권한을 의심했습니다. * * 판정 규칙(레코드마다) * - ok: 우리 조회로 값이 맞거나, Resend 자신이 verified 라고 하는 레코드(Resend 가 받아들인 값은 그대로 두어야 하므로 — 예: 예전 MX 값 * inbound-smtp.<지역>.amazonaws.com 을 Resend 가 새 값 receive-*.forge.rmta.net 과 같은 것으로 인정하는 경우). * - problem: 'missing'(조회 결과 없음 — 아직 안 넣었거나 전파 대기) / 'wrong'(다른 값이 보임 — 다른 Resend 등록·계정의 값이거나 오타) / null * @param {object} env 테넌트 환경(env.DNS_RESOLVER 가 있으면 그 리졸버로 — 시험용) * @param {object} domain Resend 도메인 상세(records 포함, getDomain 응답) * @returns {Promise<{ok:boolean, records:Array<{record:string,type:string,name:string,value:string,priority?:number,resendStatus:string,ok:boolean,found:string[],problem:string|null}>}>} */ export async function checkResendRecords(env, domain, { resolver = env.DNS_RESOLVER || null } = {}) { const records = []; for (const r of resendDnsRecords(domain)) { const check = await lookup(r, { resolver }); const byResend = String(r.status || '') === 'verified'; const ok = check.ok || byResend; records.push({ record: r.purpose.replace(/^Resend /, ''), type: r.type, name: r.name, value: r.values[0], ...(r.priority !== undefined ? { priority: r.priority } : {}), resendStatus: String(r.status || 'not_started'), ok, found: check.found || [], problem: ok ? null : check.found && check.found.length ? 'wrong' : 'missing' }); } return { ok: records.every((r) => r.ok), records }; } /** * 검증되지 않은 도메인의 원인을 한 문장으로(상태 화면·목록 상단 표시·설정 화면 점검표에 그대로 보임). * 예: "검증 실패 — 값이 다른 레코드: TXT resend._domainkey.ibit.kr (지금 p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN… / 필요 p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN…). …" */ export function describeUnverified(domain, check) { const word = DOMAIN_STATUS_WORD[String(domain?.status || '')] || String(domain?.status || '알 수 없음'); const wrong = check.records.filter((r) => r.problem === 'wrong'), missing = check.records.filter((r) => r.problem === 'missing'); const parts = []; if (wrong.length) parts.push('값이 다른 레코드: ' + wrong.map((r) => `${r.type} ${r.name} (지금 ${short(r.found[0], r.value)} / 필요 ${short(r.value, r.found[0])})`).join(', ')); if (missing.length) parts.push('없는 레코드: ' + missing.map((r) => `${r.type} ${r.name}`).join(', ')); if (!parts.length) parts.push(domain?.status === 'pending' ? 'DNS 레코드는 모두 보입니다. Resend 가 확인을 끝내기를 기다리는 중입니다(보통 몇 분)' : 'DNS 레코드는 모두 보입니다. 검증을 다시 요청하면 됩니다(1시간마다 자동으로 요청)'); const hint = wrong.length ? ' 다른 Resend 계정·예전 등록의 값일 수 있습니다. 관리자 › 메일 서비스 설정 › 자동 설정의 표에 있는 값으로 바꾸면 1시간 안에 자동으로 다시 검증합니다.' : ''; return `${word} — ${parts.join('; ')}.${hint}`; } // 도메인 상태 캐시(60초). env 마다 따로 둡니다(시험에서 여러 env 가 섞이지 않게; 운영은 env 하나). const statusCaches = new WeakMap(); /** 상태 캐시 비우기(검증 요청 직후처럼 다음 조회가 새 값을 보여야 할 때). */ export const clearStatusCache = (env) => statusCaches.delete(env.DB || env); /** * 발신 도메인의 Resend 상태. * ok 도메인이 등록되어 있고 검증 완료 * mail_domain_missing Resend 계정에 이 도메인이 없음(1.0.11 — 예전에는 이 경우를 'ok' 로 보고해 "정상" 으로 보였음) * mail_domain_unverified 등록은 됐지만 검증이 끝나지 않음(not_started/pending/failed/temporary_failure). records 에 레코드별 진단, detail 에 원인 문장 * 그 밖(mail_auth_failed 등) Resend 호출 자체의 오류 코드 * 60초 캐시(force 로 즉시). 검증되지 않은 경우에만 도메인 상세(레코드)와 공개 DNS 조회를 더 합니다(정상일 때는 호출 하나). */ export async function providerStatus(env, { force = false } = {}) { const statusCache = statusCaches.get(env.DB || env) || { at: 0, value: null }; if (!force && statusCache.value && Date.now() - statusCache.at < 60000) return statusCache.value; let value; const domainName = String(env.MAILBOX || '').split('@')[1] || ''; if (!env.RESEND_API_KEY) value = { provider: 'mail_not_configured' }; else try { const domains = await resend(env, '/domains'); const domain = (domains.data || []).find((d) => String(d.name || '').toLowerCase() === domainName); if (!domain) value = { provider: 'mail_domain_missing', domain: null, detail: `Resend 계정에 도메인 ${domainName} 이 등록되어 있지 않습니다. 관리자 › 메일 서비스 설정에서 "자동 설정 실행" 을 누르면 등록합니다.` }; else if (domain.status !== 'verified') { let detail = DOMAIN_STATUS_WORD[domain.status] || String(domain.status), records = []; // 레코드별 진단은 보조 정보라 실패해도(일시적 DNS 오류 등) 상태 자체는 보고합니다. try { const full = await resend(env, '/domains/' + encodeURIComponent(domain.id)); const check = await checkResendRecords(env, full); detail = describeUnverified(full, check); records = check.records; } catch (e) { detail += ` — 레코드 진단 실패(${e.providerMessage || e.message})`; } value = { provider: 'mail_domain_unverified', domain: { name: domain.name, status: domain.status, id: domain.id, region: domain.region }, records, detail }; } else value = { provider: 'ok', domain: { name: domain.name, status: domain.status } }; } catch (e) { value = { provider: e.message, detail: e.providerMessage, providerStatus: e.providerStatus }; } value.checkedAt = now(); statusCaches.set(env.DB || env, { at: Date.now(), value }); return value; } /** * 자동 재검증(1.0.11): 도메인 검증이 실패(failed/temporary_failure)했거나 시작되지 않았는데 Resend 가 요구하는 레코드가 이제 모두 보이면 검증을 다시 요청합니다. * Resend 는 레코드가 틀린 채 시간이 지나면 상태를 failed 로 바꾸고 멈추므로, 사용자가 DNS 를 고친 뒤 "검증 요청" 을 다시 누르지 않으면 영영 실패 상태입니다. * main.mjs 의 정기 동기화(기본 1시간)마다 테넌트별로 부릅니다. 정상(verified)·진행 중(pending)·레코드가 아직 틀린 경우는 아무 것도 하지 않습니다. * 환경 변수 RESEND_AUTO_VERIFY=0 으로 끌 수 있습니다. * @returns {Promise<{requested?:boolean, skipped?:string, status?:string, problems?:string[]}>} */ export async function autoVerifyDomain(env, { resolver = env.DNS_RESOLVER || null } = {}) { if (String(env.RESEND_AUTO_VERIFY ?? '1') === '0') return { skipped: 'disabled' }; if (!env.RESEND_API_KEY) return { skipped: 'no_key' }; const domainName = String(env.MAILBOX || '').split('@')[1] || ''; const found = await findDomain(env, domainName); if (!found) return { skipped: 'missing' }; const status = String(found.status || 'not_started'); if (status === 'verified' || status === 'pending') return { skipped: status, status }; const full = await getDomain(env, found.id); const check = await checkResendRecords(env, full, { resolver }); if (!check.ok) return { skipped: 'records', status, problems: check.records.filter((r) => !r.ok).map((r) => `${r.type} ${r.name}: ${r.problem}`) }; await verifyDomain(env, found.id); clearStatusCache(env); return { requested: true, status }; } // ------------------------------------------------------------------ 도메인·웹훅 관리(1.0.5, 요구사항 3·4) // 외부 서비스 설정 자동화(provision.mjs)가 쓰는 Resend 관리 API 묶음입니다. 모두 resend() 를 거치므로 직렬화·오류 코드 규칙이 같습니다. /** 계정의 도메인 목록. */ export const listDomains = async (env) => (await resend(env, '/domains')).data || []; /** 도메인 상세(status, capabilities, records[{record,name,type,value,priority,status}]). */ export const getDomain = (env, id) => resend(env, '/domains/' + encodeURIComponent(id)); /** * 도메인 등록. region 은 발송 서버 위치(수신 MX 값도 지역에 따라 다름). capabilities 로 발송/수신을 켭니다. * @returns {Promise} 생성된 도메인(records 포함) */ export const createDomain = (env, { name, region = 'ap-northeast-1', sending = true, receiving = false }) => resend(env, '/domains', 'POST', { name, region, capabilities: { sending: sending ? 'enabled' : 'disabled', receiving: receiving ? 'enabled' : 'disabled' } }); /** 도메인의 발송/수신 기능 켬·끔(PATCH). */ export const updateDomainCapabilities = (env, id, { sending, receiving }) => resend(env, '/domains/' + encodeURIComponent(id), 'PATCH', { capabilities: { sending: sending ? 'enabled' : 'disabled', receiving: receiving ? 'enabled' : 'disabled' } }); /** DNS 레코드 검증 요청. 결과는 잠시 뒤 getDomain 의 status 로 확인합니다(pending → verified / failed / temporary_failure). */ export const verifyDomain = (env, id) => resend(env, '/domains/' + encodeURIComponent(id) + '/verify', 'POST', {}); /** 웹훅 목록 [{id, endpoint, events[], status}]. 서명 비밀키는 목록에 없고 만들 때만 돌려줍니다. */ export const listWebhooks = async (env) => (await resend(env, '/webhooks')).data || []; /** 웹훅 만들기 → { id, signing_secret }. 비밀키는 이때만 받을 수 있으므로 호출자가 바로 설정에 저장합니다. */ export const createWebhook = (env, { endpoint, events = ['email.received'] }) => resend(env, '/webhooks', 'POST', { endpoint, events }); /** 웹훅 삭제(새 비밀키를 받기 위해 다시 만들 때). */ export const deleteWebhook = (env, id) => resend(env, '/webhooks/' + encodeURIComponent(id), 'DELETE'); /** * 우리 도메인을 찾습니다(없으면 null). 이름 비교는 소문자. */ export async function findDomain(env, name) { const want = String(name || '').toLowerCase(); return (await listDomains(env)).find((d) => String(d.name || '').toLowerCase() === want) || null; } /** * Resend 가 요구하는 DNS 레코드를 공통 모양으로 바꿉니다(dns/index.mjs 의 레코드 모양). * name: Resend 는 도메인 기준 상대 이름('' = 루트, 'send', 'resend._domainkey') → FQDN * TXT 값은 그대로(따옴표는 DNS 모듈이 붙임), MX 는 priority 와 함께, CNAME 은 값 그대로. * @param {object} domain getDomain/createDomain 응답 */ export function resendDnsRecords(domain) { const base = String(domain?.name || '').toLowerCase(); return (domain?.records || []).map((r) => ({ name: r.name ? `${String(r.name).toLowerCase()}.${base}` : base, type: String(r.type || 'TXT').toUpperCase(), ttl: 300, values: [String(r.value || '')], ...(r.priority !== undefined ? { priority: Number(r.priority) } : {}), purpose: `Resend ${r.record || r.type}`, source: 'resend', status: r.status || null, })); }