Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
@@ -0,0 +1,69 @@
/**
* 메일 서비스 모듈 선택(1.0.0, 요구사항 2) — 발송·수신 "길" 을 설정(config.json 의 inbound / outbound)으로 고릅니다.
*
* 발신(outbound)
* resend : Resend REST API(providers/resend.mjs). 예전과 같은 기본값.
* haraka : 내장 Haraka 의 로컬 제출 포트(127.0.0.1:HARAKA_RELAY_PORT, 암호화·인증 없음)로 넘기면 Haraka 가 받는 쪽 MX 로 직접 배달합니다.
* smtp : 외부 SMTP 중계 서버(SMTP_RELAY_*)로 넘깁니다(회사 메일 서버, 다른 발송 서비스의 SMTP 등).
* 수신(inbound)
* resend : Resend 웹훅 + 목록 동기화(store.mjs).
* haraka : 내장 Haraka 가 25 포트로 받은 메일을 로컬 HTTP(/api/inbound/haraka)로 넘겨 줍니다(store.mjs ingestRaw).
*
* 발송 호출 지점은 모두 sendMail(env, payload, key) 하나입니다. payload 는 Resend 요청과 같은 모양(from, to, cc, bcc, reply_to, subject,
* text, html, headers, attachments[{filename, content(base64), content_type, content_id}])이고 돌려주는 값은 { id } 입니다.
* 새 발송 서비스를 더하려면 providers/<이름>.mjs 에 같은 모양으로 send 를 만들고 여기 outboundMode·sendMail·outboundStatus 에 한 줄씩 더합니다.
*/
import { resend, providerStatus as resendStatus } from './resend.mjs';
import { sendViaSmtp } from './smtp.mjs';
import { smtpProbe } from './smtp-client.mjs';
import { now } from '../util.mjs';
export const OUTBOUND_MODES = ['resend', 'haraka', 'smtp'], INBOUND_MODES = ['resend', 'haraka'];
/** 설정된 발신 방식(알 수 없는 값은 resend). env.MAIL_OUTBOUND 는 config.mjs 의 applyConfig 가 채웁니다. */
export const outboundMode = (env) => (OUTBOUND_MODES.includes(env.MAIL_OUTBOUND) ? env.MAIL_OUTBOUND : 'resend');
/** 설정된 수신 방식(알 수 없는 값은 resend). */
export const inboundMode = (env) => (INBOUND_MODES.includes(env.MAIL_INBOUND) ? env.MAIL_INBOUND : 'resend');
/** Resend 를 어느 쪽으로든 쓰는지(동기화·웹훅·도메인 상태 확인이 의미 있는지). */
export const usesResend = (env) => inboundMode(env) === 'resend' || outboundMode(env) === 'resend';
/** 내장 Haraka 를 어느 쪽으로든 쓰는지(haraka/run.mjs 가 Haraka 를 띄울지). */
export const usesHaraka = (env) => inboundMode(env) === 'haraka' || outboundMode(env) === 'haraka';
/** 발신 방식에 따른 중계 서버 접속 정보. haraka 는 로컬 고정, smtp 는 SMTP_RELAY_* 설정. */
export function relaySettings(env, mode = outboundMode(env)) {
// haraka: 서버에 하나뿐인 Haraka 의 제출 포트는 기본 도메인의 설정(HARAKA_RELAY_PORT)이 정합니다(1.0.7: 추가 도메인도 같은 포트로 넘김).
if (mode === 'haraka') { const primary = env.TENANTS?.primary?.env || env; return { host: '127.0.0.1', port: Number(primary.HARAKA_RELAY_PORT || 2525), security: 'none', user: '', pass: '' }; }
if (mode === 'smtp') return { host: String(env.SMTP_RELAY_HOST || '').trim(), port: Number(env.SMTP_RELAY_PORT || 587), security: ['none', 'starttls', 'tls'].includes(env.SMTP_RELAY_SECURITY) ? env.SMTP_RELAY_SECURITY : 'starttls', user: String(env.SMTP_RELAY_USER || ''), pass: String(env.SMTP_RELAY_PASS || ''), rejectUnauthorized: env.SMTP_RELAY_INSECURE !== '1' };
return null;
}
/**
* 메일 한 통 발송. 어느 모듈을 쓰든 { id } 를 돌려주고, 실패는 공통 오류 코드(mail_not_configured / mail_auth_failed / mail_rejected /
* mail_rate_limited / mail_provider_unavailable)의 Error 로 던집니다(providerMessage 에 서비스 응답).
* @param {string} [key] 멱등 키(Resend 만 씀)
*/
export async function sendMail(env, payload, key) {
const mode = outboundMode(env);
if (mode === 'resend') return resend(env, '/emails', 'POST', payload, key);
return sendViaSmtp(env, payload, relaySettings(env, mode));
}
// 상태 캐시는 env(= DB) 마다 따로 둡니다(시험처럼 한 프로세스에 여러 env 가 있을 때 섞이지 않게). 운영은 env 가 하나라 전역 캐시와 같습니다.
const statusCaches = new WeakMap();
/**
* 발신 서비스 상태(설정 화면·상태 화면). resend 는 도메인 검증 상태까지, haraka/smtp 는 중계 서버 접속·EHLO(·AUTH) 확인입니다.
* 60초 캐시(force 로 즉시). 돌려주는 모양은 예전 providerStatus 와 같고 mode 와 relay 를 더합니다.
* @returns {Promise<{mode:string, provider:string, detail?:string, domain?:object, relay?:object, checkedAt:string}>}
*/
export async function outboundStatus(env, { force = false } = {}) {
const mode = outboundMode(env), key = mode + JSON.stringify(relaySettings(env, mode) || {}) + (env.RESEND_API_KEY ? 'k' : '');
const statusCache = statusCaches.get(env.DB || env) || { at: 0, key: '', value: null };
if (!force && statusCache.value && statusCache.key === key && Date.now() - statusCache.at < 60000) return statusCache.value;
let value;
if (mode === 'resend') value = { mode, ...(await resendStatus(env, { force })) };
else {
const relay = relaySettings(env, mode);
const probe = await smtpProbe({ ...relay, hostname: env.PUBLIC_HOST || 'localhost' });
value = { mode, provider: probe.ok ? 'ok' : probe.error, detail: probe.detail, relay: { host: relay.host, port: relay.port, security: relay.security, auth: !!relay.user }, checkedAt: now() };
}
statusCaches.set(env.DB || env, { at: Date.now(), key, value }); return value;
}
+223
View File
@@ -0,0 +1,223 @@
/**
* 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,
}));
}
@@ -0,0 +1,156 @@
/**
* SMTP 클라이언트(1.0.0) — 외부 의존성 없이 node:net / node:tls 로 메일 한 통을 중계 서버에 넘깁니다.
*
* 쓰는 곳: providers/smtp.mjs(발신 방식 haraka / smtp). 내장 Haraka 의 로컬 제출 포트(127.0.0.1:2525, 암호화·인증 없음)나
* 외부 SMTP 중계 서버(587 STARTTLS + 로그인, 465 TLS 등)에 모두 쓸 수 있습니다.
*
* 흐름: 접속 → 220 인사 → EHLO → (STARTTLS → EHLO) → (AUTH PLAIN 또는 LOGIN) → MAIL FROM → RCPT TO(여러 명) → DATA → 본문(점 스터핑) → QUIT.
* 응답은 여러 줄(250-… / 250 …)을 끝 줄까지 모아 코드와 본문으로 돌려줍니다. 시간 제한 안에 응답이 없으면 끊고 실패로 봅니다.
*
* 오류 코드(providers/resend.mjs 의 providerError 와 같은 어휘로 맞춰 화면·SMTP 응답 변환이 공통으로 동작):
* mail_not_configured(중계 서버 주소 없음) · mail_auth_failed(535) · mail_rejected(5xx) · mail_rate_limited(421/450/451/452)
* · mail_provider_unavailable(접속 실패·시간 초과·그 밖의 4xx). providerMessage 에 서버 응답 문구를 남깁니다.
*/
import net from 'node:net';
import tls from 'node:tls';
import { dotStuff } from '../mime.mjs';
const CRLF = '\r\n';
/** 서버 응답 코드를 공통 오류 코드로 바꿉니다. */
export function smtpErrorCode(code, phase) {
if (phase === 'auth' && (code === 535 || code === 534 || code === 530)) return 'mail_auth_failed';
if ([421, 450, 451, 452].includes(code)) return 'mail_rate_limited';
if (code >= 500) return 'mail_rejected';
return 'mail_provider_unavailable';
}
/**
* 메일 한 통을 보냅니다.
* @param {object} o
* @param {string} o.host 중계 서버 주소
* @param {number} o.port 포트(587/465/25/2525)
* @param {'none'|'starttls'|'tls'} [o.security] none = 암호화 없음(로컬 Haraka), starttls = 접속 뒤 STARTTLS(없으면 실패), tls = 접속 즉시 TLS
* @param {string} [o.user] @param {string} [o.pass] 로그인(있을 때만 AUTH)
* @param {string} [o.hostname] EHLO 에 보낼 우리 이름
* @param {string} o.from 봉투 보낸 사람(MAIL FROM)
* @param {string[]} o.to 봉투 받는 사람 전체(To·Cc·Bcc 합침)
* @param {Buffer} o.eml 보낼 원본(CRLF)
* @param {number} [o.timeoutMs] 한 응답을 기다리는 최대 시간
* @param {boolean} [o.rejectUnauthorized] TLS 인증서 검증(기본 true, 로컬 시험용 false)
* @returns {Promise<{response:string, accepted:string[], rejected:{address:string,reply:string}[]}>}
*/
export async function smtpSend({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', from, to, eml, timeoutMs = 60000, rejectUnauthorized = true }) {
if (!host || !port) { const e = new Error('mail_not_configured'); e.providerMessage = 'SMTP relay host/port missing'; throw e; }
let socket = null;
const fail = (code, message, phase) => { const e = new Error(code); e.providerMessage = String(message || '').replace(/[\r\n]+/g, ' ').slice(0, 300); e.phase = phase; return e; };
// 줄 단위 응답 읽기: 데이터가 올 때마다 버퍼에 모으고, 마지막 줄(코드 뒤가 공백)이 오면 전체 응답을 돌려줍니다.
let buffer = '', waiter = null;
const onData = (chunk) => {
buffer += chunk.toString('utf8');
while (waiter) {
const lines = buffer.split(CRLF); if (lines.length < 2) return;
// 완성된 줄들 중 "NNN " 으로 끝나는 줄이 있는지 봅니다(여러 줄 응답은 "NNN-")
let endIndex = -1;
for (let i = 0; i < lines.length - 1; i++) if (/^\d{3}(?: |$)/.test(lines[i])) { endIndex = i; break; }
if (endIndex < 0) return;
const reply = lines.slice(0, endIndex + 1); buffer = lines.slice(endIndex + 1).join(CRLF);
const w = waiter; waiter = null; w.resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)), text: reply.join(' ') });
}
};
const read = () => new Promise((resolve, reject) => {
waiter = { resolve, reject };
const timer = setTimeout(() => { if (waiter) { waiter = null; reject(fail('mail_provider_unavailable', 'timeout waiting for reply', 'read')); } }, timeoutMs);
const done = waiter.resolve; waiter.resolve = (v) => { clearTimeout(timer); done(v); };
onData(Buffer.alloc(0));
});
const attach = (s) => { socket = s; s.setTimeout(timeoutMs + 5000); s.on('data', onData); s.on('error', (e) => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', e.message, 'socket')); } }); s.on('close', () => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', 'connection closed', 'socket')); } }); };
const send = async (line, expect, phase) => {
socket.write(line + CRLF);
const r = await read();
if (!expect.includes(r.code)) throw fail(smtpErrorCode(r.code, phase), `${r.code} ${r.lines.join(' ')}`, phase);
return r;
};
const connect = () => new Promise((resolve, reject) => {
const onError = (e) => reject(fail('mail_provider_unavailable', e.message, 'connect'));
const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s));
s.once('error', onError); s.setTimeout(timeoutMs, () => onError(new Error('connect timeout')));
});
try {
attach(await connect());
const greeting = await read(); if (greeting.code !== 220) throw fail('mail_provider_unavailable', greeting.text, 'greeting');
let ehlo = await send('EHLO ' + hostname, [250], 'ehlo');
if (security === 'starttls') {
if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) throw fail('mail_provider_unavailable', 'server does not offer STARTTLS', 'starttls');
await send('STARTTLS', [220], 'starttls');
const plain = socket; plain.removeAllListeners('data'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
const secure = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', (e) => reject(fail('mail_provider_unavailable', e.message, 'starttls'))); });
attach(secure);
ehlo = await send('EHLO ' + hostname, [250], 'ehlo');
}
if (user) {
const mechanisms = (ehlo.lines.find((l) => /^AUTH\b/i.test(l)) || '').toUpperCase();
if (mechanisms.includes('PLAIN') || !mechanisms.includes('LOGIN')) {
await send('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64'), [235], 'auth');
} else {
await send('AUTH LOGIN', [334], 'auth');
await send(Buffer.from(user, 'utf8').toString('base64'), [334], 'auth');
await send(Buffer.from(pass, 'utf8').toString('base64'), [235], 'auth');
}
}
await send(`MAIL FROM:<${from}>`, [250], 'mail');
const accepted = [], rejected = [];
for (const rcpt of to) {
socket.write(`RCPT TO:<${rcpt}>` + CRLF);
const r = await read();
if (r.code === 250 || r.code === 251) accepted.push(rcpt); else rejected.push({ address: rcpt, reply: `${r.code} ${r.lines.join(' ')}` });
}
if (!accepted.length) { const first = rejected[0]; throw fail(smtpErrorCode(Number(first.reply.slice(0, 3)), 'rcpt'), first.reply, 'rcpt'); }
await send('DATA', [354], 'data');
// 본문은 점 스터핑해 보내고 "<CRLF>.<CRLF>" 로 끝냅니다. 원본이 이미 CRLF 로 끝나면 빈 줄이 하나 더 생기지 않게 CRLF 를 덧붙이지 않습니다.
const body = dotStuff(eml); const endsCrlf = body.length >= 2 && body[body.length - 2] === 13 && body[body.length - 1] === 10;
socket.write(body); socket.write((endsCrlf ? '' : CRLF) + '.' + CRLF);
const final = await read();
if (final.code !== 250) throw fail(smtpErrorCode(final.code, 'data'), `${final.code} ${final.lines.join(' ')}`, 'data');
try { socket.write('QUIT' + CRLF); } catch { /* 응답은 기다리지 않습니다 */ }
return { response: final.lines.join(' '), accepted, rejected };
} finally {
if (socket) { waiter = null; socket.removeAllListeners('close'); socket.end(); setTimeout(() => socket.destroy(), 2000).unref(); }
}
}
/**
* 연결 확인(화면의 "연결 확인" 단추·상태 표시): 접속해 EHLO 까지만 주고받고 끊습니다. 로그인 정보가 있으면 AUTH 도 시험합니다.
* @returns {Promise<{ok:boolean, error?:string, detail?:string, features?:string[]}>}
*/
export async function smtpProbe({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', timeoutMs = 10000, rejectUnauthorized = true }) {
if (!host || !port) return { ok: false, error: 'mail_not_configured' };
// smtpSend 와 같은 절차를 쓰기 위해 받는 사람 없이 호출하면 RCPT 단계에서 끝나므로, 여기서는 간단히 접속·EHLO(·AUTH)만 따로 수행합니다.
let socket = null;
const fail = (code, detail) => ({ ok: false, error: code, detail: String(detail || '').replace(/[\r\n]+/g, ' ').slice(0, 300) });
try {
let buffer = '';
const read = (s) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
const onData = (c) => { buffer += c.toString('utf8'); const lines = buffer.split(CRLF); const end = lines.findIndex((l, i) => i < lines.length - 1 && /^\d{3}(?: |$)/.test(l)); if (end < 0) return; const reply = lines.slice(0, end + 1); buffer = lines.slice(end + 1).join(CRLF); clearTimeout(timer); s.off('data', onData); resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)) }); };
s.on('data', onData); s.once('error', (e) => { clearTimeout(timer); reject(e); });
});
socket = await new Promise((resolve, reject) => { const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s)); s.once('error', reject); s.setTimeout(timeoutMs, () => reject(new Error('connect timeout'))); });
const greeting = await read(socket); if (greeting.code !== 220) return fail('mail_provider_unavailable', greeting.lines.join(' '));
socket.write('EHLO ' + hostname + CRLF); let ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' '));
if (security === 'starttls') {
if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) return fail('mail_provider_unavailable', 'server does not offer STARTTLS');
socket.write('STARTTLS' + CRLF); const r = await read(socket); if (r.code !== 220) return fail('mail_provider_unavailable', r.lines.join(' '));
const plain = socket; plain.removeAllListeners('data');
socket = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', reject); });
socket.write('EHLO ' + hostname + CRLF); ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' '));
}
if (user) {
socket.write('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64') + CRLF);
const r = await read(socket); if (r.code !== 235) return fail(smtpErrorCode(r.code, 'auth'), r.lines.join(' '));
}
socket.write('QUIT' + CRLF);
return { ok: true, features: ehlo.lines.slice(1) };
} catch (e) { return fail('mail_provider_unavailable', e.message); }
finally { if (socket) { socket.end(); setTimeout(() => socket.destroy(), 1000).unref(); } }
}
+46
View File
@@ -0,0 +1,46 @@
/**
* SMTP 중계 발송 모듈(1.0.0) — 발신 방식이 haraka(내장 Haraka 의 로컬 제출 포트) 또는 smtp(외부 중계 서버)일 때 쓰입니다.
*
* 입력은 Resend 발송 요청과 같은 모양의 메시지 객체입니다(providers/index.mjs 참고). 그래서 store.mjs(웹 발송)·smtp.mjs(제출 서버)·
* automation.mjs(전달·자동 응답)는 어느 발송 모듈을 쓰는지 몰라도 됩니다. 이 모듈은 그 객체를 RFC 5322 원본(.eml)으로 만들어
* (mime.mjs 의 buildEml 재사용) 봉투(MAIL FROM / RCPT TO)와 함께 중계 서버에 넘깁니다.
*
* 주의: Bcc 는 헤더에 쓰지 않고 봉투에만 넣습니다(buildEml 에 bcc 를 넘기면 헤더가 생기므로 넘기지 않음).
* 멱등 키(key)는 SMTP 에 해당 개념이 없어 쓰지 않습니다. 발송 전에 저장소가 send_key 로 중복을 막고 있습니다.
*/
import { randomUUID } from 'node:crypto';
import { buildEml } from '../mime.mjs';
import { address, addresses } from '../util.mjs';
import { smtpSend } from './smtp-client.mjs';
/**
* Resend 형식 메시지 객체 → { eml, envelope }. 시험과 보낸 메일함 저장이 같은 변환을 쓸 수 있게 따로 두었습니다.
* @param {object} payload { from, to[], cc?[], bcc?[], reply_to?[], subject, text?, html?, headers?{}, attachments?[{filename, content(base64), content_type, content_id?}] }
* @param {string} domain Message-ID 의 @ 뒤 도메인
*/
export function payloadToEml(payload, domain) {
const messageId = `<${randomUUID()}@${domain || 'localhost'}>`;
const eml = buildEml({
from: payload.from, to: payload.to || [], cc: payload.cc || [], replyTo: payload.reply_to || [], subject: payload.subject || '',
text: payload.text, html: payload.html, headers: payload.headers || {}, messageId,
attachments: (payload.attachments || []).map((a) => ({ filename: a.filename, contentType: a.content_type, bytes: Buffer.from(a.content || '', 'base64'), contentId: a.content_id || undefined, disposition: a.content_id ? 'inline' : 'attachment' })),
});
const envelope = { from: address(payload.from), to: addresses([...(payload.to || []), ...(payload.cc || []), ...(payload.bcc || [])]) };
return { eml, envelope, messageId };
}
/**
* 메시지 객체를 중계 서버로 보냅니다.
* @param {object} env SMTP_RELAY_* 가 아니라 호출자가 고른 relay 설정을 씁니다(env 는 도메인·호스트 이름용)
* @param {object} payload Resend 형식 메시지
* @param {object} relay { host, port, security, user, pass, rejectUnauthorized? }
* @returns {Promise<{id:string, accepted:string[], rejected:object[], response:string}>} id = 생성한 Message-ID(괄호 없이). messages.provider_id 에 저장됩니다.
*/
export async function sendViaSmtp(env, payload, relay) {
const domain = String(env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || 'localhost';
const { eml, envelope, messageId } = payloadToEml(payload, domain);
if (!envelope.to.length) { const e = new Error('mail_rejected'); e.providerMessage = 'no recipients'; throw e; }
const result = await smtpSend({ ...relay, hostname: env.PUBLIC_HOST || 'localhost', from: envelope.from, to: envelope.to, eml });
if (result.rejected.length) console.warn('SMTP 중계: 일부 받는 사람 거부', result.rejected.map((r) => `${r.address} (${r.reply})`).join(', '));
return { id: messageId.replace(/^<|>$/g, ''), ...result };
}