Files

224 lines
18 KiB
JavaScript

/**
* 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> 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<object>} 생성된 도메인(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,
}));
}