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
+479
View File
@@ -0,0 +1,479 @@
/**
* HTTP API. 표준 Request/Response 로 작성해 www 사이트 핸들러와 같은 방식으로 실행합니다.
* 모든 메일 조회는 세션 사용자의 메일함으로 범위를 제한합니다. 0.9.0 부터는 관리자도 예외가 아닙니다
* (예전의 ?mailbox= 로 다른 메일함을 여는 기능은 요구사항에 따라 삭제했습니다. 관리자 권한은 /admin 의 계정·설정 관리에만 쓰입니다).
*
* 세션 범위(0.8.4~): 요청 헤더 X-Mail-Scope 가 'admin' 이면 관리자 페이지(/admin)의 요청입니다. 이 요청은 관리자 세션 쿠키(mg_admin)로
* 인증하고 /api/admin/* 와 /api/session 만 쓸 수 있습니다. 헤더가 없으면 웹 메일 요청이며 메일 세션 쿠키(mg_session)로 인증하고
* /api/admin/* 는 쓸 수 없습니다(관리자도 /admin 에서 따로 로그인해야 합니다).
*
* 0.9.0 에 더한 경로
* POST /api/signup 회원 가입(관리자가 켠 경우). 옵션에 따라 즉시 사용 또는 승인 대기.
* PATCH /api/password 로그인한 계정의 비밀번호 변경(현재 비밀번호 + 새 비밀번호 2회). 웹 메일 "도구 → 설정" 이 씁니다.
* GET/PATCH /api/admin/config 회원 가입 설정·메일 제한(관리자 층)·POP/IMAP 서비스 스위치. 시스템 제한 값도 함께 돌려줍니다.
* PATCH /api/admin/users/<id> quotaBytes(계정별 메일함 크기), status:'active'(승인) 추가. 자기 비밀번호는 여기서 바꿀 수 없음(설정 화면으로).
*/
import { json, bad, now, uid, clean, addresses } from './util.mjs';
import { verifyUser, createSession, sessionCookie, currentUser, destroySession, requireUser, audit, listUsers, createUser, updateUser, deleteUser, knownMailboxes, readPrefs, writePrefs, mailDomain, readPasswordPolicy, writePasswordPolicy, usesInitialPassword, checkCurrentPassword, pendingLogin, externalUsers, authMode } from './auth.mjs';
import { outboundStatus, inboundMode, outboundMode, usesHaraka } from './providers/index.mjs';
import { configFile } from './config.mjs';
import { setupApi, harakaStatus } from './setup.mjs';
import { ingestRaw as ingestRawMail } from './store.mjs';
import { safeEqual as timingEqual, address as bareAddress, clean as cleanStr, uid as newId, now as nowIso } from './util.mjs';
import { knownMailboxes as mailboxList } from './auth.mjs';
import { sanitizeHtml } from './sanitize.mjs';
import { verifySvix } from './webhook.mjs';
import { setFlag, mailboxOf, ingestReceived, startSync, isSyncing, saveDraft, sendDraft, addAttachment, removeAttachment, copyAttachments } from './store.mjs';
import { autoActionsOf, autoReplyActive } from './automation.mjs';
import { readLargeFileSettings, writeLargeFileSettings, systemLargeFiles, listLargeFiles, removeLargeFile, largeFileStats } from './files.mjs';
import { systemLimits, readAdminLimits, writeAdminLimits, readUserQuota, writeUserQuota, checkUserQuota, mailboxQuota, mailboxUsage, attachmentLimit, recipientLimit, readServices, writeServices, plainLoginAllowed, readSignup, writeSignup, mailStats, accountCount } from './limits.mjs';
const FOLDERS = ['inbox', 'sent', 'drafts', 'trash'];
/**
* 메일이 상태와 맞게 들어갈 수 있는 폴더 목록(웹의 "이동" 메뉴·API 공통 규칙).
* - 받은 메일: 받은 메일함·휴지통
* - 발송한 메일(state='sent'): 보낸 메일함·휴지통
* - 그 밖의 보내는 메일(초안·발송 실패): 임시 보관함·휴지통
* 왜: 예전에는 아무 폴더로나 옮길 수 있어, 초안을 보낸 메일함에 넣으면 보내지 않은 메일이 보낸 것처럼 보이고,
* 받은 메일을 임시 보관함에 넣으면 작성 창으로 열려 저장·발송이 mail_locked 로 실패했습니다.
*/
export const movableFolders = (m) => m.direction === 'inbound' ? ['inbox', 'trash'] : m.state === 'sent' ? ['sent', 'trash'] : ['drafts', 'trash'];
// prefix(1.0.8): 화면이 경로 접두사(/mail) 아래에서 돌 때 인라인 이미지 주소도 그 아래로 만듭니다(저장된 본문은 늘 /api/mail/… 로 두고 내보낼 때만 붙임).
const previewHtml = (html, id, attachments, prefix = '') => String(html || '').replace(/(<img\b[^>]*\bsrc=")cid:([^"]+)("[^>]*>)/g, (m, a, cid, b) => {
const match = attachments.find((x) => x.content_id && x.content_id === cid.replace(/&amp;/g, '&'));
return match ? `${a}${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(match.id)}?inline=1${b}` : `${a}${b}`;
// 초안·보낸 메일에 이미 /api/mail/… 로 저장된 인라인 이미지도 접두사 아래에서 열리게 같이 바꿉니다(없으면 사이트의 404 를 보게 됨).
}).replace(/(<img\b[^>]*\bsrc=")\/api\/mail\//g, (m, a) => prefix ? `${a}${prefix}/api/mail/` : m);
/**
* 초안 본문의 인라인 이미지 주소에서 경로 접두사를 뗍니다(1.0.8). 편집기는 화면 기준 경로(/mail/api/mail/…)로 넣는데, 저장·발송(cid: 변환)은 /api/mail/… 만 알아보므로
* 저장 전에 되돌립니다. 접두사가 없으면 그대로입니다.
*/
const stripPrefix = (html, prefix, env) => { let out = String(html || ''); for (const p of new Set([prefix, env?.PUBLIC_PATH].filter(Boolean))) out = out.split(`src="${p}/api/mail/`).join('src="/api/mail/'); return html ? out : html; };
async function applyAction(env, mail, body, user) {
if (body.folder === 'trash' || body.action === 'trash') { if (mail.state === 'sending') throw new Error('mail_sending'); await env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=? AND folder<>'trash'").bind(now(), mail.id).run(); }
else if (body.restore === true || body.action === 'restore') await env.DB.prepare("UPDATE messages SET folder=COALESCE(previous_folder,CASE direction WHEN 'inbound' THEN 'inbox' ELSE 'sent' END),previous_folder=NULL,updated_at=? WHERE id=? AND folder='trash'").bind(now(), mail.id).run();
else if (body.action === 'move' && FOLDERS.includes(body.target)) { if (mail.state === 'sending') throw new Error('mail_sending'); if (!movableFolders(mail).includes(body.target)) throw new Error('mail_move_invalid'); await env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(body.target, now(), mail.id).run(); }
else if (body.unread === true || body.action === 'unread') await env.DB.prepare('UPDATE messages SET read_at=NULL WHERE id=?').bind(mail.id).run();
else if (body.read === true || body.action === 'read') await env.DB.prepare('UPDATE messages SET read_at=COALESCE(read_at,?) WHERE id=?').bind(now(), mail.id).run();
else if (body.flagged !== undefined || body.action === 'flag' || body.action === 'unflag') await setFlag(env, mail.id, '\\Flagged', body.flagged !== undefined ? !!body.flagged : body.action === 'flag');
else if (body.popReset === true || body.action === 'popReset') { await env.DB.prepare('DELETE FROM pop_deliveries WHERE message_id=?').bind(mail.id).run(); await audit(env, user.login, 'pop_reset', mail.id); }
else if (body.action === 'delete') { if (mail.folder !== 'trash') throw new Error('mail_trash_first'); await purge(env, mail); await audit(env, user.login, 'mail_delete', mail.id); }
else return false;
return true;
}
async function purge(env, mail) {
const statements = [];
if (mail.provider_id) statements.push(env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(mail.provider_id, now()));
statements.push(env.DB.prepare('DELETE FROM messages WHERE id=?').bind(mail.id));
await env.DB.batch(statements);
}
/** 메일함 폴더별 건수·읽지 않음 수(사이드바용). */
const folderCounts = async (env, mailbox) => (await env.DB.prepare("SELECT folder,COUNT(*) n,SUM(CASE WHEN read_at IS NULL AND direction='inbound' THEN 1 ELSE 0 END) unread FROM messages WHERE mailbox=? GROUP BY folder").bind(mailbox).all()).results;
export async function handle(env, request, params) {
const { path, ip } = params, prefix = params.prefix || '';
const method = request.method;
// scope: 이 요청이 어느 화면에서 왔는지. 관리자 페이지는 모든 API 호출에 X-Mail-Scope: admin 을 붙입니다.
const sessionScope = request.headers.get('x-mail-scope') === 'admin' ? 'admin' : 'mail';
try {
// ---------------------------------------------------------------- 공개
// 상태 확인. version 은 배포 후 실제로 새 코드가 돌고 있는지 확인하는 데 씁니다.
if (path[0] === 'health') return json({ ok: true, version: env.APP_VERSION || null });
if (path[0] === 'webhooks' && path[1] === 'resend' && method === 'POST') return await webhook(env, request);
// 내장 Haraka 수신 전달(1.0.0): 로컬 Haraka 플러그인이 토큰으로 인증해 부릅니다(수신 방식이 haraka 일 때만 열림).
if (path[0] === 'inbound' && path[1] === 'haraka') return await harakaInbound(env, request, path, method);
if (path[0] === 'login' && method === 'POST') {
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
// 로그인 제한(IP 당·계정 당 15분 10회)은 실패만 셉니다. 예전에는 성공도 세어 공유 IP(사무실) 에서 10명이 로그인하면 나머지가 막혔습니다.
// 로그인 ID 는 login 또는 email 필드로 옵니다. 제한 키·검사·기록 모두 같은 값을 씁니다(예전에는 email 필드 로그인이 빈 계정 키 'login:' 하나를 함께 써서 서로 잠겼습니다, 0.9.3 수정).
const loginId = clean(body.login ?? body.email, 200).toLowerCase();
const keys = ['login:' + ip, 'login:' + loginId];
if (keys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429);
const password = String(body.password ?? '');
const user = await verifyUser(env, loginId, password);
if (!user) {
// 회원 가입 뒤 승인을 기다리는 계정(비밀번호까지 맞을 때만)에는 "승인 대기" 를 알려 줍니다. 실패 횟수로 세지 않습니다.
if (await pendingLogin(env, loginId, password)) { await audit(env, null, 'login_pending', loginId, { ip }); return bad('pending_approval', 403); }
keys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, null, 'login_failed', loginId, { ip, scope: sessionScope }); return bad('invalid_credentials', 401);
}
// 관리자 페이지 로그인은 관리자 역할 계정만 통과합니다. 비밀번호는 맞아도 일반 계정이면 거절 사유를 따로 알려 줍니다.
if (sessionScope === 'admin' && user.role !== 'admin') { await audit(env, user.login, 'admin_login_denied', null, { ip }); return bad('admin_only', 403); }
const token = await createSession(env, user, request, { scope: sessionScope }); await audit(env, user.login, sessionScope === 'admin' ? 'admin_login' : 'login', null, { ip, source: user.source });
// 쿠키 Path(1.0.8): 경로 접두사 아래의 웹 메일이면 그 접두사(/mail)로 한정해 같은 사이트의 다른 경로(사이트 자체 백엔드)에는 세션 쿠키가 가지 않게 합니다.
return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie(token, { scope: sessionScope, path: prefix }) });
}
if (path[0] === 'session' && method === 'GET') {
const user = await currentUser(env, request, { scope: sessionScope });
const prefs = user ? await readPrefs(env, user) : {};
// autoReplyActive: 자동 응답이 지금 동작 중인지(기간 판단은 서버 시각 기준). 화면은 이 값으로 사이드바에 "자동 응답 작동 중" 을 표시합니다.
// initialPassword(0.8.24~): 독립 실행의 초기 관리자가 아직 adminpasswd 를 쓰면 true. 화면이 비밀번호를 바꾸라는 경고를 띄웁니다.
// signup(0.9.0~): 회원 가입이 켜져 있는지(로그인 화면의 "회원 가입" 단추). mailboxes 는 항상 자기 메일함 하나입니다(메일함 전환 삭제).
// largeFiles(0.9.3): 대용량 첨부파일 설정(사용 여부·상한). 작성 창이 단추를 보일지와 발송 조건 입력 칸의 기본값·상한을 정하는 데 씁니다. attachmentBytes 는 일반 첨부 상한(이보다 큰 파일은 대용량으로 안내).
return json({ ok: true, user, mailbox: user?.mailbox || mailboxOf(env), mailboxes: user ? [user.mailbox] : [], prefs, autoReplyActive: user ? autoReplyActive(prefs.autoReply) : false, initialPassword: user?.source === 'local' ? await usesInitialPassword(env, user) : false, domain: mailDomain(env), host: (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), authMode: authMode(env), version: env.APP_VERSION || null, signup: (await readSignup(env)).enabled,
...(user && sessionScope === 'mail' ? { largeFiles: await readLargeFileSettings(env), attachmentBytes: await attachmentLimit(env) } : {}) });
}
if (path[0] === 'session' && method === 'DELETE') { await destroySession(env, request, { scope: sessionScope }); return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: sessionScope, path: prefix }) }); }
// return await: try 안에서 프라미스를 그냥 return 하면 거부가 아래 catch 에 잡히지 않습니다(createUser 의 weak_password 등이 500 으로 새어 나감).
if (path[0] === 'signup' && method === 'POST') return await signup(env, request, ip);
// ---------------------------------------------------------------- 로그인 사용자
const user = await requireUser(env, request, { scope: sessionScope }); if (user instanceof Response) return user;
// ---------------------------------------------------------------- 관리자 페이지(/admin) 전용
// 관리자 세션으로는 아래 경로만 쓸 수 있고, 반대로 웹 메일 세션으로는 /api/admin 을 쓸 수 없습니다(관리자 페이지에서 따로 로그인).
// 초기 설정 API(1.0.0): 관리자 세션으로도 같은 API 를 씁니다(/setup 화면을 관리자 페이지에서 열 때). 토큰 인증 쪽은 setup.mjs 의 전용 포트 서버가 처리합니다.
if (path[0] === 'setup') { if (sessionScope !== 'admin') return bad('admin_login_required', 403); return await setupApi(env, request, path.slice(1), { ip, user }); }
if (path[0] === 'admin') {
if (sessionScope !== 'admin') return bad('admin_login_required', 403);
if (path[1] === 'status' && method === 'GET') return await adminStatus(env);
if (path[1] === 'sync' && method === 'POST') return json({ ok: true, ...startSync(env, 'manual') });
// 비밀번호 정책(0.8.24~): GET 은 현재 정책, PATCH 는 { minLength?, mixedCase?, digit?, special? } 를 저장합니다. 기존 비밀번호에는 소급하지 않습니다.
if (path[1] === 'policy' && method === 'GET') return json({ ok: true, policy: await readPasswordPolicy(env) });
if (path[1] === 'policy' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const policy = await writePasswordPolicy(env, body); await audit(env, user.login, 'policy_update', null, policy);
return json({ ok: true, policy });
}
// 관리자 설정(0.9.0~): 회원 가입·메일 제한·서비스 스위치. system 은 설치 시 정한 상위 제한(읽기 전용)입니다.
if (path[1] === 'config' && method === 'GET') return json({ ok: true, ...(await adminConfig(env)) });
if (path[1] === 'config' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const changed = {};
if (body.signup && typeof body.signup === 'object') changed.signup = await writeSignup(env, body.signup);
if (body.limits && typeof body.limits === 'object') changed.limits = await writeAdminLimits(env, body.limits);
if (body.services && typeof body.services === 'object') changed.services = await writeServices(env, body.services);
if (body.largeFiles && typeof body.largeFiles === 'object') changed.largeFiles = await writeLargeFileSettings(env, body.largeFiles);
await audit(env, user.login, 'config_update', null, changed);
return json({ ok: true, ...(await adminConfig(env)) });
}
if (path[1] !== 'users') return bad('not_found', 404);
const target = path[2];
if (method === 'GET' && !target) {
// 계정마다 메일 프로그램 접속 허용 여부(prefs 의 pop/imap)와 메일함 크기·사용량(0.9.0)을 붙여 돌려줍니다.
const users = await listUsers(env);
for (const u of users) {
const p = await readPrefs(env, u); u.services = { pop: p.pop, imap: p.imap };
u.quotaBytes = await readUserQuota(env, u.id); u.limit = await mailboxQuota(env, u.mailbox); u.usageBytes = await mailboxUsage(env, u.mailbox);
}
// me: www 관리자 계정의 행. 역할이 관리자인 게이트웨이 계정으로 들어온 경우에는 그 계정이 users 목록에 이미 있으므로 me 를 주지 않습니다
// (예전에는 로그인한 누구든 "admin" 행으로 그려져, 게이트웨이 관리자의 설정이 www 관리자 행에 표시됐습니다). 웹메이커 관리자(1.0.8)는 아래 external 목록에 있으므로 역시 me 가 없습니다.
const mine = user.source === 'www' ? await readPrefs(env, user) : null;
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정(읽기 전용 — 이름·비밀번호·역할은 웹메이커가 관리). POP/IMAP 허용·메일함 크기·사용량만 붙여 줍니다.
const ext = await externalUsers(env);
const external = [];
for (const a of ext.accounts) { const p = await readPrefs(env, a); external.push({ ...a, services: { pop: p.pop, imap: p.imap }, quotaBytes: await readUserQuota(env, a.id), limit: await mailboxQuota(env, a.mailbox), usageBytes: await mailboxUsage(env, a.mailbox) }); }
// policy: 계정 추가·수정 창이 비밀번호 입력 칸의 최소 길이와 안내문을 정책대로 그리는 데 씁니다. system/limits: 메일함 크기 입력 칸의 상한 안내.
const sys = systemLimits(env);
return json({ ok: true, users, external, externalSource: authMode(env) === 'webmaker' ? { kind: 'webmaker', ok: ext.ok, error: ext.error || null, site: ext.site || null, readAt: ext.readAt || null } : null, defaultMailbox: mailboxOf(env), domain: mailDomain(env), authMode: authMode(env), policy: await readPasswordPolicy(env), system: sys, limits: await readAdminLimits(env), accounts: { used: await accountCount(env), limit: sys.accounts },
me: mine ? { id: user.id, login: user.login, name: user.name, source: user.source, services: { pop: mine.pop, imap: mine.imap }, usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) } : null });
}
const body = method === 'DELETE' ? {} : await request.json().catch(() => null); if (!body) return bad('bad_request');
if (method === 'POST' && !target) {
// 메일함 크기 값은 계정을 만들기 전에 검사합니다(틀린 값으로 계정만 생기고 저장은 실패하는 일 방지).
if (body.quotaBytes !== undefined) checkUserQuota(env, body.quotaBytes);
const id = await createUser(env, body, user.login);
if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services);
if (body.quotaBytes !== undefined) await writeUserQuota(env, id, body.quotaBytes);
await audit(env, user.login, 'user_create', id, { login: clean(body.login, 64) }); return json({ ok: true, id }, 201);
}
// 'me' 는 관리자 자신(www 계정)의 메일 프로그램 접속 설정만 바꿉니다. www 계정 자체는 www 사이트에서 관리합니다.
if (method === 'PATCH' && target === 'me') { if (user.source !== 'www') return bad('not_found', 404); const prefs = await writePrefs(env, user, body.services || {}); await audit(env, user.login, 'user_update', user.id, { services: true }); return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } }); }
// 웹메이커 계정(1.0.8, id 'wm:…'): 이름·비밀번호·역할·상태는 웹메이커가 주인이라 받지 않고, 메일 프로그램 접속 허용(services)과 메일함 크기(quotaBytes)만 저장합니다. 삭제도 할 수 없습니다.
if (target && target.startsWith('wm:')) {
if (method !== 'PATCH') return bad('webmaker_managed', 403);
const ext = await externalUsers(env); const acc = ext.accounts.find((a) => a.id === target); if (!acc) return bad('not_found', 404);
if (body.quotaBytes !== undefined) { checkUserQuota(env, body.quotaBytes); await writeUserQuota(env, target, body.quotaBytes); }
const prefs = body.services && typeof body.services === 'object' ? await writePrefs(env, acc, body.services) : await readPrefs(env, acc);
await audit(env, user.login, 'user_update', target, { services: !!body.services, ...(body.quotaBytes !== undefined ? { quotaBytes: Number(body.quotaBytes) } : {}) });
return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } });
}
if (method === 'PATCH' && target) {
// 0.9.0: 자기 계정의 비밀번호는 관리자 페이지에서 바꾸지 않습니다(웹 메일 "도구 → 설정" 에서 현재 비밀번호 확인을 거쳐 바꿈).
// 다른 계정의 비밀번호는 관리자가 한 번 입력으로 재설정합니다. 화면 보조 필드(passwordConfirm/currentPassword)는 무시합니다.
const { currentPassword, passwordConfirm, quotaBytes, ...fields } = body;
if (fields.password && target === user.id) return bad('self_password_settings', 422);
// 자기 계정의 역할을 내리거나 정지하면 관리자 페이지에서 바로 쫓겨나고(독립 실행이면 남은 관리자가 없어 영영 잠김) 되돌릴 길이 없으므로 거부합니다(0.9.3).
if (target === user.id && ((fields.role !== undefined && fields.role !== 'admin') || fields.status === 'suspended')) return bad('cannot_modify_self', 409);
// 메일함 크기는 먼저 검사만 하고, 계정 수정이 성공한 뒤에 저장합니다(예전에는 없는 계정·정책 위반 비밀번호로 실패해도 크기가 먼저 저장됐습니다, 0.9.3).
if (quotaBytes !== undefined) checkUserQuota(env, quotaBytes);
await updateUser(env, target, fields);
if (quotaBytes !== undefined) await writeUserQuota(env, target, quotaBytes);
await audit(env, user.login, 'user_update', target, { fields: Object.keys(fields).filter((k) => k !== 'password'), password: fields.password ? true : undefined, ...(quotaBytes !== undefined ? { quotaBytes: Number(quotaBytes) } : {}), ...(fields.status === 'active' ? { approve: true } : {}) });
return json({ ok: true });
}
if (method === 'DELETE' && target) { if (target === user.id) return bad('cannot_delete_self', 409); await deleteUser(env, target); await audit(env, user.login, 'user_delete', target); return json({ ok: true }); }
return bad('not_found', 404);
}
if (sessionScope === 'admin') return bad('forbidden', 403);
// ---------------------------------------------------------------- 웹 메일
if (path[0] === 'settings' && method === 'GET') return await settings(env, user);
if (path[0] === 'prefs' && method === 'GET') return json({ ok: true, prefs: await readPrefs(env, user) });
if (path[0] === 'prefs' && method === 'PATCH') {
// 화면 기본값(remoteImages)·메일 프로그램 접속 허용(pop/imap)·메일 전달(forward)·자동 응답(autoReply)을 사용자가 직접 바꿉니다.
// 아는 키만 저장하고, 전달 주소·자동 응답 기간 검사 실패는 writePrefs 가 던지는 코드(forward_invalid 등)를 422 로 돌려줍니다.
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
const next = await writePrefs(env, user, body);
await audit(env, user.login, 'prefs_update', null, { keys: Object.keys(body).slice(0, 10), ...(body.forward ? { forward: next.forward.enabled ? next.forward.to : 'off' } : {}), ...(body.autoReply ? { autoReply: next.autoReply.enabled ? (next.autoReply.noLimit ? 'unlimited' : `${next.autoReply.start || 'now'}~${next.autoReply.end}`) : 'off' } : {}) });
return json({ ok: true, prefs: next, autoReplyActive: autoReplyActive(next.autoReply) });
}
// 비밀번호 변경(0.9.0~): 웹 메일 설정 화면. 게이트웨이 계정만(www 관리자는 www 사이트에서 관리). 현재 비밀번호가 맞고 새 비밀번호 두 입력이 같아야 합니다.
// 바꾸면 이 계정의 세션이 모두 끝나므로(탈취된 세션 차단) 화면은 다시 로그인하게 안내합니다.
if (path[0] === 'password' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
// 외부 계정(www 관리자·웹메이커 계정)의 비밀번호는 그쪽 서비스에서 바꿉니다(1.0.8: webmaker_managed).
if (user.source !== 'local') return bad(user.source === 'webmaker' ? 'webmaker_managed' : 'www_managed', 403);
const password = String(body.password ?? ''); if (!password) return bad('bad_request');
if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422);
// 현재 비밀번호 확인에도 로그인과 같은 제한(계정·IP 당 15분 10회)을 둡니다. 훔친 세션으로 비밀번호를 무제한 대입하지 못하게(0.9.3).
const pwKeys = ['pwchange:' + user.id, 'pwchange:' + ip];
if (pwKeys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429);
if (!(await checkCurrentPassword(env, user.id, body.currentPassword))) { pwKeys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, user.login, 'password_change_denied', user.id, { ip }); return bad('current_password_invalid', 422); }
await updateUser(env, user.id, { password }); await audit(env, user.login, 'password_change', user.id, { ip });
return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: 'mail', path: prefix }) });
}
if (path[0] !== 'mail') return bad('not_found', 404);
const [, id, action, sub] = path;
const url = new URL(request.url);
// 메일함은 언제나 로그인한 계정의 메일함입니다(0.9.0: 관리자의 다른 메일함 열람 삭제). ?mailbox= 가 와도 무시합니다.
const mailbox = user.mailbox, scope = user;
const canSee = (m) => m.mailbox === mailbox;
if (id === 'status' && method === 'GET') return json({ ok: true, mailbox, ...await outboundStatus(env, { force: url.searchParams.get('force') === '1' }) });
// 동기화 시작. 목록 화면의 1분 자동 새로 고침은 { quick: true } 를 보내 "빠른 확인"(이미 아는 메일에서 멈춤)으로,
// 설정 화면의 "지금 동기화" 버튼은 본문 없이 보내 전체 훑기로 동작합니다(store.syncMailbox 설명 참고).
if (id === 'sync' && method === 'POST') { const b = await request.json().catch(() => ({})); const quick = b?.quick === true; return json({ ok: true, ...startSync(env, quick ? 'poll' : 'manual', { quick }) }); }
// usageBytes/quotaBytes(0.9.0): 사용량은 MIME 기준(저장 원본 크기), quotaBytes 는 이 메일함에 적용 중인 최대 크기(0 = 제한 없음). 사이드바 사용량 막대가 씁니다.
if (id === 'counts' && method === 'GET') return json({ ok: true, counts: await folderCounts(env, mailbox), usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), mailbox, syncing: isSyncing(env) });
if (id === 'sanitize' && method === 'POST') { const b = await request.json().catch(() => ({})); return json({ ok: true, ...sanitizeHtml(b.html) }); }
if (id === 'bulk' && method === 'POST') {
const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.ids)) return bad('bad_request');
let done = 0;
for (const mid of body.ids.slice(0, 200)) { const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(String(mid)).first(); if (!m || !canSee(m)) continue; try { if (await applyAction(env, m, body, user)) done++; } catch { /* 개별 실패는 건너뜁니다 */ } }
return json({ ok: true, done });
}
if (method === 'GET' && !id) {
const folder = url.searchParams.get('folder') || 'inbox', q = clean(url.searchParams.get('q'), 200), unreadOnly = url.searchParams.get('unread') === '1', flaggedOnly = url.searchParams.get('flagged') === '1', attachOnly = url.searchParams.get('attach') === '1';
if (!FOLDERS.includes(folder)) return bad('validation', 422);
const page = Math.max(0, Math.min(100000, Number(url.searchParams.get('page')) || 0)), size = Math.min(100, Math.max(10, Number(url.searchParams.get('size')) || 50));
// 검색어의 % _ \ 는 LIKE 와일드카드가 아니라 글자 그대로 찾습니다(0.9.0 수정, 예전에는 "_" 하나로 모든 메일이 나왔음).
const like = Array(4).fill('%' + q.replace(/[\\%_]/g, '\\$&') + '%');
const where = `mailbox=? AND folder=? AND (subject LIKE ? ESCAPE '\\' OR text LIKE ? ESCAPE '\\' OR sender LIKE ? ESCAPE '\\' OR recipients_json LIKE ? ESCAPE '\\')${unreadOnly ? ' AND read_at IS NULL' : ''}${flaggedOnly ? " AND EXISTS(SELECT 1 FROM imap_flags f WHERE f.message_id=messages.id AND f.flags LIKE '%\\\\Flagged%')" : ''}${attachOnly ? " AND EXISTS(SELECT 1 FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL))" : ''}`;
// attachOnly: "첨부" 보기 필터. 예전에는 화면이 받아 온 한 페이지(50통) 안에서만 걸러, 페이지 수·합계는 전체 기준인데
// 목록은 비어 보이는 문제가 있었습니다. 서버 조건으로 옮겨 합계·페이지가 필터와 일치합니다.
const rows = await env.DB.prepare(`SELECT id,direction,folder,sender,recipients_json,subject,substr(text,1,160) AS preview,state,source,raw_status,read_at,received_at,created_at,updated_at,mailbox,
(SELECT COUNT(*) FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL)) AS attachment_count,(SELECT COUNT(*) FROM pop_deliveries d WHERE d.message_id=messages.id) AS pop_delivered,
(SELECT flags FROM imap_flags f WHERE f.message_id=messages.id) AS flags
FROM messages WHERE ${where} ORDER BY received_at DESC LIMIT ? OFFSET ?`).bind(mailbox, folder, ...like, size, page * size).all();
const total = await env.DB.prepare(`SELECT COUNT(*) n FROM messages WHERE ${where}`).bind(mailbox, folder, ...like).first();
return json({ ok: true, messages: rows.results.map((m) => ({ ...m, flagged: String(m.flags || '').includes('\\Flagged'), flags: undefined })), total: total.n, page, size, counts: await folderCounts(env, mailbox), mailbox, usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), syncing: isSyncing(env) });
}
if (method === 'POST' && !id) { const body = await request.json().catch(() => null); if (!body) return bad('bad_request'); if (body.html) body.html = stripPrefix(body.html, prefix, env); return json({ ok: true, id: await saveDraft(env, body, undefined, scope) }, 201); }
if (!id) return bad('not_found', 404);
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!mail || !canSee(mail)) return bad('not_found', 404);
if (method === 'GET') {
const attachments = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,(bytes IS NOT NULL) AS available FROM attachments WHERE message_id=? ORDER BY rowid').bind(id).all()).results;
if (action === 'original') {
const raw = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first(); if (!raw) return bad('not_found', 404);
return new Response(Buffer.from(raw.eml), { headers: { 'Content-Type': 'message/rfc822', 'Content-Disposition': `attachment; filename="${id}.eml"`, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } });
}
if (action === 'attachments' && !sub) return json({ ok: true, attachments });
if (action === 'attachments' && sub) {
const row = await env.DB.prepare('SELECT filename,content_type,bytes FROM attachments WHERE id=? AND message_id=?').bind(sub, id).first(); if (!row?.bytes) return bad('not_found', 404);
const inline = url.searchParams.get('inline') === '1' && /^image\//.test(row.content_type);
return new Response(Buffer.from(row.bytes), { headers: { 'Content-Type': inline ? row.content_type : 'application/octet-stream', 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.filename)}`, 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } });
}
if (action === 'files' && !sub) return json({ ok: true, files: await listLargeFiles(env, id) }); // 대용량 파일 목록(0.9.3)
if (action) return bad('not_found', 404);
if (!mail.read_at && mail.direction === 'inbound' && url.searchParams.get('peek') !== '1') await env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(now(), id).run();
const popDeliveries = (await env.DB.prepare('SELECT user_id,delivered_at,via FROM pop_deliveries WHERE message_id=?').bind(id).all()).results;
const flags = JSON.parse((await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first())?.flags || '[]');
// autoActions: 이 메일에 한 자동 처리(전달·자동 응답) 기록. 상세 정보 상자에 "전달됨 → 주소 / 자동 응답 보냄" 으로 보입니다.
// largeFiles(0.9.3): 보내는 메일(초안·보낸 메일)에 딸린 대용량 파일과 링크 상태(남은 횟수·기한·만료).
return json({ ok: true, message: { ...mail, original_html: undefined, previewHtml: previewHtml(mail.html, id, attachments, prefix), attachments, popDeliveries, autoActions: await autoActionsOf(env, id), flagged: flags.includes('\\Flagged'), answered: flags.includes('\\Answered'), largeFiles: mail.direction === 'outbound' ? await listLargeFiles(env, id) : [] } });
}
// 대용량 파일 떼기(0.9.3): 초안이면 파일 삭제, 보낸 메일이면 링크 끊기(파일 삭제·만료 처리). 업로드는 index.mjs 의 스트리밍 경로(POST /api/files)가 맡습니다.
if (method === 'DELETE' && action === 'files' && sub) { await removeLargeFile(env, sub, mailbox, id); await audit(env, user.login, 'large_file_remove', sub, { message: id }); return json({ ok: true }); }
if (method === 'POST' && action === 'send') {
// body.largeFiles({ days, downloads }, 0.9.3): 초안에 대용량 파일이 있을 때의 발송 조건. 없으면 sendDraft 가 large_file_terms 로 거부합니다.
const b = await request.json().catch(() => ({}));
await sendDraft(env, id, { largeFiles: b && typeof b === 'object' ? b.largeFiles : null }); await audit(env, user.login, 'mail_send', id); if (mail.headers_json && JSON.parse(mail.headers_json)['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(JSON.parse(mail.headers_json)['In-Reply-To'], mailbox).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); } return json({ ok: true }); }
if (method === 'POST' && action === 'attachments') {
// JSON { from, ids }: 다른 메일(전달하는 원본)의 첨부를 이 초안으로 복사합니다(0.8.25~, 웹 "전달" 이 첨부를 함께 보내기 위해). 같은 메일함의 메일만 원본이 될 수 있습니다.
if ((request.headers.get('content-type') || '').includes('application/json')) {
const body = await request.json().catch(() => null); if (!body || typeof body.from !== 'string' || !Array.isArray(body.ids)) return bad('bad_request');
const origin = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(body.from).first(); if (!origin || !canSee(origin)) return bad('not_found', 404);
const ids = await copyAttachments(env, id, origin.id, body.ids.map(String).slice(0, 50));
return json({ ok: true, ids }, 201);
}
const form = await request.formData().catch(() => null); if (!form) return bad('bad_request');
const ids = []; const inline = url.searchParams.get('inline') === '1';
for (const file of form.getAll('files')) { if (typeof file === 'string') continue; ids.push(await addAttachment(env, id, { filename: file.name, contentType: file.type, bytes: Buffer.from(await file.arrayBuffer()), inline })); }
return json({ ok: true, ids, urls: ids.map((aid) => `${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(aid)}?inline=1`) }, 201);
}
if (method === 'DELETE' && action === 'attachments' && sub) { await removeAttachment(env, id, sub); return json({ ok: true }); }
if (method === 'PATCH' && !action) {
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
if (!(await applyAction(env, mail, body, user))) {
// 동작도 아니고 초안 내용도 없는 본문(예: {})은 거부합니다. 예전에는 saveDraft 로 흘러가 받는 사람·제목·본문이 빈 값으로 덮였습니다(0.8.16 수정).
if (!['to', 'cc', 'bcc', 'subject', 'html', 'text'].some((k) => body[k] !== undefined)) return bad('bad_request');
if (body.html) body.html = stripPrefix(body.html, prefix, env);
await saveDraft(env, body, id, scope);
}
return json({ ok: true, id });
}
if (method === 'DELETE' && !action) {
if (mail.folder !== 'trash') return bad('mail_trash_first', 409);
await purge(env, mail); await audit(env, user.login, 'mail_delete', id); return json({ ok: true });
}
return bad('not_found', 404);
} catch (e) {
const status = e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
// weak_password 에는 어긋난 항목(problems)과 정책(policy)을 붙여 화면이 "6자 이상, 숫자 포함" 같은 구체적 안내를 만들 수 있게 합니다.
// 제한 오류(too_many_recipients·attachment_too_large·quota_exceeded·storage_full·monthly_limit_exceeded·limit_exceeds_system·account_limit_reached)에는 적용된 상한(limit)과 항목(field)을 붙입니다.
return bad(e.message || 'server_error', status, { ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
}
}
/**
* 회원 가입(0.9.0~). 관리자가 켜 두었을 때만 받습니다(아니면 404 signup_disabled). IP 당 1시간 5회로 제한합니다.
* 승인 옵션이 켜져 있으면 승인 대기 계정으로 만들고(로그인 불가, 관리자 페이지에 "승인 대기" 로 보임), 아니면 바로 쓸 수 있는 일반 계정을 만듭니다.
* 역할은 언제나 '사용자' 이고 비밀번호 정책·계정 수 제한은 관리자가 만드는 계정과 같습니다.
*/
async function signup(env, request, ip) {
const cfg = await readSignup(env); if (!cfg.enabled) return bad('signup_disabled', 404);
if (env.RATE.blocked('signup:' + ip, { limit: 5 })) return bad('rate_limited', 429);
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const password = String(body.password ?? '');
if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422);
env.RATE.hit('signup:' + ip, { limit: 5, windowSec: 3600 });
const id = await createUser(env, { login: body.login, name: body.name, password, role: 'user' }, 'signup', { pending: cfg.approval });
await audit(env, null, 'signup', id, { login: clean(body.login, 64), ip, pending: cfg.approval });
return json({ ok: true, id, pending: cfg.approval }, 201);
}
/** 관리자 설정 응답: 회원 가입·관리자 제한·서비스 스위치 + 시스템 제한(읽기 전용)·계정 수. */
async function adminConfig(env) {
const sys = systemLimits(env);
// largeFiles / systemLargeFiles / largeFileUsage(0.9.3): 대용량 첨부파일의 관리자 설정, 시스템 상한(읽기 전용), 디스크 사용량.
return { signup: await readSignup(env), limits: await readAdminLimits(env), services: await readServices(env), system: sys, accounts: { used: await accountCount(env), limit: sys.accounts }, largeFiles: await readLargeFileSettings(env), systemLargeFiles: systemLargeFiles(env), largeFileUsage: await largeFileStats(env) };
}
/**
* 내장 Haraka → 게이트웨이 수신 전달(1.0.0). Haraka 플러그인(haraka/plugins/arimail.js)이 같은 서버 안에서 로컬 HTTP 로 부릅니다.
* GET /api/inbound/haraka/rcpt?addr=… RCPT 단계: 이 주소가 우리 메일함인지(아니면 Haraka 가 "No such user" 로 바로 거부해 역산란을 막습니다)
* POST /api/inbound/haraka DATA 가 끝난 메일 원본(본문) + X-AriMail-Id(트랜잭션 id) / X-AriMail-From / X-AriMail-Rcpt(JSON 배열)
* 인증: X-AriMail-Token 이 설정 파일의 HARAKA_TOKEN(자동 생성)과 같아야 하고, 수신 방식이 haraka 일 때만 열립니다(그 밖에는 404).
* 응답: 저장하면 200 { imported }, 건너뛰면 200 { skipped }(플러그인이 quota_exceeded/storage_full 은 452 로 되돌려 보낸 쪽이 나중에 다시 시도), 실패는 500(플러그인이 451).
* 기록: webhook_events 에 type 'haraka.received' 로 남겨 상태 화면의 수신 기록과 함께 보입니다.
*/
async function harakaInbound(env, request, path, method) {
// 1.0.7: Haraka 는 서버에 하나이므로 이 엔드포인트도 하나입니다. 토큰은 기본 도메인의 HARAKA_TOKEN(haraka/run.mjs 가 그 설정 파일을 읽음)으로 확인하고,
// 받는 사람 주소의 @ 뒤로 어느 도메인(테넌트)의 메일함인지 고릅니다. 수신 방식이 haraka 인 도메인이 하나도 없으면 예전처럼 404 입니다.
const tenants = env.TENANTS, primary = tenants?.primary?.env || env;
const receivers = tenants ? tenants.envs().filter((e) => inboundMode(e) === 'haraka') : (inboundMode(env) === 'haraka' ? [env] : []);
if (!receivers.length) return bad('not_found', 404);
const token = request.headers.get('x-arimail-token') || '';
if (!primary.HARAKA_TOKEN || !timingEqual(String(token), String(primary.HARAKA_TOKEN))) return bad('unauthenticated', 401);
/** 주소가 속한 도메인의 env(수신 방식이 haraka 인 것만). 테넌트가 없으면(시험 등) 이 env. */
const pick = (addr) => { if (!tenants) return receivers[0]; const t = tenants.byAddress(addr); return t && t.entry.enabled !== false && inboundMode(t.env) === 'haraka' ? t.env : null; };
if (path[2] === 'rcpt' && method === 'GET') { const addr = bareAddress(new URL(request.url).searchParams.get('addr') || ''); const target = pick(addr); return json({ ok: true, local: !!target && (await mailboxList(target)).includes(addr) }); }
if (!path[2] && method === 'POST') {
const raw = Buffer.from(await request.arrayBuffer()); if (!raw.length) return bad('bad_request');
let rcpt = []; try { rcpt = JSON.parse(request.headers.get('x-arimail-rcpt') || '[]'); } catch { rcpt = []; }
if (!Array.isArray(rcpt)) rcpt = [];
const id = cleanStr(request.headers.get('x-arimail-id'), 120) || newId(), stamp = nowIso(), from = request.headers.get('x-arimail-from') || '';
// 받는 사람을 도메인별로 묶어 각 도메인의 DB 에 저장합니다(한 트랜잭션이 두 도메인의 주소를 담을 수 있음). 도메인을 못 찾는 주소는 첫 수신 도메인으로(예전 동작).
const groups = new Map();
for (const a of rcpt) { const target = pick(bareAddress(a)) || receivers[0]; if (!groups.has(target)) groups.set(target, []); groups.get(target).push(a); }
if (!groups.size) groups.set(receivers[0], []);
const record = (target, status, detail) => target.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind('haraka:' + id, 'haraka.received', 'haraka:' + id, stamp, status, detail || null).run();
const results = []; let failed = null;
for (const [target, list] of groups) {
try {
const r = await ingestRawMail(target, raw, { id, rcpt: list, from, known: await mailboxList(target) });
// 같은 트랜잭션이 다시 오면(Haraka 재시도) 이미 'stored' 로 남은 기록을 'skipped' 로 덮어쓰지 않습니다.
if (r.skipped !== 'exists') await record(target, r.imported ? 'stored' : 'skipped', r.skipped || null);
results.push(r);
} catch (e) { console.error('Haraka 수신 저장 실패', target.DOMAIN || '', id, e.message); await record(target, 'failed', String(e.message || 'ingest_failed').slice(0, 300)); failed = failed || e; }
}
const imported = results.reduce((n, r) => n + Number(r.imported || 0), 0);
if (failed && !imported) return bad(failed.message || 'ingest_failed', 500);
// 도메인이 하나면 예전과 같은 모양({ ok, imported, id, mailbox, … }), 여럿이면 합계와 도메인별 결과.
if (results.length === 1) return json({ ok: true, ...results[0] });
const skipped = imported ? undefined : results.find((r) => r.skipped)?.skipped;
return json({ ok: true, imported, ...(skipped ? { skipped } : {}), results });
}
return bad('not_found', 404);
}
async function webhook(env, request) {
const raw = Buffer.from(await request.arrayBuffer());
const headers = { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature') };
if (!env.RESEND_WEBHOOK_SECRET) { console.error('웹훅 비밀키 없음 — RESEND_WEBHOOK_SECRET 를 설정하세요'); return bad('webhook_not_configured', 503); }
const check = verifySvix(env.RESEND_WEBHOOK_SECRET, headers, raw);
if (!check.ok) return bad(check.error, 401);
let event; try { event = JSON.parse(raw.toString('utf8')); } catch { return bad('bad_request'); }
const eventId = headers['svix-id'] || uid(), stamp = now();
const type = String(event?.type || ''), data = event?.data || {};
// 같은 svix-id 는 한 번만 처리합니다. 단, 저장에 실패해 500 을 돌려준 이벤트('failed')는 Resend 가 같은 id 로
// 다시 보내므로 중복으로 보지 않고 다시 처리합니다(예전에는 재시도가 duplicate 로 끝나 메일이 저장되지 않았습니다).
if (await env.DB.prepare("SELECT id FROM webhook_events WHERE id=? AND status<>'failed'").bind(eventId).first()) return json({ ok: true, duplicate: true });
const record = (status, detail) => env.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind(eventId, type, data.email_id || null, stamp, status, detail || null).run();
if (type !== 'email.received') { await record('ignored', 'type'); return json({ ok: true, ignored: type }); }
const known = await knownMailboxes(env);
if (!addresses([...(data.to || []), ...(data.cc || []), ...(data.bcc || []), ...(data.received_for || [])]).some((a) => known.includes(a))) { await record('ignored', 'other_mailbox'); return json({ ok: true, ignored: 'other_mailbox' }); }
try {
const result = await ingestReceived(env, data.email_id, { source: 'webhook', known, summary: { from: data.from, to: data.to, subject: data.subject, created_at: data.created_at, attachments: data.attachments } });
await record(result.imported ? 'stored' : 'skipped', result.skipped || result.raw);
return json({ ok: true, ...result });
} catch (e) {
// 5xx 를 돌려주면 Resend 가 재시도합니다. 실패 이유는 기록만 남깁니다.
await record('failed', (e.message + ' ' + (e.providerMessage || '')).slice(0, 300));
return bad(e.message || 'ingest_failed', 500);
}
}
/**
* 웹 메일 설정 화면용 정보: 이 계정으로 메일 프로그램을 설정할 때 필요한 값(IMAP·SMTP·POP 호스트·포트·TLS 상태)과 사용자 설정.
* 서버 운영 정보(웹훅·동기화·저장 통계)는 0.8.4 부터 관리자 페이지의 adminStatus 로 옮겼습니다.
* 0.9.0: services(관리자가 POP/IMAP 서비스 자체를 껐는지), limits(첨부 한 개 최대 MIME 크기·받는 사람 수), quota(메일함 최대 크기·사용량),
* canChangePassword(게이트웨이 계정이면 설정 화면에서 비밀번호를 바꿀 수 있음)를 더했습니다.
*/
async function settings(env, user) {
// host: 웹 메일 주소. serverHost(1.0.7): 메일 프로그램이 접속할 IMAP·POP·SMTP 서버 이름 — 서버에 하나뿐인 메일 포트·인증서는 기본 도메인의 호스트 이름이므로
// 추가 도메인의 사용자도 그 이름으로 접속합니다(자기 도메인 이름으로 접속하면 인증서 이름이 맞지 않음).
const host = env.PUBLIC_HOST || 'mail.arihakdang.com', serverHost = env.MAIL_SERVER_HOST || host;
const plainOk = await plainLoginAllowed(env);
return json({
ok: true, mailbox: user.mailbox, user: { login: user.login, name: user.name, role: user.role, source: user.source }, host, prefs: await readPrefs(env, user),
// 보관 정책: 0.8.0 부터 기한 없음. 화면은 이 값으로 "삭제할 때까지 보관" 문구를 보여 줍니다.
retention: 'unlimited',
services: await readServices(env), limits: { attachmentBytes: await attachmentLimit(env), recipients: await recipientLimit(env) },
// managedBy(1.0.8): 비밀번호를 어디서 바꾸는지(local 이면 여기, www / webmaker 면 그 서비스). 화면은 이 값으로 안내 문구를 고릅니다.
quota: { usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) }, canChangePassword: user.source === 'local', managedBy: user.source, policy: await readPasswordPolicy(env), largeFiles: await readLargeFileSettings(env),
// SMTP(보내는 메일 서버): 이 게이트웨이의 제출 서버(smtp.mjs). 계정·비밀번호는 IMAP·POP 과 같은 로그인 정보이고,
// 발송 서비스의 API 키는 서버 내부에만 있으므로 화면에 키 관련 정보(설정 여부 포함)를 내보내지 않습니다.
smtp: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { smtps: Number(env.SMTPS_PORT || 465), starttls: Number(env.SMTP_PORT || 587) }, from: `${user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang'} <${user.mailbox}>` },
// plaintext(0.9.7): 110/143 에서 TLS 없이 로그인할 수 있는지(관리자 설정 "평문 로그인 허용" 또는 POP_ALLOW_PLAINTEXT=1). 화면이 110 포트 줄에 표시합니다.
pop: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { pop3s: Number(env.POPS_PORT || 995), starttls: Number(env.POP_PORT || 110) }, plaintext: plainOk },
imap: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { imaps: Number(env.IMAPS_PORT || 993), starttls: Number(env.IMAP_PORT || 143) }, plaintext: plainOk, mailboxes: ['INBOX', 'Sent', 'Drafts', 'Trash'] },
});
}
/**
* 관리자 페이지 "서버 상태": 발송 서비스 웹훅·동기화 기록·저장 통계·POP 전달 기록·서비스 포트 상태. 관리자 세션으로만 볼 수 있습니다.
* 0.9.0: stats(메일 현황 — 일·주·월 발신/수신/수발신, 메일함별·전체 사용량, 시스템 제한 대비 사용 비율)와 services(POP/IMAP 스위치)를 더했습니다.
*/
async function adminStatus(env) {
const last = (await env.DB.prepare('SELECT * FROM sync_runs ORDER BY id DESC LIMIT 5').all()).results;
const webhooks = (await env.DB.prepare("SELECT status,COUNT(*) n,MAX(received_at) last FROM webhook_events GROUP BY status").all()).results;
const counts = await env.DB.prepare("SELECT COUNT(*) total,SUM(CASE WHEN raw_status='stored' THEN 1 ELSE 0 END) raw_stored,SUM(CASE WHEN raw_status='built' THEN 1 ELSE 0 END) raw_built,SUM(CASE WHEN source='webhook' THEN 1 ELSE 0 END) via_webhook,SUM(CASE WHEN source='sync' THEN 1 ELSE 0 END) via_sync,SUM(CASE WHEN provider_id LIKE 'haraka:%' THEN 1 ELSE 0 END) via_haraka FROM messages WHERE direction='inbound'").first();
const perMailbox = (await env.DB.prepare('SELECT mailbox,COUNT(*) n,COALESCE(SUM(raw_size),0) bytes FROM messages GROUP BY mailbox ORDER BY mailbox').all()).results;
const pop = await env.DB.prepare('SELECT COUNT(*) n,COUNT(DISTINCT user_id) accounts,MAX(delivered_at) last FROM pop_deliveries').first();
const host = env.PUBLIC_HOST || 'mail.arihakdang.com';
return json({
ok: true, host, version: env.APP_VERSION || null, mailbox: mailboxOf(env), tls: !!env.POP_TLS_ACTIVE, retention: 'unlimited',
ports: { pop3s: Number(env.POPS_PORT || 995), pop3: Number(env.POP_PORT || 110), imaps: Number(env.IMAPS_PORT || 993), imap: Number(env.IMAP_PORT || 143), smtps: Number(env.SMTPS_PORT || 465), submission: Number(env.SMTP_PORT || 587) },
services: await readServices(env), plainLogin: await plainLoginAllowed(env),
// 1.0.0: 수·발신 방식과 발신 서비스 상태(60초 캐시), Haraka 상태(haraka/run.mjs 가 쓰는 상태 파일), 설정 파일 경로.
mail: { inbound: inboundMode(env), outbound: outboundMode(env), status: await outboundStatus(env).catch((e) => ({ provider: e.message })), haraka: usesHaraka(env) ? harakaStatus(env) : null, configFile: configFile(env) },
webhook: { url: `https://${host}/api/webhooks/resend`, secretConfigured: !!env.RESEND_WEBHOOK_SECRET, events: webhooks },
sync: { intervalMinutes: Math.round(Number(env.SYNC_INTERVAL_MS || 3600000) / 60000), running: isSyncing(env), runs: last },
storage: counts, mailboxes: perMailbox, pop: { deliveries: pop },
stats: await mailStats(env, await knownMailboxes(env), await listUsers(env)), largeFiles: await largeFileStats(env),
});
}
+416
View File
@@ -0,0 +1,416 @@
/**
* 인증.
* - www.arihakdang.com 관리자 계정: www DB(users)를 읽기 전용으로 읽어 login_id/email + PBKDF2(www 와 같은 형식)로 검사합니다.
* 이 계정은 게이트웨이의 관리자(계정 관리 가능)이며 기본 메일함(MAILBOX)을 씁니다. 별칭 admin / admin@... 도 여기로 연결됩니다.
* - 게이트웨이 사용자 계정(mail_users): 관리자가 만들거나(0.9.0 부터는 회원 가입으로도) 생기는 계정. 자기 메일함 주소를 갖고 웹·POP·IMAP·SMTP 에 같은 비밀번호로 접속합니다.
* - 0.9.0 부터 관리자도 메일 기능에서는 자기 메일함만 봅니다(다른 메일함으로 전환하는 기능 삭제). 관리자 권한은 관리자 페이지(/admin)의 계정·설정 관리에만 쓰입니다.
* - webmaker 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커의 사용자 파일을 읽기 전용으로 참조해 같은 방식(scrypt)으로 검사합니다(server/webmaker.mjs).
* 웹메이커 계정이 먼저이고, 그다음 게이트웨이 계정(mail_users)을 봅니다. 웹·POP·IMAP·SMTP 모두 이 verifyUser 를 거치므로 어디서나 같은 비밀번호입니다.
*/
import { openDb } from './db.mjs';
import { json, now, plus, secret, sha256, safeEqual, clean, uid, isEmail, addresses } from './util.mjs';
import { systemLimits, accountCount, clearUserQuota } from './limits.mjs';
import { isWebmaker, verifyWebmaker, webmakerPrincipalById, webmakerByMailbox, webmakerMailboxes, webmakerAccounts, webmakerHasLogin } from './webmaker.mjs';
export const SESSION_COOKIE = 'mg_session';
// 관리자 페이지(/admin)는 웹 메일과 별도의 로그인·세션을 씁니다(0.8.4~). 쿠키 이름이 달라 두 로그인이 서로 영향을 주지 않습니다.
export const ADMIN_COOKIE = 'mg_admin';
// 세션 범위: 'mail' = 웹 메일(/), 'admin' = 관리자 페이지(/admin). 범위가 다른 세션 쿠키로는 상대편 API 를 쓸 수 없습니다.
export const SCOPES = ['mail', 'admin'];
const cookieName = (scope) => scope === 'admin' ? ADMIN_COOKIE : SESSION_COOKIE;
const SESSION_DAYS = 7;
/** 메일함 주소의 도메인 부분(예: arihakdang.com). 계정의 메일함은 항상 "로그인 ID@이 도메인" 입니다. */
export const mailDomain = (env) => mailboxOf(env).split('@')[1];
const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
/**
* 독립 실행(standalone) 여부(0.8.18~). www 사이트 DB(WWW_DB_FILE / 테스트의 WWW_DB)가 없으면 독립 실행입니다.
* 독립 실행에서는 www 관리자가 없으므로 기본 메일함(MAILBOX)도 게이트웨이 계정(mail_users)이 갖습니다:
* - 로그인 ID 가 기본 메일함의 앞부분(예: admin)인 계정을 만들 수 있고, 그 계정의 메일함이 곧 기본 메일함입니다.
* - 첫 관리자 계정은 화면에서 만들 수 없으므로 server/user-cli.mjs 로 만듭니다.
*/
export const standalone = (env) => !env.WWW_DB && !env.WWW_DB_FILE;
/** 로그인 방식 이름(1.0.8): 'webmaker'(웹메이커 계정 연동) | 'www'(웹 서비스 DB 연동) | 'local'(자체 계정만). 화면·API 안내용. */
export const authMode = (env) => isWebmaker(env) ? 'webmaker' : standalone(env) ? 'local' : 'www';
async function pbkdf2(password, saltB64, iterations) {
const salt = Uint8Array.from(atob(saltB64), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, key, 256);
return btoa(String.fromCharCode(...new Uint8Array(bits)));
}
export async function verifyPassword(password, stored) {
if (!stored) return false;
const [scheme, iterations, salt, digest] = String(stored).split('$');
const n = Number(iterations);
if (scheme !== 'pbkdf2' || !salt || !digest || !Number.isInteger(n) || n < 1000 || n > 5000000) return false;
return safeEqual(await pbkdf2(password, salt, n), digest);
}
// 없는 계정의 로그인 시도에 쓰는 더미 해시(프로세스마다 한 번 만들어 재사용). 반복 횟수가 실제 계정과 같아 걸리는 시간이 같습니다.
let dummy = null; const dummyHash = async () => (dummy ??= await hashPassword(crypto.randomUUID()));
export async function hashPassword(password, iterations = 210000) {
const salt = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
return `pbkdf2$${iterations}$${salt}$${await pbkdf2(password, salt, iterations)}`;
}
// ------------------------------------------------------------------ www 관리자
// www DB 핸들은 파일 경로별로 하나씩 둡니다(1.0.7: 도메인마다 다른 웹 서비스 DB 와 연동할 수 있으므로 모듈 전역 하나로는 부족).
const wwwDbs = new Map(); // WWW_DB_FILE → DatabaseSync(읽기 전용)
function wwwHandle(env) {
if (env.WWW_DB) return env.WWW_DB;
if (!env.WWW_DB_FILE) throw new Error('www_db_not_configured');
let db = wwwDbs.get(env.WWW_DB_FILE);
if (!db) { db = openDb(env.WWW_DB_FILE, { readOnly: true }); wwwDbs.set(env.WWW_DB_FILE, db); }
return db;
}
function query(env, sql, params) {
try { return wwwHandle(env).prepare(sql).get(...params); }
catch {
// 배포 중 www DB 파일이 교체되면 핸들이 깨질 수 있어 한 번 닫고 다시 엽니다.
if (!env.WWW_DB && env.WWW_DB_FILE) { try { wwwDbs.get(env.WWW_DB_FILE)?.close(); } catch {} wwwDbs.delete(env.WWW_DB_FILE); }
return wwwHandle(env).prepare(sql).get(...params);
}
}
const COLS = 'SELECT id,email,login_id,name,role,status,password_hash FROM users';
/** 메일함 주소(admin@...)와 그 앞부분(admin)은 메일함 소유 관리자 계정의 별칭입니다.
* 소유자는 MAILBOX_OWNER_LOGIN → 이메일이 메일함 주소인 관리자 → 유일한 활성 관리자 순입니다. */
export function mailboxOwner(env) {
const mailbox = mailboxOf(env), configured = clean(env.MAILBOX_OWNER_LOGIN, 200).toLowerCase();
if (configured) return query(env, `${COLS} WHERE ${configured.includes('@') ? 'email' : 'login_id'} = ?`, [configured]) || null;
const byEmail = query(env, `${COLS} WHERE email = ?`, [mailbox]); if (byEmail) return byEmail;
const admins = wwwHandle(env).prepare(`${COLS} WHERE role = 'admin' AND status = 'active'`).all();
return admins.length === 1 ? admins[0] : null;
}
export function isMailboxAlias(env, login) { const mailbox = mailboxOf(env), key = clean(login, 200).toLowerCase(); return key === mailbox || key === mailbox.split('@')[0]; }
const wwwPrincipal = (row, env) => ({ id: 'www:' + row.id, source: 'www', login: row.login_id || row.email, email: row.email, name: row.name || '', role: 'admin', mailbox: mailboxOf(env) });
const localPrincipal = (row) => ({ id: row.id, source: 'local', login: row.login, email: row.mailbox, name: row.name || '', role: row.role, mailbox: row.mailbox });
/** 로그인 이름과 비밀번호로 사용자를 찾습니다. www 관리자(별칭 포함) → 게이트웨이 계정 순. 실패 사유는 나누지 않습니다. */
export async function verifyUser(env, login, password) {
const key = clean(login, 200).toLowerCase();
if (!key || !password) return null;
// webmaker 연동(1.0.8): 웹메이커 계정이 먼저입니다. 같은 로그인 ID 의 게이트웨이 계정이 있어도(createUser 가 막지만 연동 전에 만든 것) 웹메이커 쪽이 그 주소의 주인입니다.
// 웹메이커 계정이 아니거나 비밀번호가 틀리면 아래 게이트웨이 계정으로 넘어갑니다(관리자가 따로 만든 로컬 계정도 함께 쓸 수 있게).
if (isWebmaker(env)) { const wm = await verifyWebmaker(env, key, password); if (wm) return wm; }
let row = null;
try {
row = isMailboxAlias(env, key) ? mailboxOwner(env) : key.includes('@') ? query(env, `${COLS} WHERE email = ?`, [key]) : query(env, `${COLS} WHERE login_id = ?`, [key]);
} catch (e) { if (e.message !== 'www_db_not_configured') throw e; }
if (row && row.role === 'admin' && row.status === 'active' && (await verifyPassword(password, row.password_hash))) return wwwPrincipal(row, env);
const local = await env.DB.prepare('SELECT * FROM mail_users WHERE (login = ? OR mailbox = ?) AND status = ?').bind(key, key, 'active').first();
if (local && (await verifyPassword(password, local.password_hash))) { await env.DB.prepare('UPDATE mail_users SET last_login_at = ? WHERE id = ?').bind(now(), local.id).run(); return localPrincipal(local); }
// 시간 차이로 계정 존재 여부가 드러나지 않게 합니다. 예전에는 verifyPassword(password, null) 을 불렀는데 저장값이 없으면 해시 없이 바로 false 라
// 없는 계정은 0.2ms, 있는 계정은 100ms 넘게 걸려 로그인 ID 를 추측할 수 있었습니다(0.8.24). 이제 더미 해시로 실제 PBKDF2 를 돕니다.
if (!row && !local) await verifyPassword(password, await dummyHash());
return null;
}
/** @deprecated verifyUser 로 대체. 기존 호출부 호환. */
export const verifyAdmin = verifyUser;
export async function principalById(env, id) {
if (String(id).startsWith('wm:')) return isWebmaker(env) ? await webmakerPrincipalById(env, String(id)) : null;
if (String(id).startsWith('www:')) { const row = query(env, `${COLS} WHERE id = ?`, [String(id).slice(4)]); return row && row.role === 'admin' && row.status === 'active' ? wwwPrincipal(row, env) : null; }
const local = await env.DB.prepare("SELECT * FROM mail_users WHERE id = ? AND status = 'active'").bind(id).first();
return local ? localPrincipal(local) : null;
}
// ------------------------------------------------------------------ 비밀번호 정책 (0.8.24~)
// 예전에는 "10자 이상" 이 코드에 고정돼 있었습니다. 이제 관리자가 관리자 페이지에서 정하며 settings 표의 password_policy 키에 JSON 으로 저장합니다.
// minLength : 최소 글자 수. 6 ~ 64. 기본 6 (요구사항: 최소 6자 이상, 관리자가 조정)
// mixedCase : true 면 영문 대문자와 소문자를 모두 포함해야 함
// digit : true 면 숫자를 포함해야 함
// special : true 면 특수문자(영문·숫자·공백이 아닌 글자)를 포함해야 함
export const DEFAULT_PASSWORD_POLICY = { minLength: 6, mixedCase: false, digit: false, special: false };
export const PASSWORD_MIN_FLOOR = 6, PASSWORD_MIN_CEIL = 64;
/** 독립 실행의 초기 관리자 비밀번호. 정책과 무관하게 글자 수 하한(6)만 만족하면 되고, 로그인 뒤 바꾸라고 안내합니다. */
export const INITIAL_ADMIN_PASSWORD = 'adminpasswd';
const POLICY_KEY = 'password_policy';
/** 저장된 정책을 기본값과 합쳐 돌려줍니다. 손상된 값은 기본값으로 대체합니다. */
export async function readPasswordPolicy(env) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(POLICY_KEY).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
const p = { ...DEFAULT_PASSWORD_POLICY, ...(stored && typeof stored === 'object' ? stored : {}) };
p.minLength = Number.isInteger(p.minLength) && p.minLength >= PASSWORD_MIN_FLOOR && p.minLength <= PASSWORD_MIN_CEIL ? p.minLength : DEFAULT_PASSWORD_POLICY.minLength;
for (const k of ['mixedCase', 'digit', 'special']) p[k] = !!p[k];
return p;
}
/**
* 정책을 저장합니다(관리자 전용). minLength 는 6~64 의 정수여야 하며(아니면 policy_invalid), 나머지는 불리언으로 바꿉니다.
* 이미 있는 계정의 비밀번호에는 소급 적용하지 않습니다(새 비밀번호를 정할 때만 검사).
*/
export async function writePasswordPolicy(env, patch) {
const current = await readPasswordPolicy(env), next = { ...current };
if (patch.minLength !== undefined) { const n = Number(patch.minLength); if (!Number.isInteger(n) || n < PASSWORD_MIN_FLOOR || n > PASSWORD_MIN_CEIL) throw new Error('policy_invalid'); next.minLength = n; }
for (const k of ['mixedCase', 'digit', 'special']) if (patch[k] !== undefined) next[k] = !!patch[k];
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(POLICY_KEY, JSON.stringify(next), now()).run();
return next;
}
/** 비밀번호가 정책에 어긋나는 항목 목록(비어 있으면 통과): 'length' | 'mixedCase' | 'digit' | 'special'. 화면이 같은 규칙으로 안내문을 만듭니다. */
export function passwordProblems(password, policy) {
const pw = String(password ?? ''), problems = [];
if ([...pw].length < policy.minLength) problems.push('length');
if (policy.mixedCase && !(/[A-Z]/.test(pw) && /[a-z]/.test(pw))) problems.push('mixedCase');
if (policy.digit && !/[0-9]/.test(pw)) problems.push('digit');
if (policy.special && !/[^A-Za-z0-9\s]/.test(pw)) problems.push('special');
return problems;
}
/** 정책 검사. 어긋나면 weak_password 오류를 던지며, 오류 객체에 problems(어긋난 항목)와 policy 를 붙여 API 가 화면에 그대로 전달합니다. */
async function checkPassword(env, password) {
const policy = await readPasswordPolicy(env), problems = passwordProblems(password, policy);
if (problems.length) { const e = new Error('weak_password'); e.problems = problems; e.policy = policy; throw e; }
}
// 초기 비밀번호 표시: settings 의 initial_password:<계정 id> 키가 있으면 그 계정은 아직 초기 비밀번호를 씁니다. 비밀번호를 바꾸면 지웁니다.
const initialKey = (id) => 'initial_password:' + id;
export async function usesInitialPassword(env, user) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(initialKey(user.id)).first()); }
/**
* 독립 실행의 초기 관리자 계정(0.8.24~). 서버가 시작할 때 부르며, 독립 실행이고 게이트웨이 계정이 하나도 없을 때만
* 기본 메일함의 앞부분(보통 admin)을 로그인 ID 로, 비밀번호 adminpasswd 로 관리자 계정을 만듭니다.
* 예전에는 첫 관리자를 user-cli 로만 만들 수 있었고 "10자 이상" 규칙 때문에 admin 같은 짧은 초기 비밀번호가 거부돼 설치가 막혔습니다.
* @param {object} [o] password: 초기 비밀번호(기본 adminpasswd; 1.0.7 도메인 추가 폼에서 지정)
* @returns {Promise<{login:string, password:string}|null>} 만들었으면 계정 정보(시작 로그에 안내용), 아니면 null
*/
export async function ensureInitialAdmin(env, { password = INITIAL_ADMIN_PASSWORD } = {}) {
// webmaker 연동(1.0.8)에서는 웹메이커의 관리자(admin)가 곧 이 도메인의 관리자이므로 adminpasswd 계정을 만들지 않습니다.
if (!standalone(env) || isWebmaker(env)) return null;
const count = await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first();
if (Number(count?.n || 0) > 0) return null;
const login = mailboxOf(env).split('@')[0];
// password(1.0.7): 전체 설정 화면에서 도메인을 추가할 때 지정한 초기 비밀번호. 기본값은 예전과 같은 adminpasswd.
const id = await createUser(env, { login, name: '관리자', password, role: 'admin' }, 'system', { initial: true });
await audit(env, 'system', 'user_create', id, { login, initial: true });
return { login, password };
}
// ------------------------------------------------------------------ 게이트웨이 계정 관리 (관리자 전용)
const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/;
// 승인 대기(0.9.0~): 회원 가입으로 만들어졌지만 관리자 승인 전인 계정. mail_users.status 는 'suspended'(로그인 불가)로 두고
// settings 의 pending:<계정 id> 키로 "정지" 가 아니라 "승인 대기" 임을 표시합니다. 스키마의 status 제약(active/suspended)을 바꾸지 않으려는 선택이며,
// 로그인·세션·메일함 주인 판단은 모두 status='active' 만 보므로 대기 계정은 어디서도 들어올 수 없습니다. 승인 = status 를 active 로 바꾸고 이 키를 지움.
const pendingKey = (id) => 'pending:' + id;
/** 계정 목록. 승인 대기 계정은 status 를 'pending' 으로 바꿔 돌려줍니다(화면·API 용; DB 값은 suspended). */
export async function listUsers(env) {
const rows = (await env.DB.prepare(`SELECT u.id,u.login,u.name,u.mailbox,u.role,CASE WHEN p.key IS NULL THEN u.status ELSE 'pending' END AS status,u.created_by,u.created_at,u.updated_at,u.last_login_at
FROM mail_users u LEFT JOIN settings p ON p.key = 'pending:' || u.id ORDER BY u.created_at`).all()).results;
return rows;
}
/**
* 게이트웨이 계정을 만듭니다(관리자 페이지·회원 가입·명령줄 도구).
* 메일함 주소는 따로 받지 않고 항상 "로그인 ID@도메인"(예: info → info@arihakdang.com)으로 정합니다(0.8.4~).
* 예전에는 메일함 주소를 직접 입력해 여러 계정이 한 메일함을 함께 쓸 수 있었지만, 계정마다 자기 주소를 갖는 편이
* 받는 사람 주소와 로그인 ID 가 일치해 혼동이 없습니다. 기존 계정의 메일함 주소는 바꾸지 않습니다.
* 비밀번호는 관리자가 정한 정책(readPasswordPolicy)으로 검사합니다(0.8.24~, 예전에는 10자 이상 고정).
* 시스템 설정의 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0~)에 이르렀으면 account_limit_reached 를 던집니다(정지·대기 계정도 셉니다).
* @param {object} body { login, name?, password, role?, status? } — body.mailbox 는 무시합니다.
* @param {string} actor 만든 주체(관리자 로그인 이름, 'signup', 'user-cli', 'system')(기록용)
* @param {object} [options]
* @param {boolean} [options.initial] 초기 관리자 계정: 정책 대신 글자 수 하한(6)만 보고, 초기 비밀번호 표시를 남깁니다.
* @param {boolean} [options.pending] 회원 가입 승인 대기 계정으로 만듭니다(관리자가 승인하기 전에는 로그인 불가).
* @returns {Promise<string>} 새 계정 id
*/
export async function createUser(env, body, actor, { initial = false, pending = false } = {}) {
const login = clean(body.login, 64).toLowerCase(), password = String(body.password ?? '');
// 기본 메일함의 별칭(admin)은 www 관리자 몫이라 막지만, 독립 실행에서는 그 메일함을 가질 게이트웨이 계정이 필요하므로 허용합니다.
if (!LOGIN_RE.test(login) || (isMailboxAlias(env, login) && !standalone(env))) throw new Error('invalid_login');
const mailbox = `${login}@${mailDomain(env)}`;
if (!isEmail(mailbox)) throw new Error('invalid_login');
if (initial) { if ([...password].length < PASSWORD_MIN_FLOOR) throw new Error('weak_password'); } else await checkPassword(env, password);
if (await env.DB.prepare('SELECT id FROM mail_users WHERE login = ?').bind(login).first()) throw new Error('login_taken');
// webmaker 연동(1.0.8): 웹메이커 계정과 같은 로그인 ID 는 만들 수 없습니다(그 주소의 주인은 웹메이커 계정이고 verifyUser 도 그쪽을 먼저 봄).
if (isWebmaker(env) && (await webmakerHasLogin(env, login))) throw new Error('login_taken');
const limit = systemLimits(env).accounts;
if (limit > 0 && (await accountCount(env)) >= limit) { const e = new Error('account_limit_reached'); e.limit = limit; throw e; }
const id = uid(), stamp = now();
await env.DB.prepare('INSERT INTO mail_users(id,login,name,mailbox,role,status,password_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.bind(id, login, clean(body.name, 80), mailbox, body.role === 'admin' ? 'admin' : 'user', pending || body.status === 'suspended' ? 'suspended' : 'active', await hashPassword(password), actor || null, stamp, stamp).run();
const mark = (key) => env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, '1', stamp).run();
if (initial) await mark(initialKey(id));
if (pending) await mark(pendingKey(id));
return id;
}
/** 승인 대기 계정인지(settings 의 pending:<id> 키). */
export async function isPending(env, id) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(pendingKey(id)).first()); }
/**
* 게이트웨이 계정을 수정합니다(관리자 전용). 이름·역할·상태·비밀번호만 바꿀 수 있고 메일함 주소는 로그인 ID 에 묶여 있어 바꾸지 않습니다.
* body.services({ pop, imap }) 가 있으면 그 계정의 메일 프로그램 접속 허용 여부(prefs)도 함께 저장합니다.
* body.status 를 'active' 로 두면 승인 대기 계정은 승인됩니다(대기 표시를 지움). 'pending' 은 받지 않습니다(대기는 가입 때만 생김).
*/
export async function updateUser(env, id, body) {
const row = await env.DB.prepare('SELECT * FROM mail_users WHERE id = ?').bind(id).first(); if (!row) throw new Error('not_found');
// 역할·상태는 아는 값만 받습니다. 예전에는 'pending' 같은 다른 값이 모두 'active' 로 바뀌어 승인 대기 계정이 뜻하지 않게 승인됐습니다(0.9.3).
if (body.role !== undefined && !['admin', 'user'].includes(body.role)) throw new Error('validation');
if (body.status !== undefined && !['active', 'suspended'].includes(body.status)) throw new Error('validation');
const next = { name: body.name !== undefined ? clean(body.name, 80) : row.name, mailbox: row.mailbox, role: body.role !== undefined ? body.role : row.role, status: body.status !== undefined ? body.status : row.status };
if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services);
let hash = row.password_hash;
if (body.password !== undefined && body.password !== '') { await checkPassword(env, String(body.password)); hash = await hashPassword(String(body.password)); }
await env.DB.prepare('UPDATE mail_users SET name=?,role=?,status=?,password_hash=?,updated_at=? WHERE id=?').bind(next.name, next.role, next.status, hash, now(), id).run();
if (hash !== row.password_hash || next.status === 'suspended') await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run();
// 비밀번호를 바꿨으면 "초기 비밀번호 사용 중" 표시를 지웁니다(initial_password:<id>).
if (hash !== row.password_hash) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(initialKey(id)).run();
// 상태를 명시해 바꾸면 승인 대기 표시를 지웁니다: '사용' = 승인, '정지' = 대기가 아니라 정지 계정으로 둠(예전에는 정지로 바꿔도 "승인 대기" 로 남았습니다, 0.9.3).
if (body.status !== undefined) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(pendingKey(id)).run();
}
/**
* 로그인에 실패한 뒤 "승인 대기 계정이라서" 인지 알아봅니다(0.9.0~). 로그인 ID 와 비밀번호가 모두 맞는 대기 계정일 때만 true 이므로
* 계정 존재 여부가 새어 나가지 않습니다. API 는 이 경우 invalid_credentials 대신 pending_approval(403)을 돌려줍니다.
*/
export async function pendingLogin(env, login, password) {
const key = clean(login, 200).toLowerCase(); if (!key || !password) return false;
const row = await env.DB.prepare("SELECT u.id,u.password_hash FROM mail_users u JOIN settings p ON p.key = 'pending:' || u.id WHERE (u.login = ? OR u.mailbox = ?) AND u.status = 'suspended'").bind(key, key).first();
return !!row && (await verifyPassword(password, row.password_hash));
}
/**
* 자기 비밀번호를 바꿀 때 현재 비밀번호가 맞는지 확인합니다(0.8.25~). 게이트웨이 계정만 대상입니다(www 관리자는 www 사이트에서 관리).
* 세션을 훔친 사람이 비밀번호를 바꿔 계정을 가로채지 못하게 하는 장치이므로, 틀리면 false 만 돌려주고 사유는 나누지 않습니다.
*/
export async function checkCurrentPassword(env, id, password) {
const row = await env.DB.prepare('SELECT password_hash FROM mail_users WHERE id = ?').bind(id).first();
return !!row && (await verifyPassword(String(password ?? ''), row.password_hash));
}
/** 계정을 지웁니다(회원 가입 거절에도 씀). 세션과 계정에 딸린 설정 표시(초기 비밀번호·승인 대기·메일함 크기)도 함께 지웁니다. 메일함의 메일은 남습니다. */
export async function deleteUser(env, id) {
const r = await env.DB.prepare('DELETE FROM mail_users WHERE id = ?').bind(id).run(); if (!r.meta.changes) throw new Error('not_found');
await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run();
await env.DB.prepare('DELETE FROM settings WHERE key IN (?,?,?)').bind(initialKey(id), pendingKey(id), 'prefs:' + id).run(); // prefs(전달·자동 응답 등)도 함께 지움(0.9.3)
await clearUserQuota(env, id);
}
/**
* 수신을 받아들일 메일함 주소 목록: 기본 메일함 + 모든 계정(정지 포함)의 메일함.
* 예전에는 활성 계정만 넣어, 계정을 정지하면 그 주소로 오는 메일이 'other_mailbox' 로 버려졌고(동기화 창을 벗어나면 복구 불가) 정지를 풀어도 돌아오지 않았습니다(0.8.24).
* 정지는 로그인을 막는 것이지 메일함을 없애는 것이 아니므로, 메일은 계속 저장하고 관리자는 그 메일함을 볼 수 있습니다.
*/
export async function knownMailboxes(env) {
const rows = (await env.DB.prepare("SELECT DISTINCT mailbox FROM mail_users").all()).results;
// webmaker 연동(1.0.8): 웹메이커 계정의 메일함(아이디@도메인)도 받는 주소입니다. 파일을 읽지 못하면 빈 목록(마지막 스냅샷이 있으면 그것).
const external = isWebmaker(env) ? await webmakerMailboxes(env) : [];
return [...new Set([mailboxOf(env), ...rows.map((r) => r.mailbox), ...external])];
}
/**
* 외부(웹메이커) 계정 목록(1.0.8) — 관리자 페이지에 읽기 전용 행으로 보여 주는 용도. 해시는 없습니다.
* @returns {Promise<{ok:boolean, error?:string, site?:object, accounts:object[]}>} webmaker 모드가 아니면 ok:true 에 빈 목록
*/
export async function externalUsers(env) { return isWebmaker(env) ? webmakerAccounts(env) : { ok: true, accounts: [] }; }
// ------------------------------------------------------------------ 사용자별 설정(prefs)
// settings 테이블에 prefs:<사용자 id> 로 저장합니다. www 관리자(id 'www:…')와 게이트웨이 계정 모두 같은 방식입니다.
// remoteImages: 메일을 열 때 외부 이미지를 바로 표시할지
// pop / imap: 이 계정으로 POP3 / IMAP 서버에 접속할 수 있는지(0.8.4~). 끄면 메일 프로그램 로그인을 거부합니다.
// forward: 메일 전달(0.8.16~). enabled 면 이 메일함으로 받은 메일을 전부 to(주소 목록, 최대 5개)로 전달합니다. 원본은 메일함에 남습니다.
// autoReply: 자동 응답(0.8.16~). enabled 면 받은 메일에 message 로 자동 회신합니다. noLimit 이면 기한 없이, 아니면 start~end(ISO 시각) 동안만.
// subject 가 비어 있으면 "Re: 원래 제목" 으로 보냅니다. 실제 판단은 automation.mjs 의 autoReplyActive() 가 합니다.
export const DEFAULT_PREFS = {
remoteImages: false, pop: true, imap: true,
forward: { enabled: false, to: '' },
autoReply: { enabled: false, noLimit: true, start: '', end: '', subject: '', message: '' },
};
// 전달 주소 수·자동 응답 글자 수 상한. 전달은 받은 메일마다 발송이 생기므로 주소를 적게 두고, 메시지는 화면 입력 상자 기준으로 넉넉히 둡니다.
export const FORWARD_MAX = 5, AUTOREPLY_MESSAGE_MAX = 5000, AUTOREPLY_SUBJECT_MAX = 200;
/** 사용자(id 만 있어도 됨)의 설정을 기본값과 합쳐 돌려줍니다. 중첩 설정(forward·autoReply)도 키마다 기본값을 채웁니다. */
export async function readPrefs(env, user) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('prefs:' + user.id).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
if (!stored || typeof stored !== 'object') stored = {};
return {
...DEFAULT_PREFS, ...stored,
forward: { ...DEFAULT_PREFS.forward, ...(stored.forward && typeof stored.forward === 'object' ? stored.forward : {}) },
autoReply: { ...DEFAULT_PREFS.autoReply, ...(stored.autoReply && typeof stored.autoReply === 'object' ? stored.autoReply : {}) },
};
}
/** ISO 시각이면 정규화한 ISO 문자열, 비어 있으면 '', 해석할 수 없으면 null. */
const isoOrEmpty = (v) => { if (v === undefined || v === null || v === '') return ''; const ms = Date.parse(String(v)); return Number.isFinite(ms) ? new Date(ms).toISOString() : null; };
/**
* 설정 중 아는 키만 골라 저장합니다. 불리언 설정은 불리언으로 바꾸고, forward·autoReply 는 검사한 뒤 정리된 값으로 저장합니다.
* 검사 실패는 예외로 알립니다(API 가 422 로 돌려줌):
* forward_invalid — 전달을 켰는데 주소가 없거나 형식이 틀리거나 5개를 넘거나 자기 메일함 주소를 넣음
* autoreply_message — 자동 응답을 켰는데 메시지가 비어 있음
* autoreply_period — 기한 제한을 두었는데 종료 시각이 없거나, 시각 형식이 틀리거나, 종료가 시작보다 앞섬
* 돌려주는 값은 저장 후의 전체 설정입니다.
* @param {object} user { id, mailbox? } — mailbox 가 있으면 전달 주소에 자기 메일함을 넣지 못하게 막습니다(전달 고리 방지).
*/
export async function writePrefs(env, user, patch) {
const current = await readPrefs(env, user);
const next = { ...current };
for (const key of ['remoteImages', 'pop', 'imap']) if (patch[key] !== undefined) next[key] = !!patch[key];
if (patch.forward && typeof patch.forward === 'object') {
const f = patch.forward, enabled = f.enabled !== undefined ? !!f.enabled : current.forward.enabled;
const to = f.to !== undefined ? addresses(clean(f.to, 1000)) : addresses(current.forward.to);
if (enabled && (!to.length || to.length > FORWARD_MAX || to.some((a) => !isEmail(a)) || (user.mailbox && to.includes(String(user.mailbox).toLowerCase())))) throw new Error('forward_invalid');
if (!enabled && to.some((a) => !isEmail(a))) throw new Error('forward_invalid');
next.forward = { enabled, to: to.join(', ') };
}
if (patch.autoReply && typeof patch.autoReply === 'object') {
const a = patch.autoReply, base = current.autoReply;
const enabled = a.enabled !== undefined ? !!a.enabled : base.enabled, noLimit = a.noLimit !== undefined ? !!a.noLimit : base.noLimit;
const start = a.start !== undefined ? isoOrEmpty(a.start) : base.start, end = a.end !== undefined ? isoOrEmpty(a.end) : base.end;
const subject = a.subject !== undefined ? clean(a.subject, AUTOREPLY_SUBJECT_MAX) : base.subject;
const message = a.message !== undefined ? String(a.message ?? '').replace(/\r\n?/g, '\n').trim().slice(0, AUTOREPLY_MESSAGE_MAX) : base.message;
if (start === null || end === null) throw new Error('autoreply_period');
if (enabled && !message) throw new Error('autoreply_message');
// 기한 제한이 있으면 종료 시각은 꼭 있어야 하고(시작은 비우면 "지금부터"), 종료가 시작보다 앞서면 기간이 성립하지 않습니다.
if (enabled && !noLimit && (!end || (start && Date.parse(end) <= Date.parse(start)))) throw new Error('autoreply_period');
next.autoReply = { enabled, noLimit, start, end, subject, message };
}
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('prefs:' + user.id, JSON.stringify(next), now()).run();
return next;
}
/**
* 메일함 주소로 그 메일함을 쓰는 사용자(설정의 주인)를 찾습니다. 받은 메일을 자동 처리(전달·자동 응답)할 때 메일함의 설정을 읽는 데 씁니다.
* 기본 메일함(MAILBOX)은 www 관리자(mailboxOwner), 그 밖의 주소는 활성 게이트웨이 계정입니다. 주인이 없으면 null.
* 독립 실행(standalone)에서는 기본 메일함도 게이트웨이 계정(로그인 ID 가 메일함 앞부분인 계정)이 주인입니다.
*/
export async function mailboxUser(env, mailbox) {
const box = String(mailbox || '').trim().toLowerCase();
if (!box) return null;
// webmaker 연동(1.0.8): 웹메이커 계정의 메일함이면 그 계정(설정 prefs 는 'wm:…' id 로 저장됨).
if (isWebmaker(env)) { const wm = await webmakerByMailbox(env, box); if (wm) return wm; }
// 기본 메일함은 www 관리자 몫입니다. 독립 실행(www DB 없음)이면 아래의 게이트웨이 계정 조회로 넘어갑니다.
if (box === mailboxOf(env) && !standalone(env)) { try { const row = mailboxOwner(env); return row ? wwwPrincipal(row, env) : null; } catch { return null; } }
const local = await env.DB.prepare("SELECT * FROM mail_users WHERE mailbox = ? AND status = 'active' ORDER BY created_at LIMIT 1").bind(box).first();
return local ? localPrincipal(local) : null;
}
/**
* 메일 프로그램(POP3/IMAP) 서버가 로그인 성공 직후 부르는 검사. 계정 설정에서 그 서비스를 꺼 두었으면 false.
* @param {'pop'|'imap'} service
*/
export async function serviceAllowed(env, user, service) { return (await readPrefs(env, user))[service] !== false; }
// ------------------------------------------------------------------ 세션
/**
* 세션을 만들고 쿠키에 넣을 토큰을 돌려줍니다. DB 에는 토큰의 SHA-256 만 저장합니다.
* @param {'mail'|'admin'} [scope] 세션 범위. 'admin' 은 관리자 페이지(/admin) 전용이며 관리자 역할 계정만 만들 수 있습니다.
*/
export async function createSession(env, user, request, { scope = 'mail' } = {}) {
if (!SCOPES.includes(scope)) throw new Error('bad_request');
if (scope === 'admin' && user.role !== 'admin') throw new Error('forbidden');
const token = secret(32);
await env.DB.prepare('INSERT INTO sessions (id,user_id,login,expires_at,created_at,ip,ua,scope) VALUES (?,?,?,?,?,?,?,?)')
.bind(await sha256(token), user.id, user.login, plus(SESSION_DAYS * 86400000), now(), request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || '', (request.headers.get('User-Agent') || '').slice(0, 300), scope).run();
return token;
}
/**
* Set-Cookie 값. scope 에 따라 쿠키 이름이 다릅니다(mg_session / mg_admin). clear 면 즉시 만료시킵니다.
* path(1.0.8): 경로 접두사(/mail) 아래의 웹 메일이면 Path 를 그 접두사로 한정합니다(같은 호스트의 사이트 쪽 요청에 세션 쿠키가 실리지 않게). 보통은 '/'.
*/
export function sessionCookie(token, { clear = false, scope = 'mail', path = '' } = {}) {
return [`${cookieName(scope)}=${clear ? '' : token}`, `Path=${path || '/'}`, 'HttpOnly', 'Secure', 'SameSite=Lax', clear ? 'Max-Age=0' : `Max-Age=${SESSION_DAYS * 86400}`].join('; ');
}
function readCookie(request, name) { for (const part of (request.headers.get('Cookie') || '').split(';')) { const [k, ...v] = part.trim().split('='); if (k === name) return v.join('='); } return ''; }
/**
* 요청의 세션 쿠키로 사용자를 찾습니다. 범위(scope)가 맞는 세션만 인정합니다.
* 관리자 범위에서는 지금도 관리자 역할인지 다시 확인합니다(세션이 살아 있는 동안 역할이 바뀌었을 수 있음).
*/
export async function currentUser(env, request, { scope = 'mail' } = {}) {
const token = readCookie(request, cookieName(scope)); if (!token) return null;
const hash = await sha256(token);
const row = await env.DB.prepare('SELECT * FROM sessions WHERE id = ? AND scope = ?').bind(hash, scope).first(); if (!row) return null;
let principal = null;
// 사용자 조회가 일시적으로 실패하면(배포 중 www DB 교체, SQLITE_BUSY) 세션을 지우지 않고 이번 요청만 비로그인으로 처리합니다.
// 예전에는 이런 예외에도 세션을 지워 관리자가 영문 없이 로그아웃됐습니다. 만료·계정 없음·역할 불일치일 때만 지웁니다.
if (row.expires_at >= now()) { try { principal = await principalById(env, row.user_id); } catch { return null; } }
if (principal && scope === 'admin' && principal.role !== 'admin') principal = null;
if (!principal) { await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(hash).run(); return null; }
return { ...principal, expires_at: row.expires_at, scope };
}
export async function destroySession(env, request, { scope = 'mail' } = {}) { const token = readCookie(request, cookieName(scope)); if (token) await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(await sha256(token)).run(); }
export async function requireUser(env, request, { scope = 'mail' } = {}) { const user = await currentUser(env, request, { scope }); return user || json({ ok: false, error: 'unauthenticated' }, 401); }
export async function audit(env, actor, action, target, detail) {
await env.DB.prepare('INSERT INTO audit_log (id,actor,action,target,detail,created_at) VALUES (?,?,?,?,?,?)').bind(uid(), actor || null, action, target || null, detail ? JSON.stringify(detail).slice(0, 2000) : null, now()).run();
}
+191
View File
@@ -0,0 +1,191 @@
/**
* 수신 메일 자동 처리(0.8.16~): 메일 전달과 자동 응답.
*
* 설정 화면(prefs, auth.mjs 의 DEFAULT_PREFS 참고)에서 사용자가 켜 두면, 받은 메일을 저장한 직후(store.ingestReceived)
* 이 모듈의 autoProcess() 가 불려 다음을 합니다.
* - 메일 전달(forward): 받은 메일을 지정한 주소(최대 5개)로 통째로(제목·본문·첨부·인라인 이미지) 보냅니다. 원본은 메일함에 그대로 남습니다.
* 보내는 사람은 이 메일함 주소(발송 서비스가 우리 도메인만 허용)이고, 답장이 원래 보낸 사람에게 가도록 Reply-To 를 원래 발신자로 둡니다.
* - 자동 응답(reply): 사용자가 정한 메시지로 보낸 사람에게 회신합니다. 기한 없음이거나 설정한 기간(시작~종료) 안일 때만 동작하며,
* 같은 상대에게는 24시간에 한 번만 보냅니다(한 사람이 여러 통을 보내도 한 번만 답함). 보낸 자동 응답은 보낸 메일함에 남깁니다.
*
* 되풀이(loop) 방지 — 자동 발송 메일은 받는 쪽의 자동 처리가 다시 반응하지 않게 표시를 달고, 받을 때는 그 표시를 보고 건너뜁니다.
* - 자동 응답에는 RFC 3834 의 Auto-Submitted: auto-replied (와 Microsoft 용 X-Auto-Response-Suppress) 를 붙입니다.
* - 전달 메일에는 X-Ari-Auto-Forwarded 헤더를 붙입니다. 두 메일함이 서로를 전달 대상으로 두어도 한 번만 전달되고 멈춥니다.
* - 받은 메일에 Auto-Submitted(no 제외)·Precedence bulk/list/junk·List-Id 가 있거나 발신자가 mailer-daemon/no-reply 류이면 자동 응답하지 않습니다.
* - 72시간보다 오래된 메일(동기화가 뒤늦게 가져온 옛 메일)에는 아무것도 하지 않습니다.
*
* 처리 기록은 auto_actions 테이블(message_id + kind 가 기본 키)에 남깁니다. 먼저 'pending' 행을 넣어 자리를 잡기 때문에
* 웹훅과 동기화가 같은 메일을 거의 동시에 처리해도 전달·응답이 두 번 나가지 않습니다. 결과는 메일 상세의 "자동 처리" 줄에 보입니다.
*/
import { sendMail } from './providers/index.mjs';
import { mailboxUser, readPrefs } from './auth.mjs';
import { now, uid, clean, addresses, address, isEmail } from './util.mjs';
import { sanitizeHtml, textToHtml } from './sanitize.mjs';
// store.mjs 와는 서로 들여옵니다(store 가 저장 직후 autoProcess 를 부름). ESM 은 함수 선언을 호출 시점에 찾으므로 순환이어도 문제없습니다.
import { buildStoredEml, storeRaw, setFlag } from './store.mjs';
import { monthlyBlocked } from './limits.mjs';
/** 이보다 오래된 수신 메일은 자동 처리하지 않습니다(72시간). 동기화가 옛 메일을 뒤늦게 가져올 때 뜬금없는 전달·응답이 나가는 것을 막습니다. */
export const STALE_MS = 72 * 3600 * 1000;
/** 같은 상대에게 자동 응답을 다시 보내기까지의 간격(24시간). */
export const REPLY_INTERVAL_MS = 24 * 3600 * 1000;
/** 전달 메일에 붙이는 표시 헤더. 받은 메일에 이 헤더가 있으면 다시 전달하지 않습니다(전달 고리 방지). 값은 전달한 메일함 주소입니다. */
export const FORWARD_MARKER = 'X-Ari-Auto-Forwarded';
/** 전달 한 통에 넣는 첨부 바이트 합계 상한. 발송 서비스 한도(40MB)와 base64 부풀림을 고려해 25MB 로 둡니다. 넘는 첨부는 빼고 기록에 남깁니다. */
const FORWARD_ATTACH_LIMIT = 25 * 1024 * 1024;
/**
* 자동 응답이 지금 동작하는 상태인지. 설정이 켜져 있고 메시지가 있어야 하며, 기한 없음(noLimit)이면 항상,
* 아니면 시작(start, 비우면 바로)~종료(end) 사이일 때만 true. 설정 화면의 "작동 중" 표시와 수신 처리가 같은 규칙을 씁니다.
* @param {object} a prefs.autoReply
* @param {number} [at] 판단 시각(ms). 기본은 지금. 테스트에서 기간 경계를 확인하는 데 씁니다.
*/
export function autoReplyActive(a, at = Date.now()) {
if (!a || !a.enabled || !String(a.message || '').trim()) return false;
if (a.noLimit) return true;
const start = a.start ? Date.parse(a.start) : -Infinity, end = a.end ? Date.parse(a.end) : NaN;
if (!Number.isFinite(end)) return false; // 기한 제한인데 종료 시각이 없으면(저장 검사를 거치지 않은 옛 값) 동작하지 않습니다.
return at >= start && at <= end;
}
/** headers_json 객체에서 이름을 대소문자 구분 없이 찾습니다. 값이 배열이면 첫 값. 없으면 ''. */
function header(headers, name) {
const key = Object.keys(headers || {}).find((k) => k.toLowerCase() === name.toLowerCase());
const v = key ? headers[key] : ''; return String(Array.isArray(v) ? v[0] : v ?? '').trim();
}
/** "홍길동 <hong@x>" 에서 표시 이름(홍길동)만. 이름이 없으면 주소의 @ 앞부분. */
function displayName(sender) {
const s = String(sender || '').trim(); const m = s.match(/^"?(.*?)"?\s*<[^<>]+>$/);
return (m && m[1] ? m[1] : s.split('@')[0]).replace(/[\r\n"<>]/g, '').trim();
}
/**
* 자동 발송된 메일(다른 서버의 자동 응답·알림·목록 메일 등)인지 판단해 이유를 돌려줍니다. 사람이 보낸 메일이면 null.
* 'auto-submitted' Auto-Submitted 가 no 가 아님(RFC 3834) / 'forwarded' 우리 전달 표시 / 'bulk' Precedence bulk·list·junk·auto_reply
* 'list' 메일링 리스트 헤더 / 'suppressed' X-Auto-Response-Suppress(Microsoft) / 'noreply' 발신 주소가 mailer-daemon·no-reply 류
* @param {object} mail messages 행
* @param {object} headers headers_json 을 푼 객체
*/
export function automatedReason(mail, headers) {
const auto = header(headers, 'Auto-Submitted').toLowerCase();
if (auto && auto !== 'no') return 'auto-submitted';
if (header(headers, FORWARD_MARKER)) return 'forwarded';
if (/^(bulk|list|junk|auto_reply)$/i.test(header(headers, 'Precedence'))) return 'bulk';
if (header(headers, 'List-Id') || header(headers, 'List-Unsubscribe')) return 'list';
if (/\b(all|autoreply|oof)\b/i.test(header(headers, 'X-Auto-Response-Suppress'))) return 'suppressed';
const from = address(mail.sender);
if (!isEmail(from) || /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounces?|notifications?)([+@.-]|$)/i.test(from)) return 'noreply';
return null;
}
/** auto_actions 에 처리 중(pending) 행을 넣어 자리를 잡습니다. 이미 행이 있으면(다른 경로가 먼저 처리) false. */
async function claim(env, mail, kind) {
const r = await env.DB.prepare("INSERT OR IGNORE INTO auto_actions(message_id,kind,mailbox,target,status,created_at) VALUES(?,?,?,'','pending',?)").bind(mail.id, kind, mail.mailbox, now()).run();
return r.meta.changes > 0;
}
async function finish(env, mail, kind, { status, target = '', detail = null, sentMessageId = null }) {
await env.DB.prepare('UPDATE auto_actions SET status=?,target=?,detail=?,sent_message_id=?,created_at=? WHERE message_id=? AND kind=?').bind(status, target, detail ? String(detail).slice(0, 300) : null, sentMessageId, now(), mail.id, kind).run();
return { kind, status, target, detail };
}
/**
* 받은 메일 한 통에 메일함 설정에 따라 전달·자동 응답을 수행합니다. store.ingestReceived 가 저장 직후에 부릅니다.
* 예외는 밖으로 던지지 않고 기록(failed)만 남깁니다 — 자동 처리 실패가 수신 저장 자체를 실패로 만들면 안 됩니다.
* @param {string} messageId 저장한 메일 id
* @returns {Promise<{forward?:object, reply?:object}|null>} 작업별 결과(테스트·로그용). 설정이 모두 꺼져 있으면 null.
*/
export async function autoProcess(env, messageId) {
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(messageId).first();
if (!mail || mail.direction !== 'inbound') return null;
const user = await mailboxUser(env, mail.mailbox); if (!user) return null;
const prefs = await readPrefs(env, user);
const wantForward = prefs.forward.enabled && addresses(prefs.forward.to).length > 0, wantReply = autoReplyActive(prefs.autoReply);
if (!wantForward && !wantReply) return null;
let headers = {}; try { headers = JSON.parse(mail.headers_json || '{}'); } catch { headers = {}; }
const stale = Date.now() - Date.parse(mail.received_at) > STALE_MS;
const out = {};
if (wantForward) out.forward = await runForward(env, mail, user, prefs.forward, headers, stale);
if (wantReply) out.reply = await runReply(env, mail, user, prefs.autoReply, headers, stale);
return out;
}
/**
* 메일 전달. 저장된 원본 HTML(정제 전, 인라인 이미지 cid 포함)·텍스트·첨부를 그대로 실어 지정 주소로 보냅니다.
* 건너뛰는 경우: 우리 전달 표시가 있는 메일(loop), 전달 대상이 보낸 메일(self: 다시 돌려보내지 않음), 72시간 지난 메일(stale).
*/
async function runForward(env, mail, user, forward, headers, stale) {
const kind = 'forward';
const targets = addresses(forward.to).filter((a) => isEmail(a) && a !== mail.mailbox);
if (!targets.length) return { kind, status: 'skipped', detail: 'no_target' };
if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' };
const sender = address(mail.sender);
if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' });
if (header(headers, FORWARD_MARKER)) return finish(env, mail, kind, { status: 'skipped', detail: 'loop' });
if (targets.includes(sender)) return finish(env, mail, kind, { status: 'skipped', detail: 'self' });
// 월간 수발신 통수 제한(0.9.0, 시스템 설정)에 이르렀으면 전달도 보내지 않습니다(전달 한 통도 발신으로 셈).
if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', detail: 'limit' });
try {
const rows = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(mail.id).all()).results;
// 첨부는 합계 상한까지만 싣습니다. 상한을 넘겨 뺀 첨부 이름은 기록에 남겨 상세 화면에서 알 수 있게 합니다.
let total = 0; const files = [], dropped = [];
for (const a of rows) { if (!a.bytes) { dropped.push(a.filename); continue; } if (total + a.bytes.length > FORWARD_ATTACH_LIMIT) { dropped.push(a.filename); continue; } total += a.bytes.length; files.push(a); }
const html = mail.original_html || mail.html || '';
const payload = {
// 표시 이름에 괄호와 @ 가 들어가므로 따옴표로 묶습니다(RFC 5322: 괄호는 주석, @ 는 따옴표 없는 이름에 쓸 수 없음).
from: `"${displayName(mail.sender) || sender} (via ${mail.mailbox})" <${mail.mailbox}>`, to: targets,
subject: mail.subject || '(no subject)', text: mail.text || '', ...(html ? { html } : {}),
// 전달받은 사람이 "답장" 을 누르면 원래 보낸 사람에게 가도록 합니다(우리 메일함이 아니라).
...(isEmail(sender) ? { reply_to: [mail.sender] } : {}),
headers: { [FORWARD_MARKER]: mail.mailbox },
};
if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(a.content_id ? { content_id: a.content_id } : {}) }));
// 멱등 키: 같은 메일을 다시 처리하게 되더라도 발송 서비스가 한 번만 보냅니다.
const result = await sendMail(env, payload, 'auto-forward-' + mail.id);
return finish(env, mail, kind, { status: 'sent', target: targets.join(', '), detail: (result.id || '') + (dropped.length ? ' dropped:' + dropped.join(',') : '') });
} catch (e) {
console.error('메일 전달 실패', mail.id, mail.mailbox, '→', targets.join(','), e.message, e.providerMessage || '');
return finish(env, mail, kind, { status: 'failed', target: targets.join(', '), detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') });
}
}
/**
* 자동 응답. Reply-To(없으면 From)에게 설정한 제목·메시지로 회신하고 보낸 메일함에 저장합니다.
* 건너뛰는 경우: 자동 발송 메일(automatedReason), 72시간 지난 메일(stale), 같은 상대에게 24시간 안에 이미 보냄(recent), 회신 주소 없음(no_address).
*/
async function runReply(env, mail, user, autoReply, headers, stale) {
const kind = 'reply';
let replyTo = []; try { replyTo = JSON.parse(mail.reply_to_json || '[]'); } catch { replyTo = []; }
const target = addresses(replyTo)[0] || address(mail.sender);
if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' };
if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' });
if (!isEmail(target)) return finish(env, mail, kind, { status: 'skipped', detail: 'no_address' });
const reason = automatedReason(mail, headers);
if (reason) return finish(env, mail, kind, { status: 'skipped', target, detail: reason });
if (target === mail.mailbox) return finish(env, mail, kind, { status: 'skipped', target, detail: 'self' });
const recent = await env.DB.prepare("SELECT created_at FROM auto_actions WHERE mailbox=? AND kind='reply' AND target=? AND status='sent' AND created_at>? AND message_id<>? LIMIT 1").bind(mail.mailbox, target, new Date(Date.now() - REPLY_INTERVAL_MS).toISOString(), mail.id).first();
if (recent) return finish(env, mail, kind, { status: 'skipped', target, detail: 'recent' });
if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', target, detail: 'limit' });
try {
const original = String(mail.subject || '').trim();
const subject = clean(autoReply.subject, 200) || (/^re:/i.test(original) ? original : 'Re: ' + original).trim() || 'Re:';
const text = String(autoReply.message || '').trim(), content = sanitizeHtml(textToHtml(text));
const fromName = (user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang').replace(/[\r\n"<>]/g, '').trim();
const threadHeaders = mail.message_id ? { 'In-Reply-To': mail.message_id, References: mail.message_id } : {};
const payload = { from: `${fromName} <${mail.mailbox}>`, to: [target], subject, text, html: content.html, headers: { ...threadHeaders, 'Auto-Submitted': 'auto-replied', 'X-Auto-Response-Suppress': 'All' } };
const result = await sendMail(env, payload, 'auto-reply-' + mail.id);
// 보낸 메일함에 남깁니다(사용자가 무엇이 나갔는지 볼 수 있게). 원본(.eml)은 저장한 부품으로 재구성합니다.
const id = uid(), stamp = now();
await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,subject,html,text,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,'web','sent',?,?,?,?,?,?)`)
.bind(id, result.id || null, payload.from, JSON.stringify([target]), subject, content.html, text, JSON.stringify(payload.headers), stamp, stamp, stamp, stamp, stamp, mail.mailbox).run();
await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built');
await setFlag(env, mail.id, '\\Answered', true); // 원래 메일에 "답장함" 표시(IMAP \Answered 와 공유)
return finish(env, mail, kind, { status: 'sent', target, detail: result.id || null, sentMessageId: id });
} catch (e) {
console.error('자동 응답 실패', mail.id, mail.mailbox, '→', target, e.message, e.providerMessage || '');
return finish(env, mail, kind, { status: 'failed', target, detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') });
}
}
/** 메일 상세 화면용: 이 메일에 한 자동 처리 기록. */
export async function autoActionsOf(env, messageId) {
return (await env.DB.prepare('SELECT kind,target,status,detail,sent_message_id,created_at FROM auto_actions WHERE message_id=? ORDER BY kind').bind(messageId).all()).results;
}
+274
View File
@@ -0,0 +1,274 @@
/**
* 설정 파일(config.json) 모듈 — 1.0.0 에서 환경 파일(gateway.env)과 분리한 "운영 설정".
*
* 왜 분리했나
* 예전에는 Resend API 키·웹훅 비밀키·발신 표시 이름 같은 값이 모두 systemd 환경 파일(gateway.env)에 있었습니다.
* 그 값들은 처음 배포할 때는 알 수 없거나(키를 아직 만들지 않음) 배포 자체에는 필요하지 않은데, 비어 있으면 운영이 안 되고
* 채우려면 root 로 파일을 고치고 서비스를 재시작해야 했습니다. 이제 "배포에 꼭 필요한 값"(DB 경로·포트·메일함·로그인 방식·시스템 제한)만
* 환경 파일에 남기고, 나머지는 데이터 디렉터리의 config.json 에 두어 설치가 끝난 뒤 웹(초기 설정 페이지 / 관리자 페이지)에서 채웁니다.
*
* 우선순위와 호환(요구사항 5: 기존 운영 유지)
* - 설정 파일에 있는 값이 환경 변수보다 우선합니다. 설정 파일이 없으면(처음 1.0.0 으로 올린 서버) 환경 변수에 있던 값을 그대로
* 읽어 설정 파일을 만듭니다(가져오기). 그래서 두 운영 서버의 환경 파일을 바꾸지 않아도 동작이 그대로이고, 값은 설정 파일에 보관됩니다.
* - 가져오기 뒤에는 설정 파일이 기준입니다(파일에 빈 값으로 저장하면 환경 변수 값이 되살아나지 않음). 파일에 아예 없는 키만 환경 변수를 봅니다.
* - 코드의 나머지 부분은 예전처럼 env.RESEND_API_KEY 같은 환경 변수 이름으로 값을 읽습니다. applyConfig() 가 설정 파일 값을 env 객체에
* 덮어쓰므로 모듈들이 바뀌지 않았고, 저장 즉시(재시작 없이) 새 값이 쓰입니다.
*
* 파일 위치: CONFIG_FILE 환경 변수, 없으면 DB 파일 옆의 config.json(예: /srv/<사이트>/data/config.json). 소유자만 읽습니다(0600, 비밀키 포함).
* 초기 설정 페이지의 접속 토큰도 같은 디렉터리의 setup-token 파일입니다.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { generateKeyPairSync } from 'node:crypto';
import { secret } from './util.mjs';
/** 설정 파일 형식 버전. 구조가 바뀌면 올리고 loadConfig 에서 변환합니다. */
export const CONFIG_FORMAT = 1;
/**
* 설정 항목 정의.
* key : config.json 의 키이자(env 가 있으면) 코드가 읽는 환경 변수 이름
* env : 이 항목이 대신하는 환경 변수(가져오기와 applyConfig 대상). 없으면 설정 파일 전용 항목
* group : 화면의 묶음 — mail(수·발신 방식) resend smtp haraka site setup
* type : text | secret(화면에 가린 채 보임) | select | number | bool
* hidden : 화면 폼에 직접 보이지 않는 항목(DKIM 키·내부 토큰처럼 단추로 만들거나 자동 생성)
* default: 값이 없을 때의 기본값(applyConfig 는 기본값을 env 에 쓰지 않고, 읽는 쪽 코드의 기본값에 맡깁니다)
*/
export const CONFIG_SCHEMA = [
// 수·발신 방식(요구사항 1·2): inbound = 받는 길, outbound = 보내는 길. 조합은 자유입니다(예: 수신 Haraka + 발신 Resend).
{ key: 'inbound', group: 'mail', type: 'select', options: ['resend', 'haraka'], default: 'resend' },
{ key: 'outbound', group: 'mail', type: 'select', options: ['resend', 'haraka', 'smtp'], default: 'resend' },
// Resend
{ key: 'RESEND_API_KEY', env: 'RESEND_API_KEY', group: 'resend', type: 'secret' },
{ key: 'RESEND_WEBHOOK_SECRET', env: 'RESEND_WEBHOOK_SECRET', group: 'resend', type: 'secret' },
// Resend 도메인 자동 설정(1.0.5): 발송 지역(수신 MX 값도 지역에 따라 다름). RESEND_DOMAIN_ID / RESEND_WEBHOOK_ID 는 자동 설정이 찾거나 만든 id(화면에는 숨김).
{ key: 'RESEND_REGION', env: 'RESEND_REGION', group: 'resend', type: 'select', options: ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'], default: 'ap-northeast-1' },
{ key: 'RESEND_DOMAIN_ID', env: 'RESEND_DOMAIN_ID', group: 'resend', type: 'text', hidden: true },
{ key: 'RESEND_WEBHOOK_ID', env: 'RESEND_WEBHOOK_ID', group: 'resend', type: 'text', hidden: true },
// DNS 모듈(1.0.5, 요구사항 1): none 이면 레코드를 보여 주기만 하고, google / route53 이면 자동으로 적용합니다. 자격 증명은 비밀값.
{ key: 'DNS_PROVIDER', env: 'DNS_PROVIDER', group: 'dns', type: 'select', options: ['none', 'google', 'route53'], default: 'none' },
{ key: 'DNS_GOOGLE_CREDENTIALS', env: 'DNS_GOOGLE_CREDENTIALS', group: 'dns', type: 'secret', multiline: true },
{ key: 'DNS_GOOGLE_PROJECT', env: 'DNS_GOOGLE_PROJECT', group: 'dns', type: 'text' },
{ key: 'DNS_GOOGLE_ZONE', env: 'DNS_GOOGLE_ZONE', group: 'dns', type: 'text' },
{ key: 'DNS_AWS_ACCESS_KEY_ID', env: 'DNS_AWS_ACCESS_KEY_ID', group: 'dns', type: 'text' },
{ key: 'DNS_AWS_SECRET_ACCESS_KEY', env: 'DNS_AWS_SECRET_ACCESS_KEY', group: 'dns', type: 'secret' },
{ key: 'DNS_AWS_HOSTED_ZONE_ID', env: 'DNS_AWS_HOSTED_ZONE_ID', group: 'dns', type: 'text' },
// 외부 SMTP 중계 서버(outbound=smtp)
{ key: 'SMTP_RELAY_HOST', env: 'SMTP_RELAY_HOST', group: 'smtp', type: 'text' },
{ key: 'SMTP_RELAY_PORT', env: 'SMTP_RELAY_PORT', group: 'smtp', type: 'number', default: 587 },
{ key: 'SMTP_RELAY_SECURITY', env: 'SMTP_RELAY_SECURITY', group: 'smtp', type: 'select', options: ['starttls', 'tls', 'none'], default: 'starttls' },
{ key: 'SMTP_RELAY_USER', env: 'SMTP_RELAY_USER', group: 'smtp', type: 'text' },
{ key: 'SMTP_RELAY_PASS', env: 'SMTP_RELAY_PASS', group: 'smtp', type: 'secret' },
// 내장 Haraka(inbound=haraka 또는 outbound=haraka 일 때 haraka/run.mjs 가 이 값으로 Haraka 설정을 만듭니다)
{ key: 'HARAKA_PORT', env: 'HARAKA_PORT', group: 'haraka', type: 'number', default: 25 },
{ key: 'HARAKA_RELAY_PORT', env: 'HARAKA_RELAY_PORT', group: 'haraka', type: 'number', default: 2525 },
{ key: 'HARAKA_HOSTNAME', env: 'HARAKA_HOSTNAME', group: 'haraka', type: 'text' },
{ key: 'HARAKA_DKIM_SELECTOR', env: 'HARAKA_DKIM_SELECTOR', group: 'haraka', type: 'text', default: 'arimail' },
{ key: 'HARAKA_DKIM_PRIVATE', env: 'HARAKA_DKIM_PRIVATE', group: 'haraka', type: 'secret', hidden: true },
{ key: 'HARAKA_DKIM_PUBLIC', env: 'HARAKA_DKIM_PUBLIC', group: 'haraka', type: 'text', hidden: true },
// Haraka 플러그인 ↔ 게이트웨이 사이의 공유 비밀(자동 생성). 로컬 HTTP 로 넘어오는 수신 메일이 정말 Haraka 에서 온 것인지 확인합니다.
{ key: 'HARAKA_TOKEN', env: 'HARAKA_TOKEN', group: 'haraka', type: 'secret', hidden: true },
// 사이트 정보(배포에는 필요 없고 운영 중 바꿀 수 있는 값)
{ key: 'MAIL_FROM_NAME', env: 'MAIL_FROM_NAME', group: 'site', type: 'text' },
{ key: 'SITE_TITLE', env: 'SITE_TITLE', group: 'site', type: 'text' },
{ key: 'MAILBOX_OWNER_LOGIN', env: 'MAILBOX_OWNER_LOGIN', group: 'site', type: 'text' },
{ key: 'STATS_TIMEZONE', env: 'STATS_TIMEZONE', group: 'site', type: 'text', default: 'Asia/Seoul' },
{ key: 'SYNC_INTERVAL_MS', env: 'SYNC_INTERVAL_MS', group: 'site', type: 'number', default: 3600000 },
// 사용량 API(1.0.9, 요구사항 1): 외부 관리 시스템(웹메이커)이 /api/usage 를 부를 때 쓰는 비밀 키(기본 도메인 설정에만, 화면의 단추로 생성·교체·삭제)와 접속 허용 IP/CIDR 목록(비우면 제한 없음).
{ key: 'USAGE_API_KEY', env: 'USAGE_API_KEY', group: 'usage', type: 'secret', hidden: true },
{ key: 'USAGE_API_ALLOW', env: 'USAGE_API_ALLOW', group: 'usage', type: 'text' },
// 초기 설정을 마쳤다는 표시(초기 설정 페이지의 "완료" 단추). 화면 안내용이며 동작에는 영향이 없습니다.
{ key: 'SETUP_DONE', group: 'setup', type: 'bool', default: false },
];
const BY_KEY = new Map(CONFIG_SCHEMA.map((item) => [item.key, item]));
/** 설정 파일 경로: CONFIG_FILE, 없으면 DB 파일 옆의 config.json. */
export function configFile(env) {
return env.CONFIG_FILE || join(dirname(env.DB_FILE || join(process.cwd(), 'data', 'mail-gateway.sqlite')), 'config.json');
}
/** 설정 파일과 같은 디렉터리의 보조 파일(setup-token, haraka-status.json). */
export const configDir = (env) => dirname(configFile(env));
function readJsonFile(file) {
try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
}
/** 임시 파일에 쓰고 이름을 바꿔 중간에 끊겨도 반쪽 파일이 남지 않게 합니다. 비밀키가 들어 있으므로 0600. */
function writeJsonFile(file, data) {
mkdirSync(dirname(file), { recursive: true });
const tmp = file + '.tmp';
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(tmp, 0o600); } catch { /* 권한을 못 바꾸는 파일 시스템이면 그대로 둡니다 */ }
renameSync(tmp, file);
}
/**
* 값 하나를 항목 형식에 맞게 다듬습니다. 잘못된 값은 Error('validation') 를 던지며 field 에 키를 넣습니다.
* number 는 0 이상의 정수, select 는 목록 안의 값, bool 은 true/false/'1'/'0'. 빈 문자열은 "지움" 으로 보고 '' 로 둡니다.
*/
export function coerceValue(item, value) {
const fail = () => { const e = new Error('validation'); e.field = item.key; throw e; };
if (value === null || value === undefined) return '';
if (item.type === 'bool') { if (typeof value === 'boolean') return value; if (['1', 'true', 'on'].includes(String(value))) return true; if (['', '0', 'false', 'off'].includes(String(value))) return false; fail(); }
const s = String(value).trim();
if (s === '') return '';
if (item.type === 'number') { if (!/^\d{1,12}$/.test(s)) fail(); return Number(s); }
if (item.type === 'select') { if (!item.options.includes(s)) fail(); return s; }
if (s.length > 20000 || /[\0]/.test(s)) fail();
return s;
}
/**
* 환경 변수에서 설정 파일을 처음 만듭니다(1.0.0 으로 올린 기존 서버). env 에 있는 항목은 모두 그대로 복사하고,
* 수·발신 방식은 예전 동작과 같은 resend 로 둡니다. 가져온 사실은 _meta 에 남겨 화면이 "환경 파일에서 가져옴" 을 보여 줄 수 있습니다.
*/
function importFromEnv(env) {
const cfg = { _meta: { format: CONFIG_FORMAT, createdAt: new Date().toISOString(), importedFromEnv: [] } };
for (const item of CONFIG_SCHEMA) {
if (!item.env || env[item.env] === undefined || env[item.env] === '') continue;
try { cfg[item.key] = coerceValue(item, env[item.env]); cfg._meta.importedFromEnv.push(item.key); } catch { /* 형식이 안 맞는 옛 값은 건너뜁니다 */ }
}
cfg.inbound = 'resend'; cfg.outbound = 'resend';
return cfg;
}
/**
* 설정 파일을 읽어(없으면 환경 변수에서 만들어) env 에 적용합니다. 서버 시작 때 한 번 부릅니다.
* 내부 토큰(HARAKA_TOKEN)이 없으면 만들어 저장합니다. 파일을 쓸 수 없는 환경(읽기 전용)이면 경고만 남기고 메모리에서만 동작합니다.
* @returns {object} 적용된 설정(env.CONFIG 와 같은 객체)
*/
export function loadConfig(env) {
const file = configFile(env);
let cfg = readJsonFile(file), created = false;
if (!cfg || typeof cfg !== 'object') { cfg = importFromEnv(env); created = true; }
if (!cfg._meta) cfg._meta = { format: CONFIG_FORMAT };
if (!cfg.HARAKA_TOKEN) { cfg.HARAKA_TOKEN = secret(32); created = true; }
if (created) { try { writeJsonFile(file, cfg); } catch (e) { console.warn(` ⚠️ 설정 파일을 쓸 수 없습니다(${file}): ${e.message} — 환경 변수 값으로 동작합니다`); } }
applyConfig(env, cfg);
env.CONFIG_CREATED = created;
return cfg;
}
/**
* 설정 값을 env 에 적용합니다. env 를 쓰는 다른 모듈(Resend 호출·발신 표시 이름·시간대 등)은 바뀌지 않았으므로
* 저장 즉시 새 값이 쓰입니다. 파일에 있는 키만 덮어쓰고(빈 값 포함), 파일에 없는 키는 환경 변수 값을 그대로 둡니다.
* MAIL_INBOUND / MAIL_OUTBOUND 는 providers/index.mjs 가 읽는 수·발신 방식입니다.
*/
export function applyConfig(env, cfg) {
for (const item of CONFIG_SCHEMA) {
if (!item.env || !(item.key in cfg)) continue;
const v = cfg[item.key];
env[item.env] = v === null || v === undefined ? '' : String(v);
}
env.MAIL_INBOUND = BY_KEY.get('inbound').options.includes(cfg.inbound) ? cfg.inbound : 'resend';
env.MAIL_OUTBOUND = BY_KEY.get('outbound').options.includes(cfg.outbound) ? cfg.outbound : 'resend';
env.CONFIG = cfg;
env.CONFIG_VERSION = (env.CONFIG_VERSION || 0) + 1;
}
/**
* 설정 일부를 바꿔 저장하고 바로 적용합니다(초기 설정 페이지·관리자 페이지의 저장 단추).
* @param {object} patch { 키: 값 } — 알 수 없는 키는 무시, hidden 항목은 allowHidden 일 때만(내부 동작용)
* @returns {object} publicConfig(env)
*/
export function saveConfig(env, patch, { allowHidden = false } = {}) {
const cfg = { ...(env.CONFIG || readJsonFile(configFile(env)) || {}) };
for (const [key, value] of Object.entries(patch || {})) {
const item = BY_KEY.get(key); if (!item || (item.hidden && !allowHidden)) continue;
cfg[key] = coerceValue(item, value);
}
cfg._meta = { ...(cfg._meta || {}), format: CONFIG_FORMAT, updatedAt: new Date().toISOString() };
writeJsonFile(configFile(env), cfg);
applyConfig(env, cfg);
if (typeof env.ON_CONFIG_CHANGE === 'function') { try { env.ON_CONFIG_CHANGE(cfg); } catch (e) { console.error('설정 적용 알림 실패', e.message); } }
return publicConfig(env);
}
/** 비밀값은 앞뒤 일부만 보여 줍니다(있는지·어느 키인지만 확인할 수 있게). 짝수 길이 보장 없이 짧은 값은 전부 가립니다. */
export function maskSecret(value) {
const s = String(value || ''); if (!s) return '';
if (s.length <= 8) return '••••••';
return s.slice(0, 3) + '••••' + s.slice(-4);
}
/**
* 화면에 주는 설정: 비밀값은 가리고, 각 키의 출처(config = 설정 파일, env = 환경 변수 폴백, default = 기본값)를 함께 줍니다.
* hidden 항목은 값 대신 있음/없음만 알립니다.
*/
export function publicConfig(env) {
const cfg = env.CONFIG || {}, values = {}, sources = {}, present = {};
for (const item of CONFIG_SCHEMA) {
const inFile = item.key in cfg && cfg[item.key] !== '' && cfg[item.key] !== undefined && cfg[item.key] !== null;
const raw = inFile ? cfg[item.key] : item.env && env[item.env] ? env[item.env] : item.default ?? '';
sources[item.key] = inFile ? 'config' : item.env && env[item.env] ? 'env' : 'default';
if (item.hidden) { present[item.key] = !!raw; continue; }
values[item.key] = item.type === 'secret' ? maskSecret(raw) : raw;
}
return {
values, sources, present, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend',
file: configFile(env), importedFromEnv: cfg._meta?.importedFromEnv || [], updatedAt: cfg._meta?.updatedAt || cfg._meta?.createdAt || null,
schema: CONFIG_SCHEMA.filter((i) => !i.hidden).map(({ key, group, type, options, default: d, multiline }) => ({ key, group, type, ...(options ? { options } : {}), ...(d !== undefined ? { default: d } : {}), ...(multiline ? { multiline: true } : {}) })),
};
}
/**
* 초기 설정 페이지 접속 토큰. 설정 파일 옆 setup-token 에 두고 없으면 만듭니다(0600).
* 배포 스크립트가 이 파일을 읽어 접속 주소(…/?token=…)를 출력합니다.
*/
export function setupToken(env) {
const file = join(configDir(env), 'setup-token');
try { const t = readFileSync(file, 'utf8').trim(); if (t) return t; } catch { /* 없으면 만듭니다 */ }
const token = secret(24);
try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, token + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-token 을 쓸 수 없습니다:', e.message); }
return token;
}
/**
* Haraka 설치 디렉터리(node_modules·config·plugins·queue): HARAKA_DIR, 없으면 데이터 디렉터리 옆의 haraka/. haraka/run.mjs 와 같은 규칙.
* 1.0.7: 추가 도메인의 env 는 DATA_DIR(기본 도메인의 데이터 디렉터리)을 가지므로 그 옆을 봅니다 — Haraka 는 서버에 하나뿐이고 모든 도메인이 함께 씁니다.
*/
export const harakaDir = (env) => env.HARAKA_DIR || join(env.DATA_DIR || configDir(env), '..', 'haraka');
/**
* Haraka 상태: haraka/run.mjs(별도 systemd 서비스)가 데이터 디렉터리의 haraka-status.json 에 주기적으로 쓰는 값 + 설치 여부.
* 서비스가 아예 없으면(파일 없음) status 는 null 이고 화면은 "서비스 미설치(deploy/haraka-apply.sh)" 를 안내합니다. 1.0.5 에서 setup.mjs 에서 이곳으로 옮김(provision.mjs 도 씀).
*/
export function harakaStatus(env) {
const dir = harakaDir(env); let status = null;
// 상태 파일은 기본 도메인의 데이터 디렉터리(DATA_DIR)에 하나만 있습니다(1.0.7: 추가 도메인도 같은 파일을 봄).
try { status = JSON.parse(readFileSync(join(env.DATA_DIR || configDir(env), 'haraka-status.json'), 'utf8')); } catch { status = null; }
const stale = status && Date.now() - Date.parse(status.updatedAt || 0) > 90000; // 30초마다 쓰므로 90초 넘게 조용하면 서비스가 멈춘 것
const wanted = env.MAIL_INBOUND === 'haraka' || env.MAIL_OUTBOUND === 'haraka';
return { dir, installed: existsSync(join(dir, 'node_modules', 'Haraka', 'package.json')), status, stale: !!stale, wanted };
}
/** 메일 도메인(MAILBOX 의 @ 뒤)과 Haraka 호스트 이름(HARAKA_HOSTNAME → PUBLIC_HOST). */
export const mailDomain = (env) => String(env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || '';
export const harakaHostname = (env) => String(env.HARAKA_HOSTNAME || env.PUBLIC_HOST || 'mail.' + mailDomain(env)).trim();
/**
* DKIM 키 쌍을 만들어 설정에 저장합니다(RSA 2048). 공개키는 DNS TXT 레코드에 넣고 비밀키는 Haraka 가 서명에 씁니다.
* 이미 키가 있으면 force 일 때만 바꿉니다(바꾸면 DNS 도 다시 올려야 하므로).
*/
export function generateDkim(env, { force = false } = {}) {
if (env.CONFIG?.HARAKA_DKIM_PRIVATE && !force) return dnsRecords(env);
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } });
saveConfig(env, { HARAKA_DKIM_PRIVATE: privateKey, HARAKA_DKIM_PUBLIC: Buffer.from(publicKey).toString('base64') }, { allowHidden: true });
return dnsRecords(env);
}
/**
* Haraka 로 직접 수·발신할 때 도메인 DNS 에 넣어야 하는 레코드(화면 안내용).
* MX : 받는 메일이 이 서버로 오게(수신 Haraka)
* SPF : 이 서버가 보낸 메일을 받는 쪽이 신뢰하게(발신 Haraka). Resend 를 함께 쓰면 include 를 더합니다.
* DKIM : 서명 공개키(키를 만든 뒤)
* DMARC: 권장 정책(선택)
*/
export function dnsRecords(env) {
const domain = mailDomain(env), host = harakaHostname(env), selector = env.HARAKA_DKIM_SELECTOR || 'arimail', pub = env.HARAKA_DKIM_PUBLIC || '';
const includeResend = env.MAIL_OUTBOUND === 'resend' ? ' include:amazonses.com' : '';
const records = [
{ kind: 'MX', name: domain, value: `10 ${host}.`, for: 'inbound' },
{ kind: 'TXT', name: domain, value: `v=spf1 mx a:${host}${includeResend} ~all`, for: 'outbound' },
...(pub ? [{ kind: 'TXT', name: `${selector}._domainkey.${domain}`, value: `v=DKIM1; k=rsa; p=${pub}`, for: 'outbound' }] : []),
{ kind: 'TXT', name: `_dmarc.${domain}`, value: 'v=DMARC1; p=none; adkim=r; aspf=r', for: 'outbound', optional: true },
];
return { domain, host, selector, hasDkim: !!pub, records };
}
+9
View File
@@ -0,0 +1,9 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, migrate } from './db.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(file);
console.log(` DB: ${file}`);
migrate(db, join(ROOT, 'migrations'));
db.close();
+62
View File
@@ -0,0 +1,62 @@
/**
* SQLite 어댑터. www 사이트(server/db.mjs)와 같은 모양(prepare/bind/first/all/run/batch)을 유지해
* 두 저장소의 코드를 서로 옮겨 쓸 수 있게 합니다. node:sqlite 는 Node 22.13+ 에서 플래그 없이 동작합니다.
*/
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, readdirSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
class Stmt {
constructor(db, sql, params = []) { this.db = db; this.sql = sql; this.params = params; }
bind(...params) { return new Stmt(this.db, this.sql, this.params.length ? this.params : params); }
#prep() { return this.db.prepare(this.sql); }
async first() { const row = this.#prep().get(...this.params); return row === undefined ? null : row; }
async all() { return { results: this.#prep().all(...this.params) }; }
async run() { const r = this.#prep().run(...this.params); return { success: true, meta: { changes: Number(r.changes) } }; }
}
export function openDb(file, { readOnly = false } = {}) {
if (file !== ':memory:' && !readOnly) mkdirSync(dirname(file), { recursive: true });
const db = new DatabaseSync(file, readOnly ? { readOnly: true } : {});
if (!readOnly) {
db.exec('PRAGMA foreign_keys = ON;');
if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA busy_timeout = 5000;'); }
} else if (file !== ':memory:') db.exec('PRAGMA busy_timeout = 5000;');
return db;
}
export function d1(db) {
let queue = Promise.resolve();
return {
prepare: (sql) => new Stmt(db, sql),
batch: (stmts) => {
const execute = async () => {
db.exec('BEGIN');
try { const out = []; for (const s of stmts) out.push(await s.run()); db.exec('COMMIT'); return out; }
catch (e) { db.exec('ROLLBACK'); throw e; }
};
const result = queue.then(execute, execute);
queue = result.catch(() => undefined);
return result;
},
_raw: db,
};
}
export function migrate(db, dir, { quiet = false } = {}) {
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
const done = new Set(db.prepare('SELECT name FROM schema_migrations').all().map((r) => r.name));
let applied = 0;
for (const f of readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()) {
if (done.has(f)) continue;
db.exec('BEGIN');
try {
db.exec(readFileSync(join(dir, f), 'utf8'));
db.prepare('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(f, new Date().toISOString());
db.exec('COMMIT'); applied++;
if (!quiet) console.log(` 적용: ${f}`);
} catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 ${f}: ${e.message}`); }
}
if (!quiet && !applied) console.log(' 적용할 마이그레이션 없음');
return applied;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* DNS 모듈 공통 도구(1.0.5): 이름 정규화, TXT 분할, 오류 객체, 공개 DNS 조회(반영 확인).
*/
import { Resolver } from 'node:dns/promises';
/** FQDN(끝에 점). 빈 값은 그대로. */
export const fqdn = (name) => { const s = String(name || '').trim().toLowerCase(); return !s ? s : s.endsWith('.') ? s : s + '.'; };
/** 끝 점 없는 소문자 이름(비교용). */
export const bare = (name) => fqdn(name).replace(/\.$/, '');
/** TXT 값을 255자 이하 조각으로 나눕니다(RFC 1035 문자열 한도). */
export const txtChunks = (value) => { const s = String(value ?? ''); const out = []; for (let i = 0; i < Math.max(1, s.length); i += 255) out.push(s.slice(i, i + 255)); return out; };
/** dns_* 코드를 가진 Error. detail 은 화면에 그대로 보입니다. */
export function dnsError(code, detail) { const e = new Error(code); e.detail = String(detail || '').slice(0, 300); return e; }
/**
* 공개 리졸버(Google 8.8.8.8, Cloudflare 1.1.1.1)로 레코드가 실제로 보이는지 확인합니다. 권위 서버가 아니라 "세상이 보는 값" 기준이라
* DNS 업체에 적용한 직후에는 아직 안 보일 수 있습니다(캐시·전파). 그 경우 state 'wait' 로 알립니다.
* @param {object} record { name, type, values[], priority? }
* @param {object} [o] resolver: 시험용 대체 객체({ resolveTxt, resolveMx, resolveCname, resolve4 })
* @returns {Promise<{ok:boolean, found:string[], error?:string}>}
*/
export async function lookup(record, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) {
const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })();
const name = bare(record.name);
try {
if (record.type === 'TXT') {
const found = (await r.resolveTxt(name)).map((chunks) => chunks.join(''));
const ok = record.values.every((v) => found.some((f) => f.replace(/\s+/g, ' ').trim() === String(v).replace(/\s+/g, ' ').trim()));
return { ok, found };
}
if (record.type === 'MX') {
const found = (await r.resolveMx(name)).map((m) => `${m.priority} ${bare(m.exchange)}`);
const want = record.values.map((v) => `${record.priority ?? 10} ${bare(v)}`);
const ok = want.every((w) => found.includes(w));
return { ok, found };
}
if (record.type === 'CNAME') { const found = (await r.resolveCname(name)).map(bare); return { ok: record.values.every((v) => found.includes(bare(v))), found }; }
if (record.type === 'A') { const found = await r.resolve4(name); return { ok: record.values.every((v) => found.includes(v)), found }; }
return { ok: false, found: [], error: 'unsupported_type' };
} catch (e) {
// ENOTFOUND/ENODATA = 아직 없음(전파 대기), 그 밖은 조회 실패
return { ok: false, found: [], error: e.code === 'ENOTFOUND' || e.code === 'ENODATA' ? 'not_found' : e.code || e.message };
}
}
/** 호스트 이름의 A 레코드와 그 IP 의 PTR(역방향)을 함께 봅니다(Haraka 발신 준비 점검용). */
export async function reverseCheck(hostname, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) {
const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })();
let ips = []; try { ips = await r.resolve4(bare(hostname)); } catch (e) { return { ok: false, ips: [], ptr: [], error: 'a_' + (e.code || 'failed') }; }
const ptr = []; for (const ip of ips) { try { ptr.push(...(await r.reverse(ip)).map(bare)); } catch { /* PTR 없음 */ } }
return { ok: ips.length > 0 && ptr.includes(bare(hostname)), ips, ptr };
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Google Cloud DNS 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 서비스 계정(JSON 키)으로 인증해 Cloud DNS REST API(v1)를 부릅니다.
*
* 인증: 서비스 계정 JSON 의 client_email / private_key 로 RS256 JWT 를 만들어 OAuth2 토큰(https://oauth2.googleapis.com/token)과 교환합니다.
* 범위는 ndev.clouddns.readwrite. 토큰은 만료 1분 전까지 재사용합니다. 서비스 계정에는 프로젝트의 "DNS 관리자" 역할이 필요합니다.
* 존 찾기: 프로젝트의 managedZones 목록에서 dnsName 이 도메인(또는 그 상위 도메인)과 같은 공개 존을 고릅니다(DNS_GOOGLE_ZONE 으로 고정 가능).
* 적용: changes API 에 additions/deletions 를 한 번에 넘깁니다(같은 이름·형식의 기존 rrset 은 deletions 로 빼고 새 값으로 넣음 = UPSERT).
* TXT 값은 따옴표로 감싸고 255자 단위로 나눕니다(DKIM 공개키처럼 긴 값). MX 는 "우선순위 호스트." 형식, CNAME/MX 의 호스트는 끝에 점을 붙입니다.
*
* 설정(config.json): DNS_GOOGLE_CREDENTIALS(서비스 계정 JSON 전체, 비밀), DNS_GOOGLE_PROJECT(비우면 JSON 의 project_id), DNS_GOOGLE_ZONE(비우면 자동).
* 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다.
*/
import { createSign } from 'node:crypto';
import { fqdn, txtChunks, dnsError } from './common.mjs';
const SCOPE = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite';
const tokenCache = new WeakMap(); // env.DB → { token, exp }
/** 서비스 계정 JSON 해석. 비어 있거나 모양이 틀리면 dns_not_configured. */
export function credentials(env) {
const raw = String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
if (!raw) throw dnsError('dns_not_configured', 'Google 서비스 계정 JSON 이 없습니다');
let json; try { json = JSON.parse(raw); } catch { throw dnsError('dns_bad_credentials', '서비스 계정 JSON 을 해석할 수 없습니다'); }
if (!json.client_email || !json.private_key) throw dnsError('dns_bad_credentials', '서비스 계정 JSON 에 client_email / private_key 가 없습니다');
const project = String(env.DNS_GOOGLE_PROJECT || json.project_id || '').trim();
if (!project) throw dnsError('dns_not_configured', '프로젝트 id 가 없습니다(DNS_GOOGLE_PROJECT)');
return { email: json.client_email, key: json.private_key, tokenUri: json.token_uri || 'https://oauth2.googleapis.com/token', project };
}
const b64url = (v) => Buffer.from(typeof v === 'string' ? v : JSON.stringify(v)).toString('base64url');
/** RS256 JWT 를 만들어 액세스 토큰을 받습니다(캐시). */
async function accessToken(env) {
const cached = tokenCache.get(env.DB || env); if (cached && cached.exp > Date.now() + 60000) return cached.token;
const c = credentials(env), now = Math.floor(Date.now() / 1000);
const unsigned = b64url({ alg: 'RS256', typ: 'JWT' }) + '.' + b64url({ iss: c.email, scope: SCOPE, aud: c.tokenUri, iat: now, exp: now + 3600 });
let signature; try { signature = createSign('RSA-SHA256').update(unsigned).sign(c.key, 'base64url'); } catch (e) { throw dnsError('dns_bad_credentials', '개인키로 서명하지 못했습니다: ' + e.message); }
const r = await (env.DNS_FETCH || fetch)(c.tokenUri, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: unsigned + '.' + signature }), signal: AbortSignal.timeout(20000) }).catch((e) => { throw dnsError('dns_unavailable', 'Google 인증 서버 연결 실패: ' + e.message); });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.access_token) throw dnsError(r.status === 400 || r.status === 401 ? 'dns_auth_failed' : 'dns_unavailable', `토큰 발급 실패 ${r.status} ${data.error_description || data.error || ''}`.trim());
tokenCache.set(env.DB || env, { token: data.access_token, exp: Date.now() + Number(data.expires_in || 3600) * 1000 });
return data.access_token;
}
/** Cloud DNS API 호출. 오류는 dns_* 코드로. */
async function api(env, path, method = 'GET', body) {
const token = await accessToken(env), c = credentials(env);
const r = await (env.DNS_FETCH || fetch)(`https://dns.googleapis.com/dns/v1/projects/${encodeURIComponent(c.project)}${path}`, { method, headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Cloud DNS 연결 실패: ' + e.message); });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw dnsError(r.status === 401 ? 'dns_auth_failed' : r.status === 403 ? 'dns_forbidden' : r.status === 404 ? 'dns_zone_not_found' : 'dns_unavailable', `Cloud DNS ${r.status} ${data.error?.message || ''}`.trim());
return data;
}
/**
* 도메인을 담당하는 존을 찾습니다. DNS_GOOGLE_ZONE 이 있으면 그 존을, 없으면 dnsName 이 도메인과 같거나 상위인 공개 존 중 가장 긴 것.
* @returns {Promise<{id:string, name:string, dnsName:string}>}
*/
export async function findZone(env, domain) {
const want = fqdn(domain);
if (env.DNS_GOOGLE_ZONE) { const z = await api(env, '/managedZones/' + encodeURIComponent(env.DNS_GOOGLE_ZONE)); return { id: z.name, name: z.name, dnsName: z.dnsName }; }
const list = (await api(env, '/managedZones')).managedZones || [];
const candidates = list.filter((z) => z.visibility !== 'private' && (want === z.dnsName || want.endsWith('.' + z.dnsName))).sort((a, b) => b.dnsName.length - a.dnsName.length);
if (!candidates.length) throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 존이 프로젝트에 없습니다`);
return { id: candidates[0].name, name: candidates[0].name, dnsName: candidates[0].dnsName };
}
/** 레코드 값 → Cloud DNS rrdatas 문자열. */
export function rrdata(record) {
if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' '));
if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`);
if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v));
return record.values.slice();
}
/** 존의 특정 이름·형식 rrset(없으면 null). */
async function existing(env, zone, name, type) {
const data = await api(env, `/managedZones/${encodeURIComponent(zone.id)}/rrsets?name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`);
return (data.rrsets || [])[0] || null;
}
/**
* 레코드들을 UPSERT 합니다(같은 이름·형식은 새 값으로 교체). 바뀐 것이 없으면 변경을 보내지 않습니다.
* @param {object[]} records { name, type, ttl, values[], priority? }
* @returns {Promise<{zone:string, changed:string[], unchanged:string[]}>}
*/
export async function upsert(env, zone, records) {
const additions = [], deletions = [], changed = [], unchanged = [];
for (const r of records) {
const want = { name: fqdn(r.name), type: r.type, ttl: Number(r.ttl || 300), rrdatas: rrdata(r) };
const cur = await existing(env, zone, r.name, r.type);
if (cur && JSON.stringify([...cur.rrdatas].sort()) === JSON.stringify([...want.rrdatas].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; }
if (cur) deletions.push({ name: cur.name, type: cur.type, ttl: cur.ttl, rrdatas: cur.rrdatas });
additions.push(want); changed.push(`${r.type} ${r.name}`);
}
if (additions.length) await api(env, `/managedZones/${encodeURIComponent(zone.id)}/changes`, 'POST', { additions, deletions });
return { zone: zone.name, changed, unchanged };
}
/** 연결 확인: 토큰을 받고 존을 찾아 봅니다. */
export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'google', zone: zone.name, dnsName: zone.dnsName, project: credentials(env).project }; }
+99
View File
@@ -0,0 +1,99 @@
/**
* DNS 모듈 선택과 "필요한 레코드" 계산(1.0.5, 요구사항 1·2).
*
* 역할
* - dnsProvider(env): 설정 DNS_PROVIDER(none | google | route53)에 따라 Google Cloud DNS / AWS Route 53 모듈을 고릅니다. none 이면 자동 적용 없이
* 레코드를 사용자에게 보여 주기만 합니다(요구사항 1: 인증 정보가 없으면 DNS 설정 정보를 제공).
* - requiredRecords(env, { resendDomain, existing }): 지금 설정(수·발신 방식, 도메인, Haraka 호스트 이름, DKIM, Resend 도메인의 요구 레코드)으로
* 도메인에 있어야 하는 레코드 목록을 만듭니다. 기존 SPF 가 있으면 우리 메커니즘만 끼워 넣어(mergeSpf) 다른 발송 서비스 설정을 깨지 않습니다.
* - applyRecords(env, records): 고른 모듈로 존을 찾고 UPSERT 합니다.
* - checkRecords(records): 공개 리졸버로 반영 여부를 확인합니다(common.mjs lookup).
*
* 레코드 모양: { name(FQDN, 끝 점 없음), type, ttl, values[], priority?, purpose(화면 설명), source('haraka'|'resend'), optional?(권장 사항 — 준비 완료 판정에서 제외, 자동 적용도 하지 않음) }
*/
import * as google from './google.mjs';
import * as route53 from './route53.mjs';
import { lookup, bare, dnsError } from './common.mjs';
import { inboundMode, outboundMode } from '../providers/index.mjs';
import { mailDomain, harakaHostname } from '../config.mjs';
import { resendDnsRecords } from '../providers/resend.mjs';
export const DNS_PROVIDERS = ['none', 'google', 'route53'];
const MODULES = { google, route53 };
/** 설정된 DNS 모듈 이름(모르는 값은 none). */
export const dnsProviderName = (env) => (DNS_PROVIDERS.includes(env.DNS_PROVIDER) ? env.DNS_PROVIDER : 'none');
/** 모듈 객체 또는 null(none). */
export const dnsProvider = (env) => MODULES[dnsProviderName(env)] || null;
/** 모듈이 쓸 인증 정보가 설정에 있는지(값의 유효성은 probe 로 확인). */
export function dnsConfigured(env) {
const p = dnsProviderName(env);
if (p === 'google') return !!String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
if (p === 'route53') return !!(String(env.DNS_AWS_ACCESS_KEY_ID || '').trim() && String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim());
return false;
}
/**
* 기존 SPF 레코드에 우리 메커니즘을 끼워 넣습니다. 예: "v=spf1 include:_spf.google.com ~all" + ["mx","a:mail.x.com"] → "v=spf1 include:_spf.google.com mx a:mail.x.com ~all".
* 이미 있는 메커니즘은 다시 넣지 않고, 끝의 all 지시(~all/-all/?all/+all)는 그대로 둡니다. 기존 값이 없으면 새로 만듭니다(기본 ~all).
*/
export function mergeSpf(existing, mechanisms) {
const cur = String(existing || '').trim();
if (!/^v=spf1\b/i.test(cur)) return `v=spf1 ${mechanisms.join(' ')} ~all`;
const parts = cur.split(/\s+/); const all = parts.findLast((p) => /^[-~?+]?all$/i.test(p));
const body = parts.filter((p) => !/^v=spf1$/i.test(p) && !/^[-~?+]?all$/i.test(p));
for (const m of mechanisms) if (!body.some((p) => p.toLowerCase() === m.toLowerCase())) body.push(m);
return ['v=spf1', ...body, all || '~all'].join(' ');
}
/**
* 도메인에 있어야 하는 레코드 목록.
* @param {object} [o]
* @param {object|null} [o.resendDomain] Resend 도메인 상세(records 포함). 수신 방식이 resend 가 아니면 그 안의 MX 는 뺍니다(루트 MX 는 한 가지만).
* @param {string[]} [o.existingSpf] 루트 TXT 중 기존 SPF 값(있으면 병합)
*/
export function requiredRecords(env, { resendDomain = null, existingSpf = null } = {}) {
const domain = mailDomain(env), host = bare(harakaHostname(env)), inbound = inboundMode(env), outbound = outboundMode(env), out = [];
if (inbound === 'haraka') out.push({ name: domain, type: 'MX', ttl: 300, values: [host], priority: 10, purpose: '받는 메일을 이 서버(Haraka)로', source: 'haraka' });
if (outbound === 'haraka') {
out.push({ name: domain, type: 'TXT', ttl: 300, values: [mergeSpf(existingSpf, ['mx', `a:${host}`])], purpose: 'SPF — 이 서버가 보낸 메일을 신뢰', source: 'haraka' });
if (env.HARAKA_DKIM_PUBLIC) out.push({ name: `${env.HARAKA_DKIM_SELECTOR || 'arimail'}._domainkey.${domain}`, type: 'TXT', ttl: 300, values: [`v=DKIM1; k=rsa; p=${env.HARAKA_DKIM_PUBLIC}`], purpose: 'DKIM 서명 공개키', source: 'haraka' });
out.push({ name: `_dmarc.${domain}`, type: 'TXT', ttl: 300, values: ['v=DMARC1; p=none; adkim=r; aspf=r'], purpose: 'DMARC 정책(권장, 자동 적용하지 않음)', source: 'haraka', optional: true });
}
if (resendDomain) for (const r of resendDnsRecords(resendDomain)) { if (r.type === 'MX' && inbound !== 'resend') continue; out.push(r); }
return out;
}
/**
* 모든(선택 아닌) 레코드의 반영 여부. 각 레코드에 check { ok, found[], error?, byProvider?, problem? } 를 붙여 돌려줍니다.
* 1.0.11: Resend 가 자기 레코드를 verified 라고 하면 우리 조회 값이 달라도 ok 로 봅니다(byProvider). Resend 가 받아들인 값(예: 예전 수신 MX
* inbound-smtp.<지역>.amazonaws.com)을 "틀렸다" 고 표시해 사용자가 멀쩡한 레코드를 바꾸게 하는 일을 막습니다. 반대로 Resend 가 failed 라고 해도
* 우리 조회가 맞으면 ok — Resend 의 레코드 상태는 마지막 검증 시점의 것이라 DNS 를 고친 뒤에는 오래된 값일 수 있습니다(재검증이 갱신).
* problem: 'wrong'(다른 값이 보임) / 'missing'(없음) — 화면이 "값을 바꾸세요" 와 "반영을 기다리세요" 를 구분하는 근거.
*/
export async function checkRecords(records, { resolver = null } = {}) {
const out = [];
for (const r of records) {
const check = await lookup(r, { resolver });
const byProvider = !check.ok && String(r.status || '') === 'verified';
out.push({ ...r, check: { ...check, ok: check.ok || byProvider, ...(byProvider ? { byProvider: true } : {}), problem: check.ok || byProvider ? null : check.found && check.found.length ? 'wrong' : 'missing' } });
}
return out;
}
/**
* 고른 DNS 모듈로 레코드를 적용합니다(optional 레코드는 뺌). 모듈이 없으면 dns_not_configured.
* @returns {Promise<{provider:string, zone:string, changed:string[], unchanged:string[]}>}
*/
export async function applyRecords(env, records) {
const mod = dnsProvider(env); if (!mod) throw dnsError('dns_not_configured', 'DNS 모듈이 설정되지 않았습니다(DNS_PROVIDER)');
const zone = await mod.findZone(env, mailDomain(env));
const result = await mod.upsert(env, zone, records.filter((r) => !r.optional));
return { provider: dnsProviderName(env), ...result };
}
/** 연결 확인(설정 화면 단추): 인증과 존 찾기. */
export async function probeDns(env) {
const mod = dnsProvider(env); if (!mod) return { ok: false, provider: 'none', error: 'dns_not_configured' };
try { return await mod.probe(env, mailDomain(env)); } catch (e) { return { ok: false, provider: dnsProviderName(env), error: e.message, detail: e.detail }; }
}
+107
View File
@@ -0,0 +1,107 @@
/**
* AWS Route 53 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 액세스 키로 SigV4 서명을 만들어 Route 53 REST API(2013-04-01, XML)를 부릅니다.
*
* 인증: DNS_AWS_ACCESS_KEY_ID / DNS_AWS_SECRET_ACCESS_KEY (IAM 사용자 또는 역할 키). 필요한 권한: route53:ListHostedZonesByName, GetHostedZone,
* ListResourceRecordSets, ChangeResourceRecordSets, GetChange. Route 53 은 글로벌 서비스라 서명 지역은 us-east-1 입니다.
* 존 찾기: ListHostedZonesByName 으로 도메인(또는 상위 도메인)과 이름이 같은 공개 호스팅 존을 고릅니다(DNS_AWS_HOSTED_ZONE_ID 로 고정 가능).
* 적용: ChangeResourceRecordSets 의 UPSERT 로 같은 이름·형식을 새 값으로 바꿉니다. TXT 는 따옴표 + 255자 분할, MX 는 "우선순위 호스트".
* XML 은 이 API 가 쓰는 단순한 모양만 다루므로 정규식으로 만들고 읽습니다(외부 XML 라이브러리 없음).
* 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다.
*/
import { createHash, createHmac } from 'node:crypto';
import { fqdn, txtChunks, dnsError } from './common.mjs';
const HOST = 'route53.amazonaws.com', REGION = 'us-east-1', SERVICE = 'route53';
const sha256 = (v) => createHash('sha256').update(v).digest('hex');
const hmac = (key, v, enc) => createHmac('sha256', key).update(v).digest(enc);
const esc = (v) => String(v).replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
const unesc = (v) => String(v).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
export function credentials(env) {
const id = String(env.DNS_AWS_ACCESS_KEY_ID || '').trim(), secret = String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim();
if (!id || !secret) throw dnsError('dns_not_configured', 'AWS 액세스 키가 없습니다');
return { id, secret };
}
/**
* SigV4 서명 요청. 쿼리 문자열은 미리 정규화(키 정렬·인코딩)해서 넘깁니다.
* @returns {Promise<{status:number, text:string}>}
*/
async function request(env, method, path, query = '', body = '') {
const c = credentials(env), now = new Date(), amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''), date = amzDate.slice(0, 8);
const payloadHash = sha256(body);
const headers = { host: HOST, 'x-amz-date': amzDate, 'x-amz-content-sha256': payloadHash, ...(body ? { 'content-type': 'application/xml' } : {}) };
const signedHeaders = Object.keys(headers).sort().join(';');
const canonical = [method, path.split('/').map((seg) => encodeURIComponent(decodeURIComponent(seg))).join('/'), query, ...Object.keys(headers).sort().map((k) => `${k}:${String(headers[k]).trim()}`), '', signedHeaders, payloadHash].join('\n');
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n');
const kSigning = hmac(hmac(hmac(hmac('AWS4' + c.secret, date), REGION), SERVICE), 'aws4_request');
const signature = hmac(kSigning, toSign, 'hex');
const authorization = `AWS4-HMAC-SHA256 Credential=${c.id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const r = await (env.DNS_FETCH || fetch)(`https://${HOST}${path}${query ? '?' + query : ''}`, { method, headers: { ...headers, Authorization: authorization }, body: body || undefined, signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Route 53 연결 실패: ' + e.message); });
const text = await r.text().catch(() => '');
if (!r.ok) {
const code = (text.match(/<Code>([^<]+)<\/Code>/) || [])[1] || String(r.status), message = unesc((text.match(/<Message>([^<]*)<\/Message>/) || [])[1] || '');
throw dnsError(/InvalidClientTokenId|SignatureDoesNotMatch|InvalidSignature|AuthFailure|UnrecognizedClient/.test(code) ? 'dns_auth_failed' : /AccessDenied|NotAuthorized/.test(code) ? 'dns_forbidden' : /NoSuchHostedZone/.test(code) ? 'dns_zone_not_found' : 'dns_unavailable', `Route 53 ${code} ${message}`.trim());
}
return { status: r.status, text };
}
/** 호스팅 존 찾기(DNS_AWS_HOSTED_ZONE_ID 가 있으면 그 존). 사설 존은 뺍니다. */
export async function findZone(env, domain) {
if (env.DNS_AWS_HOSTED_ZONE_ID) {
const id = String(env.DNS_AWS_HOSTED_ZONE_ID).replace(/^\/hostedzone\//, '');
const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(id)}`);
return { id, name: unesc((text.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), dnsName: unesc((text.match(/<Name>([^<]+)<\/Name>/) || [])[1] || '') };
}
const want = fqdn(domain); const labels = want.split('.').filter(Boolean);
// 상위 도메인까지 차례로: mail.example.com. → example.com. → com.
for (let i = 0; i < labels.length - 1; i++) {
const candidate = labels.slice(i).join('.') + '.';
const { text } = await request(env, 'GET', '/2013-04-01/hostedzonesbyname', `dnsname=${encodeURIComponent(candidate)}&maxitems=5`);
for (const block of text.match(/<HostedZone>[\s\S]*?<\/HostedZone>/g) || []) {
const name = unesc((block.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), id = ((block.match(/<Id>([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/hostedzone\//, '');
const priv = /<PrivateZone>true<\/PrivateZone>/.test(block);
if (name === candidate && !priv) return { id, name, dnsName: name };
}
}
throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 호스팅 존이 계정에 없습니다`);
}
/** 레코드 값 → Route 53 ResourceRecord Value 목록. */
export function values(record) {
if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' '));
if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`);
if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v));
return record.values.slice();
}
/** 존의 이름·형식 rrset(없으면 null). */
async function existing(env, zone, name, type) {
const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, `maxitems=1&name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`);
const block = (text.match(/<ResourceRecordSet>[\s\S]*?<\/ResourceRecordSet>/g) || [])[0]; if (!block) return null;
const n = unesc((block.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), t = (block.match(/<Type>([^<]+)<\/Type>/) || [])[1];
if (n.toLowerCase() !== fqdn(name).toLowerCase() || t !== type) return null; // maxitems=1 은 "그 이름부터" 돌려주므로 다른 레코드일 수 있음
return { name: n, type: t, ttl: Number((block.match(/<TTL>(\d+)<\/TTL>/) || [])[1] || 300), values: [...block.matchAll(/<Value>([^<]*)<\/Value>/g)].map((m) => unesc(m[1])) };
}
/**
* UPSERT. 바뀐 것이 없으면 요청을 보내지 않습니다.
* @returns {Promise<{zone:string, changed:string[], unchanged:string[], changeId?:string}>}
*/
export async function upsert(env, zone, records) {
const changes = [], changed = [], unchanged = [];
for (const r of records) {
const want = values(r), cur = await existing(env, zone, r.name, r.type);
if (cur && JSON.stringify([...cur.values].sort()) === JSON.stringify([...want].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; }
changes.push(`<Change><Action>UPSERT</Action><ResourceRecordSet><Name>${esc(fqdn(r.name))}</Name><Type>${esc(r.type)}</Type><TTL>${Number(r.ttl || 300)}</TTL><ResourceRecords>${want.map((v) => `<ResourceRecord><Value>${esc(v)}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet></Change>`);
changed.push(`${r.type} ${r.name}`);
}
if (!changes.length) return { zone: zone.name, changed, unchanged };
const body = `<?xml version="1.0" encoding="UTF-8"?><ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><ChangeBatch><Comment>AriMail mail records</Comment><Changes>${changes.join('')}</Changes></ChangeBatch></ChangeResourceRecordSetsRequest>`;
const { text } = await request(env, 'POST', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, '', body);
return { zone: zone.name, changed, unchanged, changeId: ((text.match(/<Id>([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/change\//, '') };
}
/** 연결 확인: 서명이 맞는지와 존이 있는지. */
export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'route53', zone: zone.name, zoneId: zone.id }; }
+356
View File
@@ -0,0 +1,356 @@
/**
* 다중 도메인 등록부와 "테넌트"(1.0.7, 요구사항 1·2).
*
* 생각의 틀
* AriMail 의 모든 모듈은 `env` 객체 하나로 자기 세계(DB·설정 파일·메일 도메인·공개 호스트·로그인 방식)를 봅니다. 그래서 도메인을 하나 더 운영한다는 것은
* "env 를 하나 더 만든다" 는 뜻입니다. 이 모듈은 도메인마다 그런 env(= 테넌트)를 만들어 보관하고, 들어온 요청(HTTP Host, 로그인 ID 의 @ 뒤,
* 받는 사람 주소)으로 어느 테넌트의 일인지 고릅니다. 각 도메인은 자기 DB(계정·세션·메일), 자기 설정 파일(Resend 키·DNS·수·발신 방식), 자기 파일 저장소를
* 가지므로 로그인과 계정 관리가 도메인 단위로 완전히 나뉩니다.
*
* 기본 도메인(primary)
* 환경 파일(gateway.env)의 MAILBOX / PUBLIC_HOST / LOGIN_MODE / WWW_DB_FILE / DB_FILE 로 정해지는 기존 운영 그대로의 도메인입니다. 1.0.7 로 올린 서버는
* 첫 시작에 등록부(domains.json)가 없으므로 이 도메인 하나로 등록부를 만들고(데이터는 전혀 옮기지 않음) 예전처럼 동작합니다.
* 기본 도메인의 DB·설정 파일 위치는 계속 환경 파일이 정하고, 등록부에는 추가 호스트 이름 같은 보조 정보만 둡니다.
*
* 추가 도메인
* 데이터 디렉터리 아래 domains/<도메인>/ 에 mail.sqlite(같은 마이그레이션) · config.json · files/ 를 둡니다. 전체 설정 화면(전용 포트)에서 추가하면
* 재시작 없이 바로 만들어지고, 첫 관리자(admin / 지정한 비밀번호)를 자동으로 만들어 바로 로그인할 수 있습니다. 로그인 방식은 도메인마다
* local(자체 계정) · www(연결된 웹 서비스 DB 의 관리자 계정으로 인증) · webmaker(1.0.8: 웹메이커의 사용자 파일을 읽기 전용으로 참조, server/webmaker.mjs)입니다.
*
* 등록부 파일(domains.json, 기본 도메인의 데이터 디렉터리)
* { _meta:{format}, primary:'arihakdang.com', domains:{ 'example.com': { domain, enabled, hosts:[…], mailbox, auth:{mode, wwwDbFile, ownerLogin, webmakerDir, webmakerUser, siteId}, note, createdAt, updatedAt } } }
*
* 호스트 이름과 경로(1.0.8)
* hosts 의 항목은 "호스트" 또는 "호스트/경로"(예: site.ariweb.net/mail)입니다. 경로가 있으면 그 호스트의 그 경로 아래가 이 도메인의 웹 메일입니다 — 서브 도메인 사이트처럼
* 메일용 호스트 이름을 따로 만들기 어려울 때 nginx 가 /mail/ 을 게이트웨이로 넘기고(X-Forwarded-Prefix: /mail, deploy/site-mail-path.sh) 화면은 그 경로를 기준(<base>)으로 동작합니다.
*
* 요청 → 테넌트 고르기(resolveHost)
* 1) 호스트가 없거나 localhost / IP 주소이면 기본 도메인(nginx 뒤의 상태 확인, 로컬 도구, 서버 IP 로 직접 접속).
* 2) 어떤 도메인의 hosts 에 정확히 있으면 그 도메인. 3) 호스트가 도메인과 같거나 그 하위(…​.example.com)면 그 도메인(가장 긴 도메인 우선).
* 4) 아무 데도 없으면 null → 화면은 "메일 서비스가 존재하지 않습니다" 를 보여 줍니다. 꺼 둔(enabled=false) 도메인은 disabled 로 알려 "활성화되지 않았습니다" 를 보여 줍니다.
*/
import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, accessSync, constants } from 'node:fs';
import { dirname, join } from 'node:path';
import { openDb, d1, migrate } from './db.mjs';
import { loadConfig, configFile, configDir, harakaDir, CONFIG_SCHEMA } from './config.mjs';
import { ensureInitialAdmin, externalUsers, INITIAL_ADMIN_PASSWORD, PASSWORD_MIN_FLOOR } from './auth.mjs';
import { memoryRateStore } from './util.mjs';
export const REGISTRY_FORMAT = 1;
export const AUTH_MODES = ['local', 'www', 'webmaker'];
/** 웹메이커 사이트 id(32자리 16진수)와 sudo 로 파일을 읽을 계정 이름의 형식. */
const SITE_ID_RE = /^[a-f0-9]{32}$/, USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
/** 도메인 이름 형식: 소문자 영숫자·하이픈 이름표를 점으로 이은 것(국제화 도메인은 퓨니코드로). */
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const HOST_RE = DOMAIN_RE;
const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
/**
* 새 도메인을 만들 때 기본 도메인의 설정에서 복사해 오는 키(요구사항 2: 전체 설정에서 API 키·DNS 설정을 함께 관리).
* 도메인마다 다른 값(수·발신 방식, Resend 도메인/웹훅 id, DKIM 키, 사이트 이름, 완료 표시)은 복사하지 않습니다. DNS 존 id(DNS_GOOGLE_ZONE·DNS_AWS_HOSTED_ZONE_ID)도
* 복사하지 않습니다 — 기본 도메인의 존에 새 도메인의 레코드를 넣으려다 실패하므로, 새 도메인은 도메인 이름으로 존을 자동 검색합니다.
*/
export const COPY_KEYS = ['RESEND_API_KEY', 'RESEND_REGION', 'DNS_PROVIDER', 'DNS_GOOGLE_CREDENTIALS', 'DNS_GOOGLE_PROJECT', 'DNS_AWS_ACCESS_KEY_ID', 'DNS_AWS_SECRET_ACCESS_KEY', 'SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS', 'HARAKA_PORT', 'HARAKA_RELAY_PORT', 'HARAKA_HOSTNAME', 'STATS_TIMEZONE'];
/** 호스트 이름 정규화: 소문자, 끝 점·포트 제거. "호스트/경로" 가 들어와도 호스트 부분만 돌려줍니다. */
export const normalizeHost = (host) => String(host || '').trim().toLowerCase().split('/')[0].replace(/^\[([^\]]+)\](:\d+)?$/, '$1').replace(/:\d+$/, '').replace(/\.$/, '');
/**
* 경로 접두사 정규화(1.0.8): "/mail", "mail/", "/a/b/" → "/mail", "/mail", "/a/b". 비어 있거나 "/" 면 ''(접두사 없음). 허용 글자 밖이거나 ".." 가 있으면 null.
* X-Forwarded-Prefix 헤더(nginx)와 등록부의 "호스트/경로" 둘 다 이 함수로 다듬습니다.
*/
export function normalizePrefix(prefix) {
const raw = String(prefix ?? '').trim(); if (!raw || raw === '/') return '';
const parts = raw.split('/').filter(Boolean);
if (!parts.length) return '';
for (const p of parts) if (p === '.' || p === '..' || !/^[A-Za-z0-9._~-]{1,64}$/.test(p)) return null;
return '/' + parts.join('/');
}
/** 등록부의 호스트 항목("호스트" 또는 "호스트/경로")을 { host, prefix } 로 나눕니다(둘 다 소문자; nginx 설정과 맞추기 쉽게 경로도 소문자로 통일). */
export function splitHostEntry(entry) {
const raw = String(entry || '').trim().toLowerCase(); const i = raw.indexOf('/');
return { host: normalizeHost(i < 0 ? raw : raw.slice(0, i)), prefix: i < 0 ? '' : (normalizePrefix(raw.slice(i)) ?? null) };
}
/** 호스트 항목의 호스트 이름 부분. */
export const hostNameOf = (entry) => splitHostEntry(entry).host;
/** 도메인 이름 정규화(소문자, 앞뒤 공백·점 제거). */
export const normalizeDomain = (domain) => String(domain || '').trim().toLowerCase().replace(/^\.+|\.+$/g, '');
export const isDomainName = (domain) => DOMAIN_RE.test(normalizeDomain(domain));
/** 로컬 접속으로 보는 호스트: 비어 있음, localhost(.localhost 포함), IPv4/IPv6 주소. 이런 요청은 기본 도메인이 받습니다. */
export const isLocalHost = (host) => { const h = normalizeHost(host); return !h || h === 'localhost' || h.endsWith('.localhost') || IPV4_RE.test(h) || h.includes(':'); };
/** 등록부 파일 경로: 기본 도메인의 설정 파일(config.json) 옆 domains.json. */
export const registryFile = (baseEnv) => join(configDir(baseEnv), 'domains.json');
function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
/** 임시 파일 → 이름 바꾸기(반쪽 파일 방지). 비밀값은 없지만 운영 정보이므로 0600. */
function writeJson(file, data) {
mkdirSync(dirname(file), { recursive: true });
const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(tmp, 0o600); } catch { /* 권한을 못 바꾸는 파일 시스템 */ }
renameSync(tmp, file);
}
/** 검증 오류: Error('validation') + field. API 가 422 로 돌려줍니다. */
const invalid = (field) => { const e = new Error('validation'); e.field = field; return e; };
/**
* 등록부 항목 하나를 입력(화면 폼)에서 만듭니다. 호스트 목록은 쉼표·공백으로 나눈 문자열이나 배열을 받고, 비어 있으면 mail.<도메인>.
* @param {object} body { domain, hosts?, mailbox?, auth?:{mode, wwwDbFile, ownerLogin}, note?, enabled? }
* @param {object|null} [current] 수정할 때의 기존 항목(없는 필드는 그대로 둠)
*/
export function normalizeEntry(body, current = null, { keepAuth = false } = {}) {
const domain = normalizeDomain(current?.domain || body.domain);
if (!isDomainName(domain)) throw invalid('domain');
const rawHosts = body.hosts === undefined ? (current?.hosts || []) : Array.isArray(body.hosts) ? body.hosts : String(body.hosts || '').split(/[\s,]+/);
// 호스트 항목은 "호스트" 또는 "호스트/경로"(1.0.8). 호스트 이름을 정규화하고 경로는 /a/b 꼴로 다듬어 다시 "호스트/경로" 로 저장합니다.
const hosts = [];
for (const raw of rawHosts) {
if (!String(raw || '').trim()) continue;
const { host, prefix } = splitHostEntry(raw);
if (!host || !HOST_RE.test(host) || prefix === null) throw invalid('hosts');
const entry = prefix ? host + prefix : host; if (!hosts.includes(entry)) hosts.push(entry);
}
if (!hosts.length) hosts.push('mail.' + domain);
// 같은 호스트 이름이 경로만 다르게 두 번 들어오면(mail.x.com 과 mail.x.com/mail) 어느 경로가 그 호스트의 웹 메일인지 애매하므로 거부합니다.
if (new Set(hosts.map(hostNameOf)).size !== hosts.length) throw invalid('hosts');
const mailboxLocal = String(body.mailbox === undefined ? (current?.mailbox || 'admin@' + domain) : body.mailbox).trim().toLowerCase().split('@')[0] || 'admin';
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(mailboxLocal)) throw invalid('mailbox');
// keepAuth(기본 도메인): 로그인 방식은 환경 파일이 정하므로 입력을 무시하고 검사도 하지 않습니다(시험·www 모드에서 파일 경로가 없을 수 있음).
const authIn = keepAuth ? {} : body.auth && typeof body.auth === 'object' ? body.auth : {};
const mode = authIn.mode === undefined ? (current?.auth?.mode || 'local') : String(authIn.mode);
if (!AUTH_MODES.includes(mode)) throw invalid('auth.mode');
const wwwDbFile = authIn.wwwDbFile === undefined ? (current?.auth?.wwwDbFile || '') : String(authIn.wwwDbFile || '').trim();
const ownerLogin = authIn.ownerLogin === undefined ? (current?.auth?.ownerLogin || '') : String(authIn.ownerLogin || '').trim().slice(0, 200);
if (mode === 'www' && !keepAuth) { if (!wwwDbFile) throw invalid('auth.wwwDbFile'); try { accessSync(wwwDbFile, constants.R_OK); } catch { const e = new Error('www_db_unreadable'); e.field = 'auth.wwwDbFile'; throw e; } }
// webmaker 연동(1.0.8): 데이터 디렉터리(DATA_ROOT, 절대 경로)는 필수. webmakerUser 가 있으면 그 계정의 sudo 도우미로 읽으므로 이 프로세스의 읽기 권한을 검사하지 않고,
// 없으면 users.json 을 지금 읽을 수 있어야 합니다(webmaker_dir_unreadable). siteId 는 도메인 ↔ 사이트 대응을 손으로 고정할 때만 적습니다(비우면 도메인 이름으로 찾음).
const webmakerDir = authIn.webmakerDir === undefined ? (current?.auth?.webmakerDir || '') : String(authIn.webmakerDir || '').trim().replace(/\/+$/, '');
const webmakerUser = authIn.webmakerUser === undefined ? (current?.auth?.webmakerUser || '') : String(authIn.webmakerUser || '').trim();
const siteId = authIn.siteId === undefined ? (current?.auth?.siteId || '') : String(authIn.siteId || '').trim().toLowerCase();
if (mode === 'webmaker' && !keepAuth) {
if (!webmakerDir || !webmakerDir.startsWith('/') || webmakerDir.includes('..')) throw invalid('auth.webmakerDir');
if (webmakerUser && !USER_RE.test(webmakerUser)) throw invalid('auth.webmakerUser');
if (siteId && !SITE_ID_RE.test(siteId)) throw invalid('auth.siteId');
if (!webmakerUser) { try { accessSync(join(webmakerDir, 'users.json'), constants.R_OK); } catch { const e = new Error('webmaker_dir_unreadable'); e.field = 'auth.webmakerDir'; throw e; } }
}
const note = String(body.note === undefined ? (current?.note || '') : body.note || '').trim().slice(0, 200);
const enabled = body.enabled === undefined ? (current ? current.enabled !== false : true) : !(body.enabled === false || body.enabled === 'false' || body.enabled === '0' || body.enabled === 0);
const stamp = new Date().toISOString();
return { domain, enabled, hosts, mailbox: `${mailboxLocal}@${domain}`, auth: { mode, wwwDbFile: mode === 'www' ? wwwDbFile : '', ownerLogin: mode === 'www' ? ownerLogin : '', webmakerDir: mode === 'webmaker' ? webmakerDir : '', webmakerUser: mode === 'webmaker' ? webmakerUser : '', siteId: mode === 'webmaker' ? siteId : '' }, note, createdAt: current?.createdAt || stamp, updatedAt: stamp };
}
/**
* 등록부 항목의 로그인 방식을 env 에 반영합니다(createEnv 와 update 가 공유). LOGIN_MODE 와 그 방식의 값만 두고 다른 방식의 값은 지웁니다.
* www : WWW_DB_FILE, MAILBOX_OWNER_LOGIN webmaker : WEBMAKER_DIR, WEBMAKER_USER(선택), WEBMAKER_SITE(선택) local : standalone(자체 계정만)
*/
export function applyAuth(env, auth) {
for (const k of ['WWW_DB_FILE', 'MAILBOX_OWNER_LOGIN', 'WEBMAKER_DIR', 'WEBMAKER_USER', 'WEBMAKER_SITE']) delete env[k];
if (auth.mode === 'www') { env.LOGIN_MODE = 'www'; env.WWW_DB_FILE = auth.wwwDbFile; env.MAILBOX_OWNER_LOGIN = auth.ownerLogin || ''; }
else if (auth.mode === 'webmaker') { env.LOGIN_MODE = 'webmaker'; env.WEBMAKER_DIR = auth.webmakerDir; if (auth.webmakerUser) env.WEBMAKER_USER = auth.webmakerUser; if (auth.siteId) env.WEBMAKER_SITE = auth.siteId; }
else env.LOGIN_MODE = 'standalone';
}
/** 공개용 auth 요약(목록·API). 비밀값은 없지만 모양을 한곳에서 정합니다. */
const publicAuth = (a) => ({ mode: a.mode, wwwDbFile: a.wwwDbFile || '', ownerLogin: a.ownerLogin || '', webmakerDir: a.webmakerDir || '', webmakerUser: a.webmakerUser || '', siteId: a.siteId || '' });
/** 기본 도메인 항목: 환경 파일 값으로 만듭니다(등록부에 보조 정보가 있으면 hosts·note 만 더합니다). */
function primaryEntry(baseEnv, stored = null) {
const mailbox = String(baseEnv.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase(), domain = mailbox.split('@')[1] || '';
const host = normalizeHost(baseEnv.PUBLIC_HOST || 'mail.' + domain);
// 기본 호스트의 경로 접두사는 환경 파일의 PUBLIC_PATH(선택, 1.0.8). 등록부에 적힌 나머지 호스트는 "호스트/경로" 그대로 둡니다(이름이 겹치면 앞의 것).
const first = host + (normalizePrefix(baseEnv.PUBLIC_PATH) || '');
const hosts = []; for (const h of [first, ...(Array.isArray(stored?.hosts) ? stored.hosts : [])]) { const { host: n, prefix } = splitHostEntry(h); if (!n || prefix === null || hosts.some((x) => hostNameOf(x) === n)) continue; hosts.push(prefix ? n + prefix : n); }
// 로그인 방식은 환경 파일이 정합니다: LOGIN_MODE=webmaker(+WEBMAKER_DIR) → webmaker, WWW_DB_FILE(시험에서는 메모리 WWW_DB) → www, 그 밖에는 local.
const mode = String(baseEnv.LOGIN_MODE || '') === 'webmaker' && baseEnv.WEBMAKER_DIR ? 'webmaker' : baseEnv.WWW_DB_FILE || baseEnv.WWW_DB ? 'www' : 'local';
const auth = { mode, wwwDbFile: mode === 'www' ? String(baseEnv.WWW_DB_FILE || '') : '', ownerLogin: mode === 'www' ? String(baseEnv.MAILBOX_OWNER_LOGIN || '') : '', webmakerDir: mode === 'webmaker' ? String(baseEnv.WEBMAKER_DIR) : '', webmakerUser: mode === 'webmaker' ? String(baseEnv.WEBMAKER_USER || '') : '', siteId: mode === 'webmaker' ? String(baseEnv.WEBMAKER_SITE || '') : '' };
return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() };
}
/** 등록부를 읽습니다. 없으면 기본 도메인만 담아 만듭니다(1.0.7 로 올린 서버의 첫 시작). 파일을 쓸 수 없으면 메모리에서만 동작합니다. */
export function loadRegistry(baseEnv) {
const file = registryFile(baseEnv);
let reg = readJson(file), created = false;
const primary = primaryEntry(baseEnv, reg?.domains?.[String(reg?.primary || '')] || null);
if (!reg || typeof reg !== 'object' || !reg.domains || typeof reg.domains !== 'object') { reg = { _meta: { format: REGISTRY_FORMAT, createdAt: new Date().toISOString() }, primary: primary.domain, domains: {} }; created = true; }
// 기본 도메인은 환경 파일이 기준입니다. 환경 파일의 도메인이 바뀌었으면(드문 일) 옛 기본 항목은 일반 항목으로 남기지 않고 지웁니다(그 데이터는 기본 DB 그대로).
if (reg.primary !== primary.domain) { delete reg.domains[reg.primary]; reg.primary = primary.domain; created = true; }
reg.domains[primary.domain] = primary;
if (created) { try { writeJson(file, reg); } catch (e) { console.warn(` ⚠️ 도메인 등록부를 쓸 수 없습니다(${file}): ${e.message}`); } }
return reg;
}
/** 테넌트 env 에서 떼어 내야 하는 키: 설정 파일이 관리하는 값(기본 도메인의 비밀값이 새 도메인에 새지 않게)과 도메인마다 다른 값. */
const TENANT_OWN_KEYS = new Set(['DB', 'DB_FILE', 'CONFIG_FILE', 'FILES_DIR', 'MAILBOX', 'PUBLIC_HOST', 'PUBLIC_PATH', 'WWW_DB', 'WWW_DB_FILE', 'LOGIN_MODE', 'MAILBOX_OWNER_LOGIN', 'WEBMAKER_DIR', 'WEBMAKER_USER', 'WEBMAKER_SITE', 'WEBMAKER_SOCKET', 'STRINGS', 'PAGE', 'CONFIG', 'CONFIG_VERSION', 'CONFIG_CREATED', 'ON_CONFIG_CHANGE', 'MAIL_INBOUND', 'MAIL_OUTBOUND', 'PLAN_DNS_APPLIED_AT', 'DOMAIN', 'TENANT', 'RATE', 'SYNCING', ...CONFIG_SCHEMA.filter((i) => i.env).map((i) => i.env)]);
/**
* 테넌트 관리자. 서버 시작 때 한 번 만들고(openTenants) 모든 요청이 이 객체로 env 를 고릅니다.
* @param {object} baseEnv 기본 도메인의 env(main.mjs 가 이미 DB·설정을 연 상태)
* @param {object} o
* @param {string} o.migrationsDir 새 도메인 DB 에 적용할 마이그레이션 디렉터리
* @param {(env:object)=>void} [o.onCreate] 테넌트 env 가 생길 때(기본 도메인 포함) 부르는 훅 — main.mjs 가 버전·문구·화면 캐시를 붙입니다
*/
export function openTenants(baseEnv, { migrationsDir, onCreate = null, log = console } = {}) {
const registry = loadRegistry(baseEnv);
const dataDir = configDir(baseEnv);
const tenants = new Map(); // domain → { entry, env, db }
const creating = new Set(); // add() 진행 중인 도메인
const stripped = Object.fromEntries(Object.entries(baseEnv).filter(([k]) => !TENANT_OWN_KEYS.has(k)));
/** 기본 도메인: 이미 열린 env 를 그대로 테넌트로 씁니다(데이터 이동 없음). */
const attachPrimary = () => {
const entry = registry.domains[registry.primary];
Object.assign(baseEnv, { DOMAIN: entry.domain, TENANT: entry, DATA_DIR: dataDir, MAIL_SERVER_HOST: normalizeHost(baseEnv.PUBLIC_HOST || entry.hosts[0]), PUBLIC_PATH: splitHostEntry(entry.hosts[0]).prefix || '' });
if (!baseEnv.RATE) baseEnv.RATE = memoryRateStore();
tenants.set(entry.domain, { entry, env: baseEnv, db: null });
if (onCreate) onCreate(baseEnv);
};
/** 추가 도메인의 디렉터리. 도메인 이름은 normalizeEntry 가 검사했으므로 경로에 그대로 써도 안전합니다. */
const dirOf = (domain) => join(dataDir, 'domains', domain);
/**
* 추가 도메인의 env 를 만듭니다: 자기 DB(마이그레이션 적용)·설정 파일·파일 디렉터리. 설정 파일이 없으면(처음) seed 값으로 만듭니다.
* @param {object} entry 등록부 항목 @param {object|null} seed 처음 만들 때 넣을 설정 값(COPY_KEYS)
*/
const createEnv = (entry, { seed = null, adminPassword = null } = {}) => {
const dir = dirOf(entry.domain); mkdirSync(dir, { recursive: true });
// PUBLIC_HOST 는 첫 호스트의 이름 부분, PUBLIC_PATH 는 그 경로 접두사(없으면 '')(1.0.8). 링크(대용량 파일·설정 화면 안내)는 둘을 이어 만듭니다.
const { host: publicHost, prefix: publicPath } = splitHostEntry(entry.hosts[0]);
const env = { ...stripped, DOMAIN: entry.domain, TENANT: entry, DATA_DIR: dataDir, DB_FILE: join(dir, 'mail.sqlite'), CONFIG_FILE: join(dir, 'config.json'), FILES_DIR: join(dir, 'files'), MAILBOX: entry.mailbox, PUBLIC_HOST: publicHost, PUBLIC_PATH: publicPath || '', MAIL_SERVER_HOST: baseEnv.MAIL_SERVER_HOST || normalizeHost(baseEnv.PUBLIC_HOST || ''), HARAKA_DIR: harakaDir(baseEnv), RATE: memoryRateStore(), TENANTS: api };
applyAuth(env, entry.auth);
const db = openDb(env.DB_FILE); migrate(db, migrationsDir, { quiet: true }); env.DB = d1(db);
if (seed && !existsSync(configFile(env))) { const cfg = { _meta: { format: 1, createdAt: new Date().toISOString(), copiedFrom: registry.primary } }; for (const k of COPY_KEYS) if (seed[k] !== undefined && seed[k] !== '') cfg[k] = seed[k]; cfg.inbound = 'resend'; cfg.outbound = 'resend'; writeJson(configFile(env), cfg); }
loadConfig(env);
if (onCreate) onCreate(env);
return { entry, env, db, adminPassword };
};
const api = {
get registry() { return registry; },
get primary() { return tenants.get(registry.primary); },
/** 모든 테넌트(기본 도메인 먼저). */
all() { return [this.primary, ...[...tenants.values()].filter((t) => t.entry.domain !== registry.primary)]; },
/** 켜진 테넌트의 env 목록(주기 작업용). */
envs() { return this.all().filter((t) => t.entry.enabled !== false).map((t) => t.env); },
byDomain(domain) { return tenants.get(normalizeDomain(domain)) || null; },
/** 주소(user@domain)의 도메인으로 테넌트를 찾습니다. 도메인이 없는 로그인 ID 는 null(호출자가 기본 도메인 등으로 정함). */
byAddress(address) { const d = String(address || '').trim().toLowerCase().split('@')[1]; return d ? this.byDomain(d) : null; },
/**
* HTTP Host 로 테넌트를 고릅니다.
* @returns {{tenant:object|null, disabled:boolean, host:string}} tenant 가 null 이면 모르는 도메인, disabled 면 꺼 둔 도메인
*/
resolveHost(host) {
const h = normalizeHost(host);
let found = null, prefix = '';
if (isLocalHost(h)) found = this.primary;
else {
// 등록된 호스트 이름(경로가 붙어 있으면 그 경로가 이 호스트의 접두사, 1.0.8)
for (const t of tenants.values()) { const e = t.entry.hosts.find((x) => hostNameOf(x) === h); if (e) { found = t; prefix = splitHostEntry(e).prefix || ''; break; } }
if (!found) { let best = ''; for (const t of tenants.values()) { const d = t.entry.domain; if ((h === d || h.endsWith('.' + d)) && d.length > best.length) { best = d; found = t; } } }
}
return { tenant: found, disabled: !!found && found.entry.enabled === false, host: h, prefix };
},
/**
* 메일 프로그램 로그인(IMAP·POP·SMTP)에서 쓰는 env 고르기: 로그인 ID 에 @도메인이 있으면 그 도메인, 없으면 TLS SNI 호스트가 가리키는 도메인, 그래도 없으면 기본 도메인.
* 꺼 둔 도메인은 null → 로그인 실패. 등록되지 않은 @도메인은 기본 도메인(또는 SNI 도메인)의 계정으로 검사합니다(www 관리자 이메일 로그인 호환).
*/
resolveLogin(login, servername = '') {
const key = String(login || '').trim().toLowerCase();
// @ 뒤가 등록된 도메인이면 그 도메인(꺼 두었으면 null). 등록되지 않은 도메인(예: www 관리자가 자기 이메일 someone@gmail.com 으로 로그인)은
// 아래 SNI·기본 도메인 규칙으로 넘깁니다 — verifyUser 가 그 env 의 계정(www 관리자의 이메일 포함)으로 검사하므로 안전합니다.
if (key.includes('@')) { const t = this.byAddress(key); if (t) return t.entry.enabled !== false ? t.env : null; }
if (servername) { const r = this.resolveHost(servername); if (r.tenant && !r.disabled) return r.tenant.env; }
return this.primary.env;
},
/** 화면·API 용 요약 목록(비밀값 없음). */
async list() {
const out = [];
for (const t of this.all()) {
const e = t.entry, env = t.env; let accounts = 0, messages = 0;
try { accounts = Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); messages = Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM messages').first())?.n || 0); } catch { /* DB 오류는 0 으로 */ }
// initialPassword: 도메인을 만들 때 정한 초기 관리자 비밀번호. 관리자가 비밀번호를 바꾸면(initial_password:<id> 표시가 지워짐) 더는 보여 주지 않습니다.
let initialPassword = null;
if (t.adminPassword) { try { if (await env.DB.prepare("SELECT 1 AS x FROM settings WHERE key LIKE 'initial_password:%'").first()) initialPassword = t.adminPassword; else t.adminPassword = null; } catch { /* 무시 */ } }
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정 수(읽지 못하면 오류 문구). 화면이 "계정 N명(+웹메이커 M명)" 으로 보여 줍니다.
let external = null;
if (e.auth.mode === 'webmaker') { try { const r = await externalUsers(env); external = r.ok ? { count: r.accounts.length, site: r.site } : { count: 0, error: r.error }; } catch (err) { external = { count: 0, error: err.message }; } }
out.push({ domain: e.domain, primary: e.domain === registry.primary, enabled: e.enabled !== false, hosts: e.hosts, mailbox: e.mailbox, auth: publicAuth(e.auth), note: e.note || '', createdAt: e.createdAt, updatedAt: e.updatedAt, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', setupDone: env.CONFIG?.SETUP_DONE === true, accounts, messages, external, dir: e.domain === registry.primary ? configDir(env) : dirOf(e.domain), initialPassword });
}
return out;
},
/**
* 도메인을 추가합니다(전체 설정 화면). 등록부에 쓰고 바로 env 를 만들어 요청을 받기 시작합니다.
* @param {object} body normalizeEntry 입력 + { adminPassword?, copyFromPrimary? (기본 true) }
* @returns {Promise<{entry:object, initialAdmin:{login:string,password:string}|null}>}
*/
async add(body) {
const entry = normalizeEntry(body);
// creating: 같은 도메인을 동시에 두 번 추가하는 요청이 중복 검사를 함께 통과하지 못하게 합니다(DB 핸들 누수 방지).
if (tenants.has(entry.domain) || creating.has(entry.domain)) throw new Error('domain_exists');
if (entry.domain === registry.primary) throw new Error('domain_exists');
creating.add(entry.domain);
try { return await this._add(entry, body); } finally { creating.delete(entry.domain); }
},
async _add(entry, body) {
for (const t of tenants.values()) for (const h of entry.hosts) if (t.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; }
const adminPassword = String(body.adminPassword || INITIAL_ADMIN_PASSWORD);
if ([...adminPassword].length < PASSWORD_MIN_FLOOR) throw invalid('adminPassword');
const seed = body.copyFromPrimary === false ? null : (this.primary.env.CONFIG || {});
const t = createEnv(entry, { seed, adminPassword });
// 첫 관리자: 로그인 방식이 local 이면 admin(메일함 앞부분) 계정을 지정한 비밀번호로 만듭니다(www 연동이면 그쪽 관리자가 로그인).
let initialAdmin = null;
if (entry.auth.mode === 'local') { const made = await ensureInitialAdmin(t.env, { password: adminPassword }); if (made) initialAdmin = made; }
registry.domains[entry.domain] = entry; writeJson(registryFile(baseEnv), registry);
tenants.set(entry.domain, t);
log.log(` 도메인 추가: ${entry.domain} (${entry.hosts.join(', ')}, 로그인 ${entry.auth.mode}${initialAdmin ? `, 초기 관리자 ${initialAdmin.login}` : ''})`);
return { entry, initialAdmin };
},
/** 도메인 정보를 바꿉니다(켜기·끄기, 호스트, 로그인 방식, 메모). 기본 도메인은 hosts·note 만 바꿀 수 있습니다(나머지는 환경 파일). */
update(domain, body) {
const t = this.byDomain(domain); if (!t) throw new Error('not_found');
const isPrimary = t.entry.domain === registry.primary;
const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note }, t.entry, { keepAuth: true }), enabled: true, primary: true, mailbox: t.entry.mailbox, auth: t.entry.auth } : normalizeEntry({ ...body, domain: t.entry.domain, mailbox: undefined }, t.entry);
// 기본 도메인의 첫 호스트는 환경 파일의 PUBLIC_HOST(+PUBLIC_PATH)로 고정합니다(목록에서 빠지거나 뒤로 가면 앞에 되돌림).
if (isPrimary) { const ph = normalizeHost(baseEnv.PUBLIC_HOST || ''), first = ph + (normalizePrefix(baseEnv.PUBLIC_PATH) || ''); next.hosts = [first, ...next.hosts.filter((h) => hostNameOf(h) !== ph)]; }
for (const other of tenants.values()) if (other !== t) for (const h of next.hosts) if (other.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; }
if (!isPrimary) {
// 로그인 방식(www DB·웹메이커 디렉터리)은 env 에 바로 반영합니다(세션·계정 조회가 다음 요청부터 새 방식을 씀).
applyAuth(t.env, next.auth);
const { host: publicHost, prefix } = splitHostEntry(next.hosts[0]); t.env.PUBLIC_HOST = publicHost; t.env.PUBLIC_PATH = prefix || '';
if (typeof t.env.ON_CONFIG_CHANGE === 'function') t.env.ON_CONFIG_CHANGE(t.env.CONFIG || {});
}
t.entry = next; t.env.TENANT = next; registry.domains[next.domain] = next; writeJson(registryFile(baseEnv), registry);
log.log(` 도메인 변경: ${next.domain} (${next.enabled ? '사용' : '중지'}, ${next.hosts.join(', ')}, 로그인 ${next.auth.mode})`);
return next;
},
/** 도메인을 등록부에서 지웁니다. 데이터 디렉터리(domains/<도메인>/)는 남겨 두어 되돌릴 수 있습니다(지우려면 서버에서 직접). 기본 도메인은 지울 수 없습니다. */
remove(domain) {
const t = this.byDomain(domain); if (!t) throw new Error('not_found');
if (t.entry.domain === registry.primary) throw new Error('primary_domain');
tenants.delete(t.entry.domain); delete registry.domains[t.entry.domain]; writeJson(registryFile(baseEnv), registry);
try { t.db?.close(); } catch { /* 이미 닫힘 */ }
log.log(` 도메인 삭제: ${t.entry.domain} (데이터 디렉터리는 남김: ${dirOf(t.entry.domain)})`);
return { domain: t.entry.domain, dir: dirOf(t.entry.domain) };
},
/** 종료: 추가 도메인 DB 를 닫습니다(기본 DB 는 main.mjs 가 닫음). */
close() { for (const t of tenants.values()) if (t.db) { try { t.db.close(); } catch { /* 무시 */ } } },
};
attachPrimary();
baseEnv.TENANTS = api;
// 등록부의 추가 도메인을 모두 엽니다. 하나가 깨져도(파일 권한 등) 나머지와 기본 도메인은 뜨도록 오류는 기록만 합니다.
for (const [domain, entry] of Object.entries(registry.domains)) {
if (domain === registry.primary) continue;
try { tenants.set(domain, createEnv(entry)); log.log(` 도메인: ${domain} (${entry.hosts.join(', ')}, ${entry.enabled === false ? '중지' : '사용'}, 로그인 ${entry.auth.mode})`); }
catch (e) { log.error(` ✖ 도메인 ${domain} 을 열지 못했습니다: ${e.message}`); }
}
return api;
}
/**
* "메일 서비스가 없습니다" 안내 화면(요구사항 1). 모르는 호스트로 들어온 웹 요청과 꺼 둔 도메인의 요청에 줍니다. 스크립트 없이 HTML 만.
* @param {object} o host: 요청 호스트, disabled: 꺼 둔 도메인인지, strings: lang/ko.json 묶음(없으면 내장 문구), version
*/
export function noServicePage({ host = '', disabled = false, strings = {}, version = '' } = {}) {
const t = (k, d) => strings[k] || d;
const title = disabled ? t('domainDisabledTitle', '메일 서비스가 활성화되지 않았습니다') : t('domainMissingTitle', '메일 서비스가 존재하지 않습니다');
const body = disabled ? t('domainDisabled', '이 도메인의 메일 서비스는 지금 사용이 중지되어 있습니다.') : t('domainMissing', '이 주소에는 등록된 메일 서비스가 없습니다.');
const ask = t('domainAskAdmin', '서비스가 필요하면 관리자에게 이 도메인의 메일 서비스 등록(또는 활성화)을 요청하세요.');
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${esc(title)}</title><style>body{margin:0;background:#f3f5f8;font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif;color:#1d2733}.card{max-width:520px;margin:64px auto;background:#fff;border-radius:14px;padding:28px;box-shadow:0 8px 30px rgba(20,35,60,.12)}h1{font-size:18px;margin:0 0 12px}p{margin:0 0 10px}.host{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef2f7;padding:2px 8px;border-radius:6px;overflow-wrap:anywhere}.meta{color:#6b7a8c;font-size:12px;margin-top:16px}</style></head><body><div class="card"><h1>${disabled ? '⏸' : '✉'} ${esc(title)}</h1><p>${esc(body)}</p>${host ? `<p>요청한 주소: <span class="host">${esc(host)}</span></p>` : ''}<p>${esc(ask)}</p><p class="meta">AriMail${version ? ' v' + esc(version) : ''}</p></div></body></html>`;
}
+242
View File
@@ -0,0 +1,242 @@
/**
* 대용량 첨부파일(0.9.3~): 큰 파일을 메일에 첨부하지 않고 서버에 보관한 뒤 다운로드 링크를 보냅니다.
*
* 흐름
* 1) 작성 창에서 "대용량 첨부" 로 올리면 파일은 디스크(filesDir, 기본 <DB 디렉터리>/files/<id>)에 흘려 쓰고 large_files 표에 메타데이터만 남깁니다(state='draft').
* 업로드는 index.mjs 가 요청 본문을 모으지 않고 그대로 넘겨(스트리밍) 1GB 급 파일도 메모리를 쓰지 않습니다.
* 2) 보낼 때(store.sendDraft → finalizeLargeFiles) 보관 기간(일)과 다운로드 횟수를 **반드시** 정합니다. 둘 다 관리자 설정의 상한 이내여야 하며
* "제한 없음" 은 없습니다(요구사항: 기간 제한이나 횟수 제한 없는 발송 불가). 파일마다 비밀 토큰을 만들어 링크(https://<호스트>/dl/<토큰>)를 본문 끝에 넣습니다.
* 3) 받는 사람이 링크를 열면 랜딩 페이지(파일 이름·크기·남은 횟수·기한)가 보이고, "다운로드" 단추(POST)를 누를 때만 횟수가 1 줄며 파일을 흘려보냅니다.
* GET 으로 세지 않는 이유: 메일 보안 장치(Outlook Safe Links 등)와 미리 보기 봇이 링크를 미리 열어 보므로, 그때마다 횟수가 줄면 받는 사람이 정작 못 받습니다.
* 4) 기한이 지나거나 횟수를 다 쓰면(또는 보낸 사람이 링크를 끊으면) 파일을 지우고 state='expired' 로 둡니다. 정리 작업(cleanupLargeFiles)이 1시간마다 돌며,
* 횟수를 다 쓴 파일은 마지막 다운로드가 끝나는 즉시 지웁니다.
*
* 설정은 두 층입니다(limits.mjs 와 같은 방식, 단 어느 층도 "무제한" 이 없음).
* - 시스템(systemLargeFiles): 환경 변수 LIMIT_LARGE_FILE_BYTES(기본 1G) · LIMIT_LARGE_FILE_DAYS(기본 30) · LIMIT_LARGE_FILE_DOWNLOADS(기본 20).
* 0 이나 잘못된 값은 기본값으로 대체합니다(반드시 값이 있어야 함).
* - 관리자(readLargeFileSettings, settings 표의 large_files): 사용 여부(enabled), 최대 크기·보관 기간·다운로드 횟수. 각각 시스템 값 이내이며
* 기본값은 시스템 값의 50% 입니다(요구사항). 기본은 사용 안 함(디스크를 쓰는 기능이라 관리자가 켜야 합니다).
*/
import { createWriteStream, createReadStream } from 'node:fs';
import { mkdir, unlink, stat } from 'node:fs/promises';
import { once } from 'node:events';
import { dirname, join } from 'node:path';
import { now, uid, clean, secret, escapeHtml } from './util.mjs';
import { parseSize } from './limits.mjs';
// ------------------------------------------------------------------ 설정
/** 시스템 기본값. 환경 변수가 없거나 잘못됐을 때 쓰며, "무제한" 은 없습니다. */
export const DEFAULT_SYSTEM_LARGE = { bytes: 1024 ** 3, days: 30, downloads: 20 };
/** 한 메일에 넣을 수 있는 대용량 파일 수. */
export const LARGE_FILE_COUNT = 10;
const posInt = (v, fallback) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : fallback; };
/** 시스템 설정(환경 파일). 값이 0·비어 있음·잘못됨이면 기본값(1GB / 30일 / 20회)으로 대체해 항상 유한한 값을 돌려줍니다. */
export function systemLargeFiles(env) {
const bytes = parseSize(env.LIMIT_LARGE_FILE_BYTES);
return { bytes: bytes > 0 ? bytes : DEFAULT_SYSTEM_LARGE.bytes, days: posInt(env.LIMIT_LARGE_FILE_DAYS, DEFAULT_SYSTEM_LARGE.days), downloads: posInt(env.LIMIT_LARGE_FILE_DOWNLOADS, DEFAULT_SYSTEM_LARGE.downloads) };
}
/** 관리자 설정의 기본값: 시스템 값의 50%(바이트는 내림, 일·횟수는 올림, 최소 1). */
export function defaultAdminLarge(sys) { return { enabled: false, maxBytes: Math.max(1, Math.floor(sys.bytes / 2)), maxDays: Math.max(1, Math.ceil(sys.days / 2)), maxDownloads: Math.max(1, Math.ceil(sys.downloads / 2)) }; }
const CAP = { maxBytes: 'bytes', maxDays: 'days', maxDownloads: 'downloads' };
/**
* 관리자 설정(settings 의 large_files). 저장된 값이 손상됐거나 시스템 값을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎고, 없으면 기본값(50%)입니다.
* @returns {Promise<{enabled:boolean,maxBytes:number,maxDays:number,maxDownloads:number}>}
*/
export async function readLargeFileSettings(env) {
const sys = systemLargeFiles(env), def = defaultAdminLarge(sys);
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('large_files').first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
if (!stored || typeof stored !== 'object') stored = {};
const out = { enabled: !!stored.enabled };
for (const [k, s] of Object.entries(CAP)) { const v = posInt(stored[k], def[k]); out[k] = Math.min(v, sys[s]); }
return out;
}
/**
* 관리자 설정을 저장합니다. 크기·기간·횟수는 1 이상의 정수이고(0 = 무제한은 허용하지 않음 → limit_invalid) 시스템 값 이내여야 합니다(limit_exceeds_system).
* 오류에는 field 와 상한(limit)을 붙여 화면이 어느 항목인지 알려 줍니다.
*/
export async function writeLargeFileSettings(env, patch) {
const sys = systemLargeFiles(env), next = await readLargeFileSettings(env);
if (patch.enabled !== undefined) next.enabled = !!patch.enabled;
for (const [k, s] of Object.entries(CAP)) {
if (patch[k] === undefined) continue;
const n = Number(patch[k]);
if (!Number.isInteger(n) || n < 1) { const e = new Error('limit_invalid'); e.field = k; throw e; }
if (n > sys[s]) { const e = new Error('limit_exceeds_system'); e.field = k; e.limit = sys[s]; throw e; }
next[k] = n;
}
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('large_files', JSON.stringify(next), now()).run();
return next;
}
/** 파일을 둘 디렉터리: FILES_DIR 또는 DB 파일 옆의 files/. 배포 서비스는 <APP_DIR>/data 에 쓰기 권한이 있으므로 기본값이 바로 동작합니다. */
export function filesDir(env) { return env.FILES_DIR || join(dirname(env.DB_FILE || 'data/mail-gateway.sqlite'), 'files'); }
const pathOf = (env, id) => join(filesDir(env), id);
async function unlinkQuiet(path) { try { await unlink(path); } catch { /* 이미 없음 */ } }
// ------------------------------------------------------------------ 업로드(초안에 파일 붙이기)
/**
* 초안에 대용량 파일을 올립니다. 본문 스트림(Node 요청 객체 또는 async iterable)을 디스크에 흘려 쓰며 관리자 상한(maxBytes)을 넘는 순간 중단하고 지웁니다.
* @param {object} user 로그인 사용자(mailbox 로 초안 소유를 확인)
* @param {AsyncIterable<Buffer>} stream 파일 바이트
* @param {object} meta { draftId, filename, contentType, declaredSize? } — declaredSize(Content-Length)가 상한을 넘으면 받기 전에 거부합니다.
* @returns {Promise<object>} large_files 행
* @throws large_files_disabled | mail_locked | too_many_files | file_too_large(limit) | bad_request
*/
export async function uploadLargeFile(env, user, stream, { draftId, filename, contentType, declaredSize = 0 }) {
const cfg = await readLargeFileSettings(env);
if (!cfg.enabled) throw new Error('large_files_disabled');
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts' AND mailbox=?").bind(String(draftId || ''), user.mailbox).first();
if (!draft) throw new Error('mail_locked');
const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM large_files WHERE message_id=? AND state='draft'").bind(draft.id).first();
if (Number(count?.n || 0) >= LARGE_FILE_COUNT) throw new Error('too_many_files');
const tooBig = () => { const e = new Error('file_too_large'); e.limit = cfg.maxBytes; return e; };
if (Number(declaredSize) > cfg.maxBytes) throw tooBig();
try { await mkdir(filesDir(env), { recursive: true }); } catch (e) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; }
const id = uid(), path = pathOf(env, id), out = createWriteStream(path, { flags: 'wx', mode: 0o600 });
// 쓰기 오류(디스크 가득 참 ENOSPC 등)는 다음 네트워크 조각을 기다리는 동안에도 날 수 있습니다. 듣는 쪽이 없으면 'error' 이벤트가 프로세스를 죽이므로
// 처음부터 받아 두었다가 조각마다 확인해 업로드만 실패시킵니다(0.9.5 수정).
// failed: 쓰기 오류가 나면 거부되는 프라미스 하나. 예전(0.9.5~1.0.0)에는 조각마다 out.once('error', reject) 를 새로 달아 drain 을 기다릴 때마다
// 리스너가 쌓였고(1GB 업로드면 수천 개), 11개째에 MaxListenersExceededWarning 이 기록됐습니다(mail.heart-m.com 2026-10-13). 리스너는 이 한 개만 둡니다(1.0.5 수정).
let writeError = null; const failed = new Promise((_, reject) => out.on('error', (e) => { writeError = e; reject(e); })); failed.catch(() => {});
let size = 0;
try {
// 파일이 실제로 열린 뒤에 쓰기 시작합니다. 열리기 전에 중단·삭제하면 삭제가 먼저 돌고 그 뒤 스트림이 파일을 만들어 디스크에 찌꺼기가 남습니다.
await Promise.race([once(out, 'open'), failed]);
for await (const chunk of stream) {
if (writeError) throw writeError;
size += chunk.length;
if (size > cfg.maxBytes) throw tooBig();
if (!out.write(chunk)) await Promise.race([once(out, 'drain'), failed]);
}
if (writeError) throw writeError;
await Promise.race([new Promise((resolve) => out.end(resolve)), failed]);
} catch (e) { if (!out.destroyed) { out.destroy(); await once(out, 'close').catch(() => {}); } await unlinkQuiet(path); if (e && e.code && !e.limit) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; } throw e; }
if (size === 0) { await unlinkQuiet(path); throw new Error('bad_request'); }
const name = clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'file';
await env.DB.prepare("INSERT INTO large_files(id,message_id,mailbox,filename,content_type,size,state,created_at) VALUES(?,?,?,?,?,?,'draft',?)")
.bind(id, draft.id, user.mailbox, name, clean(contentType, 120).split(';')[0] || 'application/octet-stream', size, now()).run();
return (await env.DB.prepare('SELECT * FROM large_files WHERE id=?').bind(id).first());
}
/** 메일에 딸린 대용량 파일 목록(화면용: 바이트는 빼고 상태·남은 횟수·기한). */
export async function listLargeFiles(env, messageId) {
const rows = (await env.DB.prepare('SELECT id,filename,content_type,size,state,max_downloads,downloads,expires_at,created_at,sent_at,last_download_at FROM large_files WHERE message_id=? ORDER BY created_at').bind(messageId).all()).results;
return rows.map((r) => ({ ...r, remaining: r.max_downloads == null ? null : Math.max(0, r.max_downloads - r.downloads), expired: r.state === 'expired' || (r.state === 'sent' && isExpired(r)) }));
}
const isExpired = (r) => (r.expires_at && Date.parse(r.expires_at) <= Date.now()) || (r.max_downloads != null && r.downloads >= r.max_downloads);
/**
* 파일을 뗍니다. 초안의 파일은 행과 파일을 지우고, 보낸 메일의 파일은 링크를 끊습니다(파일 삭제 + state='expired', 행은 기록으로 남김).
* 소유 확인은 메일함(mailbox)으로 합니다. 없으면 not_found.
*/
export async function removeLargeFile(env, id, mailbox, messageId = null) {
const row = await env.DB.prepare('SELECT * FROM large_files WHERE id=? AND mailbox=?').bind(id, mailbox).first();
if (!row || (messageId && row.message_id && row.message_id !== messageId)) throw new Error('not_found'); // 다른 메일의 파일 id 를 끼워 넣어도 그 메일 경로로는 지울 수 없음(0.9.5)
await unlinkQuiet(pathOf(env, id));
if (row.state === 'draft') await env.DB.prepare('DELETE FROM large_files WHERE id=?').bind(id).run();
else await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(id).run();
return row;
}
// ------------------------------------------------------------------ 발송(링크 만들기)
/**
* 발송 직전에 초안의 대용량 파일에 기한·횟수·토큰을 붙입니다(store.sendDraft 가 부름).
* @param {object} terms { days, downloads } — 둘 다 필수, 1 이상이고 관리자 상한 이내. 어긋나면 large_file_terms(422, field·limit 동봉).
* @param {string} host 링크에 쓸 공개 호스트(PUBLIC_HOST)
* @returns {Promise<{files: object[], html: string, text: string}>} 본문에 덧붙일 HTML/텍스트 블록. 파일이 없으면 files 가 비고 html/text 는 ''.
*/
export async function finalizeLargeFiles(env, draftId, terms, host, strings = {}) {
// 발송에 실패했던 초안을 다시 보낼 때는 지난 시도에서 이미 링크를 만든 파일(state='sent')을 그대로 씁니다(0.9.5). 예전에는 실패 시 링크를 되돌리고 재시도 때 새 토큰을 만들어,
// 같은 멱등 키(send_key)로 다른 본문을 보내게 되어 발송 서비스가 거부했고, 첫 시도가 실제로는 전달됐다면 받는 사람의 링크가 죽었습니다. 같은 토큰이면 재시도 본문이 똑같습니다.
const files = (await env.DB.prepare("SELECT * FROM large_files WHERE message_id=? AND state IN ('draft','sent') ORDER BY created_at").bind(draftId).all()).results;
if (!files.length) return { files: [], html: '', text: '' };
const pending = files.filter((f) => f.state === 'draft'), kept = files.filter((f) => f.state === 'sent');
const cfg = await readLargeFileSettings(env);
const check = (v, max, field) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } return n; };
// 새 파일이 있으면 조건이 필요하고, 모두 지난 시도의 파일이면 그때의 조건을 그대로 씁니다.
const days = pending.length ? check(terms?.days, cfg.maxDays, 'days') : Math.max(1, Math.round((Date.parse(kept[0].expires_at) - Date.parse(kept[0].sent_at)) / 86400000));
const downloads = pending.length ? check(terms?.downloads, cfg.maxDownloads, 'downloads') : kept[0].max_downloads;
const stamp = now(), expiresAt = pending.length ? new Date(Date.now() + days * 86400000).toISOString() : kept[0].expires_at;
const out = kept.map((f) => ({ ...f, url: `https://${host}/dl/${f.token}` }));
for (const f of pending) {
const token = secret(24);
await env.DB.prepare("UPDATE large_files SET token=?,state='sent',max_downloads=?,expires_at=?,sent_at=? WHERE id=?").bind(token, downloads, expiresAt, stamp, f.id).run();
out.push({ ...f, token, max_downloads: downloads, expires_at: expiresAt, url: `https://${host}/dl/${token}` });
}
return { files: out, ...bodyBlock(out, { days, downloads, expiresAt }, env, strings) };
}
/** 발송 조건만 미리 검사합니다(발송 시작 전에 불러 어긋나면 초안이 '발송 실패' 로 잠기지 않게). 파일이 없으면 아무것도 하지 않습니다. */
export async function checkLargeFileTerms(env, draftId, terms) {
const n = await env.DB.prepare("SELECT SUM(CASE WHEN state='draft' THEN 1 ELSE 0 END) AS pending, COUNT(*) AS total FROM large_files WHERE message_id=? AND state IN ('draft','sent')").bind(draftId).first();
if (!Number(n?.total || 0)) return false;
if (!Number(n?.pending || 0)) return true; // 지난 시도에서 링크를 만든 파일뿐이면(재시도) 조건을 다시 묻지 않습니다.
const cfg = await readLargeFileSettings(env);
for (const [field, max] of [['days', cfg.maxDays], ['downloads', cfg.maxDownloads]]) { const v = Number(terms?.[field]); if (!Number.isInteger(v) || v < 1 || v > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } }
return true;
}
/** 바이트 수를 사람이 읽는 크기로(1.2 GB / 34.5 MB / 120 KB). */
export const humanSize = (n) => n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
/** 기한을 통계 시간대(기본 Asia/Seoul)의 "YYYY-MM-DD HH:MM" 로. 메일 본문·랜딩 페이지에 씁니다. */
export function formatExpiry(iso, env = {}) {
const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul');
try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(iso)).map((x) => [x.type, x.value])); return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`; }
catch { return String(iso).slice(0, 16).replace('T', ' '); }
}
/** 본문 끝에 덧붙이는 "대용량 첨부파일" 블록(HTML + 텍스트). 링크·파일 이름·크기·기한·횟수를 담고, 받는 사람이 만료 규칙을 알 수 있게 안내문을 붙입니다. */
function bodyBlock(files, { downloads, expiresAt }, env, s) {
const t = (k, d) => s[k] || d, until = formatExpiry(expiresAt, env);
const note = t('largeFileNote', '링크는 기한이 지나거나 다운로드 횟수를 다 쓰면 만료되어 파일을 받을 수 없습니다.');
const terms = t('largeFileTerms', '{until} 까지 · {downloads}회').replace('{until}', until).replace('{downloads}', String(downloads));
const html = `<div class="large-files" style="margin-top:18px;padding:12px 14px;border:1px solid #d9dee5;border-radius:8px;font-size:14px"><p style="margin:0 0 6px"><strong>${escapeHtml(t('largeFileHeading', '대용량 첨부파일'))}</strong></p><ul style="margin:0;padding-left:18px">${files.map((f) => `<li><a href="${escapeHtml(f.url)}">${escapeHtml(f.filename)}</a> (${humanSize(f.size)}) — ${escapeHtml(terms)}</li>`).join('')}</ul><p style="margin:8px 0 0;font-size:12px;color:#6b7a8c">${escapeHtml(note)}</p></div>`;
const text = `\n\n[${t('largeFileHeading', '대용량 첨부파일')}]\n${files.map((f) => `- ${f.filename} (${humanSize(f.size)}): ${f.url} (${terms})`).join('\n')}\n${note}\n`;
return { html, text };
}
// ------------------------------------------------------------------ 다운로드(공개 링크)
/** 토큰으로 파일 정보를 찾습니다(랜딩 페이지용, 횟수는 세지 않음). 없거나 만료면 { status: 'missing'|'expired' }. */
export async function largeFileInfo(env, token) {
const row = await env.DB.prepare('SELECT * FROM large_files WHERE token=?').bind(String(token || '')).first();
if (!row || row.state === 'draft') return { status: 'missing' };
if (row.state === 'expired' || isExpired(row)) return { status: 'expired', file: row };
return { status: 'ok', file: row, remaining: row.max_downloads - row.downloads };
}
/**
* 다운로드 한 번을 차지합니다: 살아 있고 횟수가 남은 파일에만 downloads 를 1 올리고 그 행을 돌려줍니다(한 문장의 UPDATE 라 동시에 눌러도 횟수를 넘지 않음).
* @returns {Promise<{row:object, path:string, last:boolean}|null>} last 는 이번이 마지막 허용 횟수인지(끝나면 파일을 지움)
*/
export async function claimDownload(env, token) {
const row = await env.DB.prepare("UPDATE large_files SET downloads=downloads+1,last_download_at=? WHERE token=? AND state='sent' AND downloads<max_downloads AND expires_at>? RETURNING *").bind(now(), String(token || ''), now()).first();
if (!row) return null;
try { await stat(pathOf(env, row.id)); } catch { await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(row.id).run(); return null; }
return { row, path: pathOf(env, row.id), last: row.downloads >= row.max_downloads };
}
/** 다운로드 스트림(index.mjs 가 응답에 파이프). */
export const openLargeFile = (path) => createReadStream(path);
/** 마지막 허용 횟수의 다운로드가 끝난 뒤: 파일을 지우고 만료 처리합니다. */
export async function finishDownload(env, claim) {
if (!claim?.last) return;
await unlinkQuiet(claim.path);
await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(claim.row.id).run();
}
// ------------------------------------------------------------------ 정리·통계
/** 보낸 사람이 지운 초안(메일 행이 사라져 message_id 가 NULL)의 파일이 남아 있을 수 있는 유예 시간. 그 뒤 정리합니다. */
const ORPHAN_GRACE_MS = 24 * 3600 * 1000;
/**
* 주기 정리(1시간마다): 기한이 지났거나 횟수를 다 쓴 보낸 파일, 메일이 사라진 초안 파일, 하루 넘게 보내지 않은 초안의 파일 중 초안이 휴지통에 있는 것을 지웁니다.
* 작성 중인 초안(임시 보관함)의 파일은 그대로 둡니다.
* @returns {Promise<{expired:number, removed:number}>}
*/
export async function cleanupLargeFiles(env) {
const stamp = now(); let expired = 0, removed = 0;
const dead = (await env.DB.prepare("SELECT id FROM large_files WHERE state='sent' AND (expires_at<=? OR downloads>=max_downloads)").bind(stamp).all()).results;
for (const r of dead) { await unlinkQuiet(pathOf(env, r.id)); await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(r.id).run(); expired++; }
const cutoff = new Date(Date.now() - ORPHAN_GRACE_MS).toISOString();
// 유예는 초안을 버린 시점(휴지통으로 옮긴 updated_at, 메일이 지워졌으면 파일 올린 시점) 기준입니다. 예전에는 올린 시점 기준이라 하루 넘게 쓰던 초안을 버리면 한 시간 안에 파일이 사라졌습니다(0.9.5).
const orphans = (await env.DB.prepare(`SELECT f.id FROM large_files f LEFT JOIN messages m ON m.id=f.message_id WHERE f.state='draft' AND ((m.id IS NULL AND f.created_at<?) OR (m.folder='trash' AND m.updated_at<?))`).bind(cutoff, cutoff).all()).results;
for (const r of orphans) { await unlinkQuiet(pathOf(env, r.id)); await env.DB.prepare('DELETE FROM large_files WHERE id=?').bind(r.id).run(); removed++; }
return { expired, removed };
}
/** 디스크에 있는(만료되지 않은) 대용량 파일 수와 바이트 합계. 관리자 설정 카드·서버 상태에 보여 줍니다. */
export async function largeFileStats(env) {
const row = await env.DB.prepare("SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes, SUM(CASE WHEN state='sent' THEN 1 ELSE 0 END) AS sent FROM large_files WHERE state IN ('draft','sent')").first();
return { count: Number(row?.n || 0), bytes: Number(row?.bytes || 0), sent: Number(row?.sent || 0) };
}
+228
View File
@@ -0,0 +1,228 @@
/**
* IMAP 용 MIME 해석: 헤더·주소·RFC 2047 디코딩, multipart 트리, ENVELOPE / BODYSTRUCTURE / 섹션 추출.
* 저장한 원본(.eml)을 그대로 해석하며 바이트를 바꾸지 않습니다(부분 추출은 원본 슬라이스).
*/
const CRLF = '\r\n';
const latin1 = (b) => Buffer.from(b).toString('latin1');
/**
* 인코딩되지 않은 8비트 헤더(RFC 6532 식 UTF-8 제목·이름)를 복원합니다(0.9.0). 원본을 latin1 로 읽으므로 UTF-8 바이트가 글자마다 한 글자로
* 풀려 "íê¸ ì 목" 처럼 저장됐습니다. 0x80 이상 글자가 있고 UTF-8 로 되돌렸을 때 깨진 글자(U+FFFD)가 없으면 그 결과를 씁니다.
*/
export function fixUtf8(s) {
const str = String(s ?? ''); if (!/[\u0080-\u00ff]/.test(str) || /[\u0100-\uffff]/.test(str)) return str;
const decoded = Buffer.from(str, 'latin1').toString('utf8'); return decoded.includes('\ufffd') ? str : decoded;
}
export function decodeWords(value) {
return fixUtf8(String(value ?? ''))
.replace(/(=\?[^?]+\?[bBqQ]\?[^?]*\?=)\s+(?==\?)/g, '$1')
.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (m, charset, enc, text) => {
try {
const bytes = enc.toLowerCase() === 'b' ? Buffer.from(text, 'base64') : Buffer.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
return new TextDecoder(charset.replace(/\*.*$/, '')).decode(bytes);
} catch { return m; }
});
}
export function decodeBody(bytes, encoding) {
let raw = Buffer.from(bytes);
const enc = String(encoding || '').toLowerCase();
if (enc === 'base64') raw = Buffer.from(latin1(raw).replace(/[^A-Za-z0-9+/=]/g, ''), 'base64');
else if (enc === 'quoted-printable') raw = Buffer.from(latin1(raw).replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
return raw;
}
export function decodeText(bytes, encoding, charset) {
try { return new TextDecoder(charset || 'utf-8').decode(decodeBody(bytes, encoding)); } catch { return decodeBody(bytes, encoding).toString('utf8'); }
}
/** 헤더 블록을 [name, value] 목록으로. 접힌 줄은 펼칩니다. */
export function parseHeaders(text) {
const out = [];
for (const line of String(text).replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
const i = line.indexOf(':'); if (i <= 0) continue;
out.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
}
return out;
}
export const header = (headers, name) => headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1] ?? null;
export function parseContentType(value) {
const [type, ...rest] = String(value || 'text/plain').split(';');
const [main = 'text', sub = 'plain'] = type.trim().toLowerCase().split('/');
return { type: main, subtype: sub || 'plain', params: parseParams(rest.join(';')) };
}
/**
* 헤더 파라미터(name="값"; filename*=UTF-8''%ED%95%9C.pdf …)를 객체로 바꿉니다. RFC 2231 을 지원합니다.
* - name*=charset'언어'%XX… : 퍼센트 인코딩을 풀고 지정한 문자 집합으로 해독합니다.
* - name*0=…; name*1*=… : 이어 쓰기 조각을 번호 순으로 합칩니다(별표가 붙은 조각만 퍼센트 인코딩).
* 예전에는 퍼센트 인코딩을 풀지 않아 한글 첨부 이름이 "%ED%95%9C.pdf" 로 보였고, 이어 쓰기 조각은 합치지 않았습니다.
*/
export function parseParams(text) {
const raw = {}; const re = /([A-Za-z0-9*_.-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/g; let m;
while ((m = re.exec(String(text || '')))) raw[m[1].toLowerCase()] = m[2] !== undefined ? m[2].replace(/\\(.)/g, '$1') : m[3];
const groups = {}; // 이름 → [{ index, extended, value }]
for (const [key, value] of Object.entries(raw)) {
const k = key.match(/^(.+?)(?:\*(\d+))?(\*)?$/);
(groups[k[1]] ||= []).push({ index: k[2] === undefined ? -1 : Number(k[2]), extended: !!k[3], value });
}
const params = {};
for (const [name, pieces] of Object.entries(groups)) {
// 이어 쓰기 조각(*0, *1 …)이 있으면 그것만 순서대로, 없으면 하나뿐인 값을 씁니다.
const parts = pieces.some((p) => p.index >= 0) ? pieces.filter((p) => p.index >= 0).sort((a, b) => a.index - b.index) : pieces.slice(0, 1);
if (!parts.some((p) => p.extended)) { params[name] = parts.map((p) => p.value).join(''); continue; }
// 첫 확장 조각은 charset'language'값 형식입니다.
let charset = 'utf-8'; const bytes = [];
parts.forEach((p, i) => {
let v = p.value;
if (p.extended && i === 0) { const q = v.match(/^([^']*)'[^']*'(.*)$/); if (q) { charset = q[1] || 'utf-8'; v = q[2]; } }
bytes.push(p.extended ? Buffer.from(v.replace(/%([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1') : Buffer.from(v, 'utf8'));
});
try { params[name] = new TextDecoder(charset).decode(Buffer.concat(bytes)); } catch { params[name] = Buffer.concat(bytes).toString('utf8'); }
}
return params;
}
/**
* 주소 헤더(From/To/Cc …)를 {name, mailbox, host} 목록으로 나눕니다.
* 순서가 중요합니다: 먼저 "부호화된 그대로" 쉼표(따옴표 밖)로 나누고 그 다음에 조각마다 encoded-word 를 풉니다.
* 예전에는 먼저 풀고 나눠서, 이름에 쉼표가 든 "김, 민수" 가 두 주소로 갈라졌습니다. 그룹 구문(name: a, b;)의 이름 제거도
* 조각 단위로, 따옴표 밖에 있고 뒤에 숫자가 오지 않을 때만 합니다("Meeting 10:30", "Re: invoice" 같은 이름이 잘리던 문제, 0.8.16 수정).
*/
export function parseAddresses(value) {
const out = []; let raw = String(value || '');
if (!raw.trim()) return out;
// 그룹 구문은 "이름: 구성원, 구성원;" 처럼 세미콜론으로 끝납니다. 끝나는 세미콜론이 있을 때만 이름을 버리고 구성원만 남깁니다.
// 세미콜론 없이 콜론만 있는 "Re: invoice <c@x>" 같은 (규격에는 어긋나지만 흔한) 이름은 그대로 둡니다.
raw = raw.replace(/(^|,)(\s*)[^"<>@:;,]+:\s*([^;:]*);/g, '$1$2$3');
for (const part of raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)) {
const p = decodeWords(part).trim(); if (!p) continue;
const m = p.match(/^(.*?)\s*<([^<>]*)>\s*$/);
let name = '', addr = p;
if (m) { name = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1'); addr = m[2].trim(); }
const at = addr.lastIndexOf('@');
out.push({ name: name || null, mailbox: at > 0 ? addr.slice(0, at) : addr, host: at > 0 ? addr.slice(at + 1) : null });
}
return out;
}
/**
* 원본 바이트를 파트 트리로. 각 파트: {headers, type, subtype, params, encoding, id, description, disposition, dispositionParams,
* start, headerEnd, bodyStart, end, parts(children), size, lines}
*/
export function parseMime(eml) {
const buf = Buffer.from(eml); const s = latin1(buf);
function parsePart(start, end) {
let headerEnd, bodyStart;
// 헤더가 없는 파트(RFC 2046 5.1: 경계 바로 뒤에 빈 줄 → text/plain us-ascii)는 CRLF 로 시작합니다. 예전에는 CRLFCRLF 를 찾다
// 본문을 통째로 잃거나(크기 0) 본문의 첫 단락을 헤더로 잘못 읽었습니다(0.8.25 수정).
if (s.startsWith('\r\n', start)) { headerEnd = start; bodyStart = Math.min(start + 2, end); }
else { const sep = s.indexOf('\r\n\r\n', start); if (sep < 0 || sep >= end) { headerEnd = end; bodyStart = end; } else { headerEnd = sep + 2; bodyStart = sep + 4; } }
const headers = parseHeaders(s.slice(start, headerEnd));
const ct = parseContentType(header(headers, 'content-type'));
const cd = parseContentType(header(headers, 'content-disposition') || '');
const body = s.slice(bodyStart, end);
const part = {
headers, type: ct.type, subtype: ct.subtype, params: ct.params,
encoding: (header(headers, 'content-transfer-encoding') || '7bit').trim().toLowerCase(),
id: header(headers, 'content-id'), description: header(headers, 'content-description'),
disposition: header(headers, 'content-disposition') ? cd.type : null, dispositionParams: cd.params,
start, headerEnd, bodyStart, end, parts: [],
size: end - bodyStart, lines: body ? body.split('\n').length - (body.endsWith('\n') ? 1 : 0) : 0,
};
if (ct.type === 'multipart' && ct.params.boundary) {
// 경계 줄은 "--경계" 바로 뒤가 "--"(끝), 공백 또는 줄바꿈이어야 합니다. 예전에는 접두사만 비교해서 바깥 경계 "b1" 이
// 안쪽 경계 "--b1x" 줄과도 맞아 파트가 잘못 나뉘었습니다(BODYSTRUCTURE·섹션 번호 오류).
const b = '--' + ct.params.boundary;
const isBoundary = (at) => { const c = s.charAt(at + b.length); return c === '' || c === '-' || c === '\r' || c === '\n' || c === ' ' || c === '\t'; };
const find = (from, prefix) => { let at = s.indexOf(prefix + b, from); while (at >= 0 && !isBoundary(at + prefix.length)) at = s.indexOf(prefix + b, at + 1); return at; };
let pos = s.startsWith(b, bodyStart) && isBoundary(bodyStart) ? bodyStart : find(bodyStart, '\n'); if (pos >= 0 && s[pos] === '\n') pos += 1;
while (pos >= 0 && pos < end) {
if (s.startsWith(b + '--', pos)) break;
const lineEnd = s.indexOf('\n', pos); if (lineEnd < 0) break;
const childStart = lineEnd + 1;
let next = find(childStart, '\r\n'); if (next < 0 || next > end) next = -1;
part.parts.push(parsePart(childStart, next < 0 ? end : next));
if (next < 0) break; pos = next + 2;
}
} else if (ct.type === 'message' && ct.subtype === 'rfc822') {
part.parts.push(parsePart(bodyStart, end));
}
return part;
}
return { buffer: buf, text: s, root: parsePart(0, s.length) };
}
// ------------------------------------------------------------------ IMAP 문자열 표현
export const nstring = (v) => v === null || v === undefined ? 'NIL' : imapString(String(v));
export function imapString(v) {
const s = String(v);
// 헤더 값은 원본 바이트를 latin1 로 읽은 "바이트 문자열"(문자 하나 = 바이트 하나)입니다. 리터럴 길이는 그 바이트 수이고,
// 세션(imap.mjs send)은 이런 문자열을 latin1 로 써서 바이트를 그대로 내보냅니다. 예전에는 길이를 UTF-8 로 다시 재고 UTF-8 로 써서
// 한글 제목이 두 번 인코딩돼 깨졌습니다. 진짜 유니코드(0xFF 초과 문자)가 들어오면 UTF-8 바이트 문자열로 바꿔 같은 규칙을 적용합니다.
if (/[\r\n]/.test(s) || /[^\x01-\x7f]/.test(s)) { const raw = /[^\u0000-\u00ff]/.test(s) ? Buffer.from(s, 'utf8').toString('latin1') : s; return `{${raw.length}}${CRLF}${raw}`; }
return '"' + s.replace(/[\\"]/g, (c) => '\\' + c) + '"';
}
const addressList = (list) => list.length ? '(' + list.map((a) => `(${nstring(a.name)} NIL ${nstring(a.mailbox)} ${nstring(a.host)})`).join('') + ')' : 'NIL';
export function envelope(headers) {
const from = parseAddresses(header(headers, 'from')), sender = parseAddresses(header(headers, 'sender')), replyTo = parseAddresses(header(headers, 'reply-to'));
return '(' + [
nstring(header(headers, 'date')), nstring(header(headers, 'subject')),
addressList(from), addressList(sender.length ? sender : from), addressList(replyTo.length ? replyTo : from),
addressList(parseAddresses(header(headers, 'to'))), addressList(parseAddresses(header(headers, 'cc'))), addressList(parseAddresses(header(headers, 'bcc'))),
nstring(header(headers, 'in-reply-to')), nstring(header(headers, 'message-id')),
].join(' ') + ')';
}
const paramList = (params) => { const e = Object.entries(params || {}); return e.length ? '(' + e.map(([k, v]) => `${imapString(k.toUpperCase())} ${imapString(v)}`).join(' ') + ')' : 'NIL'; };
const dispositionField = (part) => part.disposition ? `(${imapString(part.disposition.toUpperCase())} ${paramList(part.dispositionParams)})` : 'NIL';
export function bodyStructure(part, extended = true) {
if (part.type === 'multipart') {
const children = part.parts.map((p) => bodyStructure(p, extended)).join('');
return `(${children || '("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 0 0)'} ${imapString(part.subtype.toUpperCase())}${extended ? ` ${paramList(part.params)} ${dispositionField(part)} NIL NIL` : ''})`;
}
const basic = [imapString(part.type.toUpperCase()), imapString(part.subtype.toUpperCase()), paramList(part.params), nstring(part.id), nstring(part.description), imapString(part.encoding.toUpperCase()), String(part.size)];
if (part.type === 'text') basic.push(String(part.lines));
else if (part.type === 'message' && part.subtype === 'rfc822' && part.parts[0]) basic.push(envelope(part.parts[0].headers), bodyStructure(part.parts[0], extended), String(part.lines));
if (extended) basic.push('NIL', dispositionField(part), 'NIL', 'NIL');
return '(' + basic.join(' ') + ')';
}
/** 섹션 지정자(BODY[...] 의 대괄호 안)를 바이트로 돌려줍니다. */
export function section(parsed, spec) {
const { text, root } = parsed; const s = String(spec || '').trim();
const slice = (a, b) => Buffer.from(text.slice(a, b), 'latin1');
if (s === '') return parsed.buffer;
const m = s.match(/^((?:\d+\.)*\d+)(?:\.(MIME|HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\)))?$/i) || s.match(/^()(HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\))$/i);
if (!m) return null;
let part = root;
if (m[1]) {
for (const n of m[1].split('.').map(Number)) {
if (part.type === 'multipart') part = part.parts[n - 1];
else if (part.type === 'message' && part.subtype === 'rfc822') part = part.parts[0] && (part.parts[0].type === 'multipart' ? part.parts[0].parts[n - 1] : n === 1 ? part.parts[0] : null);
else part = n === 1 ? part : null;
if (!part) return null;
}
}
const what = (m[2] || '').toUpperCase();
if (!what) return m[1] ? slice(part.bodyStart, part.end) : parsed.buffer;
const target = m[1] && what !== 'MIME' && part.type === 'message' && part.parts[0] ? part.parts[0] : part;
if (what === 'MIME') return slice(part.start, part.bodyStart);
if (what === 'HEADER') return slice(target.start, target.bodyStart);
if (what === 'TEXT') return slice(target.bodyStart, target.end);
const fm = what.match(/^HEADER\.FIELDS(\.NOT)?\s*\(([^)]*)\)$/); if (!fm) return null;
const wanted = new Set(fm[2].split(/\s+/).filter(Boolean).map((x) => x.replace(/^"|"$/g, '').toLowerCase())), not = !!fm[1];
const lines = text.slice(target.start, target.headerEnd).split(/\r\n(?![ \t])/).filter(Boolean);
const picked = lines.filter((line) => { const name = line.slice(0, line.indexOf(':')).trim().toLowerCase(); return wanted.has(name) !== not; });
return Buffer.from(picked.join(CRLF) + (picked.length ? CRLF : '') + CRLF, 'latin1');
}
/** 웹 저장용 요약: 제목·발신·수신·본문(text/html)·첨부. */
export function summarize(parsed) {
const h = parsed.root.headers; const addr = (a) => a.host ? `${a.mailbox}@${a.host}` : a.mailbox;
const out = { subject: decodeWords(header(h, 'subject') || ''), from: decodeWords(header(h, 'from') || ''), to: parseAddresses(header(h, 'to')).map(addr), cc: parseAddresses(header(h, 'cc')).map(addr), bcc: parseAddresses(header(h, 'bcc')).map(addr), replyTo: parseAddresses(header(h, 'reply-to')).map(addr), messageId: header(h, 'message-id'), date: header(h, 'date'), text: '', html: '', attachments: [], headers: Object.fromEntries(h) };
const walk = (p) => {
if (p.type === 'multipart') { p.parts.forEach(walk); return; }
const body = Buffer.from(parsed.text.slice(p.bodyStart, p.end), 'latin1');
// 텍스트가 아닌 파트는 모두 첨부입니다. message/rfc822(메일을 "첨부로 전달")도 첨부로 둡니다.
// 예전에는 message 형식을 본문도 첨부도 아닌 것으로 건너뛰어, 첨부로 전달한 메일이 웹 화면과 SMTP 발송에서 사라졌습니다.
// 파일 이름이 붙은 텍스트(notes.txt, text/csv, Apple Mail 의 inline; filename=…)도 첨부입니다. 예전에는 본문도 첨부도 아닌 것으로 사라졌습니다.
const named = !!(p.dispositionParams.filename || p.params.name);
const isAttachment = p.disposition === 'attachment' || p.type !== 'text' || named || !['plain', 'html'].includes(p.subtype);
if (isAttachment) { out.attachments.push({ filename: decodeWords(p.dispositionParams.filename || p.params.name || (p.type === 'message' ? 'message.eml' : 'attachment')), contentType: `${p.type}/${p.subtype}`, contentId: p.id ? p.id.replace(/^<|>$/g, '') : null, disposition: p.disposition === 'inline' && p.id ? 'inline' : p.disposition === 'inline' ? 'attachment' : p.disposition || 'attachment', bytes: decodeBody(body, p.encoding) }); return; }
if (p.type === 'text' && p.subtype === 'html' && !out.html) out.html = decodeText(body, p.encoding, p.params.charset);
else if (p.type === 'text' && !out.text) out.text = decodeText(body, p.encoding, p.params.charset);
};
walk(parsed.root);
return out;
}
+476
View File
@@ -0,0 +1,476 @@
/**
* IMAP4rev1 서버 (RFC 3501) + LITERAL+, IDLE, UIDPLUS, MOVE, SPECIAL-USE, NAMESPACE, ID, UNSELECT, ENABLE.
*
* - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음.
* - 메일함: INBOX(받은), Sent(보낸), Drafts(초안), Trash(휴지통) — 웹 메일함의 folder 와 같은 저장소.
* - \Seen 은 read_at, \Draft 는 초안 상태, 나머지 플래그는 imap_flags 에 저장. UID 는 imap_uids(전역 단조 증가).
* - EXPUNGE 는 휴지통이 아니면 휴지통으로 이동, 휴지통에서는 영구 삭제(재수신 차단). MOVE/COPY 도 같은 저장소를 씁니다.
* - 평문 포트(143): 993 은 즉시 TLS 이고 143 은 STARTTLS 뒤 로그인이 원칙이지만, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이
* 켜져 있으면 TLS 없이도 LOGIN/AUTHENTICATE 를 받습니다(0.9.7, POP3 110 과 같은 스위치). 꺼져 있으면 LOGINDISABLED 를 광고하고 거절합니다.
* 접속마다 설정을 한 번 읽어(plainOk) 인사말의 CAPABILITY 와 로그인 허용에 같은 값을 씁니다. 그래서 인사말은 설정을 읽은 뒤에 보냅니다.
*/
import net from 'node:net';
import tls from 'node:tls';
import { verifyAdmin, serviceAllowed } from './auth.mjs';
import { serviceEnabled, plainLoginAllowed } from './limits.mjs';
import { normalizeCrlf } from './mime.mjs';
import { buildStoredEml, appendMessage, copyMessage } from './store.mjs';
import { parseMime, envelope, bodyStructure, section, header, decodeWords, decodeText, parseAddresses, summarize } from './imap-mime.mjs';
import { now, memoryRateStore } from './util.mjs';
const CRLF = '\r\n';
const rate = memoryRateStore();
const MAILBOXES = [['INBOX', 'inbox', []], ['Sent', 'sent', ['\\Sent']], ['Drafts', 'drafts', ['\\Drafts']], ['Trash', 'trash', ['\\Trash']]];
const FOLDER_NAME = Object.fromEntries(MAILBOXES.map(([n, f]) => [f, n]));
const PERSISTED = new Set(['\\Flagged', '\\Answered', '\\Deleted']);
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function folderOf(name) { const n = String(name ?? '').replace(/^"|"$/g, ''); if (/^inbox$/i.test(n)) return 'inbox'; return MAILBOXES.find(([m]) => m === n)?.[1] || null; }
export function internalDate(iso) { const d = new Date(iso || Date.now()); const p = (n) => String(n).padStart(2, '0'); return `${p(d.getUTCDate())}-${MONTHS[d.getUTCMonth()]}-${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} +0000`; }
/**
* APPEND 의 date-time("05-Oct-2026 23:30:00 +0900", 앞에 공백이 붙은 " 7-Oct-2026 …" 포함)을 ms 로. 시각과 시간대까지 반영합니다.
* 예전에는 SEARCH 용 parseImapDate(날짜만)를 써서 시각·시간대가 버려지고, 공백이 붙은 날은 아예 무시돼 보낸 메일함 정렬이 어긋났습니다.
*/
export function parseImapDateTime(s) {
const m = String(s).replace(/^"|"$/g, '').match(/^\s?(\d{1,2})-([A-Za-z]{3})-(\d{4}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/); if (!m) return null;
const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); if (mon < 0) return null;
const offset = (m[7] === '-' ? -1 : 1) * (Number(m[8]) * 60 + Number(m[9])) * 60000;
return Date.UTC(Number(m[3]), mon, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6])) - offset;
}
function parseImapDate(s) { const m = String(s).replace(/^"|"$/g, '').match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})/); if (!m) return null; const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); return mon < 0 ? null : Date.UTC(Number(m[3]), mon, Number(m[1])); }
function wildcard(pattern) { return new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/%/g, '[^/]*') + '$', 'i'); }
// ------------------------------------------------------------------ 명령 토큰화
export function tokenize(raw, literals = []) {
const out = []; const stack = [out]; let i = 0; const s = raw;
// 회선에서는 latin1 로 읽으므로 따옴표 문자열·원자는 UTF-8 로 되돌립니다(검색어·메일함 이름의 한글).
const utf8 = (v) => Buffer.from(v, 'latin1').toString('utf8');
const push = (v) => stack[stack.length - 1].push(typeof v === 'string' ? utf8(v) : v);
while (i < s.length) {
const c = s[i];
if (c === ' ' || c === '\t') { i++; continue; }
if (c === '(') { const arr = []; push(arr); stack.push(arr); i++; continue; }
if (c === ')') { if (stack.length > 1) stack.pop(); i++; continue; }
if (c === '"') { let j = i + 1, v = ''; while (j < s.length && s[j] !== '"') { if (s[j] === '\\' && j + 1 < s.length) { v += s[j + 1]; j += 2; } else v += s[j++]; } push(v); i = j + 1; continue; }
// NUL 쌍(\0번호\0)은 리터럴 자리표시자입니다. 짝이 없는 NUL(클라이언트가 보낸 날 바이트)은 indexOf 가 -1 을 돌려주고 i 가 0 으로 되돌아가
// 무한 루프(인증 전 한 줄로 프로세스 전체가 멈춤)가 됐으므로, 짝이 없으면 남은 글자를 버리고 끝냅니다(0.8.24).
if (c === '\0') { const j = s.indexOf('\0', i + 1); if (j < 0) break; const lit = literals[Number(s.slice(i + 1, j))]; stack[stack.length - 1].push(lit ? lit.toString('utf8') : ''); i = j + 1; continue; }
let j = i, depth = 0;
while (j < s.length) { const d = s[j]; if (d === '[') depth++; else if (d === ']') depth--; else if (depth === 0 && (d === ' ' || d === ')' || d === '(')) break; j++; }
push(s.slice(i, j)); i = j;
}
return out;
}
/** 시퀀스/UID 집합 → 스냅샷 인덱스 배열 */
export function resolveSet(spec, messages, byUid) {
const out = new Set(); if (!messages.length) return [];
const last = byUid ? messages[messages.length - 1].uid : messages.length;
for (const piece of String(spec).split(',')) {
let [a, b] = piece.split(':'); if (b === undefined) b = a;
const lo = a === '*' ? last : Number(a), hi = b === '*' ? last : Number(b);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
const [from, to] = lo <= hi ? [lo, hi] : [hi, lo];
messages.forEach((m, idx) => { const key = byUid ? m.uid : idx + 1; if (key >= from && key <= to) out.add(idx); });
}
return [...out].sort((x, y) => x - y);
}
class Session {
constructor(server, socket, { secure }) {
this.server = server; this.env = server.env; this.secure = secure; this.state = 'notauth'; this.user = null;
this.buf = Buffer.alloc(0); this.raw = ''; this.literals = []; this.needLiteral = 0; this.closed = false;
this.mailbox = null; this.messages = []; this.cache = new Map(); this.idle = null; this.continuation = null; this.failures = 0;
// plainOk: 이 접속에서 TLS 없이 로그인을 받을지(pop3.mjs 와 같은 규칙). 설정 읽기가 끝난 뒤 인사말을 보내고, 그 뒤에야 명령 큐가 돌기 시작합니다.
this.plainOk = secure || server.allowPlaintext === true;
this.ip = socket.remoteAddress || ''; this.attach(socket);
console.log(`IMAP 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`);
this.queue = this.loadPlainLogin().then(() => this.send(`* OK [CAPABILITY ${this.capabilities()}] ${server.hostname} IMAP4rev1 ready`));
}
async loadPlainLogin() {
if (this.secure || typeof this.server.allowPlaintext === 'boolean') return;
try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('IMAP 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; }
}
attach(socket) { this.socket = socket; socket.setTimeout(35 * 60 * 1000); socket.on('data', (c) => this.onData(c)); socket.on('timeout', () => this.close('* BYE timeout')); socket.on('error', () => { this.closed = true; }); socket.on('close', () => { this.closed = true; this.stopIdle(); }); }
// 응답 문자열은 latin1(문자 = 바이트)로 씁니다. ENVELOPE/BODYSTRUCTURE 의 리터럴은 원본 헤더 바이트를 담은 바이트 문자열이라
// UTF-8 로 쓰면 두 번 인코딩됩니다. 0xFF 를 넘는 문자가 섞인(진짜 유니코드) 줄만 UTF-8 로 씁니다.
send(line) { if (!this.closed) { const text = line + CRLF; this.socket.write(Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1')); } }
write(buffer) { if (!this.closed) this.socket.write(buffer); }
close(line) { if (line) this.send(line); this.closed = true; this.stopIdle(); try { this.socket.end(); } catch {} }
capabilities() {
const caps = ['IMAP4rev1', 'LITERAL+', 'IDLE', 'UIDPLUS', 'MOVE', 'SPECIAL-USE', 'NAMESPACE', 'ID', 'UNSELECT', 'ENABLE', 'CHILDREN'];
if (this.plainOk) caps.push('AUTH=PLAIN'); else caps.push('LOGINDISABLED'); // 평문 접속에서 로그인을 안 받으면 LOGINDISABLED(RFC 3501 7.2.1)
if (!this.secure && this.server.secureContext) caps.push('STARTTLS');
return caps.join(' ');
}
onData(chunk) {
// STARTTLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(명령 주입 방지, 아래 설명).
if (this.upgrading) return;
this.buf = Buffer.concat([this.buf, chunk]);
if (this.buf.length > 64 * 1024 * 1024) { this.close('* BYE too much data'); return; }
for (;;) {
if (this.needLiteral > 0) {
if (this.buf.length < this.needLiteral) return;
this.literals.push(this.buf.subarray(0, this.needLiteral)); this.raw += `\0${this.literals.length - 1}\0`;
this.buf = this.buf.subarray(this.needLiteral); this.needLiteral = 0; continue;
}
const idx = this.buf.indexOf('\r\n'); if (idx < 0) { if (this.buf.length > 65536) this.close('* BYE line too long'); return; }
const line = this.buf.subarray(0, idx).toString('latin1'); this.buf = this.buf.subarray(idx + 2);
const lit = line.match(/\{(\d+)(\+?)\}$/);
if (lit && !this.continuation && !this.idle) {
const n = Number(lit[1]); if (n > 40 * 1024 * 1024) { this.raw = ''; this.literals = []; this.send('* BAD literal too large'); continue; }
this.raw += line.slice(0, -lit[0].length); this.needLiteral = n; if (n === 0) { this.literals.push(Buffer.alloc(0)); this.raw += `\0${this.literals.length - 1}\0`; this.needLiteral = 0; }
if (!lit[2]) this.send('+ Ready for literal data');
continue;
}
const raw = this.raw + line, literals = this.literals; this.raw = ''; this.literals = [];
// STARTTLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STARTTLS 뒤에 붙어 온 명령은 TLS 전환 뒤에
// 암호화된 명령처럼 실행될 수 있으므로, STARTTLS 를 큐에 넣은 뒤 남은 입력을 버리고 전환이 끝날 때까지 받지 않습니다.
const isStartTls = !this.idle && !this.continuation && /^\S+\s+STARTTLS\s*$/i.test(raw);
if (isStartTls) { this.upgrading = true; this.buf = Buffer.alloc(0); }
this.queue = this.queue.then(() => this.dispatch(raw, literals)).catch((e) => { console.error('IMAP 오류', e?.stack || e?.message); this.send('* BAD internal error'); });
if (isStartTls) return;
}
}
async dispatch(raw, literals) {
if (this.closed) return;
if (this.continuation) { const fn = this.continuation; this.continuation = null; return fn(raw); }
if (this.idle) { if (raw.trim().toUpperCase() === 'DONE') { const tag = this.idle.tag; this.stopIdle(); await this.refresh({ expunge: true }); this.send(`${tag} OK IDLE terminated`); } return; }
const tokens = tokenize(raw, literals);
const tag = tokens[0]; if (!tag) return;
let name = String(tokens[1] || '').toUpperCase(), args = tokens.slice(2);
if (name === 'UID') { name = 'UID ' + String(args[0] || '').toUpperCase(); args = args.slice(1); }
try { await this.command(tag, name, args, raw, literals); }
catch (e) { if (e?.imap) this.send(`${tag} ${e.imap} ${e.message}`); else { console.error('IMAP 명령 오류', name, e?.stack || e?.message); this.send(`${tag} NO internal error`); } }
}
fail(kind, message) { const e = new Error(message); e.imap = kind; throw e; }
async command(tag, name, args, raw, literals = []) {
const needAuth = () => { if (this.state === 'notauth') this.fail('NO', 'login first'); };
const needSelected = () => { needAuth(); if (!this.mailbox) this.fail('NO', 'no mailbox selected'); };
switch (name) {
case 'CAPABILITY': this.send(`* CAPABILITY ${this.capabilities()}`); return this.send(`${tag} OK CAPABILITY completed`);
case 'NOOP': if (this.mailbox) await this.refresh({ expunge: true }); return this.send(`${tag} OK NOOP completed`);
case 'LOGOUT': this.send('* BYE logging out'); this.send(`${tag} OK LOGOUT completed`); return this.close();
case 'ID': this.send('* ID ("name" "arihakdang-mail-gateway" "vendor" "Ari Hakdang")'); return this.send(`${tag} OK ID completed`);
case 'ENABLE': this.send('* ENABLED'); return this.send(`${tag} OK ENABLE completed`);
case 'NAMESPACE': needAuth(); this.send('* NAMESPACE (("" "/")) NIL NIL'); return this.send(`${tag} OK NAMESPACE completed`);
case 'STARTTLS': return this.startTls(tag);
case 'LOGIN': return this.login(tag, args[0], args[1]);
case 'AUTHENTICATE': return this.authenticate(tag, args);
case 'LIST': case 'LSUB': case 'XLIST': needAuth(); return this.list(tag, name, args);
case 'SUBSCRIBE': case 'UNSUBSCRIBE': needAuth(); return this.send(`${tag} OK ${name} completed`);
case 'CREATE': case 'DELETE': case 'RENAME': needAuth(); return this.send(`${tag} NO [CANNOT] mailboxes are fixed (INBOX, Sent, Drafts, Trash)`);
case 'STATUS': needAuth(); return this.status(tag, args);
case 'SELECT': case 'EXAMINE': needAuth(); return this.select(tag, args[0], name === 'EXAMINE');
case 'APPEND': needAuth(); return this.append(tag, args, literals);
case 'CHECK': needSelected(); await this.refresh({ expunge: true }); return this.send(`${tag} OK CHECK completed`);
case 'CLOSE': needSelected(); if (!this.readOnly) await this.expunge(false); this.mailbox = null; this.messages = []; return this.send(`${tag} OK CLOSE completed`);
case 'UNSELECT': needSelected(); this.mailbox = null; this.messages = []; return this.send(`${tag} OK UNSELECT completed`);
case 'EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true); return this.send(`${tag} OK EXPUNGE completed`);
// EXAMINE(읽기 전용) 세션에서는 EXPUNGE 와 마찬가지로 UID EXPUNGE 도 거부합니다(예전에는 검사가 빠져 읽기 전용 세션이 메일을 지웠습니다, 0.8.24).
case 'UID EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true, resolveSet(args[0], this.messages, true)); return this.send(`${tag} OK EXPUNGE completed`);
case 'SEARCH': case 'UID SEARCH': needSelected(); return this.search(tag, args, name.startsWith('UID'));
case 'FETCH': case 'UID FETCH': needSelected(); return this.fetch(tag, args, name.startsWith('UID'));
case 'STORE': case 'UID STORE': needSelected(); return this.store(tag, args, name.startsWith('UID'));
case 'COPY': case 'UID COPY': case 'MOVE': case 'UID MOVE': needSelected(); return this.copy(tag, args, name.startsWith('UID'), name.endsWith('MOVE'));
case 'IDLE': needAuth(); return this.startIdle(tag);
default: return this.send(`${tag} BAD unknown command`);
}
}
// ---------------------------------------------------------------- 인증
async startTls(tag) {
this.upgrading = false; // 거절하면 평문 입력을 다시 받고, 전환하면 새 TLS 소켓으로 받습니다.
if (this.secure) return this.send(`${tag} NO already using TLS`);
if (!this.server.secureContext) return this.send(`${tag} NO TLS not available`);
this.send(`${tag} OK Begin TLS negotiation now`);
const plain = this.socket; ['data', 'timeout', 'error', 'close'].forEach((ev) => plain.removeAllListeners(ev));
const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) });
this.secure = true; this.plainOk = true; this.buf = Buffer.alloc(0); this.attach(secureSocket);
}
async login(tag, login, password) {
if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); }
if (this.state !== 'notauth') return this.send(`${tag} BAD already logged in`);
if (rate.blocked('imap:' + this.ip, { limit: 20 })) return this.close(`${tag} NO [AUTHENTICATIONFAILED] too many attempts`);
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다.
const env = this.server.resolveEnv ? this.server.resolveEnv(String(login || ''), sniName(this.socket)) : this.env;
const user = env ? await verifyAdmin(env, String(login || ''), String(password || '')) : null;
if (user) this.env = env;
if (!user) rate.hit('imap:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다
console.log(`IMAP 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login || '').slice(0, 80)}`);
if (!user) { this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500)); if (this.failures >= 3) return this.close(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); return this.send(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); }
// 관리자가 IMAP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다.
if (!(await serviceEnabled(this.env, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (관리자가 IMAP 서비스를 껐음)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP service is disabled by the administrator`); }
// 계정 설정에서 IMAP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~).
if (!(await serviceAllowed(this.env, user, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 IMAP 사용 안 함)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP access is disabled for this account`); }
this.user = user; this.state = 'auth';
this.send(`${tag} OK [CAPABILITY ${this.capabilities()}] LOGIN completed`);
}
async authenticate(tag, args) {
if (String(args[0] || '').toUpperCase() !== 'PLAIN') return this.send(`${tag} NO unsupported mechanism`);
if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); }
const finish = async (payload) => {
if (payload.trim() === '*') return this.send(`${tag} BAD cancelled`);
let parts; try { parts = Buffer.from(payload.trim(), 'base64').toString('utf8').split('\0'); } catch { return this.send(`${tag} BAD invalid response`); }
if (parts.length < 3) return this.send(`${tag} BAD invalid response`);
return this.login(tag, parts[1], parts[2]);
};
if (args[1]) return finish(args[1]);
this.send('+ '); this.continuation = finish;
}
// ---------------------------------------------------------------- 메일함
async list(tag, name, args) {
const reference = String(args[0] ?? ''), pattern = String(args[1] ?? '');
if (pattern === '') { this.send(`* ${name} (\\Noselect) "/" ""`); return this.send(`${tag} OK ${name} completed`); }
const re = wildcard((reference + pattern).replace(/^\/+/, ''));
for (const [mailbox, , attrs] of MAILBOXES) if (re.test(mailbox)) this.send(`* ${name} (\\HasNoChildren${attrs.length ? ' ' + attrs.join(' ') : ''}) "/" "${mailbox}"`);
this.send(`${tag} OK ${name} completed`);
}
async uidValidity() { const row = await this.env.DB.prepare("SELECT value FROM settings WHERE key='imap_uidvalidity'").first(); return Number(row?.value || 1); }
async snapshot(folder) {
// 새 UID 는 도착 순서대로: 인덱스 순회 방향에 따라 순서가 뒤집히지 않도록 명시적으로 정렬합니다.
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) SELECT id,folder FROM messages WHERE folder=? AND mailbox=? ORDER BY received_at, id').bind(folder, this.user.mailbox).run();
const rows = (await this.env.DB.prepare(`SELECT m.id, u.id AS uid, m.read_at, m.state, m.folder, m.raw_size, m.received_at, COALESCE(f.flags,'[]') AS flags FROM messages m JOIN imap_uids u ON u.message_id=m.id AND u.folder=m.folder LEFT JOIN imap_flags f ON f.message_id=m.id WHERE m.folder=? AND m.mailbox=? ORDER BY u.id`).bind(folder, this.user.mailbox).all()).results;
// state: 메일 상태(draft/sending/sent/received/failed). COPY/MOVE 가 Drafts 로 옮길 수 있는 메일인지 판단하는 데 씁니다(0.8.24).
return rows.map((r) => ({ id: r.id, uid: r.uid, seen: !!r.read_at, draft: r.state === 'draft' || r.folder === 'drafts', state: r.state, flags: JSON.parse(r.flags), size: r.raw_size, date: r.received_at }));
}
flagList(m) { const f = []; if (m.seen) f.push('\\Seen'); if (m.draft) f.push('\\Draft'); for (const x of m.flags) if (!f.includes(x)) f.push(x); return f; }
async uidNext() { const row = await this.env.DB.prepare('SELECT COALESCE(MAX(id),0)+1 AS n FROM imap_uids').first(); return row.n; }
async select(tag, name, readOnly) {
const folder = folderOf(name); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`);
this.mailbox = folder; this.readOnly = readOnly; this.messages = await this.snapshot(folder); this.cache.clear();
const unseen = this.messages.findIndex((m) => !m.seen);
this.send(`* ${this.messages.length} EXISTS`); this.send('* 0 RECENT');
if (unseen >= 0) this.send(`* OK [UNSEEN ${unseen + 1}] first unseen`);
this.send(`* OK [UIDVALIDITY ${await this.uidValidity()}] UIDs valid`); this.send(`* OK [UIDNEXT ${await this.uidNext()}] predicted next UID`);
this.send('* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)'); this.send(`* OK [PERMANENTFLAGS (${readOnly ? '' : '\\Answered \\Flagged \\Deleted \\Seen'})] flags`);
this.send(`${tag} OK [${readOnly ? 'READ-ONLY' : 'READ-WRITE'}] ${readOnly ? 'EXAMINE' : 'SELECT'} completed`);
}
async status(tag, args) {
const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`);
const items = Array.isArray(args[1]) ? args[1] : [args[1]].filter(Boolean);
const rows = await this.snapshot(folder); const out = [];
for (const item of items.map((x) => String(x).toUpperCase())) {
if (item === 'MESSAGES') out.push(`MESSAGES ${rows.length}`); else if (item === 'RECENT') out.push('RECENT 0');
else if (item === 'UNSEEN') out.push(`UNSEEN ${rows.filter((m) => !m.seen).length}`); else if (item === 'UIDNEXT') out.push(`UIDNEXT ${await this.uidNext()}`);
else if (item === 'UIDVALIDITY') out.push(`UIDVALIDITY ${await this.uidValidity()}`);
}
this.send(`* STATUS "${FOLDER_NAME[folder]}" (${out.join(' ')})`); this.send(`${tag} OK STATUS completed`);
}
/** 다른 세션·웹·수신으로 바뀐 내용을 알립니다. */
async refresh({ expunge = false } = {}) {
if (!this.mailbox) return;
const fresh = await this.snapshot(this.mailbox); const freshByUid = new Map(fresh.map((m) => [m.uid, m]));
if (expunge) { for (let i = this.messages.length - 1; i >= 0; i--) if (!freshByUid.has(this.messages[i].uid)) { this.messages.splice(i, 1); this.send(`* ${i + 1} EXPUNGE`); } }
const known = new Set(this.messages.map((m) => m.uid));
this.messages.forEach((m, i) => { const f = freshByUid.get(m.uid); if (f && this.flagList(f).join(' ') !== this.flagList(m).join(' ')) { Object.assign(m, f); this.send(`* ${i + 1} FETCH (FLAGS (${this.flagList(m).join(' ')}))`); } });
const added = fresh.filter((m) => !known.has(m.uid)); if (added.length) { this.messages.push(...added); this.send(`* ${this.messages.length} EXISTS`); }
}
// ---------------------------------------------------------------- 내용
async loadParsed(m) {
const hit = this.cache.get(m.id); if (hit) return hit;
const row = await this.env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(m.id).first();
let eml = row ? Buffer.from(row.eml) : null;
if (!eml) { const stored = await this.env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(m.id).first(); eml = stored ? await buildStoredEml(this.env, stored) : Buffer.from('Subject: (missing)\r\n\r\n'); }
const parsed = parseMime(normalizeCrlf(eml)); parsed.size = parsed.buffer.length;
if (this.cache.size > 40) this.cache.delete(this.cache.keys().next().value);
this.cache.set(m.id, parsed); return parsed;
}
parseFetchItems(spec) {
let items = Array.isArray(spec) ? spec : [spec];
const upper = items.map((x) => String(x).toUpperCase());
if (upper.length === 1 && upper[0] === 'ALL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE'];
else if (upper.length === 1 && upper[0] === 'FAST') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE'];
else if (upper.length === 1 && upper[0] === 'FULL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE', 'BODY'];
return items.map(String);
}
async fetch(tag, args, byUid) {
const indexes = resolveSet(args[0], this.messages, byUid); const items = this.parseFetchItems(args[1] ?? []);
if (byUid && !items.some((x) => x.toUpperCase() === 'UID')) items.unshift('UID');
for (const idx of indexes) {
const m = this.messages[idx]; const parts = []; let markSeen = false;
for (const item of items) {
const key = item.toUpperCase();
if (key === 'UID') parts.push(`UID ${m.uid}`);
else if (key === 'FLAGS') parts.push(`FLAGS (${this.flagList(m).join(' ')})`);
else if (key === 'INTERNALDATE') parts.push(`INTERNALDATE "${internalDate(m.date)}"`);
else if (key === 'RFC822.SIZE') parts.push(`RFC822.SIZE ${(await this.loadParsed(m)).size}`);
else if (key === 'ENVELOPE') parts.push(`ENVELOPE ${envelope((await this.loadParsed(m)).root.headers)}`);
else if (key === 'BODYSTRUCTURE') parts.push(`BODYSTRUCTURE ${bodyStructure((await this.loadParsed(m)).root, true)}`);
else if (key === 'BODY') parts.push(`BODY ${bodyStructure((await this.loadParsed(m)).root, false)}`);
else if (key === 'RFC822' || key === 'RFC822.HEADER' || key === 'RFC822.TEXT') {
const parsed = await this.loadParsed(m); const data = section(parsed, key === 'RFC822' ? '' : key === 'RFC822.HEADER' ? 'HEADER' : 'TEXT');
if (key !== 'RFC822.HEADER') markSeen = true; parts.push({ label: key, data });
} else {
const bm = item.match(/^(BODY(?:\.PEEK)?)\[([^\]]*)\](?:<(\d+)\.(\d+)>)?$/i); if (!bm) continue;
const parsed = await this.loadParsed(m); let data = section(parsed, bm[2]); if (!data) { parts.push(`BODY[${bm[2]}] NIL`); continue; }
let label = `BODY[${bm[2].toUpperCase().replace(/\s+/g, ' ')}]`;
if (bm[3] !== undefined) { const start = Number(bm[3]), len = Number(bm[4]); data = data.subarray(start, start + len); label += `<${start}>`; }
if (!/PEEK/i.test(bm[1])) markSeen = true; parts.push({ label, data });
}
}
if (markSeen && !m.seen && !this.readOnly) { await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=? AND read_at IS NULL').bind(now(), m.id).run(); m.seen = true; if (!items.some((x) => x.toUpperCase() === 'FLAGS')) parts.push(`FLAGS (${this.flagList(m).join(' ')})`); }
this.write(Buffer.from(`* ${idx + 1} FETCH (`));
// 문자열 조각(ENVELOPE·BODYSTRUCTURE 등)은 바이트 문자열이므로 latin1 로 씁니다(send 와 같은 규칙). 예전에는 UTF-8 로 써서 한글 제목이 깨졌습니다.
const bytes = (text) => Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1');
parts.forEach((p, i) => { if (i) this.write(Buffer.from(' ')); if (typeof p === 'string') this.write(bytes(p)); else { this.write(Buffer.from(`${p.label} {${p.data.length}}${CRLF}`)); this.write(p.data); } });
this.write(Buffer.from(`)${CRLF}`));
}
this.send(`${tag} OK FETCH completed`);
}
async store(tag, args, byUid) {
if (this.readOnly) return this.send(`${tag} NO mailbox is read-only`);
const indexes = resolveSet(args[0], this.messages, byUid); const op = String(args[1] || '').toUpperCase();
const om = op.match(/^([+-]?)FLAGS(\.SILENT)?$/); if (!om) return this.send(`${tag} BAD invalid STORE`);
const flags = (Array.isArray(args[2]) ? args[2] : args.slice(2)).map((f) => String(f)).map((f) => { const l = f.toLowerCase(); return l === '\\seen' ? '\\Seen' : l === '\\flagged' ? '\\Flagged' : l === '\\answered' ? '\\Answered' : l === '\\deleted' ? '\\Deleted' : l === '\\draft' ? '\\Draft' : f; });
for (const idx of indexes) {
const m = this.messages[idx]; let seen = m.seen; let persisted = new Set(m.flags);
const wantSeen = flags.includes('\\Seen'), persistedWanted = flags.filter((f) => PERSISTED.has(f));
if (om[1] === '+') { if (wantSeen) seen = true; persistedWanted.forEach((f) => persisted.add(f)); }
else if (om[1] === '-') { if (wantSeen) seen = false; persistedWanted.forEach((f) => persisted.delete(f)); }
else { seen = wantSeen; persisted = new Set(persistedWanted); }
if (seen !== m.seen) await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(seen ? now() : null, m.id).run();
const list = [...persisted]; if (list.join() !== m.flags.join()) await this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(list), now()).run();
m.seen = seen; m.flags = list;
if (!om[2]) this.send(`* ${idx + 1} FETCH (FLAGS (${this.flagList(m).join(' ')})${byUid ? ` UID ${m.uid}` : ''})`);
}
this.send(`${tag} OK STORE completed`);
}
async search(tag, args, byUid) {
let keys = args.slice(); if (String(keys[0] || '').toUpperCase() === 'CHARSET') keys = keys.slice(2);
// text(m): 검색용 캐시. body 는 원본 바이트 그대로(7bit 로 읽은 것), decoded 는 base64·quoted-printable 과
// 문자 집합을 풀어 낸 본문(text + html). 우리가 만드는 원본(buildEml)은 본문을 base64 로 쓰므로, 원본 바이트만
// 보던 예전 BODY/TEXT 검색은 본문 단어를 찾지 못했습니다.
const parsedCache = new Map(); const text = async (m) => { if (!parsedCache.has(m.id)) { const p = await this.loadParsed(m); let decoded = ''; try { const s = summarize(p); decoded = (s.text + '\n' + s.html).toLowerCase(); } catch { /* 해석 실패 시 원본 바이트 검색만 */ } parsedCache.set(m.id, { p, body: decodeText(p.buffer, '7bit').toLowerCase(), decoded }); } return parsedCache.get(m.id); };
const hdr = async (m, name) => header((await text(m)).p.root.headers, name) || '';
const dateOf = async (m, sent) => sent ? Date.parse(await hdr(m, 'date')) || Date.parse(m.date) : Date.parse(m.date);
const day = (t) => Math.floor(t / 86400000);
const evaluate = async (m, idx, list) => {
const next = () => list.shift(); const item = next();
// 괄호 묶음 "(FROM a SUBJECT b)" 은 토큰화에서 배열이 됩니다. OR/NOT 뒤에 오면 여기로 들어오므로 묶음 전체를
// AND 로 평가합니다. 예전에는 배열을 문자열 "FROM,a" 로 바꿔 default(참)로 빠져, OR (…) (…) 가 모두 참이 됐습니다.
if (Array.isArray(item)) return evalAll(m, idx, item);
const key = String(item ?? '').toUpperCase();
const has = (f) => this.flagList(m).includes(f);
switch (key) {
case 'ALL': return true; case 'ANSWERED': return has('\\Answered'); case 'DELETED': return has('\\Deleted'); case 'DRAFT': return has('\\Draft'); case 'FLAGGED': return has('\\Flagged'); case 'SEEN': return m.seen;
case 'UNANSWERED': return !has('\\Answered'); case 'UNDELETED': return !has('\\Deleted'); case 'UNDRAFT': return !has('\\Draft'); case 'UNFLAGGED': return !has('\\Flagged'); case 'UNSEEN': return !m.seen;
case 'NEW': case 'RECENT': return false; case 'OLD': return true; case 'KEYWORD': next(); return false; case 'UNKEYWORD': next(); return true;
case 'UID': return resolveSet(next(), this.messages, true).includes(idx);
case 'LARGER': return (await this.loadParsed(m)).size > Number(next()); case 'SMALLER': return (await this.loadParsed(m)).size < Number(next());
case 'SINCE': return day(await dateOf(m)) >= day(parseImapDate(next())); case 'BEFORE': return day(await dateOf(m)) < day(parseImapDate(next())); case 'ON': return day(await dateOf(m)) === day(parseImapDate(next()));
case 'SENTSINCE': return day(await dateOf(m, true)) >= day(parseImapDate(next())); case 'SENTBEFORE': return day(await dateOf(m, true)) < day(parseImapDate(next())); case 'SENTON': return day(await dateOf(m, true)) === day(parseImapDate(next()));
case 'FROM': case 'TO': case 'CC': case 'BCC': case 'SUBJECT': { const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, key)).toLowerCase().includes(v); }
case 'HEADER': { const name = String(next() ?? ''); const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, name)).toLowerCase().includes(v); }
case 'BODY': case 'TEXT': { const v = String(next() ?? '').toLowerCase(); const t = await text(m); return t.body.includes(v) || t.decoded.includes(v) || decodeWords(t.p.text).toLowerCase().includes(v); }
case 'NOT': return !(await evaluate(m, idx, list));
case 'OR': { const a = await evaluate(m, idx, list); const b = await evaluate(m, idx, list); return a || b; }
default:
if (/^[\d*:,]+$/.test(key)) return resolveSet(key, this.messages, false).includes(idx);
return true;
}
};
const evalAll = async (m, idx, list) => { const copy = list.slice(); let ok = true; while (copy.length) { const item = copy[0]; if (Array.isArray(item)) { copy.shift(); if (!(await evalAll(m, idx, item))) ok = false; } else if (!(await evaluate(m, idx, copy))) ok = false; } return ok; };
const hits = [];
for (let i = 0; i < this.messages.length; i++) if (await evalAll(this.messages[i], i, keys)) hits.push(byUid ? this.messages[i].uid : i + 1);
this.send(`* SEARCH${hits.length ? ' ' + hits.join(' ') : ''}`); this.send(`${tag} OK SEARCH completed`);
}
async copy(tag, args, byUid, move) {
const indexes = resolveSet(args[0], this.messages, byUid); const folder = folderOf(args[1]);
if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`);
if (move && this.readOnly) return this.send(`${tag} NO mailbox is read-only`);
// Drafts 에는 초안(발신·draft 상태)만 둘 수 있습니다. 받은 메일을 Drafts 로 옮기면 웹 메일이 그것을 초안으로 열어 편집·발송을 시도하다
// mail_locked 로 막혀 열 수 없는 메일이 됐습니다(0.8.24). 초안이 아닌 메일은 NO [CANNOT] 로 거부합니다.
if (folder === 'drafts' && indexes.some((idx) => !['draft', 'failed'].includes(this.messages[idx].state))) return this.send(`${tag} NO [CANNOT] only drafts can be put in Drafts`);
const validity = await this.uidValidity(); const src = [], dst = [];
for (const idx of indexes) {
const m = this.messages[idx];
if (move) {
if (folder !== this.mailbox) await this.env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(folder, now(), m.id).run();
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(m.id, folder).run();
const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(m.id, folder).first(); src.push(m.uid); dst.push(row.id);
} else {
const newId = await copyMessage(this.env, m.id, folder);
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(newId, folder).run();
const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(newId, folder).first(); src.push(m.uid); dst.push(row.id);
}
}
if (src.length) this.send(`* OK [COPYUID ${validity} ${src.join(',')} ${dst.join(',')}] ${move ? 'moved' : 'copied'}`);
if (move && folder !== this.mailbox) for (const idx of indexes.slice().reverse()) { this.messages.splice(idx, 1); this.send(`* ${idx + 1} EXPUNGE`); }
this.send(`${tag} OK ${move ? 'MOVE' : 'COPY'} completed`);
}
async expunge(report, onlyIndexes = null) {
const targets = this.messages.map((m, i) => ({ m, i })).filter(({ m, i }) => m.flags.includes('\\Deleted') && (!onlyIndexes || onlyIndexes.includes(i)));
for (const { m } of targets) {
// 휴지통에서 지우면 영구 삭제. 임시 보관함(Drafts)의 초안도 바로 지웁니다: Thunderbird·Apple Mail 은 초안을 자동 저장할 때마다
// 새 버전을 APPEND 하고 옛 버전에 \Deleted 를 붙여 EXPUNGE 하므로, 예전처럼 휴지통으로 옮기면 저장할 때마다 옛 초안이 휴지통에 쌓였습니다(0.8.16 수정).
// 초안은 발송 서비스에서 온 메일이 아니라 tombstone 이 필요 없습니다.
if (this.mailbox === 'trash' || this.mailbox === 'drafts') {
const row = await this.env.DB.prepare('SELECT provider_id FROM messages WHERE id=?').bind(m.id).first();
const statements = []; if (row?.provider_id) statements.push(this.env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(row.provider_id, now()));
statements.push(this.env.DB.prepare('DELETE FROM messages WHERE id=?').bind(m.id)); await this.env.DB.batch(statements);
} else {
await this.env.DB.batch([
this.env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=?").bind(now(), m.id),
this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(m.flags.filter((f) => f !== '\\Deleted')), now()),
]);
}
}
for (const { i } of targets.slice().reverse()) { this.messages.splice(i, 1); if (report) this.send(`* ${i + 1} EXPUNGE`); }
}
/**
* APPEND: 클라이언트가 올린 메일 원본을 메일함에 저장합니다(보낸 메일 사본·초안 등).
* @param {string} tag 명령 태그
* @param {Array} args 토큰화된 인자(메일함, [플래그 목록], [날짜], 원본 리터럴)
* @param {Buffer[]} literals 회선에서 받은 리터럴 원본 바이트. 메일 원본은 마지막 리터럴입니다.
* 왜 literals 를 따로 받는가: 토큰화는 리터럴을 UTF-8 문자열로 바꾸는데, 8bit EUC-KR 본문이나 이진 부분처럼
* UTF-8 이 아닌 바이트는 이 과정에서 U+FFFD 로 깨집니다. 원본은 받은 바이트 그대로 저장해야 합니다.
*/
async append(tag, args, literals = []) {
const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`);
let i = 1; let flags = []; let date = null;
if (Array.isArray(args[i])) { flags = args[i].map(String); i++; }
if (typeof args[i] === 'string' && /^\s?\d{1,2}-[A-Za-z]{3}-\d{4}/.test(args[i])) { date = args[i]; i++; }
const literal = args[i]; if (typeof literal !== 'string' || !literal.length) return this.send(`${tag} BAD APPEND needs message literal`);
const bytes = literals.length ? literals[literals.length - 1] : Buffer.from(literal, 'utf8');
// 메일함 최대 크기(0.9.0)를 넘으면 RFC 9208 의 OVERQUOTA 응답 코드로 거부합니다(메일 프로그램이 "용량 초과" 로 보여 줌).
let result;
try { result = await appendMessage(this.env, folder, bytes, { flags, internalDate: date && parseImapDateTime(date) ? new Date(parseImapDateTime(date)).toISOString() : null, mailbox: this.user.mailbox }); }
catch (e) { if (e.message === 'quota_exceeded') return this.send(`${tag} NO [OVERQUOTA] mailbox quota exceeded`); if (e.message === 'storage_full') return this.send(`${tag} NO [OVERQUOTA] server storage is full`); throw e; }
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(result.id, folder).run();
const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(result.id, folder).first();
if (this.mailbox === folder) await this.refresh();
this.send(`${tag} OK [APPENDUID ${await this.uidValidity()} ${row.id}] APPEND completed`);
}
startIdle(tag) {
this.send('+ idling'); this.idle = { tag, timer: setInterval(() => { this.queue = this.queue.then(() => this.refresh({ expunge: true })).catch(() => {}); }, Number(this.env.IMAP_IDLE_POLL_MS || 10000)) };
this.socket.setTimeout(35 * 60 * 1000);
}
stopIdle() { if (this.idle) { clearInterval(this.idle.timer); this.idle = null; } }
}
/** @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions */
/** allowPlaintext: true 항상 허용 / false 항상 거부 / null(기본) 접속마다 관리자 설정 "평문 로그인 허용" 을 읽음(0.9.7, pop3.mjs 와 같음). */
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */
const sniName = (socket) => String(socket?.servername || '');
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */
export function createImap(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) {
const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv };
const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); });
// secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7).
const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); });
secure.on('tlsClientError', () => {});
let pendingSecure = null;
const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); }));
return {
plain, secure,
setTls(options) {
shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options);
if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'imaps', []).then(() => console.log(` IMAP TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' IMAP TLS 포트 열기 실패:', e.message)); }
},
async listen({ plainPort, securePort, host = '0.0.0.0' }) {
const out = [];
if (plainPort) await listenOn(plain, plainPort, host, 'imap', out);
if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'imaps', out); else pendingSecure = { port: securePort, host }; }
return out;
},
close() { plain.close(); if (secure.listening) secure.close(); },
};
}
+17
View File
@@ -0,0 +1,17 @@
/**
* AriMail 진입점(1.0.5 부터 부트스트랩). systemd 의 ExecStart(`node server/index.mjs`)와 `npm start` 가 이 파일을 실행하고, 실제 서버는 main.mjs 입니다.
*
* 왜 두 파일로 나눴나
* Node 22 는 node:sqlite 를 처음 쓸 때 "ExperimentalWarning: SQLite is an experimental feature" 두 줄을 stderr 로 내보냅니다. 운영 로그(journal)에는
* 재시작마다 이 경고가 남아 오류 수집(1.0.5 요구사항 0) 때 잡음이 됐습니다. 경고 필터는 node:sqlite 를 import 하는 모듈이 평가되기 **전에** 걸어야 하는데,
* ESM 의 정적 import 는 모듈 본문보다 먼저 실행되므로 main.mjs 안에서는 늦습니다. 그래서 여기서 먼저 필터를 걸고 main.mjs 를 동적으로 불러옵니다.
* (ExecStart 에 --disable-warning 을 더하는 방법은 systemd 유닛을 다시 설치해야 해서 피했습니다: 운영 서버의 환경·유닛 파일은 바꾸지 않습니다.)
*
* 필터 규칙: ExperimentalWarning 중 SQLite 에 관한 것만 삼키고, 그 밖의 경고(Deprecation, MaxListenersExceeded 등)는 그대로 기록합니다 — 그것들은 진짜 문제의 신호입니다.
*/
process.removeAllListeners('warning');
process.on('warning', (warning) => {
if (warning?.name === 'ExperimentalWarning' && /sqlite/i.test(String(warning.message))) return;
console.warn(`(node:${process.pid}) ${warning.name}: ${warning.message}${warning.stack && process.env.NODE_DEBUG ? '\n' + warning.stack : ''}`);
});
await import('./main.mjs');
+296
View File
@@ -0,0 +1,296 @@
/**
* 시스템 제한·관리자 제한·사용량·현황 (0.9.0~).
*
* 세 층의 제한이 있습니다. 위 층이 아래 층의 상한이며, 어느 층이든 0 은 "그 층에서는 제한하지 않음" 입니다.
* 1) 시스템 설정(systemLimits): 설치 시 환경 파일(gateway.env)에 적는 값. 메일 시스템 전체의 상위 설정이라 관리자 화면에서 바꿀 수 없습니다.
* LIMIT_ACCOUNTS 허용 가능한 메일 계정 수(mail_users 행 수)
* LIMIT_TOTAL_BYTES 모든 메일함을 합친 최대 저장 용량(MIME 기준)
* LIMIT_MAILBOX_BYTES 메일함 하나의 최대 용량(MIME 기준)
* LIMIT_ATTACHMENT_BYTES 첨부 파일 한 개의 최대 크기(MIME 기준, 아래 mimeSize 참고)
* LIMIT_RECIPIENTS 한 메일의 최대 받는 사람 수(받는 사람 + 참조 + 숨은 참조)
* LIMIT_MONTHLY_MESSAGES 한 달(매월 1일~말일)의 최대 수발신 통수(보낸 메일 + 받은 메일)
* 크기 값은 바이트 수 또는 K / M / G 접미사(예: 500M, 2G)로 적습니다.
* 2) 관리자 설정(readAdminLimits): 관리자 페이지에서 정하는 값(settings 표의 limits 키). 시스템 제한이 있으면 그 값 이내로만 저장됩니다.
* mailboxBytes(계정별 기본 메일함 크기) · attachmentBytes(첨부 최대 크기) · recipients(동시 수신자 수)
* 3) 계정별 설정(readUserQuota): 관리자가 계정마다 정하는 메일함 최대 크기(settings 표의 quota:<계정 id> 키). 역시 시스템 제한 이내.
*
* 왜 "MIME 기준" 인가: 메일은 저장·전송될 때 첨부가 base64 로 바뀌어 약 4/3 배(37%)로 늘어납니다. 발송 서비스(Resend)의 한도와 저장 공간은
* 이 늘어난 크기에 맞춰 소진되므로, 모든 크기 제한과 사용량을 원본(.eml)의 바이트, 즉 MIME 변환 뒤의 크기로 셉니다.
* 화면은 이 점을 안내하고, 첨부 파일을 올릴 때는 파일 크기를 mimeSize() 로 바꿔 비교합니다.
*
* 그 밖에 이 모듈이 맡는 것: POP/IMAP 서비스 전체 켜기·끄기(services 키), 회원 가입 설정(signup 키), 서버 상태 화면의 메일 현황(stats).
* auth.mjs 가 이 모듈을 들여오므로 여기서는 auth.mjs 를 들여오지 않습니다(순환 방지). 메일함 주소·계정 id 는 인자로 받습니다.
*/
import { now } from './util.mjs';
import { isWebmaker, webmakerByMailbox } from './webmaker.mjs';
// ------------------------------------------------------------------ 상수
/** 어느 층에서도 제한하지 않았을 때 적용되는 내장 상한. 발송 서비스(Resend)의 한도에서 나온 값이라 넘길 수 없습니다. */
export const HARD_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 첨부 한 개(MIME 기준). Resend 는 메일 전체 40MB 까지 받습니다.
export const HARD_RECIPIENTS = 50; // Resend 의 받는 사람 한도
/** base64 로 바뀐 뒤의 크기(줄바꿈 제외). 첨부 파일의 원본 바이트를 MIME 기준 크기로 바꿀 때 씁니다. */
export const mimeSize = (bytes) => Math.ceil(Number(bytes || 0) / 3) * 4;
/** 0 이 아닌 값들 중 가장 작은 값. 모두 0(제한 없음)이면 0. 여러 층의 제한을 합칠 때 씁니다. */
export const minNonZero = (...values) => { const positive = values.map((v) => Number(v) || 0).filter((v) => v > 0); return positive.length ? Math.min(...positive) : 0; };
/** 사용 비율(%). 제한이 0(없음)이면 null. 화면이 "8개 사용 중 / 최대 10개 (80%)" 를 그리는 데 씁니다. */
export const percentOf = (used, limit) => (limit > 0 ? Math.min(999, Math.round((Number(used || 0) / limit) * 100)) : null);
/**
* 크기 문자열을 바이트 수로. "500M", "2G", "1024K", "123456" 을 받습니다(소수 허용: 1.5G). 비어 있거나 해석할 수 없으면 0(제한 없음).
* 환경 파일에 사람이 적는 값이므로 대소문자·공백·"B" 접미사(MB, GiB)는 너그럽게 받습니다.
*/
export function parseSize(value) {
const s = String(value ?? '').trim().toUpperCase().replace(/I?B$/, '');
const m = s.match(/^(\d+(?:\.\d+)?)\s*([KMGT]?)$/); if (!m) return 0;
const unit = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }[m[2]];
return Math.floor(Number(m[1]) * unit);
}
const nonNegInt = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : 0; };
/** 시스템 설정(환경 변수)을 읽습니다. 모든 값은 0 이면 제한 없음. */
export function systemLimits(env) {
return {
accounts: nonNegInt(env.LIMIT_ACCOUNTS), totalBytes: parseSize(env.LIMIT_TOTAL_BYTES), mailboxBytes: parseSize(env.LIMIT_MAILBOX_BYTES),
attachmentBytes: parseSize(env.LIMIT_ATTACHMENT_BYTES), recipients: nonNegInt(env.LIMIT_RECIPIENTS), monthlyMessages: nonNegInt(env.LIMIT_MONTHLY_MESSAGES),
};
}
// ------------------------------------------------------------------ settings 표 읽기·쓰기
async function readJson(env, key, defaults) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(key).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
return { ...defaults, ...(stored && typeof stored === 'object' ? stored : {}) };
}
async function writeJson(env, key, value) {
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, JSON.stringify(value), now()).run();
}
/** 제한 값 하나를 검사합니다: 0 이상의 정수여야 하고(아니면 limit_invalid), 시스템 제한이 있으면 그 이내여야 합니다(limit_exceeds_system). 오류에 field 를 붙입니다. */
function checkAgainstSystem(value, systemValue, field) {
// 숫자(또는 숫자 문자열)만 받습니다. 예전에는 true 가 1 로 저장돼 "받는 사람 1명" 같은 뜻밖의 제한이 생겼습니다(0.9.3).
const n = typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN;
if (!Number.isInteger(n) || n < 0) { const e = new Error('limit_invalid'); e.field = field; throw e; }
if (systemValue > 0 && n > systemValue) { const e = new Error('limit_exceeds_system'); e.field = field; e.limit = systemValue; throw e; }
return n;
}
export const DEFAULT_ADMIN_LIMITS = { mailboxBytes: 0, attachmentBytes: 0, recipients: 0 };
/** 관리자 제한(settings 의 limits). 저장된 값이 손상됐거나 시스템 제한을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎아 돌려줍니다. */
export async function readAdminLimits(env) {
const stored = await readJson(env, 'limits', DEFAULT_ADMIN_LIMITS), sys = systemLimits(env), out = {};
for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) { const v = nonNegInt(stored[k]); const cap = sys[k]; out[k] = cap > 0 && v > cap ? cap : v; }
return out;
}
/** 관리자 제한을 저장합니다(관리자 페이지). 각 값은 시스템 제한 이내여야 하며 0 은 "시스템 값을 따름" 입니다. */
export async function writeAdminLimits(env, patch) {
const current = await readAdminLimits(env), sys = systemLimits(env), next = { ...current };
// 내장 상한(첨부 25MB, 받는 사람 50)도 시스템 값처럼 넘길 수 없습니다. 예전에는 그보다 큰 값이 저장돼 관리자 화면과 실제 적용값이 달랐습니다(0.9.3).
const hard = { attachmentBytes: HARD_ATTACHMENT_BYTES, recipients: HARD_RECIPIENTS };
for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) if (patch[k] !== undefined) next[k] = checkAgainstSystem(patch[k], minNonZero(sys[k], hard[k] || 0), k);
await writeJson(env, 'limits', next);
return next;
}
/** 계정별 메일함 최대 크기(바이트, 0 = 관리자 기본값·시스템 값을 따름). */
export async function readUserQuota(env, userId) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('quota:' + userId).first();
return row ? nonNegInt(row.value) : 0;
}
/** 계정별 메일함 크기 값만 검사합니다(저장 전 검사용). 돌려주는 값은 정수 바이트. */
export function checkUserQuota(env, bytes) { return checkAgainstSystem(bytes, systemLimits(env).mailboxBytes, 'mailboxBytes'); }
/** 계정별 메일함 최대 크기를 저장합니다. 시스템의 메일함 제한을 넘을 수 없습니다. 0 이면 설정을 지웁니다. */
export async function writeUserQuota(env, userId, bytes) {
const n = checkUserQuota(env, bytes);
if (n === 0) await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run();
else await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('quota:' + userId, String(n), now()).run();
return n;
}
/** deleteUser 가 부릅니다: 계정의 제한 설정을 지웁니다. */
export async function clearUserQuota(env, userId) { await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run(); }
// ------------------------------------------------------------------ 실제로 적용되는 값(세 층 합산)
/**
* 메일함 주소에 적용되는 최대 크기(바이트, 0 = 제한 없음): 계정별 설정 → 관리자 기본값 → 시스템 제한 중 가장 작은 0 아닌 값.
* 기본 메일함(www 관리자가 쓰는 경우)은 계정 행이 없으므로 관리자 기본값·시스템 제한만 적용됩니다.
*/
export async function mailboxQuota(env, mailbox) {
const row = await env.DB.prepare('SELECT id FROM mail_users WHERE mailbox=? ORDER BY created_at LIMIT 1').bind(String(mailbox || '').toLowerCase()).first();
// 웹메이커 계정(1.0.8)은 mail_users 행이 없으므로 그 주체 id('wm:…')로 계정별 설정을 찾습니다.
const ownerId = row ? row.id : isWebmaker(env) ? (await webmakerByMailbox(env, mailbox))?.id : null;
const user = ownerId ? await readUserQuota(env, ownerId) : 0, admin = await readAdminLimits(env), sys = systemLimits(env);
return minNonZero(user, admin.mailboxBytes, sys.mailboxBytes);
}
/** 첨부 한 개의 최대 크기(MIME 기준 바이트). 관리자·시스템 값이 없어도 내장 상한(25MB)은 항상 적용됩니다. */
export async function attachmentLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.attachmentBytes, systemLimits(env).attachmentBytes, HARD_ATTACHMENT_BYTES); }
/** 한 메일의 최대 받는 사람 수. 내장 상한(50)은 항상 적용됩니다. */
export async function recipientLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.recipients, systemLimits(env).recipients, HARD_RECIPIENTS); }
// ------------------------------------------------------------------ 사용량
/**
* 메일함 사용량(바이트). 저장된 원본(.eml, raw_size)이 곧 MIME 기준 크기입니다. 원본이 아직 없는 메일(작성 중인 초안)은 첨부 크기를 대신 더합니다.
* 왜 raw_size 인가: 첨부가 base64 로 늘어난 뒤의 크기가 실제로 저장·전송되는 크기이고, 요구사항도 "MIME 변환시 늘어나는 크기를 감안" 하라고 합니다.
*/
export async function mailboxUsage(env, mailbox) {
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.mailbox=? AND d.raw_status='none'),0) AS bytes FROM messages WHERE mailbox=?`).bind(mailbox, mailbox).first();
return Number(row?.bytes || 0);
}
/** 모든 메일함을 합친 사용량(바이트). */
export async function totalUsage(env) {
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.raw_status='none'),0) AS bytes FROM messages`).first();
return Number(row?.bytes || 0);
}
/** 게이트웨이 계정 수(mail_users 행 수, 정지·승인 대기 포함). 계정 수 제한과 비교합니다. */
export async function accountCount(env) { return Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); }
// ------------------------------------------------------------------ 기간(일·주·월) — 통계 시간대 기준
/** 통계 시간대. 서버의 시간대가 UTC 여도 "오늘" 은 한국 기준이어야 하므로 기본값은 Asia/Seoul 이고 STATS_TIMEZONE 으로 바꿀 수 있습니다. */
export function statsTimezone(env) {
const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul').trim();
try { new Intl.DateTimeFormat('en-CA', { timeZone: tz }); return tz; } catch { return 'Asia/Seoul'; }
}
/** 시각을 주어진 시간대의 연·월·일·시·분·초로 풉니다. */
function tzParts(date, tz) {
const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
const p = Object.fromEntries(f.formatToParts(date).map((x) => [x.type, x.value]));
return { y: +p.year, m: +p.month, d: +p.day, h: +p.hour % 24, mi: +p.minute, s: +p.second };
}
/** 그 시각에 적용되는 시간대 오프셋(ms, UTC 보다 앞서면 양수). */
function tzOffset(date, tz) { const p = tzParts(date, tz); return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) - Math.floor(date.getTime() / 1000) * 1000; }
/** 시간대 기준 어느 날 0시의 순간(ms). 월·일이 범위를 넘어도(13월, 32일) Date.UTC 가 넘겨 계산합니다. 서머타임 경계는 두 번째 보정으로 맞춥니다. */
function localMidnight(y, m, d, tz) { const base = Date.UTC(y, m - 1, d); let t = base - tzOffset(new Date(base), tz); const off = tzOffset(new Date(t), tz); if (base - off !== t) t = base - off; return t; }
/**
* 통계 기간의 경계(ISO 문자열): 'day' 오늘 0시~내일 0시, 'week' 이번 주 월요일 0시~다음 월요일 0시, 'month' 이달 1일 0시~다음 달 1일 0시
* (요구사항: 월간은 매월 첫째 날부터 마지막 날까지). start 이상 end 미만이 그 기간입니다.
* @param {'day'|'week'|'month'} kind
* @param {Date} [at] 기준 시각(테스트용). 기본은 지금.
*/
export function periodBounds(kind, tz = 'Asia/Seoul', at = new Date()) {
const p = tzParts(at, tz); let start, end;
if (kind === 'day') { start = localMidnight(p.y, p.m, p.d, tz); end = localMidnight(p.y, p.m, p.d + 1, tz); }
else if (kind === 'week') { const dow = new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay(); const monday = p.d - ((dow + 6) % 7); start = localMidnight(p.y, p.m, monday, tz); end = localMidnight(p.y, p.m, monday + 7, tz); }
else { start = localMidnight(p.y, p.m, 1, tz); end = localMidnight(p.y, p.m + 1, 1, tz); }
return { start: new Date(start).toISOString(), end: new Date(end).toISOString() };
}
/**
* 기간 안의 보낸 메일·받은 메일 수. 보낸 메일은 발송이 끝난 메일(state='sent')의 sent_at, 받은 메일은 received_at 기준이며 폴더(휴지통 포함)는 가리지 않습니다.
* 영구 삭제한 메일은 세지 않습니다(행이 없음). mailbox 를 주면 그 메일함만.
*/
export async function countMail(env, { start, end }, mailbox = null) {
const scope = mailbox ? ' AND mailbox=?' : '', extra = mailbox ? [mailbox] : [];
const sent = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?${scope}`).bind(start, end, ...extra).first();
const received = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?${scope}`).bind(start, end, ...extra).first();
const s = Number(sent?.n || 0), r = Number(received?.n || 0);
return { sent: s, received: r, total: s + r, start, end };
}
/** 이달의 수발신 통수(월간 제한과 비교). */
export async function monthlyCount(env) { return (await countMail(env, periodBounds('month', statsTimezone(env)))).total; }
// ------------------------------------------------------------------ 제한 검사(발송·수신·첨부)
/**
* 발송 전 검사. 어긋나면 오류 코드를 던지며, 오류 객체에 limit(적용된 상한)을 붙여 화면·SMTP 응답이 구체적으로 안내합니다.
* too_many_recipients 받는 사람(참조·숨은 참조 포함)이 상한을 넘음
* attachment_too_large 첨부 한 개가 상한(MIME 기준)을 넘음
* quota_exceeded 보낸 메일을 저장하면 메일함 최대 크기를 넘음
* storage_full 전체 저장 용량(시스템)을 넘음
* monthly_limit_exceeded 이달 수발신 통수가 시스템 제한에 이름
* @param {object} p
* @param {string} p.mailbox 보내는 메일함(보낸 메일이 저장될 곳)
* @param {number} p.recipients 받는 사람 수
* @param {number[]} [p.attachmentSizes] 첨부별 원본 바이트(여기서 MIME 크기로 바꿔 비교)
* @param {number} [p.bytes] 저장될 원본 크기 추정(바이트, 메일함·전체 용량 검사용)
*/
export async function checkOutbound(env, { mailbox, recipients = 0, attachmentSizes = [], bytes = 0 }) {
const maxRcpt = await recipientLimit(env);
if (maxRcpt > 0 && recipients > maxRcpt) { const e = new Error('too_many_recipients'); e.limit = maxRcpt; throw e; }
const maxAttach = await attachmentLimit(env);
for (const size of attachmentSizes) if (mimeSize(size) > maxAttach) { const e = new Error('attachment_too_large'); e.limit = maxAttach; throw e; }
const sys = systemLimits(env);
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) { const e = new Error('monthly_limit_exceeded'); e.limit = sys.monthlyMessages; throw e; }
const quota = await mailboxQuota(env, mailbox);
if (quota > 0 && (await mailboxUsage(env, mailbox)) + bytes > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; }
if (sys.totalBytes > 0 && (await totalUsage(env)) + bytes > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; }
}
/**
* 수신 저장 전 검사. 받은 메일은 크기를 미리 알 수 없으므로 "이미 가득 찼는지" 만 봅니다.
* @returns {Promise<null|'quota_exceeded'|'storage_full'>} 저장할 수 없으면 사유. 동기화는 공간이 생기면 같은 메일을 다시 가져옵니다.
*/
export async function checkInbound(env, mailbox) {
const quota = await mailboxQuota(env, mailbox);
if (quota > 0 && (await mailboxUsage(env, mailbox)) >= quota) return 'quota_exceeded';
const sys = systemLimits(env);
if (sys.totalBytes > 0 && (await totalUsage(env)) >= sys.totalBytes) return 'storage_full';
return null;
}
/** 자동 전달·자동 응답처럼 메일 한 통당 발송이 생기는 작업 전에 월간 통수 제한만 확인합니다. 넘었으면 'limit'. */
export async function monthlyBlocked(env) { const sys = systemLimits(env); return sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages; }
// ------------------------------------------------------------------ POP/IMAP 서비스 전체 켜기·끄기, 회원 가입 설정
/**
* 서비스 전체 스위치(settings 의 services).
* pop / imap : 끄면 계정 설정과 무관하게 모든 POP(또는 IMAP) 로그인을 거부합니다(0.9.0).
* plainLogin : 평문 포트(POP3 110, IMAP 143)에서 STLS/STARTTLS 없이 비밀번호 로그인을 받을지(0.9.7). 기본 허용.
* Gmail 의 "다른 계정에서 메일 확인" 은 110 포트에서 STLS 를 쓰지 않고 바로 USER/PASS 를 보내므로,
* 이 값이 꺼져 있으면 Gmail 은 "사용자 이름과 비밀번호가 일치하지 않음" 으로 보여 줍니다. 비밀번호가 암호화되지 않고
* 지나가므로 보안을 우선하면 끄고 995/993(또는 STARTTLS 를 지원하는 메일 프로그램)만 쓰게 합니다.
*/
export const DEFAULT_SERVICES = { pop: true, imap: true, plainLogin: true };
export async function readServices(env) { const s = await readJson(env, 'services', DEFAULT_SERVICES); return { pop: s.pop !== false, imap: s.imap !== false, plainLogin: s.plainLogin !== false }; }
export async function writeServices(env, patch) {
const next = await readServices(env);
for (const k of ['pop', 'imap', 'plainLogin']) if (patch[k] !== undefined) next[k] = !!patch[k];
await writeJson(env, 'services', next); return next;
}
/** 메일 서버가 로그인 직후 부릅니다: 관리자가 그 서비스를 꺼 두었으면 false. */
export async function serviceEnabled(env, service) { return (await readServices(env))[service] !== false; }
/**
* 평문 포트(110/143)에서 TLS 없이 로그인을 받을지(0.9.7). POP3·IMAP 서버가 접속마다 한 번 읽어 CAPA 광고와 USER/PASS 허용에 씁니다.
* 환경 변수 POP_ALLOW_PLAINTEXT=1 은 관리자 설정과 무관하게 항상 허용(진단용, 예전 의미 유지)이고, 그 밖의 값(0, 빈 값)은 관리자 설정을 따릅니다.
* 0.9.5 까지는 이 변수가 0 이면 110 로그인을 항상 거부했는데, 설치 템플릿의 기본값이 0 이라 Gmail 110 접속이 모두 실패했습니다.
* 설정을 읽지 못하면(DB 오류) 안전한 쪽인 거부로 둡니다.
*/
export async function plainLoginAllowed(env) {
if (env.POP_ALLOW_PLAINTEXT === '1') return true;
try { return (await readServices(env)).plainLogin !== false; } catch { return false; }
}
export const DEFAULT_SIGNUP = { enabled: false, approval: true };
/**
* 회원 가입 설정(settings 의 signup).
* enabled : 로그인 화면에 "회원 가입" 을 보여 주고 POST /api/signup 을 받을지
* approval : true 면 가입한 계정을 관리자가 승인해야 쓸 수 있음(승인 대기), false 면 즉시 사용
*/
export async function readSignup(env) { const s = await readJson(env, 'signup', DEFAULT_SIGNUP); return { enabled: !!s.enabled, approval: s.approval !== false }; }
export async function writeSignup(env, patch) {
const next = await readSignup(env);
for (const k of ['enabled', 'approval']) if (patch[k] !== undefined) next[k] = !!patch[k];
await writeJson(env, 'signup', next); return next;
}
// ------------------------------------------------------------------ 서버 상태 화면의 메일 현황
/**
* 관리자 "서버 상태" 의 메일 현황(요구사항 8).
* periods.day/week/month : 기간별 발신·수신·수발신 통수와 기간 경계
* mailboxes : 메일함별 사용량(MIME 기준)과 적용 중인 최대 크기·비율
* usage.total/accounts/monthly : 전체 사용량·계정 수·이달 통수를 시스템 제한과 견준 값({ used, limit, percent }, limit 0 = 제한 없음)
* system / admin : 적용 중인 시스템 제한·관리자 제한 값(화면 안내용)
* @param {string[]} mailboxes 알려진 메일함 주소 목록(기본 메일함 + 계정 메일함). auth.knownMailboxes 결과를 넘깁니다.
* @param {object[]} users listUsers 결과(login·name·mailbox 로 메일함에 이름을 붙임)
*/
export async function mailStats(env, mailboxes, users = []) {
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
const periods = {};
for (const kind of ['day', 'week', 'month']) periods[kind] = await countMail(env, periodBounds(kind, tz));
const boxes = [];
for (const mailbox of mailboxes) {
const used = await mailboxUsage(env, mailbox), quota = await mailboxQuota(env, mailbox), owner = users.find((u) => u.mailbox === mailbox);
const counts = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(mailbox).first();
boxes.push({ mailbox, login: owner?.login || null, name: owner?.name || '', messages: Number(counts?.n || 0), bytes: used, limit: quota, percent: percentOf(used, quota) });
}
const total = await totalUsage(env), accounts = await accountCount(env), monthly = periods.month.total;
return {
timezone: tz, periods, mailboxes: boxes, system: sys, admin,
usage: {
total: { used: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes) },
accounts: { used: accounts, limit: sys.accounts, percent: percentOf(accounts, sys.accounts) },
monthly: { used: monthly, limit: sys.monthlyMessages, percent: percentOf(monthly, sys.monthlyMessages) },
},
};
}
+342
View File
@@ -0,0 +1,342 @@
/**
* AriMail 게이트웨이 본체(1.0.5 부터 index.mjs 는 경고 필터만 거는 부트스트랩이고 이 파일이 서버입니다; 1.0.0 부터 프로젝트 이름 AriMail — 저장소 https://source.ibit.kr/OZ-Group/AriMail).
* - HTTP: 웹 메일함 + API + Resend 웹훅 + Haraka 수신 전달(/api/inbound/haraka) (nginx 뒤, 127.0.0.1:8790)
* - 전체 설정 화면(1.0.7)·초기 설정 페이지: 전용 포트(SETUP_PORT, 기본 8791, 토큰 인증)와 /setup(관리자 세션) — 설정 파일(config.json)을 웹에서 채웁니다(1.0.0)
* - POP3: 110(STLS) / 995(TLS)
* - IMAP: 143(STARTTLS) / 993(TLS)
* - SMTP 제출: 587(STARTTLS) / 465(TLS) — 로그인 비밀번호로 인증하고 설정된 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)로 발송
* - 작업: 1시간마다 Resend 목록 동기화(Resend 를 쓰는 방향만), 만료 세션 정리
* (0.8.0 부터 메일 보관 기한이 없어 주기 작업이 메일을 지우지 않습니다.)
*
* 설정의 두 층(1.0.0): 환경 파일(gateway.env, systemd EnvironmentFile)에는 배포에 꼭 필요한 값(DB·포트·메일함·로그인 방식·시스템 제한)만 있고,
* 운영 설정(발송·수신 방식, Resend 키, 중계 서버, Haraka, 사이트 정보)은 config.json(config.mjs)에 있어 재시작 없이 바뀝니다.
*
* 다중 도메인(1.0.7, 요구사항 1): 한 프로세스가 여러 도메인을 섬깁니다(server/domains.mjs). 환경 파일의 도메인이 "기본 도메인" 이고, 전체 설정 화면에서
* 더한 도메인은 데이터 디렉터리의 domains/<도메인>/ 에 자기 DB·설정·파일을 갖습니다. HTTP 요청은 Host 헤더로, 메일 프로그램 로그인은 로그인 ID 의 @도메인으로
* 어느 도메인(테넌트 env)의 일인지 고릅니다. 모르는 호스트나 꺼 둔 도메인으로 온 웹 요청에는 "메일 서비스가 존재하지 않습니다/활성화되지 않았습니다" 화면을 줍니다.
*/
import { createServer } from 'node:http';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, d1, migrate } from './db.mjs';
import { handle } from './api.mjs';
import { createPop3 } from './pop3.mjs';
import { createImap } from './imap.mjs';
import { createSmtp } from './smtp.mjs';
import { syncMailbox, cleanupSessions } from './store.mjs';
import { ensureInitialAdmin, currentUser, authMode } from './auth.mjs';
import { uploadLargeFile, largeFileInfo, claimDownload, openLargeFile, finishDownload, cleanupLargeFiles, humanSize, formatExpiry, filesDir, systemLargeFiles } from './files.mjs';
import { escapeHtml } from './util.mjs';
import { memoryRateStore, siteName } from './util.mjs';
import { systemLimits } from './limits.mjs';
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
import { autoVerifyDomain } from './providers/resend.mjs';
import { openTenants, noServicePage, normalizePrefix } from './domains.mjs';
import { isWebmaker } from './webmaker.mjs';
import { usageApi, clientIp } from './usage.mjs';
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const PORT = Number(process.env.PORT || 8790), HOST = process.env.HOST || '127.0.0.1';
const DB_FILE = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
loadConfig(env); console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행,
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
const LOGIN_MODE = String(env.LOGIN_MODE || (env.WWW_DB_FILE ? 'www' : 'standalone')).trim().toLowerCase();
env.LOGIN_MODE = LOGIN_MODE;
if (LOGIN_MODE === 'standalone') { delete env.WWW_DB_FILE; delete env.WEBMAKER_DIR; }
else if (LOGIN_MODE === 'www') { delete env.WEBMAKER_DIR; if (!env.WWW_DB_FILE) { console.error(' ✖ LOGIN_MODE=www 인데 WWW_DB_FILE 이 없습니다 — www 사이트 DB 경로를 적거나 LOGIN_MODE=standalone 으로 바꾸세요'); process.exit(1); } }
else if (LOGIN_MODE === 'webmaker') { delete env.WWW_DB_FILE; if (!env.WEBMAKER_DIR) { console.error(' ✖ LOGIN_MODE=webmaker 인데 WEBMAKER_DIR 이 없습니다 — 웹메이커 데이터 디렉터리(DATA_ROOT, 예: /data/webmaker)를 적으세요'); process.exit(1); } }
else { console.error(` ✖ LOGIN_MODE 값을 모릅니다: ${LOGIN_MODE} (standalone | www | webmaker)`); process.exit(1); }
// WWW_DB_FILE 이 없으면 독립 실행(0.8.18~): www 관리자 대신 게이트웨이 계정(mail_users)만 쓰고, 기본 메일함도 게이트웨이 계정이 갖습니다.
if (isWebmaker(env)) console.log(` 웹메이커 연동(LOGIN_MODE=webmaker): ${env.WEBMAKER_DIR}${env.WEBMAKER_USER ? ` (sudo -u ${env.WEBMAKER_USER} 도우미로 읽음)` : ''}${env.WEBMAKER_SITE ? `, 사이트 ${env.WEBMAKER_SITE}` : ''}`);
else if (!env.WWW_DB_FILE) console.warn(' ℹ️ 독립 실행(LOGIN_MODE=standalone): www 관리자 없이 게이트웨이 계정만 씁니다'); else console.log(` www 연동(LOGIN_MODE=www): ${env.WWW_DB_FILE}`);
// 시스템 제한(0.9.0~): 설치 시 환경 파일에 적는 상위 제한. 0 은 제한 없음. 시작 로그에 남겨 운영자가 적용 값을 확인할 수 있게 합니다.
{ const l = systemLimits(env); const show = (n) => (n > 0 ? String(n) : '제한 없음'); console.log(` 시스템 제한: 계정 ${show(l.accounts)} · 전체 ${show(l.totalBytes)}B · 메일함 ${show(l.mailboxBytes)}B · 첨부 ${show(l.attachmentBytes)}B · 수신자 ${show(l.recipients)} · 월간 ${show(l.monthlyMessages)}통`); }
// 초기 관리자(0.8.24~): 독립 실행이고 계정이 하나도 없으면 admin / adminpasswd 관리자 계정을 만들어 설치 직후 바로 로그인할 수 있게 합니다.
// 화면은 이 계정이 초기 비밀번호를 쓰는 동안 경고를 보여 주고, 비밀번호를 바꾸면 경고가 사라집니다.
try { const made = await ensureInitialAdmin(env); if (made) console.warn(` ⚠️ 초기 관리자 계정을 만들었습니다: ${made.login} / ${made.password} — 관리자 페이지(/admin)에서 바로 비밀번호를 바꾸세요`); }
catch (e) { console.error(' 초기 관리자 계정 생성 실패:', e.message); }
// ------------------------------------------------------------------ 정적 화면
/**
* JSON 파일 읽기(1.0.5): 잠시 뒤 다시 시도합니다. mail.heart-m.com 의 0.9.7 배포 직후 첫 시작이 package.json 을 읽다 "Unexpected end of JSON input" 으로
* 죽은 기록이 있습니다(systemd 가 2초 뒤 다시 띄워 정상). 릴리스 복사와 재시작 사이의 짧은 경쟁으로 보이며, 어느 경우든 비어 있는 파일 한 번으로
* 프로세스가 죽지 않게 3번(200ms 간격) 다시 읽고, 끝내 실패하면 기본값을 쓰며 오류를 기록합니다.
*/
function readJsonSync(file, fallback) {
for (let attempt = 1; ; attempt++) {
try { const text = readFileSync(file, 'utf8'); if (!text.trim()) throw new Error('empty file'); return JSON.parse(text); }
catch (e) {
if (attempt >= 3) { if (fallback === undefined) throw e; console.error(` ✖ ${file} 을 읽지 못해 기본값을 씁니다: ${e.message}`); return fallback; }
const until = Date.now() + 200; while (Date.now() < until) { /* 동기 대기(시작 단계라 이벤트 루프를 막아도 무방) */ }
}
}
}
const baseStrings = readJsonSync(join(ROOT, 'lang', 'ko.json'));
// VERSION: package.json 의 버전(예: 0.8.1). 화면의 "아리학당 메일" 아래 작은 글씨와 /api/health 응답에 씁니다.
// 배포 스크립트가 릴리스마다 package.json 을 함께 복사하므로 지금 돌고 있는 코드의 버전과 항상 같습니다.
const VERSION = readJsonSync(join(ROOT, 'package.json'), { version: '0.0.0' }).version;
env.APP_VERSION = VERSION;
{ const l = systemLargeFiles(env); console.log(` 대용량 첨부파일 시스템 상한: ${humanSize(l.bytes)} · ${l.days}일 · ${l.downloads}회 · 저장 위치 ${filesDir(env)}`); }
const indexHtml = readFileSync(join(ROOT, 'web', 'index.html'), 'utf8');
// ------------------------------------------------------------------ TLS(메일 포트·설정 페이지)
function readTls() {
if (!env.POP_TLS_CERT || !env.POP_TLS_KEY) return null;
try { return { key: readFileSync(env.POP_TLS_KEY), cert: readFileSync(env.POP_TLS_CERT), mtime: statSync(env.POP_TLS_CERT).mtimeMs }; }
catch (e) { console.warn(' ⚠️ POP TLS 인증서를 읽지 못했습니다:', e.message); return null; }
}
let tlsFiles = readTls(); env.POP_TLS_ACTIVE = !!tlsFiles;
/**
* 도메인(테넌트) env 하나를 쓸 준비(1.0.7): 버전, 문구 묶음, 화면 캐시, 설정 변경 훅, TLS 상태. 기본 도메인과 추가 도메인 모두 같은 함수로 준비합니다.
* 지금 설정으로 만든 문구 묶음: 왼쪽 위 서비스 이름(appName, 0.8.25~)은 메일 도메인에서 정합니다(heart-m.com → "HEART-M 메일").
* 탭 제목(title)은 SITE_TITLE(1.0.0 부터 설정 파일에서 바꿀 수 있음), 없으면 서비스 이름. 설정이 바뀌면(ON_CONFIG_CHANGE) 다시 만듭니다.
* STRINGS: 서버가 만드는 메일 본문 문구(대용량 첨부파일 블록, 0.9.3)에도 같은 묶음을 씁니다.
*/
function prepareTenant(t) {
const stringsFor = () => { const s = { ...baseStrings }; s.appName = siteName(mailDomain(t), s.appName); s.title = t.SITE_TITLE ? String(t.SITE_TITLE).trim() : s.appName; return s; };
t.APP_VERSION = VERSION; t.POP_TLS_ACTIVE = !!tlsFiles; t.STRINGS = stringsFor(); t.PAGE = null;
// 설정 저장(초기 설정 페이지·관리자) 뒤 바로 반영할 것: 문구(탭 제목)와 화면 캐시. 발송·수신 모듈은 호출마다 env 를 읽으므로 따로 할 일이 없습니다.
t.ON_CONFIG_CHANGE = () => { t.STRINGS = stringsFor(); t.PAGE = null; };
if (t !== env) {
// 추가 도메인의 시작 안내(기본 도메인은 위에서 이미 출력). 빠진 운영 설정은 경고만 하고 계속 뜹니다.
console.log(` [${t.DOMAIN}] 메일 서비스: 수신 ${inboundMode(t)} · 발신 ${outboundMode(t)} · DB ${t.DB_FILE}`);
if (usesResend(t) && !t.RESEND_API_KEY) console.warn(` [${t.DOMAIN}] ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(전체 설정 화면에서 채우세요)`);
}
}
// 화면 한 장(index.html)에 문구·버전·호스트·도메인을 심어 둡니다. 로그인 안내 팝업이 "이 도메인의 메일 서비스" 를 설명하는 데 씁니다.
// __STANDALONE__: www 사이트 연동 여부. 화면은 이 값으로 www 관리자 관련 안내(접속 안내 3번 항목 등)를 숨깁니다. __AUTH__(1.0.8): 로그인 방식(local|www|webmaker) — 웹메이커 안내 문구용.
// __BASE__(1.0.8): 화면의 기준 경로. 보통 "/" 이고, 경로 접두사(/mail) 아래로 들어온 요청이면 "/mail/" — 정적 파일·API 주소가 모두 이 값을 앞에 붙입니다. 설정이 바뀌면 다시 만듭니다(PAGE 캐시).
const renderPage = (t, prefix) => indexHtml.replace('__STRINGS__', JSON.stringify(t.STRINGS).replace(/</g, '\\u003c')).replace(/__TITLE__/g, t.STRINGS.title).replace(/__VERSION__/g, VERSION).replace(/__HOST__/g, (t.PUBLIC_HOST || 'mail.arihakdang.com') + (t.PUBLIC_PATH || '')).replace(/__DOMAIN__/g, mailDomain(t)).replace(/__STANDALONE__/g, t.WWW_DB_FILE ? '0' : '1').replace(/__AUTH__/g, authMode(t)).replace(/__BASE__/g, prefix + '/');
// 접두사 없는 화면은 캐시하고(대부분의 요청), 접두사가 있는 화면은 그때그때 만듭니다(문자열 치환 몇 번이라 비용이 작음).
const page = (t, prefix = '') => prefix ? renderPage(t, prefix) : t.PAGE || (t.PAGE = renderPage(t, ''));
/**
* 이 요청의 경로 접두사(1.0.8). nginx 가 /mail/ 아래를 게이트웨이로 넘기며 붙인 X-Forwarded-Prefix 가 우선이고, 없으면 등록부의 "호스트/경로" 가 정한 값입니다.
* 잘못된 값(허용 글자 밖·..)은 무시합니다. 돌려주는 값은 '' 또는 '/mail' 꼴(끝 슬래시 없음).
*/
const prefixOf = (req, resolved) => {
// 등록부에 경로가 없는 호스트(보통의 mail.<도메인>)는 헤더를 믿지 않습니다 — 클라이언트가 X-Forwarded-Prefix 를 직접 보내 화면의 기준 경로를 바꾸지 못하게(nginx 템플릿도 이 헤더를 비웁니다).
if (!resolved?.prefix) return '';
const h = req.headers['x-forwarded-prefix']; if (h !== undefined) { const p = normalizePrefix(String(h).split(',')[0]); if (p !== null) return p; }
return resolved.prefix;
};
// 도메인 등록부(1.0.7): 기본 도메인(이 env)과 추가 도메인의 env 를 엽니다. 등록부가 없으면 기본 도메인만으로 만듭니다(데이터 이동 없음).
const tenants = openTenants(env, { migrationsDir: join(ROOT, 'migrations'), onCreate: prepareTenant });
console.log(` 도메인 등록부: ${join(env.DATA_DIR, 'domains.json')} — 기본 ${env.DOMAIN} (${env.TENANT.hosts.join(', ')})${tenants.all().length > 1 ? `, 추가 ${tenants.all().length - 1}개` : ''}`);
console.log(` 메일 서비스: 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`);
// 빠진 운영 설정은 경고만 하고 서버는 뜹니다(요구사항 3: 값이 없어도 배포는 성공). 값은 초기 설정 페이지에서 채우면 재시작 없이 적용됩니다.
if (usesResend(env) && !env.RESEND_API_KEY) console.warn(' ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(초기 설정 페이지에서 채우세요)');
if (inboundMode(env) === 'resend' && !env.RESEND_WEBHOOK_SECRET) console.warn(' ⚠️ RESEND_WEBHOOK_SECRET 없음 — 웹훅 수신을 거부합니다(1시간 동기화만 동작)');
if (outboundMode(env) === 'smtp' && !env.SMTP_RELAY_HOST) console.warn(' ⚠️ SMTP_RELAY_HOST 없음 — 발송이 동작하지 않습니다(초기 설정 페이지에서 중계 서버를 적으세요)');
if (tenants.envs().some(usesHaraka)) console.log(' 내장 Haraka 사용: haraka/run.mjs(별도 서비스)가 설정 파일을 보고 Haraka 를 띄웁니다');
// 정적 파일: 경로 → Content-Type. favicon.svg 는 브라우저 탭 아이콘입니다(없으면 매 방문마다 404 가 기록됐습니다).
const STATIC = { '/app.js': 'application/javascript; charset=utf-8', '/style.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml', '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8' };
// X-App-Version(0.8.25~): 모든 응답에 지금 돌고 있는 버전을 실어, 열어 둔 화면이 배포 뒤 옛 app.js 로 새 API 를 부르지 않도록 화면이 스스로 새로고침합니다.
const SECURITY = { 'X-App-Version': VERSION, 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store, no-transform' };
// img-src 에 https: 를 두는 이유: 읽기 창의 srcdoc iframe 은 이 페이지의 CSP 를 상속하므로, 여기서 막으면
// "외부 이미지 표시"를 눌러도 보이지 않습니다. 기본 차단은 iframe 쪽 CSP 와 src 제거로 유지합니다.
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; font-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
async function readBody(req) {
if (req.method === 'GET' || req.method === 'HEAD') return undefined;
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > 32 * 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); }
return chunks.length ? Buffer.concat(chunks) : undefined;
}
/** 요청의 호스트 이름(nginx 가 넘긴 X-Forwarded-Host 우선). 테넌트 선택과 표준 Request 의 주소에 씁니다. */
const hostOf = (req) => String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
const server = createServer(async (req, res) => {
let pathname = '/';
try {
const url = new URL(req.url, 'http://x'); pathname = url.pathname; const headOnly = req.method === 'HEAD';
// /favicon.ico 를 직접 찾는 브라우저·크롤러에는 SVG 아이콘으로 안내합니다(404 오류 기록 방지). 정적 파일은 도메인과 무관하므로 먼저 줍니다.
if (pathname === '/favicon.ico') { res.writeHead(301, { Location: prefixOf(req, tenants.resolveHost(hostOf(req))) + '/favicon.svg', ...SECURITY }); return res.end(); }
if (STATIC[pathname]) { const body = readFileSync(join(ROOT, 'web', pathname)); res.writeHead(200, { 'Content-Type': STATIC[pathname], ...SECURITY, 'Cache-Control': 'no-cache' }); return res.end(headOnly ? undefined : body); }
// 상태 확인은 어느 호스트로 와도(배포 스크립트는 127.0.0.1 로 부름) 기본 도메인의 버전을 돌려줍니다.
if (pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: true, version: VERSION, domains: tenants.all().length })); }
// 사용량 API(1.0.9): 어느 호스트로 와도 같은 응답(키·IP 로 인증, 읽기 전용). 본문 없는 GET 만 받습니다.
if (pathname === '/api/usage' || pathname.startsWith('/api/usage/')) {
const hdrs = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => hdrs.append(k, x)); else if (v !== undefined) hdrs.set(k, v); }
// 접속 IP 는 클라이언트가 넣을 수 있는 헤더(X-Forwarded-For 첫 값·CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)으로 — 허용 목록·시도 제한이 이 값에 걸려 있음.
const ip = clientIp(hdrs, req.socket.remoteAddress);
// HEAD 는 GET 으로 처리하고 본문만 빼서 보냅니다(상태 확인용).
const response = await usageApi(tenants, new Request(`https://x${req.url}`, { method: headOnly ? 'GET' : req.method, headers: hdrs }), { path: pathname.split('/').slice(3).filter(Boolean), ip, query: url.searchParams });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; }); const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length);
res.writeHead(response.status, out); return res.end(headOnly ? undefined : buf);
}
// 테넌트 선택(1.0.7): Host 로 도메인을 고릅니다. 모르는 호스트·꺼 둔 도메인이면 화면에는 안내 페이지, API 에는 404 JSON 을 줍니다.
const resolved = tenants.resolveHost(hostOf(req));
const isHaraka = pathname.startsWith('/api/inbound/haraka');
if ((!resolved.tenant || resolved.disabled) && !isHaraka) {
const disabled = resolved.disabled;
if (pathname.startsWith('/api/')) { res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: disabled ? 'domain_disabled' : 'domain_not_found', host: resolved.host })); }
res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", ...SECURITY });
return res.end(headOnly ? undefined : noServicePage({ host: resolved.host, disabled, strings: baseStrings, version: VERSION }));
}
// Haraka 수신 전달은 로컬(127.0.0.1)에서 오며 도메인은 받는 사람 주소로 고르므로 기본 도메인의 env 로 들어가 api.mjs 가 나눕니다.
const t = isHaraka ? env : resolved.tenant.env;
// 경로 접두사(1.0.8): nginx 가 /mail/ 을 떼고 넘기므로 여기 경로는 늘 "/…" 이고, 화면이 돌아갈 기준 경로만 prefix 로 압니다.
const prefix = prefixOf(req, resolved);
// "/" 는 웹 메일, "/admin" 은 관리자 페이지입니다(0.8.4~). 같은 화면 파일을 주고, 앱이 주소(location.pathname)로 모드를 정합니다.
// /admin 은 항상 관리자 페이지로 이어지고 별도의 로그인(관리자 세션 쿠키)을 거칩니다.
if (pathname === '/' || pathname === '/index.html' || /^\/admin\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : page(t, prefix)); }
// /setup(1.0.0): 메일 서비스 설정 화면. 관리자 세션으로 /api/setup/* 를 부릅니다(전용 포트의 토큰 방식과 같은 화면, setup.mjs).
if (/^\/setup\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : setupPage(t, join(ROOT, 'web'), 'admin', { base: prefix + '/' })); }
// 대용량 첨부파일 다운로드 링크(0.9.3): /dl/<토큰>. GET 은 랜딩 페이지(횟수를 세지 않음), POST(페이지의 단추)가 실제 다운로드입니다.
// return await: 프라미스를 그냥 돌려주면 그 안의 예외(잘못된 Host 헤더로 new Request 가 던지는 TypeError 등)가 아래 catch 에 잡히지 않고 프로세스를 죽였습니다(0.9.5 수정).
if (pathname.startsWith('/dl/')) return await download(t, req, res, pathname.slice(4).split('/')[0], prefix);
if (!pathname.startsWith('/api/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); }
// 대용량 파일 업로드(0.9.3): 본문을 메모리에 모으지 않고 디스크로 흘려 씁니다(일반 API 의 32MB 본문 한도 밖). 로그인·초안 소유 확인은 uploadLargeFile 이 합니다.
if (pathname === '/api/files' && req.method === 'POST') return await upload(t, req, res, url);
const body = await readBody(req);
const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com';
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); }
const request = new Request(`${proto}://${host}${req.url}`, { method: headOnly ? 'GET' : req.method, headers, body });
// 웹훅과 Haraka 수신 전달은 브라우저가 아니므로 Origin 검사(CSRF)를 하지 않습니다(각자 서명·토큰으로 인증).
const isWebhook = pathname === '/api/webhooks/resend' || pathname.startsWith('/api/inbound/');
if (!isWebhook && !['GET', 'HEAD'].includes(req.method) && headers.get('origin') && headers.get('origin') !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); }
const ip = headers.get('cf-connecting-ip') || (headers.get('x-forwarded-for') || '').split(',')[0].trim() || req.socket.remoteAddress || '';
const response = await handle(t, request, { path: pathname.slice(5).split('/').filter(Boolean).map(decodeURIComponent), ip, prefix });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { if (k.toLowerCase() !== 'set-cookie') out[k] = v; });
const cookies = response.headers.getSetCookie?.() || []; if (cookies.length) out['Set-Cookie'] = cookies;
const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length);
res.writeHead(response.status, out); res.end(headOnly ? undefined : buf);
} catch (e) {
console.error(`오류 ${req.method} ${pathname}`, e?.message);
if (!res.headersSent) res.writeHead(e.message === 'body_too_large' ? 413 : 500, { 'Content-Type': 'application/json', ...SECURITY });
res.end(JSON.stringify({ ok: false, error: 'server_error' }));
}
});
server.listen(PORT, HOST, () => console.log(` HTTP 대기 중: http://${HOST}:${PORT}`));
const jsonOut = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); res.end(JSON.stringify(obj)); };
/** 요청 헤더만으로 표준 Request 를 만듭니다(세션 쿠키 확인용). */
function headerRequest(t, req) {
const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com';
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); }
return new Request(`${proto}://${host}${req.url}`, { method: 'GET', headers });
}
/**
* POST /api/files?draft=<초안 id> — 대용량 파일 업로드(0.9.3). 본문이 파일 바이트이고 이름은 X-File-Name(URL 인코딩), 형식은 Content-Type 입니다.
* 웹 메일 세션(쿠키)으로 인증하고 같은 출처(Origin)만 받습니다. 상한을 넘기면 413 과 limit 을 돌려주고 남은 본문은 버립니다.
* @param {object} t 이 요청의 도메인 env(1.0.7)
*/
async function upload(t, req, res, url) {
const request = headerRequest(t, req);
if (req.headers.origin && req.headers.origin !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); }
const user = await currentUser(t, request, { scope: 'mail' });
if (!user) { req.resume(); return jsonOut(res, 401, { ok: false, error: 'unauthenticated' }); }
try {
// X-File-Name 은 URL 인코딩이 깨져 있어도(잘못된 % 열) 오류 대신 원문을 이름으로 씁니다.
const rawName = String(req.headers['x-file-name'] || ''); let filename; try { filename = decodeURIComponent(rawName); } catch { filename = rawName; }
const file = await uploadLargeFile(t, user, req, { draftId: url.searchParams.get('draft'), filename, contentType: req.headers['content-type'], declaredSize: Number(req.headers['content-length'] || 0) });
return jsonOut(res, 201, { ok: true, file: { id: file.id, filename: file.filename, size: file.size, content_type: file.content_type, state: file.state } });
} catch (e) {
req.resume();
const status = e.message === 'file_too_large' ? 413 : e.message === 'large_files_disabled' ? 404 : e.message === 'mail_locked' ? 409 : e.message === 'bad_request' ? 400 : 422;
return jsonOut(res, status, { ok: false, error: e.message || 'server_error', ...(e.limit !== undefined ? { limit: e.limit } : {}) });
}
}
/** 랜딩 페이지 HTML(외부 공개). 스크립트 없이 폼(POST)만 쓰며 검색 엔진이 색인하지 않게 합니다. */
function landing(t, title, body) {
return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${escapeHtml(title)}</title><style>body{margin:0;background:#f3f5f8;font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif;color:#1d2733}.card{max-width:520px;margin:48px auto;background:#fff;border-radius:14px;padding:28px;box-shadow:0 8px 30px rgba(20,35,60,.12)}h1{font-size:18px;margin:0 0 14px}dl{display:grid;grid-template-columns:110px 1fr;gap:6px 12px;margin:0 0 18px}dt{color:#6b7a8c}dd{margin:0;overflow-wrap:anywhere}button{background:#2b64c8;color:#fff;border:0;border-radius:8px;padding:12px 18px;font:inherit;font-weight:600;cursor:pointer;width:100%}.meta{color:#6b7a8c;font-size:12px;margin-top:14px}.warn{color:#9b2c1f}</style></head><body><div class="card">${body}<p class="meta">${escapeHtml(t.STRINGS.appName)} · ${escapeHtml(t.PUBLIC_HOST || '')}</p></div></body></html>`;
}
/**
* /dl/<토큰> — 받는 사람이 여는 다운로드 링크(0.9.3, 로그인 없음).
* GET : 파일 이름·크기·남은 횟수·기한을 보여 주는 랜딩 페이지. 메일 보안 장치·미리 보기 봇이 미리 열어도 횟수가 줄지 않습니다.
* POST : "다운로드" 단추. 횟수를 1 차지한 뒤 파일을 흘려보내고, 마지막 허용 횟수였으면 끝난 뒤 파일을 지웁니다.
* 만료·없는 토큰에는 410/404 안내 페이지를 줍니다.
*/
async function download(t, req, res, token, prefix = '') {
const page = (status, title, body) => { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY, 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" }); res.end(landing(t, title, body)); };
const s = (k) => t.STRINGS[k] || k;
if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) return page(404, s('dl_missingTitle'), `<h1>${escapeHtml(s('dl_missingTitle'))}</h1><p class="warn">${escapeHtml(s('dl_missing'))}</p>`);
if (req.method === 'GET' || req.method === 'HEAD') {
const info = await largeFileInfo(t, token);
if (info.status === 'missing') return page(404, s('dl_missingTitle'), `<h1>${escapeHtml(s('dl_missingTitle'))}</h1><p class="warn">${escapeHtml(s('dl_missing'))}</p>`);
if (info.status === 'expired') return page(410, s('dl_expiredTitle'), `<h1>${escapeHtml(s('dl_expiredTitle'))}</h1><p class="warn">${escapeHtml(s('dl_expired'))}</p><dl><dt>${escapeHtml(s('dl_file'))}</dt><dd>${escapeHtml(info.file.filename)}</dd></dl>`);
const f = info.file;
return page(200, s('dl_title'), `<h1>${escapeHtml(s('dl_title'))}</h1><dl><dt>${escapeHtml(s('dl_file'))}</dt><dd>${escapeHtml(f.filename)}</dd><dt>${escapeHtml(s('dl_size'))}</dt><dd>${humanSize(f.size)}</dd><dt>${escapeHtml(s('dl_remaining'))}</dt><dd>${info.remaining} / ${f.max_downloads}</dd><dt>${escapeHtml(s('dl_until'))}</dt><dd>${formatExpiry(f.expires_at, t)}</dd></dl><form method="post" action="${prefix}/dl/${encodeURIComponent(token)}"><button type="submit">⬇ ${escapeHtml(s('dl_button'))}</button></form><p class="meta">${escapeHtml(s('dl_note'))}</p>`);
}
if (req.method !== 'POST') { res.writeHead(405, SECURITY); return res.end(); }
req.resume();
const claim = await claimDownload(t, token);
if (!claim) return page(410, s('dl_expiredTitle'), `<h1>${escapeHtml(s('dl_expiredTitle'))}</h1><p class="warn">${escapeHtml(s('dl_expired'))}</p>`);
const { row } = claim;
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size), 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(row.filename)}`, ...SECURITY });
const stream = openLargeFile(claim.path);
stream.on('error', (e) => { console.error('대용량 파일 전송 오류', row.id, e.message); res.destroy(); });
// 전송이 끝나면(또는 끊기면) 마지막 허용 횟수였던 파일을 지웁니다. 끊긴 경우도 횟수는 이미 차지했으므로 같은 처리입니다.
res.on('close', () => { finishDownload(t, claim).catch((e) => console.error('대용량 파일 정리 오류', row.id, e.message)); });
stream.pipe(res);
}
// ------------------------------------------------------------------ POP3 / IMAP / SMTP
// 평문 포트(110/143)의 TLS 없는 로그인(0.9.7): POP_ALLOW_PLAINTEXT=1 이면 항상 허용(진단용), 그 밖에는 null 을 넘겨 관리자 설정 "평문 로그인 허용"(기본 켬)을 접속마다 읽습니다.
// 0.9.5 까지는 이 변수가 1 이 아니면 항상 거부해, 설치 템플릿 기본값(0)에서 STLS 를 쓰지 않는 Gmail 의 110 접속이 전부 실패했습니다.
const allowPlaintext = env.POP_ALLOW_PLAINTEXT === '1' ? true : null;
// resolveEnv(1.0.7): 로그인 ID 의 @도메인(없으면 TLS SNI 호스트, 그래도 없으면 기본 도메인)으로 그 접속이 쓸 도메인 env 를 고릅니다. 메일 서버 이름·인증서는 기본 도메인 것 하나입니다.
const resolveEnv = (login, servername) => tenants.resolveLogin(login, servername);
const mailOpts = { hostname: env.PUBLIC_HOST || 'mail.arihakdang.com', tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, resolveEnv };
const pop = createPop3(env, { ...mailOpts, allowPlaintext });
if (env.POP_DISABLED !== '1') {
// TLS 포트는 인증서가 없으면 지금 열지 않고 기억해 두어, 아래 1시간 점검이 인증서를 읽는 순간 엽니다(0.9.7, 예전에는 재시작 전까지 닫혀 있었음).
pop.listen({ plainPort: Number(env.POP_PORT || 110), securePort: Number(env.POPS_PORT || 995), host: env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' POP3 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ POP TLS 인증서 없음 — 995 포트는 인증서를 읽을 수 있게 되면 엽니다(110 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); })
.catch((e) => console.error(' POP3 시작 실패:', e.message));
}
const imap = createImap(env, { ...mailOpts, allowPlaintext });
if (env.IMAP_DISABLED !== '1') {
imap.listen({ plainPort: Number(env.IMAP_PORT || 143), securePort: Number(env.IMAPS_PORT || 993), host: env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' IMAP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 993 포트는 인증서를 읽을 수 있게 되면 엽니다(143 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); })
.catch((e) => console.error(' IMAP 시작 실패:', e.message));
}
// 메일 프로그램의 "보내는 메일 서버". 웹 메일과 같은 아이디·비밀번호로 인증하고, 실제 발송에만 서버의 RESEND_API_KEY 를 씁니다.
// 인증은 TLS 위에서만 받습니다(SMTP_ALLOW_PLAINTEXT=1 은 진단용). 인증서는 POP·IMAP 과 같은 파일입니다.
const smtp = createSmtp(env, { ...mailOpts, allowPlaintext: env.SMTP_ALLOW_PLAINTEXT === '1' });
if (env.SMTP_DISABLED !== '1') {
smtp.listen({ plainPort: Number(env.SMTP_PORT || 587), securePort: Number(env.SMTPS_PORT || 465), host: env.SMTP_HOST || env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' SMTP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 465 포트는 인증서를 읽을 수 있게 되면 열고, 587 은 STARTTLS 없이 로그인을 거부합니다'); })
.catch((e) => console.error(' SMTP 시작 실패:', e.message));
}
// 인증서 갱신(certbot 훅이 사본을 바꿈)을 1시간마다 확인해 POP·IMAP·SMTP 에 새 인증서를 넣습니다. 시작 때 인증서가 없었다면(tlsFiles=null) 이때 TLS 포트도 열립니다(0.9.7).
setInterval(() => { const next = readTls(); if (next && next.mtime !== tlsFiles?.mtime) { tlsFiles = next; pop.setTls({ key: next.key, cert: next.cert }); imap.setTls({ key: next.key, cert: next.cert }); smtp.setTls({ key: next.key, cert: next.cert }); setup.setTls({ key: next.key, cert: next.cert }); for (const t of tenants.envs()) t.POP_TLS_ACTIVE = true; console.log(' TLS 인증서를 다시 읽었습니다'); } }, 3600000).unref();
// ------------------------------------------------------------------ 전체 설정 화면·초기 설정 페이지(1.0.0 요구사항 4, 1.0.7 요구사항 2)
// 전용 포트(SETUP_PORT, 기본 8791, 0 이면 끔)에서 토큰으로 인증하는 설정 화면을 엽니다. 인증서를 읽을 수 있으면 HTTPS, 없으면 HTTP.
// 1.0.7: 이 화면이 "전체 설정" 입니다 — 도메인 목록·추가·중지와 도메인별 설정(API 키·DNS·수·발신 방식)을 기본 도메인의 이 포트에서 관리합니다.
// 접속 주소는 로그와 데이터 디렉터리의 setup-url.txt 에 남겨 배포 스크립트가 끝에 출력합니다.
const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, tenants });
const SETUP_PORT = Number(env.SETUP_PORT ?? 8791);
if (SETUP_PORT > 0) {
setup.listen({ port: SETUP_PORT, host: env.SETUP_HOST || '0.0.0.0' })
.then(() => { const urls = setup.urls(setupToken(env)); console.log(' 전체 설정 화면: ' + urls[0] + (urls.length > 1 ? ` (또는 ${urls.slice(1).join(' , ')})` : '')); writeSetupUrls(env, urls); })
.catch((e) => console.error(' 전체 설정 화면 시작 실패:', e.message));
}
// ------------------------------------------------------------------ 주기 작업(도메인마다, 1.0.7)
const SYNC_INTERVAL = Number(env.SYNC_INTERVAL_MS || 3600000);
const runSync = (trigger) => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) syncMailbox(t, { trigger }).then((r) => { if (!r.busy) console.log(` 동기화(${trigger}${t === env ? '' : ', ' + t.DOMAIN}): ${r.imported}건`); }).catch((e) => console.error(` 동기화 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); };
// 도메인 검증 자동 재요청(1.0.11): 검증이 실패 상태로 멈춘 도메인의 Resend 레코드가 모두 보이면 검증을 다시 요청합니다(동기화와 같은 주기).
// 운영(webmail.ibit.kr)에서 DKIM 레코드 불일치로 failed 가 된 뒤 사용자가 DNS 를 고쳐도 다시 요청하지 않으면 영영 실패 상태라 발송이 막혔습니다.
const runAutoVerify = () => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) autoVerifyDomain(t).then((r) => { if (r.requested) console.log(` Resend 도메인 검증 자동 요청(${t.DOMAIN}): 이전 상태 ${r.status}`); else if (r.skipped === 'records') console.warn(` Resend 도메인 검증 대기(${t.DOMAIN}): ${r.status} — ${r.problems.join(', ')}`); }).catch((e) => console.warn(` Resend 도메인 검증 확인 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); };
setTimeout(runAutoVerify, 20000).unref(); setInterval(runAutoVerify, SYNC_INTERVAL).unref();
setTimeout(() => runSync('startup'), 10000).unref();
setInterval(() => runSync('timer'), SYNC_INTERVAL).unref();
// 만료된 로그인 세션 정리. 예전에는 같은 작업이 보관 기간(30일)이 지난 메일도 지웠지만 0.8.0 에서 그 삭제를 없앴습니다.
const runCleanup = () => { for (const t of tenants.envs()) cleanupSessions(t).catch((e) => console.error(` 세션 정리 실패(${t.DOMAIN}):`, e.message)); };
// 대용량 첨부파일 정리(0.9.3): 기한·횟수가 끝난 파일과 버려진 초안의 파일을 1시간마다 지웁니다.
const runFileCleanup = () => { for (const t of tenants.envs()) cleanupLargeFiles(t).then((r) => { if (r.expired || r.removed) console.log(` 대용량 파일 정리(${t.DOMAIN}): 만료 ${r.expired}건, 삭제 ${r.removed}건`); }).catch((e) => console.error(` 대용량 파일 정리 실패(${t.DOMAIN}):`, e.message)); };
setTimeout(runFileCleanup, 45000).unref(); setInterval(runFileCleanup, 3600000).unref();
setTimeout(runCleanup, 30000).unref(); setInterval(runCleanup, 3600000).unref();
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { console.log(`\n ${sig} — 종료합니다`); pop.close(); imap.close(); smtp.close(); setup.close(); server.close(() => { tenants.close(); db.close(); process.exit(0); }); setTimeout(() => process.exit(0), 3000).unref(); });
+117
View File
@@ -0,0 +1,117 @@
/**
* RFC 5322/2045 메시지 생성. Resend 가 원본(raw)을 내려주지 못한 경우, 저장한 본문·헤더·첨부로
* POP 클라이언트에 줄 수 있는 완전한 메시지를 만듭니다. 원본이 있으면 이 코드는 쓰지 않습니다.
*/
const CRLF = '\r\n';
const isAscii = (s) => /^[\x20-\x7e]*$/.test(s);
export function encodeHeaderWord(value) {
const s = String(value ?? '');
if (isAscii(s)) return s;
// RFC 2047 encoded-word, base64, split to keep each word under 75 characters.
const bytes = Buffer.from(s, 'utf8'); const words = []; const chunk = 45;
for (let i = 0; i < bytes.length; i += chunk) {
// avoid splitting inside a multi-byte sequence
let end = Math.min(bytes.length, i + chunk);
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--;
words.push(`=?UTF-8?B?${bytes.subarray(i, end).toString('base64')}?=`);
i = end - chunk;
}
return words.join(CRLF + ' ');
}
export function formatAddress(value) {
const s = String(value || '').trim();
const m = s.match(/^(.*?)\s*<([^<>]+)>$/);
if (!m) return s;
const name = m[1].replace(/^"|"$/g, '').trim();
if (!name) return `<${m[2]}>`;
return `${isAscii(name) ? `"${name.replace(/"/g, '\\"')}"` : encodeHeaderWord(name)} <${m[2]}>`;
}
export function foldHeader(name, value) {
const line = `${name}: ${value}`;
if (line.length <= 78 || /\r\n/.test(value)) return line;
// fold at commas or spaces
const out = []; let current = '';
for (const piece of line.split(/(?<=,)\s+|\s+/)) {
if (current && (current + ' ' + piece).length > 76) { out.push(current); current = ' ' + piece; } else current = current ? current + ' ' + piece : piece;
}
out.push(current); return out.join(CRLF);
}
export function base64Lines(buffer) { return Buffer.from(buffer).toString('base64').replace(/(.{76})/g, `$1${CRLF}`).replace(/\r\n$/, ''); }
function boundary(tag) { return `----=_ari_${tag}_${crypto.randomUUID().replace(/-/g, '')}`; }
function part(headers, body) { return headers.join(CRLF) + CRLF + CRLF + body; }
/**
* @param {object} m
* @param {string} m.from @param {string[]} m.to @param {string[]} [m.cc] @param {string[]} [m.bcc] @param {string[]} [m.replyTo]
* @param {string} m.subject @param {string|Date} [m.date] @param {string} [m.messageId]
* @param {Record<string,string>} [m.headers] @param {string} [m.text] @param {string} [m.html]
* @param {{filename:string,contentType:string,bytes:Buffer,contentId?:string,disposition?:string}[]} [m.attachments]
*/
export function buildEml(m) {
const date = m.date ? new Date(m.date) : new Date();
const headers = [];
const custom = Object.fromEntries(Object.entries(m.headers || {}).map(([k, v]) => [k.toLowerCase(), [k, v]]));
// 표준 헤더는 현재 값으로만 씁니다. 값이 비어 있어도 custom(headers_json 에 남은 옛 값)에서 지워, IMAP 으로 올린 초안의 Cc·Reply-To 를 웹에서 지운 뒤
// 재구성한 원본에 옛 받는 사람이 되살아나지 않게 합니다(0.9.0 수정). Bcc 도 재구성 원본에는 넣지 않습니다.
const push = (name, value) => { delete custom[name.toLowerCase()]; if (value === undefined || value === null || value === '') return; headers.push(foldHeader(name, value)); };
push('Date', date.toUTCString().replace('GMT', '+0000'));
push('From', formatAddress(m.from));
push('To', (m.to || []).map(formatAddress).join(', '));
push('Cc', (m.cc || []).map(formatAddress).join(', '));
push('Reply-To', (m.replyTo || []).map(formatAddress).join(', '));
// Bcc 는 현재 값(m.bcc, 초안의 bcc_json)으로만 씁니다. headers_json 에 남은 옛 Bcc 는 쓰지 않습니다.
push('Bcc', (m.bcc || []).map(formatAddress).join(', '));
push('Subject', encodeHeaderWord(m.subject || ''));
// Message-ID 의 도메인은 이 게이트웨이의 메일 도메인(MAILBOX 의 @ 뒤)입니다. 다른 사이트에 독립 설치해도 남의 도메인이 찍히지 않게 합니다.
push('Message-ID', m.messageId || `<${crypto.randomUUID()}@${String(process.env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || 'arihakdang.com'}>`);
push('MIME-Version', '1.0');
for (const [k, v] of Object.values(custom)) {
if (/^(content-|mime-version|received$|return-path$|delivered-to$)/i.test(k)) continue;
if (!/^[!-9;-~]+$/.test(k) || /[\r\n]/.test(String(v))) continue;
headers.push(foldHeader(k, isAscii(String(v)) ? String(v) : encodeHeaderWord(String(v))));
}
const text = m.text ?? '', html = m.html ?? '';
const textPart = part(['Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(text || ' ', 'utf8')));
const htmlPart = html ? part(['Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(html, 'utf8'))) : null;
const inline = (m.attachments || []).filter((a) => a.contentId && (a.disposition || 'inline') === 'inline');
const files = (m.attachments || []).filter((a) => !inline.includes(a));
const attachmentPart = (a) => part([
`Content-Type: ${a.contentType || 'application/octet-stream'}; name="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: ${a.contentId && inline.includes(a) ? 'inline' : 'attachment'}; filename="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`,
...(a.contentId ? [`Content-ID: <${String(a.contentId).replace(/^<|>$/g, '')}>`] : []),
], base64Lines(a.bytes));
let body, contentType;
let alternative;
if (htmlPart) {
const b = boundary('alt');
alternative = { contentType: `multipart/alternative; boundary="${b}"`, body: `--${b}${CRLF}${textPart}${CRLF}--${b}${CRLF}${htmlPart}${CRLF}--${b}--` };
} else alternative = { contentType: 'text/plain; charset=UTF-8', body: base64Lines(Buffer.from(text || ' ', 'utf8')), encoding: 'base64' };
let main = alternative;
if (inline.length) {
const b = boundary('rel');
main = { contentType: `multipart/related; boundary="${b}"`, body: `--${b}${CRLF}${part([`Content-Type: ${alternative.contentType}`, ...(alternative.encoding ? ['Content-Transfer-Encoding: base64'] : [])], alternative.body)}${inline.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--` };
}
if (files.length) {
const b = boundary('mix');
contentType = `multipart/mixed; boundary="${b}"`;
body = `--${b}${CRLF}${part([`Content-Type: ${main.contentType}`, ...(main.encoding ? ['Content-Transfer-Encoding: base64'] : [])], main.body)}${files.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--`;
} else { contentType = main.contentType; body = main.body; if (main.encoding) headers.push('Content-Transfer-Encoding: base64'); }
headers.push(foldHeader('Content-Type', contentType));
return Buffer.from(headers.join(CRLF) + CRLF + CRLF + body + CRLF, 'utf8');
}
/** POP 전송용: CRLF 정규화와 점 스터핑(RFC 1939 §3). */
export function normalizeCrlf(buffer) {
const s = Buffer.from(buffer).toString('latin1').replace(/\r?\n/g, '\r\n');
return Buffer.from(s, 'latin1');
}
export function dotStuff(buffer) {
const s = Buffer.from(buffer).toString('latin1').replace(/(^|\r\n)\./g, '$1..');
return Buffer.from(s, 'latin1');
}
/** 헤더와 본문을 분리합니다(TOP 명령용). */
export function splitHeader(buffer) {
const s = Buffer.from(buffer).toString('latin1');
const i = s.indexOf('\r\n\r\n');
if (i < 0) return { header: Buffer.from(s, 'latin1'), body: Buffer.alloc(0) };
return { header: Buffer.from(s.slice(0, i + 2), 'latin1'), body: Buffer.from(s.slice(i + 4), 'latin1') };
}
+244
View File
@@ -0,0 +1,244 @@
/**
* POP3 서버 (RFC 1939 + RFC 2449 CAPA + RFC 2595 STLS).
*
* - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음.
* - 메일드롭: 받은 메일함(inbox)의 수신 메일 중 이 계정에 아직 전달하지 않은 것.
* - 중복 방지: RETR 또는 DELE 한 메일은 QUIT 시점에 pop_deliveries 에 기록되어 같은 계정에 다시 내려가지 않습니다.
* 서버의 저장본은 지우지 않으며, 웹 메일함에서 영구 삭제하기 전까지 기한 없이 계속 볼 수 있습니다.
* - 평문 포트(110): 995 는 즉시 TLS 이고, 110 은 STLS 뒤에 로그인하는 것이 원칙입니다. 다만 Gmail 의 "다른 계정에서 메일 확인" 처럼
* STLS 를 지원하지 않는 클라이언트가 110 으로 USER/PASS 를 바로 보내므로, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이
* 켜져 있으면 TLS 없이도 로그인을 받습니다(0.9.7). 꺼져 있으면 예전처럼 `-ERR [SYS/PERM] use STLS or port 995 before login` 으로 거절합니다.
* 접속마다 설정을 한 번 읽어(plainOk) CAPA 광고와 로그인 허용에 같은 값을 씁니다.
* - 세션 로그: 접속·인증·거부 외에 세션이 끝날 때 한 줄(내려준 통수·삭제 표시·걸린 시간·끝난 이유)을 남겨 "되다말다" 하는 증상을 추적할 수 있게 합니다(0.9.7).
*/
import net from 'node:net';
import tls from 'node:tls';
import { verifyAdmin, serviceAllowed } from './auth.mjs';
import { serviceEnabled, plainLoginAllowed } from './limits.mjs';
import { normalizeCrlf, dotStuff, splitHeader } from './mime.mjs';
import { now, memoryRateStore } from './util.mjs';
const CRLF = '\r\n';
/** latin1 로 읽은 줄(문자 = 바이트)을 UTF-8 문자열로 되돌립니다. */
const utf8 = (s) => Buffer.from(s, 'latin1').toString('utf8');
// user_id -> 그 메일함을 잠근 세션. RFC 1939 의 메일드롭 잠금: 같은 계정이 동시에 두 세션을 열면 두 번째는 [IN-USE] 로 거절합니다.
// 잠금은 세션이 끝날 때(QUIT·끊김·오류·시간 초과) 풉니다. 상대가 FIN 없이 사라진 소켓은 10분 유휴 시간 초과로 정리됩니다.
const locks = new Map();
const rate = memoryRateStore();
/** 잠금을 든 세션이 아직 살아 있는지. closed 표시가 없어도 소켓이 이미 파기됐으면(오류 뒤 close 이벤트 지연 등) 잠금을 비어 있는 것으로 봅니다. */
const lockHeld = (userId) => { const s = locks.get(userId); return !!s && !s.closed && !s.socket?.destroyed; };
export async function maildrop(env, userId, mailbox = null) {
mailbox = mailbox || String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
const rows = (await env.DB.prepare(`SELECT m.id, m.raw_size FROM messages m WHERE m.mailbox=? AND m.direction='inbound' AND m.folder='inbox' AND m.raw_status IN ('stored','built')
AND NOT EXISTS (SELECT 1 FROM pop_deliveries d WHERE d.message_id=m.id AND d.user_id=?) ORDER BY m.received_at ASC, m.id ASC LIMIT 5000`).bind(mailbox, userId).all()).results;
return rows.map((r) => ({ id: r.id, size: Number(r.raw_size) || 0 }));
}
async function loadEml(env, id) {
const row = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first();
return row ? normalizeCrlf(Buffer.from(row.eml)) : null;
}
/**
* QUIT 시점에 이 세션이 내려준(RETR)·삭제 표시한(DELE) 메일을 pop_deliveries 에 기록합니다.
* 세션 중에 웹(영구 삭제)이나 IMAP(휴지통 비우기)으로 이미 지워진 메일은 건너뜁니다(WHERE EXISTS). 예전에는 그런 행 하나가 외래 키 위반을 일으켜
* 묶음 전체가 되돌아갔고, QUIT 이 -ERR 로 끝나며 그 세션의 다른 메일도 전부 기록되지 않아 다음 접속에 다시 내려갔습니다(0.9.7).
*/
export async function commitDeliveries(env, userId, entries) {
if (!entries.length) return;
const stamp = now();
await env.DB.batch(entries.map(([id, via]) => env.DB.prepare('INSERT OR REPLACE INTO pop_deliveries(user_id,message_id,delivered_at,via) SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM messages WHERE id=?)').bind(userId, id, stamp, via, id)));
}
class Session {
constructor(server, socket, { secure }) {
this.server = server; this.env = server.env; this.secure = secure; this.state = 'auth'; this.user = null; this.pendingUser = '';
this.messages = []; this.deleted = new Set(); this.retrieved = new Set(); this.failures = 0; this.buffer = ''; this.closed = false;
this.startedAt = Date.now(); this.endReason = 'close'; this.summarized = false;
// plainOk: 이 접속에서 TLS 없이 로그인을 받을지. TLS 접속이면 항상 참. 평문 접속은 서버 옵션(allowPlaintext 가 true/false 로 고정) 또는
// 관리자 설정(plainLoginAllowed)을 따릅니다. 설정 읽기는 비동기라 명령 큐의 첫 작업으로 두어, 첫 명령(CAPA/USER)이 처리될 때는 값이 정해져 있습니다.
this.plainOk = secure || server.allowPlaintext === true;
this.queue = this.loadPlainLogin();
this.ip = socket.remoteAddress || ''; this.attach(socket);
console.log(`POP3 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`);
this.write(`+OK ${server.hostname} POP3 ready`);
}
async loadPlainLogin() {
if (this.secure || typeof this.server.allowPlaintext === 'boolean') return;
try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('POP3 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; }
}
attach(socket) {
this.socket = socket; socket.setTimeout(10 * 60 * 1000);
socket.on('data', (chunk) => this.onData(chunk));
socket.on('timeout', () => { this.endReason = 'timeout'; this.close('-ERR timeout'); });
socket.on('error', (e) => { if (this.endReason === 'close') this.endReason = 'error:' + (e?.code || e?.message || 'unknown'); this.finish(); });
socket.on('close', () => this.finish());
}
write(line) { if (!this.closed) this.socket.write(line + CRLF); }
close(line) { if (line) this.write(line); this.closed = true; try { this.socket.end(); } catch {} this.finish(); }
/** 세션 종료 공통 처리: 잠금 해제 + 요약 로그 한 줄(로그인한 세션만, 한 번만). close·error·close 이벤트가 겹쳐 불려도 안전합니다. */
finish() {
if (this.user && locks.get(this.user.id) === this) locks.delete(this.user.id);
this.closed = true;
if (this.user && !this.summarized) {
this.summarized = true;
const secs = ((Date.now() - this.startedAt) / 1000).toFixed(1);
console.log(`POP3 종료 ${this.ip} user=${this.user.login} 메일 ${this.messages.length}통 중 RETR ${this.retrieved.size}·DELE ${this.deleted.size} ${secs}s (${this.endReason})`);
}
}
onData(chunk) {
// STLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(아래 설명).
if (this.upgrading) return;
this.buffer += chunk.toString('latin1');
let i;
while ((i = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, i).replace(/\r$/, ''); this.buffer = this.buffer.slice(i + 1);
if (line.length > 4096) { this.close('-ERR line too long'); return; }
this.queue = this.queue.then(() => this.handle(line)).catch((e) => { console.error('POP3 오류', e?.message); this.write('-ERR internal error'); });
// STLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STLS 뒤에 붙어 온 명령은 TLS 전환 뒤에 암호화된 명령처럼
// 실행될 수 있으므로 버립니다. 클라이언트는 STLS 응답을 받은 뒤에만 다음 명령을 보내야 합니다(RFC 2595).
if (/^\s*STLS\s*$/i.test(line)) { this.upgrading = true; this.buffer = ''; return; }
}
// 줄 길이 검사는 줄 단위로 합니다. 예전에는 쌓인 버퍼 전체가 4KB 를 넘으면 끊어, 파이프라인으로 여러 명령을 보내는 클라이언트가 끊겼습니다.
if (this.buffer.length > 4096) this.close('-ERR line too long');
}
async handle(line) {
if (this.closed) return;
const [cmd, ...rest] = line.trim().split(/\s+/); const command = (cmd || '').toUpperCase(); const args = rest;
if (command === 'QUIT') return this.quit();
if (command === 'CAPA') return this.capa();
if (command === 'NOOP') return this.write('+OK');
if (this.state === 'auth') return this.authCommand(command, args, line);
return this.transactionCommand(command, args);
}
capa() {
const caps = ['TOP', 'UIDL', 'RESP-CODES', 'PIPELINING', 'IMPLEMENTATION arihakdang-mail-gateway'];
if (this.plainOk) caps.push('USER', 'SASL PLAIN'); // 평문 접속에서 로그인을 안 받으면 USER 를 광고하지 않습니다(RFC 2449)
if (!this.secure && this.server.secureContext) caps.push('STLS');
this.write('+OK Capability list follows'); for (const c of caps) this.write(c); this.write('.');
}
async authCommand(command, args, line) {
const loginAllowed = this.plainOk; // TLS 접속이면 항상 참, 평문 접속이면 관리자 설정(또는 서버 옵션)
if (command === 'STLS') {
this.upgrading = false; // 아래에서 거절하면 평문 입력을 다시 받습니다. 전환에 성공하면 새 TLS 소켓으로 받습니다.
if (this.secure) return this.write('-ERR already using TLS');
if (!this.server.secureContext) return this.write('-ERR TLS not available');
this.write('+OK Begin TLS negotiation');
const plain = this.socket; plain.removeAllListeners('data'); plain.removeAllListeners('timeout'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) });
this.secure = true; this.plainOk = true; this.buffer = ''; this.attach(secureSocket); return;
}
if (!loginAllowed && ['USER', 'PASS', 'AUTH', 'APOP'].includes(command)) { console.log(`POP3 평문 로그인 거부 ${this.ip} (STLS 필요)`); return this.write('-ERR [SYS/PERM] use STLS or port 995 before login'); }
// USER/PASS 인자는 줄을 latin1 로 읽었으므로 바이트 그대로입니다. 한글 등 비 ASCII 비밀번호는 UTF-8 로 되돌려야 웹·IMAP·AUTH PLAIN 과 같은 값이 됩니다(0.9.7).
// 예전에는 latin1 문자열을 그대로 검사해 비 ASCII 비밀번호가 POP USER/PASS 에서만 항상 틀렸습니다.
if (command === 'USER') { this.pendingUser = utf8(args.join(' ')); return this.write('+OK name accepted'); }
if (command === 'PASS') { const password = utf8(line.trim().slice(5)); return this.login(this.pendingUser, password); }
if (command === 'AUTH') {
if ((args[0] || '').toUpperCase() !== 'PLAIN') return this.write('-ERR unsupported mechanism');
let payload = args[1];
if (!payload) { this.write('+ '); this.awaitingSasl = true; return; }
return this.saslPlain(payload);
}
if (this.awaitingSasl) { this.awaitingSasl = false; if (line.trim() === '*') return this.write('-ERR cancelled'); return this.saslPlain(line.trim()); }
return this.write('-ERR unknown command');
}
saslPlain(payload) {
let parts; try { parts = Buffer.from(payload, 'base64').toString('utf8').split('\0'); } catch { return this.write('-ERR invalid'); }
if (parts.length < 3) return this.write('-ERR invalid');
return this.login(parts[1], parts[2]);
}
async login(login, password) {
if (rate.blocked('pop:' + this.ip, { limit: 20 })) return this.close('-ERR [AUTH] too many attempts');
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다.
const env = this.server.resolveEnv ? this.server.resolveEnv(login, sniName(this.socket)) : this.env;
const user = env ? await verifyAdmin(env, login, password) : null;
if (user) this.env = env;
if (!user) rate.hit('pop:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다(성공한 접속은 제한에 들지 않음)
console.log(`POP3 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login).slice(0, 80)}${this.secure ? '' : ' (plain)'}`);
if (!user) {
this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500));
if (this.failures >= 3) return this.close('-ERR [AUTH] authentication failed');
return this.write('-ERR [AUTH] authentication failed');
}
// 관리자가 POP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다.
if (!(await serviceEnabled(this.env, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (관리자가 POP 서비스를 껐음)`); return this.close('-ERR [SYS/PERM] POP service is disabled by the administrator'); }
// 계정 설정에서 POP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~). 비밀번호 오류와 구분되는 문구를 돌려줍니다.
if (!(await serviceAllowed(this.env, user, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 POP 사용 안 함)`); return this.close('-ERR [SYS/PERM] POP access is disabled for this account'); }
// 같은 계정의 다른 세션이 아직 열려 있으면 거절합니다. 예전에는 로그가 없어 "비밀번호는 맞는데 가끔 실패" 로 보였습니다(0.9.7 부터 기록).
if (lockHeld(user.id)) { const other = locks.get(user.id); console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (다른 세션 ${other.ip} 이 ${Math.round((Date.now() - other.startedAt) / 1000)}s 전부터 메일함 사용 중)`); return this.write('-ERR [IN-USE] mailbox is locked by another session'); }
locks.set(user.id, this); this.user = user; this.state = 'transaction';
this.messages = await maildrop(this.env, user.id, user.mailbox);
this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`);
}
total() { return this.messages.reduce((n, m, i) => this.deleted.has(i) ? n : n + m.size, 0); }
index(arg) { const n = Number(arg); if (!Number.isInteger(n) || n < 1 || n > this.messages.length || this.deleted.has(n - 1)) return -1; return n - 1; }
async transactionCommand(command, args) {
const live = () => this.messages.length - this.deleted.size;
if (command === 'STAT') return this.write(`+OK ${live()} ${this.total()}`);
if (command === 'LIST' || command === 'UIDL') {
const value = (m) => command === 'LIST' ? m.size : m.id;
if (args[0] !== undefined) { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); return this.write(`+OK ${i + 1} ${value(this.messages[i])}`); }
this.write(`+OK ${live()} messages (${this.total()} octets)`);
this.messages.forEach((m, i) => { if (!this.deleted.has(i)) this.write(`${i + 1} ${value(m)}`); }); return this.write('.');
}
if (command === 'RETR' || command === 'TOP') {
const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message');
const eml = await loadEml(this.env, this.messages[i].id); if (!eml) return this.write('-ERR message unavailable');
let out = eml;
if (command === 'TOP') { const lines = Number(args[1]); if (!Number.isInteger(lines) || lines < 0) return this.write('-ERR bad argument'); const { header, body } = splitHeader(eml); const bodyLines = body.toString('latin1').split(CRLF).slice(0, lines).join(CRLF); out = Buffer.concat([header, Buffer.from(CRLF + bodyLines, 'latin1')]); }
const data = dotStuff(out);
this.write(`+OK ${data.length} octets`);
if (!this.closed) this.socket.write(data);
if (!data.length || !data.subarray(-2).equals(Buffer.from(CRLF))) this.write('');
this.write('.');
if (command === 'RETR') this.retrieved.add(i);
return;
}
if (command === 'DELE') { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); this.deleted.add(i); return this.write(`+OK message ${i + 1} deleted`); }
if (command === 'RSET') { this.deleted.clear(); return this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`); }
if (command === 'LAST') { return this.write(`+OK ${Math.max(0, ...[...this.retrieved].map((i) => i + 1))}`); }
return this.write('-ERR unknown command');
}
async quit() {
this.endReason = 'quit';
if (this.state === 'transaction' && this.user) {
const entries = [];
for (const i of new Set([...this.retrieved, ...this.deleted])) entries.push([this.messages[i].id, this.deleted.has(i) ? 'dele' : 'retr']);
try { await commitDeliveries(this.env, this.user.id, entries); } catch (e) { console.error('POP 전달 기록 실패', e.message); return this.close('-ERR some deleted messages not removed'); }
}
this.close(`+OK ${this.server.hostname} signing off`);
}
}
/**
* @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions 인증서 PEM. 갱신 시 setTls 로 교체합니다.
* @param {boolean|null} allowPlaintext 평문 포트의 TLS 없는 로그인: true 면 항상 허용, false 면 항상 거부(테스트·진단), null(기본)이면
* 접속마다 관리자 설정 "평문 로그인 허용" 을 읽습니다(0.9.7). index.mjs 는 POP_ALLOW_PLAINTEXT=1 일 때만 true 를, 그 밖에는 null 을 넘깁니다.
*/
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 도메인 없이 로그인한 사용자가 어느 도메인의 사용자인지 짐작하는 데 씁니다(1.0.7). */
const sniName = (socket) => String(socket?.servername || '');
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작(단일 도메인). */
export function createPop3(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) {
const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv };
const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); });
// secure 서버는 인증서가 없어도 만들어 둡니다(핸드셰이크는 setSecureContext 뒤에만 성공). 인증서가 없으면 포트를 열지 않고 기억만 합니다(아래 pendingSecure).
const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); });
secure.on('tlsClientError', () => {});
let pendingSecure = null;
// 0.9.7: 인증서가 시작 때 없거나 읽히지 않았으면 TLS 포트를 바로 열 수 없으므로 포트를 기억해 두고(pendingSecure), 시간마다 돌아오는 setTls 가
// 인증서를 넣을 때 그 포트를 엽니다. 예전에는 secure 서버를 만들지 않아 setTls 가 아무 일도 하지 않았고, 재시작 전까지 TLS 포트가 닫힌 채였습니다.
const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); }));
return {
plain, secure,
setTls(options) {
shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options);
if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'pop3s', []).then(() => console.log(` POP3 TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' POP3 TLS 포트 열기 실패:', e.message)); }
},
async listen({ plainPort, securePort, host = '0.0.0.0' }) {
const out = [];
if (plainPort) await listenOn(plain, plainPort, host, 'pop3', out);
if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'pop3s', out); else pendingSecure = { port: securePort, host }; }
return out;
},
close() { plain.close(); if (secure.listening) secure.close(); },
};
}
@@ -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 };
}
+209
View File
@@ -0,0 +1,209 @@
/**
* 메일 서비스 자동 설정(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 이 다시 진단하는 것으로 충분
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* 호환용 재수출(1.0.0). Resend 모듈은 server/providers/resend.mjs 로 옮겼습니다(요구사항 2: 발송 서비스 모듈화).
* 예전 import 경로('./resend.mjs')를 쓰는 코드·시험이 그대로 동작하게 같은 이름을 다시 내보냅니다. 새 코드는 providers/ 를 직접 쓰세요.
*/
export { resend, download, providerStatus, providerError } from './providers/resend.mjs';
+116
View File
@@ -0,0 +1,116 @@
/**
* 수신 HTML 정제. www 사이트의 build/sanitize_mail.py 와 같은 허용 목록을 JS 로 옮겼습니다.
* 스크립트·스타일·이벤트 속성·위험한 URL 스킴은 남기지 않으며, 미리보기는 그 위에 sandbox iframe + CSP 를 더 씁니다.
*/
const ALLOWED = new Set(['p','br','strong','b','em','i','u','ul','ol','li','blockquote','div','span','h1','h2','h3','h4','pre','code','s','strike','font','a','img','hr','table','tbody','thead','tr','td','th']);
const VOID = new Set(['br','img','hr']);
const BLOCKED = new Set(['script','style','iframe','object','embed','form','svg','math','template','noscript','head','title']);
// embed 는 HTML5 빈 요소(닫는 태그 없음)라 열린 범위를 만들면 뒤의 본문이 모두 사라집니다. 원문 텍스트 요소(script/style/title/textarea 류)는
// 자기 닫는 태그까지 바로 건너뛰고, 닫는 태그가 없으면 범위를 열지 않습니다(예전에는 닫히지 않은 <title>·<form> 하나로 본문 전체가 비었습니다, 0.8.25).
const VOID_BLOCKED = new Set(['embed']);
const RAW_TEXT_BLOCKED = new Set(['script', 'style', 'title', 'noscript', 'template']);
// 껍데기만 벗기는 요소(0.9.0): form·head·object·iframe 은 태그만 버리고 안의 내용은 계속 정제합니다. 예전에는 이 태그가 범위(depth)를 열어
// <form> 으로 감싼 뉴스레터 본문이 통째로 사라지고, 닫는 태그가 없는 <form>/<iframe>/<head>(브라우저는 <body> 에서 자동으로 닫음) 뒤의 본문이 모두 비었습니다.
// 범위를 여는 것은 svg·math(안이 HTML 이 아님)뿐이며, 그마저도 <body> 시작 태그를 만나면 닫습니다.
const UNWRAP_BLOCKED = new Set(['form', 'head', 'object', 'iframe']);
// svg/math 안에서 만나면 브라우저가 외래 내용(foreign content)을 끝내는 HTML 시작 태그(HTML 명세 13.2.6.5). 닫히지 않은 <svg> 뒤의 본문이 사라지지 않게 같은 규칙으로 범위를 닫습니다(0.9.3).
const BREAKOUT = new Set(['b','big','blockquote','body','br','center','code','dd','div','dl','dt','em','embed','h1','h2','h3','h4','h5','h6','head','hr','i','img','li','listing','menu','meta','nobr','ol','p','pre','ruby','s','small','span','strong','strike','sub','sup','table','tt','u','ul','var']);
// text-decoration-line/-style/-color: Chrome 은 편집기의 밑줄·취소선을 text-decoration 이 아니라 text-decoration-line 으로 씁니다.
// 이 키가 없어 Chrome 에서 넣은 밑줄·취소선이 저장·발송에서 사라졌습니다(0.8.12 수정).
const STYLE_KEYS = new Set(['color','background-color','font-family','font-size','font-weight','font-style','text-decoration','text-decoration-line','text-decoration-style','text-decoration-color','text-align','line-height','margin','margin-left','margin-right','margin-top','margin-bottom','padding','padding-left','padding-right','padding-top','padding-bottom','border','border-collapse','border-color','border-width','border-style','width','height','min-height','max-width','vertical-align','white-space']);
// height 를 그대로 둘 요소(0.9.9). 그림은 크기 자체이고 표·칸은 내용이 많으면 어차피 늘어나므로 height 가 겹침을 만들지 않습니다.
// 그 밖의 요소(div·p·span·blockquote 등)는 height 를 min-height 로 바꿉니다: 웹 페이지·문서 편집기에서 복사한 본문에는 `<div style="height:40px">` 처럼
// 고정 높이가 붙어 오는데, 안의 글이 그 높이보다 길면 overflow 가 visible 이라 다음 문단 위로 번져 겹쳐 보입니다(편집기에서도, 받는 쪽 메일에서도).
// min-height 로 바꾸면 비어 있는 간격용 div 는 그 높이를 지키고, 글이 든 div 는 글 길이대로 자랍니다.
const KEEP_HEIGHT = new Set(['img', 'table', 'tr', 'td', 'th']);
const NUMERIC = new Set(['width','height','colspan','rowspan','cellpadding','cellspacing','border','size']);
const SIMPLE = new Set(['color','bgcolor','align','valign','face']);
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
export const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export function decodeEntities(s) {
return String(s).replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') { const code = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m; }
return ENTITIES[e.toLowerCase()] ?? m;
});
}
function scheme(value) { const m = String(value).trim().replace(/[\u0000- ]/g, '').match(/^([a-z][a-z0-9+.-]*):/i); return m ? m[1].toLowerCase() : ''; }
function parseAttrs(raw) {
const out = []; const re = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>`]+)))?/g; let m;
while ((m = re.exec(raw))) out.push([m[1].toLowerCase(), decodeEntities(m[2] ?? m[3] ?? m[4] ?? '')]);
return out;
}
function cleanAttrs(tag, attrs) {
const clean = [];
for (const [name, value] of attrs) {
if (name === 'href' && tag === 'a' && ['https', 'http', 'mailto'].includes(scheme(value))) clean.push(`href="${escapeHtml(value)}"`);
if (name === 'src' && tag === 'img' && (['https', 'http', 'cid'].includes(scheme(value)) || /^\/api\/mail\/[A-Za-z0-9-]+\/attachments\/[A-Za-z0-9-]+(?:\?inline=1)?$/.test(value))) clean.push(`src="${escapeHtml(value)}"`);
if (name === 'style') {
const styles = [];
for (const declaration of value.split(';')) {
const i = declaration.indexOf(':'); if (i < 0) continue;
const key = declaration.slice(0, i).trim().toLowerCase(), v = declaration.slice(i + 1).trim();
// 음수 여백은 버립니다(0.9.9). margin-top:-400px 처럼 음수 여백이 붙은 문단은 앞 문단 위로 올라가 겹치거나 화면 밖으로 사라져(margin-left:-9999px),
// 편집기에서도 읽기 창에서도 글이 가려졌습니다. 양수 여백·들여쓰기는 그대로 둡니다.
if (/^margin/.test(key) && /(^|[\s,(])-\.?\d/.test(v)) continue;
if (STYLE_KEYS.has(key) && v.length < 160 && /^[a-zA-Z0-9#(),.% "'_-]+$/.test(v) && !/url|expression|javascript|behavior|var\(/i.test(v)) styles.push((key === 'height' && !KEEP_HEIGHT.has(tag) ? 'min-height' : key) + ':' + v);
}
if (styles.length) clean.push(`style="${escapeHtml(styles.join(';'))}"`);
}
if (NUMERIC.has(name) && /^[0-9]{1,4}%?$/.test(value)) clean.push(`${name}="${value}"`);
if (SIMPLE.has(name) && /^[a-zA-Z0-9# ,'-]{1,100}$/.test(value)) clean.push(`${name}="${escapeHtml(value)}"`);
if (name === 'alt' || name === 'title') clean.push(`${name}="${escapeHtml(value.slice(0, 300))}"`);
}
if (tag === 'a') clean.push('target="_blank"', 'rel="noopener noreferrer"');
return clean;
}
/**
* 허용 목록 방식 HTML 정제.
* @param {string} input 정제할 HTML
* @param {{limit?: number}} [options] limit: 받는 최대 글자 수. 넘으면 message_too_large 를 던집니다.
* 기본 40만 자는 화면 입력(미리 보기 정제 API)용입니다. 받은 메일·초안처럼 큰 HTML(뉴스레터 등)이 정상인 경로는
* 더 큰 한도를 넘기거나 store.mjs 의 safeContent 로 감싸 예외 대신 텍스트로 대체합니다.
* @returns {{html:string,text:string}}
*/
export function sanitizeHtml(input, { limit = 400000 } = {}) {
const html = String(input || '');
if (html.length > limit) throw new Error('message_too_large');
const parts = [], text = []; let depth = 0, i = 0;
const re = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<![^>]*>|<\?[^>]*>|<\/([a-zA-Z][a-zA-Z0-9:-]*)\s*>|<([a-zA-Z][a-zA-Z0-9:-]*)((?:\s+[^\s"'<>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>`]+))?)*)\s*\/?>/g;
let m;
while ((m = re.exec(html))) {
if (m.index > i) { const data = html.slice(i, m.index); if (!depth) { const decoded = decodeEntities(data); parts.push(escapeHtml(decoded)); text.push(decoded); } }
i = re.lastIndex;
const token = m[0];
if (token.startsWith('<!') || token.startsWith('<?')) continue;
if (m[1]) { // end tag
const tag = m[1].toLowerCase();
if (BLOCKED.has(tag)) { if (!UNWRAP_BLOCKED.has(tag) && !RAW_TEXT_BLOCKED.has(tag)) depth = Math.max(0, depth - 1); continue; }
if (depth) continue;
if (ALLOWED.has(tag) && !VOID.has(tag)) parts.push(`</${tag}>`);
if (['p', 'div', 'br', 'li', 'h1', 'h2', 'h3', 'h4', 'tr', 'blockquote', 'pre'].includes(tag)) text.push('\n');
continue;
}
const tag = m[2].toLowerCase();
if (depth && BREAKOUT.has(tag)) depth = 0; // svg/math 범위 안의 HTML 시작 태그: 브라우저처럼 외래 내용을 끝내고 그 태그부터 보통 HTML 로 처리합니다.
if (tag === 'body') continue; // 본문 시작 태그 자체는 쓰지 않습니다.
if (BLOCKED.has(tag)) {
if (VOID_BLOCKED.has(tag) || UNWRAP_BLOCKED.has(tag) || token.endsWith('/>')) continue;
if (RAW_TEXT_BLOCKED.has(tag)) {
// 닫는 태그까지의 내용(스크립트·스타일 본문)을 통째로 건너뜁니다. 닫는 태그가 없으면 열린 범위를 만들지 않습니다(뒤의 본문 보존).
const close = html.slice(re.lastIndex).search(new RegExp(`</${tag}\\s*>`, 'i'));
if (close >= 0) { const after = re.lastIndex + close; const endTag = html.slice(after).match(/^<\/[a-zA-Z0-9:-]+\s*>/); re.lastIndex = after + (endTag ? endTag[0].length : 0); }
else { const next = html.indexOf('<', re.lastIndex); re.lastIndex = next < 0 ? html.length : next; } // 닫는 태그가 없으면 바로 뒤의 글자(제목·스크립트 본문)만 버리고 다음 태그부터 이어 갑니다.
i = re.lastIndex; continue;
}
depth++; continue;
}
if (depth || !ALLOWED.has(tag)) continue;
const attrs = cleanAttrs(tag, parseAttrs(m[3] || ''));
parts.push(`<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>`);
if (tag === 'br') text.push('\n');
}
if (i < html.length && !depth) { const decoded = decodeEntities(html.slice(i)); parts.push(escapeHtml(decoded)); text.push(decoded); }
return { html: parts.join(''), text: text.join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() };
}
export function textToHtml(value) { return `<p>${escapeHtml(value || '').replaceAll('\n', '<br>')}</p>`; }
export function imageSources(html) { return [...String(html || '').matchAll(/<img\b[^>]*\bsrc="([^"]+)"/g)].map((x) => decodeEntities(x[1])); }
+230
View File
@@ -0,0 +1,230 @@
/**
* 전체 설정 화면(1.0.7, 요구사항 2)·초기 설정 페이지(1.0.0, 요구사항 4) — 운영 설정(config.json)과 도메인 등록부(domains.json)를 웹에서 다루는 화면과 API.
*
* 두 곳에서 같은 화면·API 를 씁니다.
* 1) 전용 포트(SETUP_PORT, 기본 8791): 배포 스크립트가 출력하는 주소 http(s)://<기본 도메인 호스트>:8791/?token=<토큰> 으로 엽니다. 이것이 "전체 설정" 입니다.
* 로그인 계정이 아직 없거나 nginx·인증서가 준비되기 전에도 쓸 수 있어야 하므로, 데이터 디렉터리의 setup-token 파일(배포 계정만 읽음)로 인증합니다.
* 메일 포트 인증서(POP_TLS_CERT)를 읽을 수 있으면 같은 인증서로 HTTPS 를, 없으면 HTTP 를 씁니다(주소는 로그·setup-url.txt 에 남김).
* 1.0.7: 도메인 목록·추가·중지·삭제(/api/setup/domains)와, ?domain=<도메인> 으로 고른 도메인의 설정(아래 config·plan…)을 모두 여기서 합니다.
* 요구사항대로 기본 도메인의 주소로 열어야 하며, 추가 도메인의 호스트 이름으로 이 포트에 오면 안내 화면만 줍니다.
* 2) 운영 중 수정: 관리자 페이지(/admin)의 "메일 서비스 설정" → /setup. 관리자 세션으로 /api/setup/* 를 부릅니다(api.mjs 가 이 모듈의 setupApi 로 넘김).
* 이쪽은 그 도메인(Host 로 고른 테넌트)의 설정만 다루며 도메인 관리는 할 수 없습니다.
*
* API(/api/setup/…) — 전용 포트에서는 ?domain=<도메인> 으로 대상 도메인을 고릅니다(없으면 기본 도메인)
* GET config 현재 설정(비밀값은 가림)·출처·스키마 + 메타(호스트·도메인·웹훅 주소·Haraka 상태·DNS 레코드·발신 서비스 상태)
* PUT config { values: { 키: 값 } } 저장 → 즉시 적용(재시작 없음) → GET 과 같은 응답
* POST check { what: 'outbound' } 발신 서비스 연결 확인(캐시 무시)
* POST dkim { force?: true } DKIM 키 생성(이미 있으면 force 일 때만 교체) → DNS 레코드
* GET plan / POST plan/run / POST dns/probe 자동 설정(1.0.5, provision.mjs)
* POST restart systemd 아래에서 돌고 있을 때만: 응답 뒤 프로세스를 끝내 systemd(Restart=always)가 다시 띄움
* GET/POST domains, PATCH/DELETE domains/<도메인> 도메인 등록부(1.0.7, 전용 포트 전용) — domains.mjs
*/
import { createServer as createHttpServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { networkInterfaces } from 'node:os';
import { json, bad, safeEqual, escapeHtml, secret } from './util.mjs';
import { parseAllowList, invalidAllowEntries } from './usage.mjs';
import { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs';
import { buildPlan, runPlan } from './provision.mjs';
import { probeDns } from './dns/index.mjs';
import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs';
import { noServicePage, normalizeHost } from './domains.mjs';
/** systemd 서비스 안에서 돌고 있는지(부모가 PID 1 이고 INVOCATION_ID 가 있음). 재시작 단추를 보일지 정합니다. */
export const underSystemd = () => process.ppid === 1 && !!process.env.INVOCATION_ID;
// harakaDir / harakaStatus 는 1.0.5 에서 config.mjs 로 옮겼습니다(provision.mjs 와 공유). 예전 import 경로를 위해 다시 내보냅니다.
export { harakaDir, harakaStatus };
/** 이 서버의 공개 IPv4 후보(초기 설정 주소 안내용). 루프백·내부 주소는 뺍니다. */
export function serverAddresses() {
const out = [];
for (const list of Object.values(networkInterfaces())) for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address);
return out;
}
/** 설정 화면이 쓰는 메타 정보. 비용이 있는 발신 상태 확인은 60초 캐시(outboundStatus)를 씁니다. */
async function meta(env) {
const host = env.PUBLIC_HOST || 'mail.arihakdang.com';
return {
// loginMode(1.0.8): standalone | www | webmaker. publicPath: 이 도메인 첫 호스트의 경로 접두사('' 또는 '/mail').
host, mailbox: String(env.MAILBOX || ''), domain: mailDomain(env), harakaHost: harakaHostname(env), version: env.APP_VERSION || null, loginMode: env.LOGIN_MODE === 'webmaker' && env.WEBMAKER_DIR ? 'webmaker' : env.WWW_DB_FILE ? 'www' : 'standalone', publicPath: env.PUBLIC_PATH || '',
webhookUrl: `https://${host}${env.PUBLIC_PATH || ''}/api/webhooks/resend`, configFile: configFile(env), setupPort: Number(env.SETUP_PORT ?? 8791), gatewayPort: Number(env.PORT || 8790),
inbound: inboundMode(env), outbound: outboundMode(env), usesResend: usesResend(env), usesHaraka: usesHaraka(env),
haraka: harakaStatus(env), dns: dnsRecords(env), outboundStatus: await outboundStatus(env).catch((e) => ({ provider: e.message })),
systemd: underSystemd(), tlsActive: !!env.POP_TLS_ACTIVE,
// 1.0.7: 이 설정이 기본 도메인의 것인지(Haraka 포트 같은 서버 공통 값은 기본 도메인만 바꿀 수 있음), 메일 프로그램이 접속할 서버 이름(기본 도메인의 호스트).
primary: !env.TENANTS || env.TENANTS.primary.env === env, serverHost: env.MAIL_SERVER_HOST || host, domains: env.TENANTS ? env.TENANTS.all().length : 1,
// 사용량 API(1.0.9): 키는 기본 도메인 설정에만 있으므로 기본 도메인 env 로 상태를 봅니다. 키 자체는 주지 않고 있음/없음과 가린 값, 허용 IP, 주소만.
usageApi: (() => { const p = env.TENANTS ? env.TENANTS.primary.env : env; const key = String(p.USAGE_API_KEY || ''); return { enabled: !!key, keyMasked: key ? maskSecret(key) : '', allow: parseAllowList(p.USAGE_API_ALLOW), endpoint: `https://${normalizeHost(p.PUBLIC_HOST || host)}${p.PUBLIC_PATH || ''}/api/usage`, updatedAt: p.CONFIG?._meta?.usageKeyAt || null }; })(),
};
}
/**
* /api/setup/* 처리(인증은 호출자가 끝낸 뒤 부릅니다: 전용 서버는 토큰, api.mjs 는 관리자 세션).
* @param {string[]} path 'setup' 뒤의 경로 조각
*/
export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) {
const method = request.method, action = path[0] || '';
const respond = async () => json({ ok: true, ...publicConfig(env), meta: await meta(env) });
// 도메인 등록부(1.0.7): 전용 포트(토큰)에서만. 관리자 세션(/setup)은 자기 도메인만 다룹니다.
if (action === 'domains') { if (!tenants) return bad('not_found', 404); return await domainsApi(tenants, request, path.slice(1), ip); }
if (action === 'config' && method === 'GET') return respond();
if (action === 'config' && method === 'PUT') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object' || typeof body.values !== 'object') return bad('bad_request');
// 사용량 API 허용 IP(1.0.9): 잘못된 항목이 하나라도 있으면 저장하지 않습니다(오타 하나로 모든 호출이 403 이 되는 일 방지).
if (body.values.USAGE_API_ALLOW !== undefined) { const badEntries = invalidAllowEntries(body.values.USAGE_API_ALLOW); if (badEntries.length) return bad('validation', 422, { field: 'USAGE_API_ALLOW', detail: badEntries.join(', ') }); }
try { saveConfig(env, body.values); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; }
console.log(`설정 저장(${user ? user.login : 'setup-token'} ${ip}): ${Object.keys(body.values).join(', ')} → 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`);
return respond();
}
if (action === 'check' && method === 'POST') {
const body = await request.json().catch(() => ({}));
if (body.what === 'outbound') return json({ ok: true, outboundStatus: await outboundStatus(env, { force: true }) });
if (body.what === 'haraka') return json({ ok: true, haraka: harakaStatus(env) });
return bad('bad_request');
}
// 자동 설정(1.0.5, 요구사항 2·4·5): 계획(단계 목록·상태) 조회와 실행. 실행은 전부(자동 가능한 단계) 또는 한 단계(step)이며, 사용자 입력(input)은 먼저 설정에 저장합니다.
if (action === 'plan' && !path[1] && method === 'GET') return json({ ok: true, plan: await buildPlan(env) });
if (action === 'plan' && path[1] === 'run' && method === 'POST') {
const body = await request.json().catch(() => ({}));
try { const result = await runPlan(env, { step: body.step || null, stepAction: body.action || null, input: body.input && typeof body.input === 'object' ? body.input : {} }); console.log(`자동 설정 실행(${user ? user.login : 'setup-token'} ${ip}): ${body.step || '전체'}${body.action ? '/' + body.action : ''} → ${result.log.map((l) => l.id + ':' + l.result).join(', ') || '변화 없음'}`); return json({ ok: true, ...result }); }
catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; }
}
if (action === 'dns' && path[1] === 'probe' && method === 'POST') return json({ ok: true, probe: await probeDns(env) });
// 사용량 API 키(1.0.9): { action:'generate' } 새 키를 만들어(있으면 교체) 한 번만 돌려줍니다 · { action:'revoke' } 키를 지워 API 를 끕니다. 기본 도메인에서만(키는 서버 하나에 하나).
if (action === 'usage-key' && method === 'POST') {
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
if (primary !== env) return bad('primary_only', 409);
const body = await request.json().catch(() => ({}));
if (body.action === 'revoke') { saveConfig(env, { USAGE_API_KEY: '' }, { allowHidden: true }); console.log(`사용량 API 키 삭제(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: false }); }
if (body.action !== 'generate') return bad('bad_request');
const key = 'au_' + secret(32);
// _meta.usageKeyAt: 언제 만든 키인지(화면 안내). saveConfig 의 _meta 갱신 뒤에 덧붙입니다.
saveConfig(env, { USAGE_API_KEY: key }, { allowHidden: true }); env.CONFIG._meta = { ...(env.CONFIG._meta || {}), usageKeyAt: new Date().toISOString() }; saveConfig(env, {});
console.log(`사용량 API 키 생성(${user ? user.login : 'setup-token'} ${ip})`);
return json({ ok: true, enabled: true, key, endpoint: `https://${normalizeHost(env.PUBLIC_HOST || 'mail.arihakdang.com')}${env.PUBLIC_PATH || ''}/api/usage` });
}
if (action === 'dkim' && method === 'POST') { const body = await request.json().catch(() => ({})); return json({ ok: true, dns: generateDkim(env, { force: !!body.force }) }); }
if (action === 'restart' && method === 'POST') {
// systemd 서비스로 돌 때만(부모가 PID 1). INVOCATION_ID 는 사용자 셸에도 있을 수 있어 쓰지 않습니다. 개발 실행·시험에서는 거절합니다.
if (!underSystemd()) return bad('restart_unavailable', 409);
console.log(`재시작 요청(${user ? user.login : 'setup-token'} ${ip}) — systemd 가 다시 띄웁니다`);
setTimeout(() => process.exit(0), 500).unref();
return json({ ok: true, restarting: true });
}
return bad('not_found', 404);
}
/**
* 도메인 등록부 API(1.0.7, 요구사항 2). 전용 포트(토큰)에서만 부릅니다.
* GET domains 목록(기본 도메인 먼저; 계정·메일 수, 수·발신 방식, 로그인 방식, 사용 여부)
* POST domains { domain, hosts?, mailbox?, auth?:{mode:'local'|'www', wwwDbFile?, ownerLogin?}, adminPassword?, copyFromPrimary?, note? } → 추가(즉시 동작)
* PATCH domains/<도메인> { enabled?, hosts?, auth?, note? } → 변경(기본 도메인은 hosts·note 만)
* DELETE domains/<도메인> 등록 해제(데이터 디렉터리는 남김). 기본 도메인은 불가
* 오류: validation(422, field) · domain_exists(409) · host_taken(409, detail) · www_db_unreadable(422) · primary_domain(409) · not_found(404)
*/
async function domainsApi(tenants, request, path, ip) {
const method = request.method, target = path[0] ? decodeURIComponent(path[0]) : '';
const list = async () => ({ domains: await tenants.list(), primary: tenants.registry.primary, serverHost: tenants.primary.env.MAIL_SERVER_HOST || '' });
try {
if (method === 'GET' && !target) return json({ ok: true, ...(await list()) });
if (method === 'POST' && !target) {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const r = await tenants.add(body);
console.log(`도메인 추가(setup-token ${ip}): ${r.entry.domain}`);
return json({ ok: true, domain: r.entry, initialAdmin: r.initialAdmin, ...(await list()) }, 201);
}
if (method === 'PATCH' && target) {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const entry = tenants.update(target, body);
console.log(`도메인 변경(setup-token ${ip}): ${entry.domain} ${JSON.stringify(Object.keys(body))}`);
return json({ ok: true, domain: entry, ...(await list()) });
}
if (method === 'DELETE' && target) { const r = tenants.remove(target); console.log(`도메인 삭제(setup-token ${ip}): ${r.domain}`); return json({ ok: true, removed: r, ...(await list()) }); }
return bad('not_found', 404);
} catch (e) {
const status = e.message === 'validation' || e.message === 'www_db_unreadable' || e.message === 'webmaker_dir_unreadable' ? 422 : ['domain_exists', 'host_taken', 'primary_domain'].includes(e.message) ? 409 : e.message === 'not_found' ? 404 : 500;
if (status === 500) console.error('도메인 등록부 오류', e);
return bad(status === 500 ? 'server_error' : e.message, status, { ...(e.field ? { field: e.field } : {}), ...(e.detail ? { detail: e.detail } : {}) });
}
}
/**
* 초기 설정 전용 HTTP(S) 서버. 화면 파일(web/setup.html·setup.js·setup.css)과 토큰 인증 API 만 제공합니다.
* @param {object} o
* @param {string} o.webDir web/ 디렉터리
* @param {object|null} [o.tlsOptions] { key, cert } 가 있으면 HTTPS
* @param {object|null} [o.tenants] 도메인 관리자(domains.mjs openTenants). 있으면 전체 설정(도메인 목록·?domain= 선택)으로 동작합니다(1.0.7).
*/
export function createSetupServer(env, { webDir, tlsOptions = null, tenants = null }) {
const SECURITY = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
const STATIC = { '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml' };
let server = null, bound = null; const secure = !!tlsOptions;
const handler = async (req, res) => {
const url = new URL(req.url, 'http://x'); const ip = req.socket.remoteAddress || '';
try {
// 1.0.7: 전체 설정은 기본 도메인의 주소로 엽니다. 추가 도메인의 호스트 이름으로 오면(인증서도 맞지 않음) 안내만 줍니다. IP·localhost 는 기본 도메인으로 봅니다.
if (tenants) { const r = tenants.resolveHost(String(req.headers.host || '')); if (r.tenant && r.tenant !== tenants.primary) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY }); return res.end(noServicePage({ host: r.host, strings: { domainMissingTitle: '전체 설정은 기본 도메인 주소로 엽니다', domainMissing: `이 주소(${r.host})로는 전체 설정 화면을 열 수 없습니다.`, domainAskAdmin: `기본 도메인의 주소 https://${normalizeHost(env.PUBLIC_HOST || '')}:${bound?.port || Number(env.SETUP_PORT ?? 8791)}/ 로 여세요(배포 출력에 토큰 포함 주소가 있습니다).` }, version: env.APP_VERSION })); } }
if (url.pathname === '/' || url.pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(setupPage(env, webDir, 'token')); }
if (url.pathname === '/favicon.ico') { res.writeHead(301, { Location: '/favicon.svg' }); return res.end(); }
if (STATIC[url.pathname]) { res.writeHead(200, { 'Content-Type': STATIC[url.pathname], ...SECURITY }); return res.end(readFileSync(join(webDir, url.pathname))); }
if (url.pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, version: env.APP_VERSION || null, setup: true })); }
if (!url.pathname.startsWith('/api/setup/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); }
// 토큰 인증: 헤더(X-Setup-Token) 또는 주소(?token=). 틀리면 IP 당 15분 10회만 허용합니다(토큰 추측 방지).
const rateKey = 'setup:' + ip;
if (env.RATE?.blocked(rateKey, { limit: 10 })) { res.writeHead(429, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'rate_limited' })); }
const token = req.headers['x-setup-token'] || url.searchParams.get('token') || '';
if (!safeEqual(String(token), setupToken(env))) { env.RATE?.hit(rateKey, { limit: 10, windowSec: 900 }); res.writeHead(401, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'setup_token_invalid' })); }
const chunks = []; let size = 0; if (!['GET', 'HEAD'].includes(req.method)) for await (const c of req) { size += c.length; if (size > 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); }
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers.set(k, v);
const request = new Request(`${secure ? 'https' : 'http'}://${req.headers.host || 'localhost'}${req.url}`, { method: req.method, headers, body: chunks.length ? Buffer.concat(chunks) : undefined });
// 대상 도메인(1.0.7): ?domain=<도메인> 이 있으면 그 테넌트의 env, 없으면 기본 도메인. 모르는 도메인은 404.
let target = env; const want = url.searchParams.get('domain');
if (want && tenants) { const t = tenants.byDomain(want); if (!t) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'domain_not_found' })); } target = t.env; }
const response = await setupApi(target, request, url.pathname.slice('/api/setup/'.length).split('/').filter(Boolean), { ip, tenants });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; });
const buf = Buffer.from(await response.arrayBuffer()); res.writeHead(response.status, out); res.end(buf);
} catch (e) {
console.error('초기 설정 페이지 오류', req.method, url.pathname, e.message);
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json', ...SECURITY }); res.end(JSON.stringify({ ok: false, error: 'server_error' }));
}
};
return {
/** 포트를 엽니다. 실패(이미 쓰는 포트 등)는 예외로 알립니다. */
listen({ port, host = '0.0.0.0' }) {
return new Promise((resolve, reject) => {
server = secure ? createHttpsServer({ ...tlsOptions, minVersion: 'TLSv1.2' }, handler) : createHttpServer(handler);
server.once('error', reject);
server.listen(port, host, () => { bound = { port: server.address().port, host, secure }; resolve(bound); });
});
},
/** 접속 주소. HTTPS 면 인증서와 맞는 공개 호스트 이름을, HTTP 면 서버 IP 를 씁니다(DNS 가 아직 안 바뀐 새 서버에서도 열 수 있게). */
urls(token) {
if (!bound) return [];
const scheme = bound.secure ? 'https' : 'http', host = env.PUBLIC_HOST || 'localhost';
const hosts = bound.secure ? [host, ...serverAddresses()] : [...serverAddresses(), host];
return [...new Set(hosts)].map((h) => `${scheme}://${h}:${bound.port}/?token=${encodeURIComponent(token)}`);
},
/** 인증서 갱신(index.mjs 의 1시간 점검)을 반영합니다. HTTP 로 시작했으면 바꾸지 않습니다(주소 체계가 달라지므로 다음 재시작부터 HTTPS). */
setTls(opts) { if (server && bound?.secure && opts) server.setSecureContext(opts); },
close() { if (server) server.close(); },
get bound() { return bound; },
};
}
/**
* 설정 화면 HTML. web/setup.html 의 자리표시자를 채웁니다: __MODE__(token | admin — 인증 방식), __VERSION__, __HOST__, __TITLE__, __BASE__(1.0.8: 기준 경로, 보통 "/").
* index.mjs 가 /setup 에서(경로 접두사 아래면 base 가 "/mail/"), 전용 서버가 / 에서 같은 함수를 씁니다.
*/
export function setupPage(env, webDir, mode, { base = '/' } = {}) {
const title = (env.STRINGS?.appName || 'AriMail') + ' 설정';
return readFileSync(join(webDir, 'setup.html'), 'utf8').replace(/__MODE__/g, mode).replace(/__VERSION__/g, escapeHtml(env.APP_VERSION || '')).replace(/__HOST__/g, escapeHtml(env.PUBLIC_HOST || '')).replace(/__TITLE__/g, escapeHtml(title)).replace(/__BASE__/g, escapeHtml(base));
}
/** 배포 스크립트가 읽어 출력할 접속 주소 파일(데이터 디렉터리의 setup-url.txt). 쓸 수 없어도 서버는 계속 뜹니다. */
export function writeSetupUrls(env, urls) {
try { writeFileSync(join(configDir(env), 'setup-url.txt'), urls.join('\n') + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-url.txt 를 쓸 수 없습니다:', e.message); }
}
+446
View File
@@ -0,0 +1,446 @@
/**
* SMTP 제출(submission) 서버 — RFC 5321 + RFC 6409(메일 제출) + RFC 3207(STARTTLS) + RFC 4954(AUTH).
*
* 왜 필요한가
* 0.8.0 까지는 메일 프로그램(Outlook·Thunderbird·Apple Mail 등)이 Resend SMTP(smtp.resend.com)에 직접 접속했고,
* SMTP 비밀번호로 Resend API 키를 입력해야 했습니다. 키가 사용자 PC·메일 프로그램 설정에 퍼지면 노출 위험이 큽니다.
* 이제 메일 프로그램은 이 게이트웨이(mail.arihakdang.com:465/587)에 **웹 메일 로그인과 같은 아이디·비밀번호**로 인증하고,
* 게이트웨이가 받은 메일을 서버에만 보관된 RESEND_API_KEY 로 Resend API 를 호출해 실제로 발송합니다.
* API 키는 서버 환경 파일 밖으로 나가지 않습니다.
*
* 동작 요약
* - 465: 접속 즉시 TLS(SMTPS). 587: 평문으로 접속한 뒤 STARTTLS 로 암호화(권장 제출 포트).
* - 인증(AUTH PLAIN / AUTH LOGIN)은 TLS 위에서만 받습니다(POP·IMAP 과 같은 정책, 진단용 SMTP_ALLOW_PLAINTEXT 예외).
* - 인증한 사용자는 자기 메일함 주소로만 보낼 수 있습니다(0.9.0 부터 관리자도 마찬가지 — 메일 기능에서는 자기 메일함만 씀).
* 다른 주소를 보내는 사람으로 쓰면 거부합니다(우리 키로 남의 주소를 사칭하지 못하게).
* - 받는 사람 수·첨부 크기·메일함 용량·월간 통수 제한(0.9.0, limits.mjs)을 발송 전에 검사해 SMTP 오류로 돌려줍니다.
* - DATA 로 받은 원본(.eml)을 해석해 Resend API 요청(받는 사람·참조·숨은 참조·제목·본문·첨부·인라인 이미지)으로 바꿔 보냅니다.
* - 발송에 성공하면 원본을 그대로 보낸 메일함에 저장합니다. 메일 프로그램이 IMAP 으로 같은 메일을 보낸 메일함에
* 올려도(APPEND) Message-ID 로 한 통으로 합칩니다.
* - 이 서버는 외부에서 오는 메일을 받는 MX 가 아닙니다. 인증하지 않은 접속은 MAIL 단계에서 거부합니다(오픈 릴레이 방지).
*/
import net from 'node:net';
import tls from 'node:tls';
import { createHash } from 'node:crypto';
import { verifyUser, audit } from './auth.mjs';
import { sendMail } from './providers/index.mjs';
import { normalizeCrlf } from './mime.mjs';
import { parseMime, summarize, header, parseAddresses, decodeWords } from './imap-mime.mjs';
import { appendMessage, setFlag } from './store.mjs';
import { now, isEmail, memoryRateStore } from './util.mjs';
import { checkOutbound, recipientLimit } from './limits.mjs';
const CRLF = '\r\n';
// 한 통의 최대 크기(원본 바이트). Resend 는 base64 로 늘어난 뒤 40MB 까지 받으므로 원본 기준 30MB 로 둡니다.
export const SMTP_MAX_SIZE = 30 * 1024 * 1024;
// 한 통의 최대 받는 사람 수는 0.9.0 부터 관리자·시스템 설정(limits.recipientLimit, 내장 상한 50 = Resend 한도)을 따릅니다.
// 명령 한 줄의 최대 길이. RFC 5321 은 512 바이트지만 AUTH 초기 응답이 길 수 있어 넉넉히 둡니다.
const MAX_LINE = 4096;
// 인증 실패 요청 제한(접속 IP 기준). 비밀번호 대입을 늦춥니다.
const rate = memoryRateStore();
const TERMINATOR = Buffer.from('\r\n.\r\n');
/**
* SMTP 응답 코드를 실어 나르는 오류. submitMessage 가 던지면 세션이 그대로 클라이언트에 전달합니다.
* @param {number} code 3자리 응답 코드(예: 550)
* @param {string} enhanced 확장 상태 코드(RFC 3463, 예: '5.7.1')
* @param {string} message 사람이 읽는 설명(영문, 메일 프로그램이 그대로 보여 줌)
*/
export class SmtpError extends Error {
constructor(code, enhanced, message) { super(message); this.code = code; this.enhanced = enhanced; }
get reply() { return `${this.code} ${this.enhanced} ${this.message}`; }
}
/**
* 발송 모듈의 오류 코드(providers/resend.mjs 의 providerError, providers/smtp-client.mjs 의 smtpErrorCode)를 SMTP 응답으로 바꿉니다.
* 키·도메인 설정 문제나 일시 장애는 4xx(임시 실패)로 돌려 메일 프로그램이 나중에 다시 보내게 하고,
* 내용이 거부된 경우만 5xx(영구 실패)로 돌려 사용자가 고치게 합니다. 응답에 키 관련 정보는 넣지 않습니다.
*/
function providerReply(e) {
if (e instanceof SmtpError) return e;
switch (e?.message) {
case 'mail_rejected': return new SmtpError(550, '5.6.0', 'Message rejected by the mail service' + (e.providerMessage ? ': ' + String(e.providerMessage).replace(/[\r\n]+/g, ' ').slice(0, 200) : ''));
case 'mail_rate_limited': return new SmtpError(451, '4.7.0', 'Sending rate limit reached, try again shortly');
default: return new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
}
}
/** "이름 <주소>" 형식 문자열. 이름이 없으면 주소만. 이름에 따옴표·꺾쇠가 있으면 따옴표로 감쌉니다. */
function displayAddress(name, addr) {
if (!name) return addr;
const clean = String(name).replace(/[\r\n]+/g, ' ').trim();
return /[",<>()@;:]/.test(clean) ? `"${clean.replace(/["\\]/g, '\\$&')}" <${addr}>` : `${clean} <${addr}>`;
}
/** parseAddresses 결과({name, mailbox, host})를 소문자 주소와 표시 문자열로 바꿉니다. */
const toEntries = (value) => parseAddresses(value).filter((a) => a.host).map((a) => { const addr = `${a.mailbox}@${a.host}`.toLowerCase(); return { addr, display: displayAddress(a.name, addr) }; });
/**
* 이 사용자가 보내는 사람으로 쓸 수 있는 주소 목록: 자기 메일함 하나뿐입니다.
* 0.8.x 까지는 관리자가 게이트웨이의 모든 메일함 주소로 보낼 수 있었지만(웹에서도 모든 메일함을 열 수 있었음), 0.9.0 에서 관리자의
* 메일함 전환 기능을 없애면서 발신 주소도 자기 메일함으로 좁혔습니다. 목록 형태는 호출부 호환을 위해 유지합니다.
*/
export async function allowedSenders(env, user) { return [user.mailbox]; }
/** limits.checkOutbound 의 오류 코드를 SMTP 응답으로 바꿉니다. 용량·통수 제한은 임시(4xx)/영구(5xx) 성격에 맞춰 돌려줍니다. */
function limitReply(e) {
switch (e?.message) {
case 'too_many_recipients': return new SmtpError(452, '4.5.3', `Too many recipients (limit ${e.limit})`);
case 'attachment_too_large': return new SmtpError(552, '5.3.4', `Attachment exceeds the size limit (${e.limit} bytes after MIME encoding)`);
case 'quota_exceeded': return new SmtpError(452, '4.2.2', 'Mailbox full (quota exceeded), free space and try again');
case 'storage_full': return new SmtpError(452, '4.3.1', 'Insufficient system storage');
case 'monthly_limit_exceeded': return new SmtpError(550, '5.7.1', `Monthly message limit reached (${e.limit})`);
default: return null;
}
}
/**
* 인증된 사용자가 DATA 로 보낸 원본을 Resend API 로 발송하고 보낸 메일함에 저장합니다.
*
* @param {object} env
* @param {object} user verifyUser 가 돌려준 사용자(id, login, name, role, mailbox)
* @param {object} envelope
* @param {string} envelope.mailFrom MAIL FROM 주소(빈 문자열 = 반송 주소 없음 "<>")
* @param {string[]} envelope.rcpt RCPT TO 주소들(실제로 배달할 대상, 소문자)
* @param {Buffer} envelope.eml DATA 원본(점 스터핑을 푼 RFC 5322 메시지)
* @param {string} [envelope.ip] 접속 IP(감사 기록용)
* @returns {Promise<{id:string, providerId:string}>} 저장한 메일 id 와 Resend 발송 id
* @throws {SmtpError} 클라이언트에 돌려줄 SMTP 오류
*/
export async function submitMessage(env, user, { mailFrom = '', rcpt = [], eml, ip = '' }) {
if (!env.RESEND_API_KEY) throw new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
const raw = normalizeCrlf(eml);
let parsed, info;
try { parsed = parseMime(raw); info = summarize(parsed); } catch { throw new SmtpError(550, '5.6.0', 'Malformed message'); }
const headers = parsed.root.headers;
// 1) 보내는 사람 확인: 헤더 From 과 봉투 MAIL FROM 이 모두 이 사용자에게 허용된 주소여야 합니다.
const allowed = await allowedSenders(env, user);
const fromEntry = toEntries(header(headers, 'from'))[0];
const fromAddr = fromEntry?.addr || mailFrom || user.mailbox;
if (!allowed.includes(fromAddr) || (mailFrom && !allowed.includes(mailFrom))) throw new SmtpError(550, '5.7.1', `Sender address not allowed for this account (use ${user.mailbox})`);
// From 헤더가 없으면 계정 이름으로 채웁니다. 이름만 비어 있으면 계정 이름을 씁니다.
const from = fromEntry && fromEntry.display !== fromEntry.addr ? fromEntry.display : displayAddress(user.name || '', fromAddr);
// 2) 받는 사람 나누기. 실제 배달 대상은 봉투(RCPT)입니다. 헤더의 To/Cc 중 봉투에 있는 주소만 to/cc 로 두고,
// 봉투에만 있는 주소는 숨은 참조(bcc)로 보냅니다(메일 프로그램은 Bcc 헤더를 지우고 RCPT 로만 알려 주기 때문).
const rcptSet = new Set(rcpt);
const to = toEntries(header(headers, 'to')).filter((a) => rcptSet.has(a.addr));
const cc = toEntries(header(headers, 'cc')).filter((a) => rcptSet.has(a.addr) && !to.some((t) => t.addr === a.addr));
const listed = new Set([...to, ...cc].map((a) => a.addr));
const bcc = rcpt.filter((a) => !listed.has(a));
// Resend 는 to 가 꼭 있어야 합니다. 모두 숨은 참조로 보낸 경우(예: "받는 사람 없음" 단체 메일)에는
// 보내는 사람 자신을 to 로 둡니다. 받는 사람들에게는 서로의 주소가 보이지 않고, 보낸 사람에게 사본이 한 통 갑니다.
const toList = to.length ? to.map((a) => a.display) : [from];
// 3) Resend 요청 만들기
const replyTo = toEntries(header(headers, 'reply-to')).map((a) => a.display);
const custom = {};
for (const name of ['In-Reply-To', 'References']) { const v = header(headers, name); if (v) custom[name] = String(v).replace(/[\r\n]+/g, ' ').slice(0, 2000); }
const payload = {
from, to: toList, ...(cc.length ? { cc: cc.map((a) => a.display) } : {}), ...(bcc.length ? { bcc } : {}), ...(replyTo.length ? { reply_to: replyTo } : {}),
subject: info.subject || '', ...(info.html ? { html: info.html } : {}), text: info.text || (info.html ? undefined : ' '),
...(Object.keys(custom).length ? { headers: custom } : {}),
};
if (payload.text === undefined) delete payload.text;
const cidUsed = new Set([...String(info.html || '').matchAll(/\bsrc\s*=\s*["']?cid:([^"'\s>]+)/gi)].map((x) => x[1].replace(/&amp;/g, '&')));
if (info.attachments.length) payload.attachments = info.attachments.map((a) => ({
filename: decodeWords(a.filename || '') || 'attachment', content: Buffer.from(a.bytes).toString('base64'), content_type: a.contentType,
// 본문에 cid: 로 끼워 넣은 이미지는 content_id 를 그대로 넘겨 받는 쪽에서도 본문 안에 보이게 합니다.
// Content-Disposition 없이 Content-ID 만 있는 파트(Outlook·Exchange 류)도 본문이 cid: 로 가리키면 인라인입니다(0.9.0 수정, 예전에는 깨진 그림 + 별도 첨부로 갔음).
...(a.contentId && (a.disposition === 'inline' || cidUsed.has(a.contentId)) ? { content_id: a.contentId } : {}),
}));
// 같은 메일을 메일 프로그램이 다시 보내는 경우(응답을 못 받고 재전송) 두 번 발송되지 않게, 사용자 + 원본 바이트로 멱등 키를 만듭니다.
// 같은 내용을 일부러 다시 보내도 메일 프로그램이 Date·Message-ID 를 새로 만들므로 키가 달라 정상 발송됩니다.
// 키에는 봉투(MAIL FROM, RCPT TO 집합)도 넣습니다. 예전에는 사용자+원본만 써서, 같은 DATA 를 받는 사람만 바꿔 따로 보내는 경우
// (메일 머지·"개별 발송")에 키가 겹쳐 두 번째 받는 사람에게 가지 않았습니다(0.8.25 수정).
const key = 'smtp-' + createHash('sha256').update(user.id + '\n' + (mailFrom || '') + '\n' + [...rcptSet].sort().join(',') + '\n').update(raw).digest('hex').slice(0, 48);
// 제한 검사(0.9.0): 받는 사람 수·첨부 크기(MIME 기준)·메일함 용량(원본 크기만큼 늘어남)·월간 통수. 발송 전에 하므로 어긋나면 아무것도 보내지 않습니다.
try { await checkOutbound(env, { mailbox: fromAddr, recipients: rcpt.length, attachmentSizes: info.attachments.map((a) => a.bytes.length), bytes: raw.length }); }
catch (e) { const reply = limitReply(e); if (reply) throw reply; throw e; }
let result;
// 1.0.0: 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)은 설정이 정합니다. 어느 쪽이든 같은 오류 코드로 실패하므로 SMTP 응답 변환(providerReply)은 그대로입니다.
try { result = await sendMail(env, payload, key); } catch (e) { throw providerReply(e); }
// 4) 보낸 메일함 저장. 메일 프로그램이 IMAP 으로 먼저 올린 사본(같은 Message-ID, 발송 id 없음)이 있으면 그 메일에 발송 id 만 붙입니다.
const stamp = now();
let id = null;
if (info.messageId) id = (await env.DB.prepare("SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=? AND provider_id IS NULL").bind(fromAddr, info.messageId).first())?.id || null;
if (!id) id = (await appendMessage(env, 'sent', raw, { flags: ['\\Seen'], internalDate: stamp, mailbox: fromAddr, enforceQuota: false })).id;
// provider_id 를 채워 두면 정기 동기화(ingestSent)가 같은 메일을 다시 가져오지 않습니다.
await env.DB.prepare("UPDATE messages SET provider_id=?,sent_at=?,state='sent',bcc_json=?,updated_at=? WHERE id=?").bind(result.id, stamp, JSON.stringify(bcc), stamp, id).run();
// 답장이면 원래 메일에 \Answered(답장함) 표시를 남깁니다(웹 발송과 같은 규칙).
if (custom['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(custom['In-Reply-To'], fromAddr).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); }
await audit(env, user.login, 'smtp_send', id, { ip, recipients: rcpt.length });
return { id, providerId: result.id };
}
/**
* DATA 본문 수집기. 받은 바이트를 조각으로 모으며 끝 표시(<CRLF>.<CRLF>)를 찾습니다.
* 큰 첨부(수십 MB)를 받을 때 매번 버퍼를 이어 붙이면 복사량이 제곱으로 늘어나므로, 조각 목록과 마지막 4바이트(tail)만 유지합니다.
* 끝 표시가 조각 경계에 걸쳐도 tail 과 새 조각을 붙여 찾으므로 놓치지 않습니다.
*/
export class DataCollector {
// tail 을 CRLF 로 시작하는 이유: DATA 명령 줄 끝의 CRLF 가 본문 바로 앞에 있으므로, 본문이 비어 있어
// 첫 줄이 곧바로 "." 인 경우(".\r\n")도 같은 끝 표시(<CRLF>.<CRLF>) 규칙으로 찾을 수 있습니다. 이 CRLF 는 본문에 넣지 않습니다.
constructor(max) { this.parts = []; this.size = 0; this.tail = Buffer.from(CRLF); this.max = max; this.tooBig = false; }
/**
* @param {Buffer} chunk 새로 받은 바이트
* @returns {null | {message: Buffer|null, rest: Buffer}} 끝 표시를 찾으면 원본(크기 초과면 null)과 그 뒤에 남은 바이트
*/
feed(chunk) {
const combined = Buffer.concat([this.tail, chunk]);
const idx = combined.indexOf(TERMINATOR);
if (idx < 0) {
this.push(chunk);
this.tail = Buffer.from(combined.subarray(Math.max(0, combined.length - 4)));
return null;
}
// 본문은 끝 표시의 앞 CRLF 까지 포함합니다. bodyEnd 가 tail 안에 있으면(끝 표시가 이전 조각에서 시작) 이미 넣은 바이트를 덜어 냅니다.
const bodyEnd = idx + 2, fromChunk = bodyEnd - this.tail.length;
let drop = 0;
if (fromChunk >= 0) this.push(chunk.subarray(0, fromChunk)); else drop = -fromChunk;
const rest = combined.subarray(idx + TERMINATOR.length);
if (this.tooBig) return { message: null, rest };
let message = Buffer.concat(this.parts); if (drop) message = message.subarray(0, Math.max(0, message.length - drop));
return { message, rest };
}
push(buf) {
this.size += buf.length;
if (this.size > this.max) { this.tooBig = true; this.parts = []; return; } // 크기를 넘으면 더 모으지 않고 끝 표시만 찾습니다.
if (!this.tooBig && buf.length) this.parts.push(Buffer.from(buf));
}
}
/** 점 스터핑 해제(RFC 5321 §4.5.2): 줄 맨 앞의 ".." 를 "." 로 되돌립니다. 바이트를 바꾸지 않도록 latin1 로 다룹니다. */
export function dotUnstuff(buffer) {
return Buffer.from(Buffer.from(buffer).toString('latin1').replace(/(^|\r\n)\.\./g, '$1.'), 'latin1');
}
/** "<user@host>" 경로에서 주소만 꺼냅니다(소문자). 파라미터(SIZE=…)는 따로 돌려줍니다. */
function parsePath(text) {
const m = String(text).match(/^\s*<([^<>]*)>\s*(.*)$/) || String(text).match(/^\s*([^\s<>]+)\s*(.*)$/);
if (!m) return null;
const params = Object.fromEntries((m[2] || '').split(/\s+/).filter(Boolean).map((p) => { const [k, v = ''] = p.split('='); return [k.toUpperCase(), v]; }));
return { address: m[1].trim().replace(/^@[^:]+:/, '').toLowerCase(), params }; // 소스 라우트(@a,@b:user@host)는 무시
}
/**
* 접속 한 개의 상태 기계.
* 상태: greeted(EHLO 여부) → 인증(user) → 트랜잭션(mailFrom, rcpt) → DATA 수집(collector) → 처리 후 트랜잭션 초기화.
* 입력은 pump() 가 한 번에 한 명령씩 순서대로 처리합니다. PIPELINING 으로 여러 명령이 한꺼번에 와도
* 앞 명령(예: RCPT 확인)이 끝난 뒤 다음 명령(DATA)을 판단해야 하므로 비동기 처리를 직렬화합니다.
*/
class Session {
constructor(server, socket, { secure }) {
this.server = server; this.env = server.env; this.secure = secure;
this.buf = Buffer.alloc(0); // 아직 처리하지 않은 명령 바이트
this.collector = null; // DATA 수집 중이면 DataCollector
this.pendingData = null; // 수집이 끝나 처리를 기다리는 DATA 결과
this.pumping = false; this.closed = false;
this.greeted = false; this.user = null; this.failures = 0;
this.sasl = null; // 진행 중인 AUTH 대화 { mech, step, login }
this.reset();
this.ip = socket.remoteAddress || ''; this.attach(socket);
console.log(`SMTP 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`);
this.send(`220 ${server.hostname} ESMTP ready`);
}
/** 메일 한 통의 트랜잭션 상태(MAIL FROM·RCPT TO)를 비웁니다. 인증 상태는 유지합니다. */
reset() { this.mailFrom = null; this.rcpt = []; }
attach(socket) {
this.socket = socket; socket.setTimeout(5 * 60 * 1000);
socket.on('data', (chunk) => this.onData(chunk));
socket.on('timeout', () => this.close('421 4.4.2 Idle timeout'));
socket.on('error', () => { this.closed = true; });
socket.on('close', () => { this.closed = true; });
}
send(line) { if (!this.closed) this.socket.write(line + CRLF); }
/** 여러 줄 응답: 마지막 줄만 "250 " 이고 나머지는 "250-" 입니다. */
sendMulti(code, lines) { lines.forEach((l, i) => this.send(`${code}${i === lines.length - 1 ? ' ' : '-'}${l}`)); }
close(line) { if (line) this.send(line); this.closed = true; try { this.socket.end(); } catch {} }
onData(chunk) {
if (this.closed) return;
if (this.collector) {
// DATA 수집 중: 명령으로 해석하지 않고 수집기에 넘깁니다.
const done = this.collector.feed(chunk);
// pendingData 는 { message } 로 감쌉니다. 크기 초과면 message 가 null 인데, 예전에는 null 자체를 넣어 "처리할 것 없음" 과 구분되지 않아
// 552 응답이 나가지 않고 세션이 멈췄습니다.
if (done) { this.collector = null; this.pendingData = { message: done.message }; this.buf = Buffer.from(done.rest); this.pump(); }
return;
}
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : Buffer.from(chunk);
this.pump();
}
/** 쌓인 입력을 순서대로 처리합니다. 이미 처리 중이면 그 루프가 새 입력까지 이어서 처리합니다. */
async pump() {
if (this.pumping) return; this.pumping = true;
try {
while (!this.closed) {
if (this.pendingData) { const { message } = this.pendingData; this.pendingData = null; await this.finishData(message); continue; }
if (this.collector) {
// DATA 를 막 시작했고 같은 묶음에 본문이 이미 와 있던 경우
if (!this.buf.length) break;
const pending = this.buf; this.buf = Buffer.alloc(0);
const done = this.collector.feed(pending);
if (!done) break;
this.collector = null; this.pendingData = { message: done.message }; this.buf = Buffer.from(done.rest); continue;
}
const i = this.buf.indexOf('\r\n');
if (i < 0) { if (this.buf.length > MAX_LINE) this.close('500 5.5.6 Line too long'); break; }
const line = this.buf.subarray(0, i).toString('utf8'); this.buf = this.buf.subarray(i + 2);
try { await this.handle(line); } catch (e) { console.error('SMTP 오류', e?.stack || e?.message); this.send('451 4.3.0 Internal error'); }
}
} finally { this.pumping = false; }
}
async handle(line) {
if (this.sasl) return this.saslStep(line);
const sp = line.indexOf(' '); const verb = (sp < 0 ? line : line.slice(0, sp)).toUpperCase(); const arg = sp < 0 ? '' : line.slice(sp + 1).trim();
const loginAllowed = this.secure || this.server.allowPlaintext;
switch (verb) {
case 'EHLO': case 'HELO': {
if (!arg) return this.send('501 5.5.4 Domain required');
this.greeted = true; this.reset();
if (verb === 'HELO') return this.send(`250 ${this.server.hostname}`);
const ext = [this.server.hostname, `SIZE ${SMTP_MAX_SIZE}`, '8BITMIME', 'PIPELINING', 'ENHANCEDSTATUSCODES'];
if (!this.secure && this.server.secureContext) ext.push('STARTTLS');
if (loginAllowed && !this.user) ext.push('AUTH PLAIN LOGIN');
ext.push('HELP');
return this.sendMulti(250, ext);
}
case 'STARTTLS': {
if (this.secure) return this.send('503 5.5.1 Already using TLS');
if (!this.server.secureContext) return this.send('454 4.7.0 TLS not available');
this.send('220 2.0.0 Ready to start TLS');
// RFC 3207: STARTTLS 이전 상태(EHLO·인증)는 모두 잊습니다. 평문 구간에 끼워 넣은 명령이 암호화 뒤에 실행되지 않도록
// 남은 입력도 버립니다(명령 주입 방지).
const plain = this.socket; plain.removeAllListeners('data'); plain.removeAllListeners('timeout'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext });
this.secure = true; this.greeted = false; this.reset(); this.buf = Buffer.alloc(0); this.attach(secureSocket); return;
}
case 'AUTH': {
if (!this.greeted) return this.send('503 5.5.1 Send EHLO first');
if (this.user) return this.send('503 5.5.1 Already authenticated');
if (!loginAllowed) { console.log(`SMTP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send('530 5.7.0 Must issue a STARTTLS command first'); }
const [mech, initial] = arg.split(/\s+/); const m = String(mech || '').toUpperCase();
if (m === 'PLAIN') { if (initial && initial !== '=') return this.authPlain(initial); this.sasl = { mech: 'PLAIN' }; return this.send('334 '); }
if (m === 'LOGIN') {
if (initial) { this.sasl = { mech: 'LOGIN', login: b64(initial) }; return this.send('334 UGFzc3dvcmQ6'); } // "Password:"
this.sasl = { mech: 'LOGIN' }; return this.send('334 VXNlcm5hbWU6'); // "Username:"
}
return this.send('504 5.5.4 Unrecognized authentication mechanism');
}
case 'MAIL': {
if (!this.greeted) return this.send('503 5.5.1 Send EHLO first');
if (!this.user) return this.send('530 5.7.0 Authentication required');
if (this.mailFrom !== null) return this.send('503 5.5.1 Nested MAIL command');
const path = /^FROM:/i.test(arg) ? parsePath(arg.slice(5)) : null; if (!path) return this.send('501 5.5.4 Syntax: MAIL FROM:<address>');
if (path.params.SIZE && Number(path.params.SIZE) > SMTP_MAX_SIZE) return this.send('552 5.3.4 Message size exceeds fixed limit');
if (path.address && !(await allowedSenders(this.env, this.user)).includes(path.address)) return this.send(`550 5.7.1 Sender address not allowed for this account (use ${this.user.mailbox})`);
this.mailFrom = path.address; return this.send('250 2.1.0 Sender OK');
}
case 'RCPT': {
if (this.mailFrom === null) return this.send('503 5.5.1 Need MAIL command first');
const path = /^TO:/i.test(arg) ? parsePath(arg.slice(3)) : null; if (!path || !isEmail(path.address)) return this.send('501 5.1.3 Bad recipient address syntax');
// 이미 봉투에 있는 주소(받는 사람과 참조에 같은 주소)는 세지 않고 그대로 받습니다. 예전에는 한도에서 중복 주소가 452 를 받아 메일 프로그램이 발송 전체를 포기했습니다(0.9.3).
if (this.rcpt.includes(path.address)) return this.send('250 2.1.5 Recipient OK');
if (this.rcpt.length >= (await recipientLimit(this.env))) return this.send('452 4.5.3 Too many recipients');
this.rcpt.push(path.address);
return this.send('250 2.1.5 Recipient OK');
}
case 'DATA': {
if (this.mailFrom === null) return this.send('503 5.5.1 Need MAIL command first');
if (!this.rcpt.length) return this.send('554 5.5.1 No valid recipients');
this.collector = new DataCollector(SMTP_MAX_SIZE);
return this.send('354 Start mail input; end with <CRLF>.<CRLF>');
}
case 'BDAT': return this.send('502 5.5.1 CHUNKING not supported');
case 'RSET': this.reset(); return this.send('250 2.0.0 OK');
case 'NOOP': return this.send('250 2.0.0 OK');
case 'VRFY': return this.send('252 2.5.0 Cannot VRFY user');
case 'HELP': return this.send('214 2.0.0 See https://' + this.server.hostname + '/');
case 'QUIT': return this.close(`221 2.0.0 ${this.server.hostname} closing connection`);
default: return this.send('500 5.5.2 Command not recognized');
}
}
/** AUTH 대화의 다음 줄(334 질문에 대한 base64 응답)을 처리합니다. "*" 는 취소입니다. */
async saslStep(line) {
const s = this.sasl; this.sasl = null;
if (line.trim() === '*') return this.send('501 5.7.0 Authentication cancelled');
if (s.mech === 'PLAIN') return this.authPlain(line.trim());
if (s.login === undefined) { this.sasl = { mech: 'LOGIN', login: b64(line.trim()) }; return this.send('334 UGFzc3dvcmQ6'); }
return this.login(s.login, b64(line.trim()));
}
/** AUTH PLAIN: base64("authzid\0authcid\0password"). authcid(로그인 이름)와 비밀번호만 씁니다. */
authPlain(payload) {
const parts = b64(payload).split('\0');
if (parts.length < 3) return this.send('501 5.5.2 Invalid AUTH PLAIN response');
return this.login(parts[1], parts.slice(2).join('\0'));
}
/**
* 웹 메일·IMAP·POP 과 같은 계정으로 인증합니다(verifyUser: www 관리자·별칭 admin·게이트웨이 계정).
* 실패하면 잠시 기다렸다 응답하고, 같은 접속에서 3번 틀리면 끊습니다. IP 당 15분에 20번까지만 시도할 수 있습니다.
*/
async login(login, password) {
if (rate.blocked('smtp:' + this.ip, { limit: 20 })) return this.close('421 4.7.0 Too many authentication attempts');
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 이후 발송·저장(submitMessage)도 그 도메인의 env 로 합니다.
const env = this.server.resolveEnv ? this.server.resolveEnv(login, sniName(this.socket)) : this.env;
const user = env ? await verifyUser(env, login, password) : null;
if (user) this.env = env;
if (!user) rate.hit('smtp:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다. 메일마다 새로 접속하는 메일 프로그램이 성공 횟수로 막히지 않게.
console.log(`SMTP 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login).slice(0, 80)}${this.secure ? '' : ' (plain)'}`);
if (!user) {
this.failures++; await new Promise((r) => setTimeout(r, this.env.SMTP_AUTH_DELAY_MS ?? this.env.POP_AUTH_DELAY_MS ?? 1500));
if (this.failures >= 3) return this.close('535 5.7.8 Authentication credentials invalid');
return this.send('535 5.7.8 Authentication credentials invalid');
}
this.user = user; return this.send('235 2.7.0 Authentication successful');
}
/** DATA 수집이 끝났을 때: 크기 확인 → 점 스터핑 해제 → 발송·저장 → 결과 응답. 성공·실패와 무관하게 트랜잭션은 비웁니다. */
async finishData(message) {
const envelope = { mailFrom: this.mailFrom || '', rcpt: this.rcpt.slice() }; this.reset();
if (message === null) return this.send('552 5.3.4 Message size exceeds fixed limit');
try {
const r = await submitMessage(this.env, this.user, { ...envelope, eml: dotUnstuff(message), ip: this.ip });
console.log(`SMTP 발송 ${this.ip} user=${this.user.login} rcpt=${envelope.rcpt.length} id=${r.providerId}`);
this.send(`250 2.0.0 OK queued as ${r.providerId}`);
} catch (e) {
const reply = providerReply(e);
console.log(`SMTP 발송 실패 ${this.ip} user=${this.user.login} ${reply.code} ${e.message}`);
this.send(reply.reply);
}
}
}
/** base64 → UTF-8 문자열(잘못된 입력은 빈 문자열). */
function b64(s) { try { return Buffer.from(String(s || ''), 'base64').toString('utf8'); } catch { return ''; } }
/**
* SMTP 제출 서버를 만듭니다. listen() 으로 포트를 열고, 인증서가 갱신되면 setTls() 로 바꿉니다.
* @param {object} env
* @param {object} [options]
* @param {string} [options.hostname] 인사말·EHLO 에 쓸 서버 이름
* @param {{key, cert}|null} [options.tlsOptions] 인증서 PEM(POP·IMAP 과 같은 파일). 없으면 465 를 열지 않고 587 의 STARTTLS 도 없습니다.
* @param {boolean} [options.allowPlaintext] true 면 TLS 없이도 AUTH 를 받습니다(진단용, 운영에서는 끔).
*/
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */
const sniName = (socket) => String(socket?.servername || '');
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */
export function createSmtp(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = false, resolveEnv = null } = {}) {
const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv };
const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); });
// secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7).
const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); });
secure.on('tlsClientError', () => {});
let pendingSecure = null;
const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); }));
return {
plain, secure,
setTls(options) {
shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options);
if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'smtps', []).then(() => console.log(` SMTP TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' SMTP TLS 포트 열기 실패:', e.message)); }
},
async listen({ plainPort, securePort, host = '0.0.0.0' }) {
const out = [];
if (plainPort) await listenOn(plain, plainPort, host, 'submission', out);
if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'smtps', out); else pendingSecure = { port: securePort, host }; }
return out;
},
close() { plain.close(); if (secure.listening) secure.close(); },
};
}
+574
View File
@@ -0,0 +1,574 @@
/**
* 메일 저장소 모듈.
*
* 하는 일
* - 수신: Resend 웹훅·정기 동기화가 알려 준 메일을 가져와 원본(.eml)·첨부·정제 본문을 SQLite 에 저장합니다.
* - 보낸 메일: 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다.
* - 동기화: Resend 목록을 훑어 웹훅이 놓친 메일을 보충합니다(전체 훑기 / 빠른 확인 두 가지 방식).
* - 정리: 만료된 로그인 세션을 지웁니다.
* - 초안·발송, IMAP APPEND/COPY 저장.
*
* 보관 정책(0.8.0~)
* 메일은 기한 없이 보관합니다. 사용자가 휴지통에서 "영구 삭제"할 때만 지워지며, 그때 남기는 tombstone 이
* 동기화가 같은 메일을 다시 가져오지 않게 막습니다. 예전의 30일 자동 삭제(RETENTION_DAYS)는 없앴습니다.
* messages.expires_at 열은 이전 릴리스로 되돌릴 때 스키마가 맞도록 남겨 두었지만 더는 쓰지 않습니다(항상 NULL).
*/
import { resend, download } from './providers/resend.mjs';
import { sendMail, inboundMode, outboundMode } from './providers/index.mjs';
import { sanitizeHtml, textToHtml } from './sanitize.mjs';
import { buildEml, normalizeCrlf } from './mime.mjs';
import { now, uid, clean, isEmail, addresses, address, formatSender, nameOf } from './util.mjs';
import { parseMime, summarize } from './imap-mime.mjs';
import { knownMailboxes } from './auth.mjs';
import { autoProcess } from './automation.mjs';
import { checkOutbound, checkInbound, attachmentLimit, mimeSize, mailboxQuota, mailboxUsage, systemLimits, totalUsage } from './limits.mjs';
import { checkLargeFileTerms, finalizeLargeFiles } from './files.mjs';
/** 기본 메일함 주소(환경 변수 MAILBOX, 소문자). 수신자가 비어 있는 메일과 계정 없는 발송의 기본값입니다. */
export const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
/** 발신 표시 이름의 기본값. 계정 이름이 없을 때 "이름 <주소>" 의 이름 부분으로 씁니다. */
const senderName = (env) => String(env.MAIL_FROM_NAME || 'Ari Hak Dang');
// ATTACHMENT_LIMIT: 수신 첨부 한 개를 내려받는 최대 크기, RAW_LIMIT: 원본 .eml 최대 크기. 넘으면 바이트 없이 목록만 남깁니다.
const ATTACHMENT_LIMIT = 25 * 1024 * 1024, RAW_LIMIT = 40 * 1024 * 1024;
// 받은 메일·초안 HTML 의 정제 한도(글자 수). 뉴스레터는 인라인 CSS 때문에 수백 KB 가 흔해 화면 입력 한도(40만 자)보다 크게 둡니다.
const STORED_HTML_LIMIT = 5 * 1000 * 1000;
/**
* 저장용 본문 정제. HTML 이 한도를 넘어도 예외를 던지지 않고 텍스트 본문을 HTML 로 바꿔 대신 씁니다.
* 왜: 예전에는 40만 자가 넘는 HTML 메일에서 sanitizeHtml 이 예외를 던져 수신 저장 전체가 실패했습니다.
* 웹훅은 500 을 돌려 Resend 가 계속 재시도하고, 동기화도 매번 실패해 그 메일은 영영 메일함에 들어오지 못했습니다.
* @param {string|null} html 원본 HTML(없으면 텍스트를 씀)
* @param {string} text 텍스트 본문(HTML 이 없거나 너무 클 때 대신 표시)
*/
export function safeContent(html, text) {
// 텍스트만 있는 메일도 같은 한도(STORED_HTML_LIMIT)를 씁니다. 예전에는 기본 한도(40만 자)로 정제해 40만 자가 넘는 텍스트 메일(로그 첨부 본문 등)의 저장이 실패했습니다(0.9.0 수정).
if (!html) { try { return sanitizeHtml(textToHtml(text || ''), { limit: STORED_HTML_LIMIT }); } catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); } }
try { return sanitizeHtml(html, { limit: STORED_HTML_LIMIT }); }
catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); }
}
/**
* 시각 문자열을 ISO 8601 UTC(예: 2026-09-28T06:20:26.514Z)로 맞춥니다.
*
* 왜 필요한가: Resend 의 보낸 메일 목록은 created_at 을 "2026-09-28 06:20:26.514000+00" 처럼 공백과 짧은
* 시간대 표기로 돌려줍니다. 이 값을 그대로 received_at 에 넣으면, 다른 메일(ISO 형식)과 같은 날짜일 때
* 문자열 정렬(ORDER BY received_at)에서 공백(0x20)이 'T'(0x54)보다 앞서 시각과 무관하게 순서가 뒤섞였습니다.
* 저장 전에 한 형식으로 맞추면 목록·POP·IMAP 의 날짜순 정렬이 실제 시각 순서와 같아집니다.
*
* @param {string|undefined|null} value Resend 등에서 받은 시각 문자열
* @param {string} [fallback] 해석할 수 없을 때 쓸 값(기본: 지금)
* @returns {string} ISO 8601 UTC 문자열
*/
export function isoTime(value, fallback = now()) {
if (!value) return fallback;
// "+00" 처럼 분이 빠진 시간대는 Date.parse 가 환경에 따라 거부할 수 있어 "+00:00" 으로 보완합니다.
// 시각 부분(HH:MM[:SS[.fff]]) 바로 뒤에 붙은 경우만 고쳐, "2026-09-28" 같은 날짜만 있는 값의 "-28" 을 건드리지 않습니다.
const text = String(value).trim().replace(/(\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)([+-]\d{2})$/, '$1$2:00');
const ms = Date.parse(text);
return Number.isFinite(ms) ? new Date(ms).toISOString() : fallback;
}
function recipientsOf(full) { return addresses([...(full.to || []), ...(full.cc || []), ...(full.bcc || []), ...(full.received_for || [])]); }
/** 원본(.eml)을 message_raw 에 넣고 messages.raw_status/raw_size 를 갱신합니다. automation.mjs(자동 응답의 보낸 메일 원본)도 씁니다. */
export async function storeRaw(env, messageId, eml, status) {
await env.DB.batch([
env.DB.prepare('INSERT OR REPLACE INTO message_raw(message_id,eml,stored_at) VALUES(?,?,?)').bind(messageId, eml, now()),
env.DB.prepare('UPDATE messages SET raw_status=?,raw_size=?,updated_at=? WHERE id=?').bind(status, eml.length, now(), messageId),
]);
}
async function attachmentRows(env, messageId) {
return (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(messageId).all()).results;
}
export async function buildStoredEml(env, message) {
const rows = await attachmentRows(env, message.id);
return buildEml({
from: message.sender, to: JSON.parse(message.recipients_json || '[]'), cc: JSON.parse(message.cc_json || '[]'), bcc: JSON.parse(message.bcc_json || '[]'), replyTo: JSON.parse(message.reply_to_json || '[]'),
subject: message.subject, date: message.received_at, messageId: message.message_id || undefined, headers: JSON.parse(message.headers_json || '{}'),
text: message.text, html: message.original_html || message.html || '',
attachments: rows.filter((a) => a.bytes).map((a) => ({ filename: a.filename, contentType: a.content_type, contentId: a.content_id, disposition: a.disposition, bytes: Buffer.from(a.bytes) })),
});
}
/**
* 수신 메일 한 통을 Resend 에서 가져와 본문·첨부·원본까지 저장합니다.
*
* 건너뛰는 경우(반환값 skipped): 'tombstone' = 사용자가 영구 삭제한 메일, 'exists' = 원본까지 이미 저장한 메일,
* 'other_mailbox' = 수신자 중 이 게이트웨이가 다루는 메일함이 없는 메일. 동기화의 빠른 확인 방식은
* 'exists'/'tombstone' 을 "여기부터는 이미 확인함" 표시로 씁니다.
*
* @param {object} env
* @param {string} providerId Resend 수신 메일 id
* @param {object} [options]
* @param {'sync'|'webhook'} [options.source] 어떤 경로로 알게 됐는지(설정 화면 통계용)
* @param {object|null} [options.summary] 목록·웹훅이 준 요약(상세 응답에 값이 없을 때 대신 씁니다)
* @param {string[]|null} [options.known] 알려진 메일함 주소 목록(반복 호출 시 DB 조회를 아끼려고 넘깁니다)
*/
export async function ingestReceived(env, providerId, { source = 'sync', summary = null, known = null } = {}) {
known = known || await knownMailboxes(env);
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
const existing = await env.DB.prepare('SELECT id,raw_status FROM messages WHERE provider_id=?').bind(providerId).first();
if (existing && existing.raw_status !== 'none') return { skipped: 'exists', id: existing.id };
const full = await resend(env, '/emails/receiving/' + encodeURIComponent(providerId));
const recipients = recipientsOf(full);
// 메일함 라우팅: 수신자 중 알려진 메일함(기본 메일함 + 계정 메일함) 첫 번째가 주 저장본. 수신자가 없으면 기본 메일함.
// 수신자에 우리 메일함이 둘 이상이면(예: info@ 와 admin@ 에 함께 보낸 메일) 나머지 메일함에도 사본을 둡니다(0.8.24, 예전에는 첫 메일함에만 저장).
const locals = [...new Set(recipients.filter((r) => known.includes(r)))];
const mailbox = locals[0] || (recipients.length ? null : mailboxOf(env));
if (!mailbox) return { skipped: 'other_mailbox' };
// 메일함·전체 용량 제한(0.9.0): 가득 찬 메일함에는 저장하지 않고 'quota_exceeded' / 'storage_full' 로 건너뜁니다(웹훅 기록·동기화 상세에 남음).
// 메일은 발송 서비스 쪽에 남아 있으므로 사용자가 메일을 지워 공간이 생기면 다음 동기화가 다시 가져옵니다. 원본만 없던 메일(existing)은 다시 채웁니다.
if (!existing) { const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox }; }
const content = safeContent(full.html, full.text);
// 수신 시각은 ISO 형식으로 맞춰 저장합니다(isoTime 설명 참고). 보관 만료일(expires_at)은 두지 않습니다.
const receivedAt = isoTime(full.created_at || summary?.created_at), stamp = now();
const id = existing?.id || uid();
const values = [full.from || summary?.from || '', JSON.stringify(full.to || summary?.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject || summary?.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), full.message_id || null, receivedAt, source, stamp, mailbox];
// 원본을 받지 못했던 메일(raw_status='none')은 같은 id 로 내용을 다시 채우고, 처음이면 새로 넣습니다.
if (existing) await env.DB.prepare('UPDATE messages SET sender=?,recipients_json=?,cc_json=?,bcc_json=?,reply_to_json=?,subject=?,html=?,text=?,original_html=?,headers_json=?,message_id=?,received_at=?,source=?,updated_at=?,mailbox=?,expires_at=NULL WHERE id=?').bind(...values, id).run();
else {
// 웹훅과 정기 동기화가 같은 메일을 거의 동시에 만나면 둘 다 위의 "이미 있나" 확인을 통과한 뒤 INSERT 에서 provider_id UNIQUE 에 걸립니다(운영 heart 10/12·10/13 의
// 동기화 '일부 실패' 기록). 먼저 넣은 쪽이 원본·첨부까지 처리하므로 늦은 쪽은 오류가 아니라 '이미 있음' 으로 조용히 끝냅니다(1.0.11).
try { await env.DB.prepare(`INSERT INTO messages(sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,message_id,received_at,source,updated_at,mailbox,id,provider_id,direction,folder,state,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'inbound','inbox','received',?)`).bind(...values, id, providerId, stamp).run(); }
catch (e) { if (/UNIQUE constraint failed: messages\.provider_id/.test(String(e.message))) { const race = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first(); return { skipped: 'exists', id: race?.id }; } throw e; }
}
// 첨부: 목록에서 다운로드 주소를 받아 바이트까지 보관합니다.
const declared = full.attachments || summary?.attachments || [];
if (declared.length) {
let list = [];
try { list = (await resend(env, '/emails/receiving/' + encodeURIComponent(providerId) + '/attachments?limit=100')).data || []; } catch (e) { console.error('첨부 목록 실패', providerId, e.message); }
await env.DB.prepare('DELETE FROM attachments WHERE message_id=?').bind(id).run();
// disposition(0.9.9): Content-ID 가 있는 inline 만 'inline'(본문에 끼운 그림)으로 저장하고, 나머지는 모두 'attachment' 입니다. IMAP 으로 올린 메일(imap-mime.mjs)과 같은 규칙입니다.
// 예전에는 Resend 가 준 "inline" 을 그대로 저장해, Apple Mail 이 "inline; filename=x.pdf" 로 보낸 PDF 가 읽기 창에는 보이면서 전달·첨부 수(📎)·"첨부" 필터에서는 빠졌습니다.
for (const item of list.length ? list : declared) {
let bytes = null;
if (item.download_url && Number(item.size || 0) <= ATTACHMENT_LIMIT) { try { bytes = await download(env, item.download_url, { maxBytes: ATTACHMENT_LIMIT }); } catch (e) { console.error('첨부 다운로드 실패', providerId, item.id, e.message); } }
await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.bind(uid(), id, item.id || null, clean(item.filename, 255) || 'attachment', clean(item.content_type, 120) || 'application/octet-stream', item.content_id ? String(item.content_id).replace(/^<|>$/g, '') : null, /^inline/i.test(String(item.content_disposition || item.disposition || '')) && item.content_id ? 'inline' : 'attachment', Number(item.size || bytes?.length || 0), bytes, stamp).run();
}
}
// 원본: Resend 가 서명한 다운로드 주소를 주면 그대로 저장하고, 없으면 저장한 부품으로 재구성합니다.
let eml = null, status = 'built';
if (full.raw?.download_url) { try { eml = await download(env, full.raw.download_url, { maxBytes: RAW_LIMIT }); status = 'stored'; } catch (e) { console.error('원본 다운로드 실패', providerId, e.message); } }
if (!eml) eml = await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first());
await storeRaw(env, id, eml, status);
// 저장이 끝난 뒤 메일함 설정에 따라 전달·자동 응답을 합니다(0.8.16~). 실패해도 수신 저장은 성공으로 끝냅니다
// (실패는 auto_actions 에 남고 메일 상세에 보입니다). 처리 전에 저장을 마치므로 전달에 첨부·원본 HTML 을 그대로 쓸 수 있습니다.
let auto = null;
try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); }
// 다른 우리 메일함에도 받은 메일: 주 저장본을 복제(원본·첨부 포함)해 그 메일함의 받은 메일함에 둡니다. provider_id 는 UNIQUE 라
// "<provider id>#<메일함>" 으로 구분해 두어, 동기화가 다시 만나도(주 저장본이 exists) 중복되지 않고 영구 삭제 시 tombstone 도 따로 남습니다.
const copies = [];
for (const other of locals.slice(1)) {
const copyPid = `${providerId}#${other}`;
if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue;
if (await checkInbound(env, other)) continue; // 사본을 받을 메일함이 가득 찼으면 건너뜁니다(다음 동기화가 다시 시도).
const copyId = await copyMessage(env, id, 'inbox');
await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run();
try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); }
copies.push({ id: copyId, mailbox: other });
}
return { id, imported: true, raw: status, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) };
}
/**
* 내장 Haraka(로컬 SMTP 수신)가 넘긴 원본(.eml)을 받은 메일함에 저장합니다(1.0.0, 수신 방식 haraka).
*
* Resend 경로(ingestReceived)와 같은 규칙을 지킵니다: provider_id 로 중복·영구 삭제(tombstone) 확인, 메일함 라우팅(수신자 중 우리 메일함 첫 번째가
* 주 저장본, 나머지 우리 메일함에는 사본), 메일함·전체 용량 검사, 저장 뒤 자동 처리(전달·자동 응답). 원본은 받은 바이트 그대로 보관합니다(raw_status 'stored').
* provider_id 는 'haraka:<Haraka 트랜잭션 uuid>' 입니다. source 열의 CHECK 제약(webhook|sync|web)을 바꾸려면 messages 표를 다시 만들어야 하므로
* "밖에서 밀어 넣어 준 메일" 이라는 뜻이 같은 'webhook' 을 쓰고, 통계는 provider_id 접두어로 Haraka 수신을 구분합니다.
*
* @param {Buffer} eml 원본 바이트
* @param {object} o
* @param {string} o.id Haraka 트랜잭션 id(중복 전달 방지 키)
* @param {string[]} [o.rcpt] 봉투 받는 사람(RCPT TO). 헤더 To/Cc 보다 우선해 메일함을 정합니다(Bcc 로 받은 메일도 올바른 메일함에).
* @param {string} [o.from] 봉투 보낸 사람(기록용)
* @param {string[]} [o.known] 알려진 메일함 목록(반복 호출 시 재사용)
* @returns {Promise<{id?:string, imported?:boolean, skipped?:string, mailbox?:string, copies?:object[]}>}
*/
export async function ingestRaw(env, eml, { id: transactionId, rcpt = [], from = '', known = null } = {}) {
known = known || await knownMailboxes(env);
const providerId = 'haraka:' + String(transactionId || uid()).slice(0, 100);
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
const existing = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first();
if (existing) return { skipped: 'exists', id: existing.id };
eml = normalizeCrlf(eml);
const info = summarize(parseMime(eml));
// 봉투 수신자(RCPT TO) → 헤더 수신자 순서로 우리 메일함을 찾습니다. 봉투가 비어 있는 경우(시험·수동 호출)만 헤더를 봅니다.
const envelope = addresses(rcpt), header = addresses([...(info.to || []), ...(info.cc || []), ...(info.bcc || [])]);
const locals = [...new Set([...envelope, ...header].filter((r) => known.includes(r)))];
const mailbox = locals[0];
if (!mailbox) return { skipped: 'other_mailbox' };
const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox };
// appendMessage 가 해석·정제·첨부·원본 저장을 모두 합니다. 수신 시각은 도착 시각(now)입니다 — Date 헤더는 보낸 쪽이 적는 값이라 정렬이 흔들립니다.
const { id } = await appendMessage(env, 'inbox', eml, { mailbox, enforceQuota: false, internalDate: now() });
await env.DB.prepare("UPDATE messages SET provider_id=?, source='webhook', updated_at=? WHERE id=?").bind(providerId, now(), id).run();
let auto = null;
try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); }
const copies = [];
for (const other of locals.slice(1)) {
const copyPid = `${providerId}#${other}`;
if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue;
if (await checkInbound(env, other)) continue;
const copyId = await copyMessage(env, id, 'inbox');
await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run();
try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); }
copies.push({ id: copyId, mailbox: other });
}
console.log(`Haraka 수신 저장 ${mailbox} from=${from || info.from || '-'} id=${id}${copies.length ? ' 사본 ' + copies.length : ''}`);
return { id, imported: true, mailbox, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) };
}
/**
* 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다.
* 발신 주소가 알려진 메일함이 아니면 'other_sender' 로 건너뜁니다. 원본은 저장한 부품으로 재구성합니다.
*/
export async function ingestSent(env, providerId, { summary = null, known = null } = {}) {
known = known || await knownMailboxes(env);
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
if (await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first()) return { skipped: 'exists' };
// 자동 전달(0.8.16)로 나간 메일은 보낸 메일함에 쌓지 않습니다. 예전에는 동기화가 Resend 보낸 목록에서 전달 사본을 다시 가져와
// 받은 메일 한 통마다 보낸 메일함에 "Sender (via admin@…)" 메일이 생겼습니다(0.8.24). auto_actions 의 전달 기록(detail 이 발송 id 로 시작)으로 알아봅니다.
if (await env.DB.prepare("SELECT 1 AS x FROM auto_actions WHERE kind='forward' AND status='sent' AND (detail = ? OR detail LIKE ? || ' %')").bind(providerId, providerId).first()) return { skipped: 'auto_forward' };
const full = await resend(env, '/emails/' + encodeURIComponent(providerId));
const mailbox = address(full.from);
if (!known.includes(mailbox)) return { skipped: 'other_sender' };
const content = safeContent(full.html, full.text);
// Resend 보낸 메일 목록의 created_at 은 "YYYY-MM-DD HH:MM:SS.ffffff+00" 형식이라 ISO 로 바꿔 저장합니다.
const sentAt = isoTime(full.created_at || summary?.created_at), stamp = now(), id = uid();
await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,?,?,?,?,'sync','sent',?,?,?,?,?,?)`)
.bind(id, providerId, full.from, JSON.stringify(full.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), sentAt, stamp, stamp, sentAt, sentAt, mailbox).run();
await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built');
return { id, imported: true };
}
// 동기화는 도메인(env)마다 한 번에 하나만 돕니다. 웹 화면 여러 개가 동시에 요청해도 Resend 호출이 겹치지 않게 합니다.
// 1.0.7: 예전의 모듈 전역 플래그는 도메인이 여럿이면 한 도메인의 동기화가 다른 도메인을 막으므로 env 단위(SYNCING)로 바꿨습니다.
/** 지금 이 도메인의 동기화가 진행 중인지(화면의 "동기화 중" 표시용). env 가 없으면 false. */
export const isSyncing = (env) => !!env?.SYNCING;
// 한 번의 동기화가 훑는 최대 페이지 수(페이지당 50건). 받은 메일 500건, 보낸 메일 200건까지 거슬러 올라갑니다.
// 예전에는 "보관 기간(30일)보다 오래된 메일"에서 멈췄지만 보관 기한이 없어졌으므로 이 페이지 한도가 유일한 상한입니다.
const SYNC_PAGES = { inbound: 10, outbound: 4 };
/**
* Resend 목록(최신순)을 훑어 웹훅이 놓친 메일을 보충합니다.
*
* 두 가지 방식이 있습니다.
* - 전체 훑기(quick=false): 시작 직후·1시간 타이머·설정 화면의 "지금 동기화". 페이지 한도(SYNC_PAGES)까지 모두
* 확인해 중간에 빠진 메일(예: 일시 오류로 실패한 웹훅)까지 채웁니다.
* - 빠른 확인(quick=true): 웹 메일함 목록이 1분마다 자동으로 부르는 경우. 목록은 최신순이므로, 이미 저장했거나
* 영구 삭제(tombstone)한 메일을 처음 만나면 그보다 오래된 메일은 이전 동기화가 확인했다고 보고 멈춥니다.
* 예전에는 30일 기준 시각에서 멈췄기 때문에 매분 호출해도 가벼웠는데, 기준 시각이 사라진 뒤에도 매분 수백 건을
* 다시 훑지 않도록 이 방식을 둡니다. 빠진 메일이 있어도 다음 전체 훑기(최대 1시간 뒤)가 채웁니다.
*
* @param {object} env 실행 환경(DB, RESEND_API_KEY 등)
* @param {object} [options]
* @param {string} [options.trigger] 기록용 실행 계기: 'startup' | 'timer' | 'manual' | 'poll'
* @param {boolean} [options.quick] true 면 빠른 확인 방식
* @returns {Promise<{imported:number, errors:number}|{busy:true}>}
*/
export async function syncMailbox(env, { trigger = 'timer', quick = false } = {}) {
if (env.SYNCING) return { busy: true };
env.SYNCING = true; let imported = 0; const errors = []; let run = null;
try {
// 실행 기록과 메일함 목록 조회도 try 안에 둡니다. 예전에는 밖에 있어서 여기서 예외(예: SQLITE_BUSY)가 나면
// finally 가 돌지 않아 syncing 이 true 로 남고, 프로세스를 재시작할 때까지 모든 동기화가 busy 로 끝났습니다.
run = await env.DB.prepare('INSERT INTO sync_runs(trigger,started_at) VALUES(?,?) RETURNING id').bind(trigger, now()).first();
// known: 이 게이트웨이가 다루는 메일함 주소 목록(기본 메일함 + 계정 메일함). 다른 주소의 메일은 건너뜁니다.
const known = await knownMailboxes(env);
// 1.0.0: Resend 를 쓰는 방향만 훑습니다. 수신이 Haraka 면 받은 메일 목록은 보지 않고(Resend 에 없음), 발신이 SMTP 중계면 보낸 메일 목록도 보지 않습니다.
const directions = ['inbound', 'outbound'].filter((d) => (d === 'inbound' ? inboundMode(env) === 'resend' : outboundMode(env) === 'resend'));
for (const direction of directions) {
// stop: 빠른 확인에서 이미 아는 메일을 만나 이 방향의 훑기를 끝낼 때 true.
const endpoint = direction === 'inbound' ? '/emails/receiving' : '/emails'; let cursor = ''; let stop = false;
for (let page = 0; page < SYNC_PAGES[direction] && !stop; page++) {
const list = await resend(env, endpoint + '?limit=50' + (cursor ? '&after=' + encodeURIComponent(cursor) : ''));
for (const summary of list.data || []) {
try {
let r;
// 미리 거르기는 웹훅과 같은 주소 집합(to·cc·bcc·received_for)으로 합니다. 예전에는 to 만 봐서 우리 메일함이 참조(Cc)에만 있는 메일을 동기화가 영영 가져오지 않았습니다(0.9.0 수정).
if (direction === 'inbound') { if (!addresses([...(summary.to || []), ...(summary.cc || []), ...(summary.bcc || []), ...(summary.received_for || [])]).some((a) => known.includes(a))) continue; r = await ingestReceived(env, summary.id, { source: 'sync', summary, known }); }
else { if (!known.includes(address(summary.from))) continue; r = await ingestSent(env, summary.id, { summary, known }); }
if (r.imported) imported++;
// 이미 저장했거나 영구 삭제한 메일 = 이전 동기화(또는 웹훅)가 여기까지 확인했다는 표시입니다.
if (quick && (r.skipped === 'exists' || r.skipped === 'tombstone')) { stop = true; break; }
} catch (e) { errors.push(summary.id + ':' + e.message); if (['mail_auth_failed', 'mail_forbidden', 'mail_not_configured'].includes(e.message)) throw e; }
}
if (stop || !list.has_more || !list.data?.length) break;
cursor = list.data.at(-1).id;
}
}
await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status=?,detail=? WHERE id=?").bind(now(), imported, errors.length ? 'partial' : 'ok', errors.slice(0, 20).join('\n') || null, run.id).run();
return { imported, errors: errors.length };
} catch (e) {
if (run) await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status='failed',detail=? WHERE id=?").bind(now(), imported, (e.message + (e.providerMessage ? ' ' + e.providerMessage : '')).slice(0, 500), run.id).run();
throw e;
} finally { env.SYNCING = false; }
}
/**
* API 요청에서 동기화를 시작하고 끝나기를 기다리지 않고 바로 돌아옵니다(화면은 몇 초 뒤 목록을 다시 읽습니다).
* @param {object} env
* @param {string} [trigger] 기록용 실행 계기('manual' = 설정 화면 버튼, 'poll' = 목록 자동 새로 고침)
* @param {{quick?: boolean}} [options] quick: 빠른 확인 방식(syncMailbox 설명 참고)
* @returns {{started:true}|{busy:true}}
*/
export function startSync(env, trigger = 'manual', { quick = false } = {}) {
if (env.SYNCING) return { busy: true };
syncMailbox(env, { trigger, quick }).catch((e) => console.error('메일 동기화 실패', e.message, e.providerMessage || ''));
return { started: true };
}
/**
* 주기 정리 작업: 만료된 로그인 세션을 지웁니다.
*
* 0.8.0 이전에는 같은 자리에서 "보관 기간(30일)이 지난 메일"도 원본·첨부와 함께 삭제했습니다(enforceRetention).
* 메일 보관 기한을 없애면서 메일 삭제는 빼고 세션 정리만 남겼습니다. 메일은 사용자가 영구 삭제할 때만 지워집니다.
*
* @returns {Promise<{sessions:number}>} 지운 세션 수
*/
export async function cleanupSessions(env) {
const result = await env.DB.prepare('DELETE FROM sessions WHERE expires_at < ?').bind(now()).run();
return { sessions: result.meta.changes };
}
// ------------------------------------------------------------------ 초안·발송
export async function saveDraft(env, body, id, user = null) {
const mailbox = user?.mailbox || mailboxOf(env), fromName = user?.name || senderName(env);
// 보내는 사람 표시 이름은 formatSender 로 만듭니다(0.9.0): 쉼표·따옴표·괄호가 든 이름("Lee, Director")을 그대로 넣으면 RFC 5322 에 어긋나 발송 서비스가 거부하거나 주소가 깨졌습니다.
const existing = id ? await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first() : null;
if (id && !existing) throw new Error('not_found');
if (existing && (existing.folder !== 'drafts' || existing.state !== 'draft')) throw new Error('mail_locked');
// 초안은 아직 완성되지 않은 주소("bob", "bob@example" 등)도 그대로 저장합니다. 주소 검사는 발송(sendDraft) 때 합니다.
// 왜: 예전에는 여기서 422 를 던져 자동 저장이 조용히 실패했고, 창을 닫으면 작성 중인 본문이 통째로 사라졌습니다.
// 부분 수정(0.9.5): 본문에 없는 키는 기존 초안 값을 유지합니다. 예전에는 PATCH { subject } 처럼 일부만 보내도 받는 사람·참조·본문이 빈 값으로 덮였습니다(화면은 늘 전체를 보내 드러나지 않았음).
// bcc(0.9.5~): 웹 작성 창의 숨은 참조. 예전에는 IMAP 으로 올린 초안의 Bcc 만 저장·발송됐고 웹에서는 입력할 수 없었습니다.
const keep = (key, col) => (body[key] !== undefined ? addresses(body[key]).slice(0, 100) : existing ? JSON.parse(existing[col] || '[]') : []);
const to = keep('to', 'recipients_json'), cc = keep('cc', 'cc_json'), bcc = keep('bcc', 'bcc_json');
const subject = body.subject !== undefined ? clean(body.subject, 500) : existing ? existing.subject : '', stamp = now();
const content = body.html != null ? sanitizeHtml(body.html, { limit: STORED_HTML_LIMIT }) : body.text != null || !existing ? sanitizeHtml(textToHtml(body.text || '')) : { html: existing.html, text: existing.text };
// 답장이면 In-Reply-To / References 를 남겨 스레드와 \Answered 표시를 유지합니다.
const inReplyTo = clean(body.inReplyTo, 300); const headers = inReplyTo && /^<[^\s<>]+>$/.test(inReplyTo) ? { 'In-Reply-To': inReplyTo, References: inReplyTo } : null;
if (existing) {
const updated = await env.DB.prepare("UPDATE messages SET recipients_json=?,cc_json=?,bcc_json=?,subject=?,html=?,text=?,received_at=?,updated_at=?,headers_json=COALESCE(?,headers_json) WHERE id=? AND state='draft' AND folder='drafts'").bind(JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, headers ? JSON.stringify(headers) : null, id).run();
if (!updated.meta.changes) throw new Error('mail_locked');
await invalidateRaw(env, id);
} else {
id = uid();
await env.DB.prepare(`INSERT INTO messages(id,direction,folder,sender,recipients_json,cc_json,bcc_json,subject,html,text,source,state,received_at,created_at,updated_at,read_at,mailbox,headers_json) VALUES(?,'outbound','drafts',?,?,?,?,?,?,?,'web','draft',?,?,?,?,?,?)`)
.bind(id, formatSender(fromName, mailbox), JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, stamp, stamp, mailbox, JSON.stringify(headers || {})).run();
}
return id;
}
/**
* 초안의 저장된 원본(.eml)을 버립니다. IMAP APPEND 로 올린 초안은 원본을 그대로 보관하는데, 웹에서 제목·본문·첨부를 고치면
* 그 원본이 옛 내용으로 남아 IMAP/POP/"원본 내려받기" 가 수정 전 내용을 보여 줬습니다(0.8.16 수정). raw_status 를 'none' 으로 돌리면
* IMAP 은 저장된 행과 첨부로 메시지를 다시 만들고, 발송 때는 sendDraft 가 새 원본을 저장합니다.
*/
async function invalidateRaw(env, id) {
await env.DB.batch([env.DB.prepare('DELETE FROM message_raw WHERE message_id=?').bind(id), env.DB.prepare("UPDATE messages SET raw_status='none',raw_size=0 WHERE id=? AND raw_status<>'none'").bind(id)]);
}
// 초안 첨부의 합계·개수 상한(발송 서비스의 메일 전체 40MB 한도에서 나온 내장값). 첨부 한 개의 상한은 0.9.0 부터 관리자·시스템 설정(limits.attachmentLimit, MIME 기준)을 따릅니다.
const DRAFT_TOTAL_LIMIT = 20 * 1024 * 1024, DRAFT_FILE_COUNT = 10;
/** 첨부 한 개의 크기 검사: 파일 바이트를 MIME(base64) 크기로 바꿔 적용 중인 상한과 비교합니다. 넘으면 attachment_too_large 에 limit(MIME 바이트)을 붙여 던집니다. */
async function checkAttachmentSize(env, bytes) {
const limit = await attachmentLimit(env);
if (mimeSize(bytes) > limit) { const e = new Error('attachment_too_large'); e.limit = limit; throw e; }
}
export async function addAttachment(env, draftId, { filename, contentType, bytes, inline = false }) {
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
if (!draft) throw new Error('mail_locked');
if (!bytes?.length) throw new Error('attachment_too_large');
await checkAttachmentSize(env, bytes.length);
const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first();
if (current.n >= DRAFT_FILE_COUNT || current.total + bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large');
const id = uid(), isInline = inline && /^image\//.test(String(contentType || ''));
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)")
.bind(id, draftId, clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'attachment', clean(contentType, 120) || 'application/octet-stream', isInline ? `${id}@${mailboxOf(env).split('@')[1]}` : null, isInline ? 'inline' : 'attachment', bytes.length, bytes, now()).run();
await invalidateRaw(env, draftId);
return id;
}
export async function removeAttachment(env, draftId, attachmentId) {
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
if (!draft) throw new Error('mail_locked');
await env.DB.prepare('DELETE FROM attachments WHERE id=? AND message_id=?').bind(attachmentId, draftId).run();
await invalidateRaw(env, draftId);
}
/**
* 초안을 발송합니다.
* @param {object} [options.largeFiles] 대용량 첨부파일(0.9.3)의 발송 조건 { days, downloads }. 초안에 대용량 파일이 있으면 필수이며 관리자 상한 이내여야 합니다(large_file_terms).
* 발송 직전에 파일마다 링크를 만들어 본문 끝에 "대용량 첨부파일" 블록을 덧붙이고, 발송에 실패하면 링크를 되돌립니다.
*/
export async function sendDraft(env, id, { largeFiles = null } = {}) {
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first();
if (!mail) throw new Error('not_found');
if (mail.state === 'sent') return mail;
if (mail.direction !== 'outbound' || mail.folder !== 'drafts') throw new Error('mail_locked');
// bcc: IMAP 으로 올린 초안(메일 프로그램이 Bcc 헤더를 남김)의 숨은 참조. 예전에는 저장·발송에서 빠져 받는 사람이 조용히 사라졌습니다(0.8.25 수정).
// 같은 주소는 한 번만(0.9.9): 받는 사람·참조·숨은 참조를 통틀어 소문자 기준으로 중복을 지웁니다(받는 사람 > 참조 > 숨은 참조 순으로 남김).
// 예전에는 전체 답장에서 대소문자만 다른 주소가 받는 사람과 참조에 함께 들어가면 Resend 에 그대로 넘어가 같은 사람이 두 통을 받았습니다.
const seen = new Set(); const uniq = (list) => list.filter((a) => { const k = String(a).toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
const to = uniq(JSON.parse(mail.recipients_json)), cc = uniq(JSON.parse(mail.cc_json)), bcc = uniq(JSON.parse(mail.bcc_json || '[]'));
// 받는 사람 주소 검사는 여기서 합니다(초안 저장은 미완성 주소도 허용). 잘못된 주소는 invalid_email, 그 밖의 빈 값은 validation.
if ([...to, ...cc, ...bcc].some((a) => !isEmail(a))) throw new Error('invalid_email');
// 대용량 파일이 있으면 발송 조건(기한·횟수)을 먼저 검사합니다. 본문이 비어 있어도 대용량 파일이 있으면 보낼 수 있습니다(링크 블록이 본문이 됨).
const hasLarge = await checkLargeFileTerms(env, id, largeFiles); // 대용량 파일이 있는지(재시도면 조건 생략 가능)
if (!to.length || !mail.subject || !mail.text.trim() && !mail.html?.includes('<img') && !hasLarge) throw new Error('validation');
if (mail.state === 'sending' && Date.parse(mail.send_started_at) > Date.now() - 120000) throw new Error('mail_sending');
// 제한 검사(0.9.0): 받는 사람 수(예전 20명 고정 → 관리자·시스템 설정), 첨부 한 개 크기, 메일함·전체 용량, 월간 통수. 발송을 시작(claim)하기 전에 검사해
// 어긋나도 초안이 '발송 실패' 상태가 되지 않고 그대로 고칠 수 있게 합니다. 저장될 원본 크기는 본문 + 첨부의 MIME 크기 + 헤더 여유로 어림합니다.
const sizes = (await env.DB.prepare('SELECT size FROM attachments WHERE message_id=?').bind(id).all()).results.map((a) => Number(a.size || 0));
await checkOutbound(env, { mailbox: mail.mailbox || mailboxOf(env), recipients: to.length + cc.length + bcc.length, attachmentSizes: sizes, bytes: String(mail.html || '').length + String(mail.text || '').length + sizes.reduce((n, s) => n + mimeSize(s), 0) + 2000 });
const key = mail.send_key || 'gw-' + uid(), stamp = now();
const claim = await env.DB.prepare("UPDATE messages SET state='sending',send_key=?,send_started_at=?,updated_at=? WHERE id=? AND (state IN ('draft','failed') OR (state='sending' AND send_started_at<?))").bind(key, stamp, stamp, id, new Date(Date.now() - 120000).toISOString()).run();
if (!claim.meta.changes) throw new Error('mail_sending');
try {
// 답장·전달 인용문에 들어 있는 "원래 메일의" 인라인 이미지(/api/mail/<원래 id>/attachments/<aid>)를 이 초안의 인라인 첨부로 복사합니다.
// 왜: 예전에는 초안 자신의 첨부만 cid: 로 바꿔, 인용한 원래 메일의 이미지는 받는 사람에게 상대 주소(깨진 이미지)로 갔습니다.
// 같은 메일함의 메일만 복사합니다(다른 메일함의 첨부 id 를 끼워 넣어 빼내지 못하게).
let source = String(mail.html || '');
for (const [, mid, aid] of [...source.matchAll(/<img\b[^>]*\bsrc="\/api\/mail\/([^"/?]+)\/attachments\/([^"/?]+)(?:\?[^"]*)?"/g)]) {
const originId = decodeURIComponent(mid), attachmentId = decodeURIComponent(aid); if (originId === id) continue;
const orig = await env.DB.prepare('SELECT a.filename,a.content_type,a.size,a.bytes,m.mailbox FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=? AND a.message_id=?').bind(attachmentId, originId).first();
if (!orig?.bytes || orig.mailbox !== mail.mailbox) continue;
const copyId = uid();
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,'inline',?,?,?)").bind(copyId, id, orig.filename, orig.content_type, `${copyId}@${mailboxOf(env).split('@')[1]}`, orig.size, orig.bytes, now()).run();
source = source.split(`/api/mail/${mid}/attachments/${aid}`).join(`/api/mail/${encodeURIComponent(id)}/attachments/${copyId}`);
}
if (source !== String(mail.html || '')) await env.DB.prepare('UPDATE messages SET html=? WHERE id=?').bind(source, id).run();
let files = await attachmentRows(env, id);
// 편집기가 넣은 인라인 이미지(/api/mail/<id>/attachments/<aid>?inline=1)는 cid: 로 바꿔 보냅니다.
const used = new Set();
const html = source.replace(/(<img\b[^>]*\bsrc=")\/api\/mail\/[^"/]+\/attachments\/([^"?]+)(?:\?[^"]*)?("[^>]*>)/g, (m, a, aid, b) => { const f = files.find((x) => x.id === aid && x.content_id); if (!f) return m; used.add(f.id); return `${a}cid:${f.content_id}${b}`; });
// IMAP 으로 올린 초안(메일 프로그램에 그림을 넣은 경우)은 본문이 처음부터 cid: 를 가리킵니다. 그 첨부도 "쓰는 중" 으로 표시합니다.
// 예전에는 이 첨부가 고아로 잡혀 지워지고 발송에서도 빠져, 받는 사람에게 깨진 그림이 갔습니다(0.8.16 수정).
for (const [, cid] of html.matchAll(/<img\b[^>]*\bsrc="cid:([^"]+)"/g)) { const f = files.find((x) => x.content_id && x.content_id === cid.replace(/&amp;/g, '&')); if (f) used.add(f.id); }
// 본문에서 지운 인라인 이미지는 보내지 않고 초안에서도 지웁니다. 첨부 칩 목록에는 인라인 파일이 보이지 않아
// 사용자가 직접 지울 방법이 없는데, 예전에는 이런 이미지가 일반 첨부로 함께 나갔습니다.
// Content-ID 가 없는 'inline' 첨부(Apple Mail 등이 PDF·문서를 inline; filename= 으로 올림)는 본문이 가리킬 수 없으므로 고아가 아니라 일반 첨부입니다.
// 예전에는 이런 첨부가 고아로 잡혀 조용히 지워지고 발송에서도 빠졌습니다(0.8.25 수정).
const orphans = files.filter((a) => a.disposition === 'inline' && a.content_id && !used.has(a.id));
for (const a of orphans) await env.DB.prepare('DELETE FROM attachments WHERE id=?').bind(a.id).run();
files = files.filter((a) => !orphans.includes(a));
// 보내는 주소는 항상 초안이 속한 메일함입니다. 저장된 sender 는 표시 이름을 쓰기 위해서만 보고, 주소가 메일함과 다르면 버립니다.
// 왜: IMAP APPEND 로 올린 초안은 From 헤더를 그대로 sender 에 저장하므로, 예전에는 일반 계정이 From 을 다른 주소(예: 관리자 메일함)로
// 적은 초안을 올린 뒤 웹에서 보내기를 누르면 그 주소로 발송됐습니다(SMTP 제출은 allowedSenders 로 막고 있었음). 0.8.16 수정.
const mailbox = mail.mailbox || mailboxOf(env);
const sender = address(mail.sender) === mailbox ? formatSender(nameOf(mail.sender), mailbox) : formatSender(senderName(env), mailbox);
// 발송에 넘기는 사용자 지정 헤더는 답장 스레드용 두 개뿐입니다. IMAP 으로 올린 초안(appendMessage)은 원본의 모든 헤더
// (Content-Type, MIME-Version, Date, Message-ID …)를 headers_json 에 갖고 있어, 그대로 보내면 발송 서비스가 거부하거나 MIME 이 깨집니다.
const savedHeaders = JSON.parse(mail.headers_json || '{}'); const headers = {};
for (const name of ['In-Reply-To', 'References']) { const k = Object.keys(savedHeaders).find((x) => x.toLowerCase() === name.toLowerCase()); if (k && savedHeaders[k]) headers[name] = String(savedHeaders[k]); }
// 대용량 첨부파일(0.9.3): 링크·기한·횟수를 확정하고 본문(HTML·텍스트) 끝에 블록을 덧붙입니다. 저장본에도 같은 본문을 남겨 보낸 메일함에서 링크를 볼 수 있게 합니다.
// 링크의 호스트(1.0.8): 경로 접두사 아래의 웹 메일이면 "호스트/mail" 처럼 접두사까지 붙여 https://host/mail/dl/… 이 됩니다.
const large = await finalizeLargeFiles(env, id, largeFiles, (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), env.STRINGS || {});
const finalHtml = html + large.html, finalText = String(mail.text || '') + large.text;
const payload = { from: sender, to, ...(cc.length ? { cc } : {}), ...(bcc.length ? { bcc } : {}), subject: mail.subject, html: finalHtml, text: finalText, ...(Object.keys(headers).length ? { headers } : {}) };
if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(used.has(a.id) ? { content_id: a.content_id } : {}) }));
// 발송 모듈은 설정(outbound)에 따라 Resend 또는 SMTP 중계(내장 Haraka·외부 서버)입니다(1.0.0, providers/index.mjs).
const result = await sendMail(env, payload, key);
const sentAt = now();
// 발송이 끝나면 보낸 메일함으로 옮깁니다. 보관 기한이 없으므로 expires_at 은 채우지 않습니다.
// 저장본 html 은 인라인 이미지 복사를 반영한 source(위에서 갱신) 기준입니다. 0.9.3 은 복사 전 값(mail.html)을 다시 써서 원본 메일을 지우면 보낸 메일의 그림이 깨졌습니다(0.9.5 수정).
await env.DB.prepare("UPDATE messages SET state='sent',folder='sent',provider_id=?,sent_at=?,received_at=?,updated_at=?,read_at=COALESCE(read_at,?),original_html=?,html=?,text=? WHERE id=?").bind(result.id, sentAt, sentAt, sentAt, sentAt, finalHtml, source + large.html, finalText, id).run();
const stored = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first();
await storeRaw(env, id, await buildStoredEml(env, stored), 'built');
return stored;
} catch (e) {
await env.DB.prepare("UPDATE messages SET state='failed',updated_at=? WHERE id=? AND state='sending'").bind(now(), id).run();
// 대용량 파일의 링크는 되돌리지 않습니다: 다시 보낼 때 같은 토큰·같은 본문으로 보내야 멱등 키가 맞고, 첫 시도가 실제로 전달됐을 때 링크가 살아 있습니다(0.9.5).
throw e;
}
}
/**
* 메일 한 통의 IMAP 영구 플래그(\Flagged 중요, \Answered 답장함 등)를 켜거나 끕니다.
* 웹 API(별표·답장 표시)와 SMTP 제출(답장 발송 시 원래 메일에 \Answered) 이 함께 쓰도록 여기에 둡니다.
* @param {string} id 메일 id
* @param {string} flag 플래그 이름(예: '\Flagged')
* @param {boolean} on true 면 켜고 false 면 끕니다
*/
export async function setFlag(env, id, flag, on) {
const row = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first();
const flags = new Set(JSON.parse(row?.flags || '[]')); if (on) flags.add(flag); else flags.delete(flag);
await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(id, JSON.stringify([...flags]), now()).run();
}
// ------------------------------------------------------------------ IMAP APPEND / COPY
/**
* 클라이언트가 올린 원본(.eml)을 메일함에 저장합니다(보낸 메일 복사본·초안 등). 원본은 그대로 보관합니다.
* @param {boolean} [options.enforceQuota] 메일함 최대 크기(0.9.0)를 검사할지. IMAP APPEND 는 검사하고(넘으면 quota_exceeded),
* SMTP 제출은 발송 전에 이미 검사했으므로 false 로 넘깁니다(발송된 메일의 보낸 메일함 저장이 실패하면 안 되므로).
*/
export async function appendMessage(env, folder, eml, { flags = [], internalDate = null, mailbox = null, enforceQuota = true } = {}) {
if (!['inbox', 'sent', 'drafts', 'trash'].includes(folder)) throw new Error('not_found');
// 줄 끝을 CRLF 로 맞춘 뒤 해석합니다. 일부 프로그램·스크립트는 LF 만 쓴 메시지를 올리는데, 해석기는 CRLF 경계만 알아
// 예전에는 본문이 빈 메일로 저장됐습니다(0.8.16 수정). 원본도 정규화한 바이트로 보관해 IMAP/POP 이 같은 것을 돌려줍니다.
eml = normalizeCrlf(eml);
const parsed = parseMime(eml), info = summarize(parsed);
// 보낸 메일함 중복 방지: 메일 프로그램은 SMTP 로 보낸 뒤 같은 메일을 IMAP 으로 보낸 메일함에 다시 올립니다(APPEND).
// SMTP 제출(smtp.mjs)이 이미 같은 Message-ID 로 저장했다면 새로 만들지 않고 그 메일을 돌려줍니다(반대 순서는 smtp.mjs 가 처리).
// 용량 검사보다 먼저 합니다: 발송으로 메일함이 가득 찬 직후 올라오는 사본이 OVERQUOTA 로 거부되면 메일 프로그램이 "보낸 편지함 복사 실패" 를 반복했습니다(0.9.3).
if (folder === 'sent' && info.messageId) {
const same = await env.DB.prepare("SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=?").bind(mailbox || mailboxOf(env), info.messageId).first();
if (same) return { id: same.id, duplicate: true };
}
if (enforceQuota) {
const box = mailbox || mailboxOf(env), quota = await mailboxQuota(env, box);
if (quota > 0 && (await mailboxUsage(env, box)) + eml.length > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; }
// 전체 저장 용량(시스템)도 봅니다. 예전에는 APPEND 만 이 제한을 비켜 가 메일 프로그램으로 저장소를 상한 너머로 키울 수 있었습니다(0.9.3).
const sys = systemLimits(env);
if (sys.totalBytes > 0 && (await totalUsage(env)) + eml.length > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; }
}
const content = safeContent(info.html, info.text);
const receivedAt = internalDate || (Date.parse(info.date) ? new Date(Date.parse(info.date)).toISOString() : now()), stamp = now(), id = uid();
mailbox = mailbox || mailboxOf(env); const inbound = folder === 'inbox' || (folder === 'trash' && address(info.from) !== mailbox);
const direction = inbound ? 'inbound' : 'outbound', state = folder === 'drafts' ? 'draft' : inbound ? 'received' : 'sent';
const seen = flags.some((f) => f.toLowerCase() === '\\seen') || !inbound;
// bcc_json: 원본의 Bcc 헤더(메일 프로그램이 초안에 남겨 둠). 발송(sendDraft)이 숨은 참조로 씁니다(0.8.25~).
await env.DB.prepare(`INSERT INTO messages(id,message_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'web',?,?,?,?,?,?,?)`)
.bind(id, info.messageId || null, direction, folder, info.from, JSON.stringify(info.to), JSON.stringify(info.cc), JSON.stringify(info.bcc || []), JSON.stringify(info.replyTo), clean(info.subject, 500), content.html, info.text || content.text, info.html || null, JSON.stringify(info.headers), state, receivedAt, stamp, stamp, inbound ? null : receivedAt, seen ? stamp : null, mailbox).run();
for (const a of info.attachments) await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)").bind(uid(), id, clean(a.filename, 255) || 'attachment', clean(a.contentType, 120), a.contentId, a.disposition, a.bytes.length, a.bytes, stamp).run();
const persisted = flags.filter((f) => ['\\Flagged', '\\Answered', '\\Deleted'].some((x) => x.toLowerCase() === f.toLowerCase())).map((f) => f[0] + f[1].toUpperCase() + f.slice(2).toLowerCase());
if (persisted.length) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(id, JSON.stringify(persisted), stamp).run();
await storeRaw(env, id, Buffer.from(eml), 'stored');
return { id };
}
/** IMAP COPY: 메시지·원본·첨부를 다른 메일함에 복제합니다. */
/**
* 다른 메일의 첨부(일반 첨부만)를 초안으로 복사합니다(0.8.25~). 웹의 "전달" 이 원본 첨부를 함께 보내기 위한 것으로,
* 예전에는 전달 메일에 인용 본문의 그림만 따라가고 PDF 같은 일반 첨부는 조용히 빠졌습니다.
* 초안 상태의 메일에만, 초안의 첨부 개수·용량 한도(DRAFT_FILE_COUNT / DRAFT_TOTAL_LIMIT) 안에서 복사합니다. 원본이 같은 메일함인지는 API 가 확인합니다.
* @returns {Promise<string[]>} 새 첨부 id 목록
*/
export async function copyAttachments(env, draftId, originId, ids) {
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
if (!draft) throw new Error('mail_locked');
const out = [];
for (const aid of ids) {
// content_id 가 없는 inline(예전 규칙으로 저장된 행)도 파일 첨부로 보고 복사합니다(0.9.9).
const a = await env.DB.prepare("SELECT filename,content_type,size,bytes FROM attachments WHERE id=? AND message_id=? AND (disposition='attachment' OR content_id IS NULL)").bind(aid, originId).first();
if (!a?.bytes) continue;
await checkAttachmentSize(env, a.bytes.length);
const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first();
if (current.n >= DRAFT_FILE_COUNT || current.total + a.bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large');
const id = uid();
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,NULL,'attachment',?,?,?)").bind(id, draftId, a.filename, a.content_type, a.bytes.length, a.bytes, now()).run();
out.push(id);
}
if (out.length) await invalidateRaw(env, draftId);
return out;
}
export async function copyMessage(env, id, folder) {
const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!m) throw new Error('not_found');
const newId = uid(), stamp = now();
// 복제할 열 목록. expires_at 은 더 쓰지 않으므로 빼서 복사본은 항상 NULL 이 됩니다.
const cols = ['message_id','direction','sender','recipients_json','cc_json','bcc_json','reply_to_json','subject','html','text','original_html','headers_json','source','state','received_at','sent_at','read_at','raw_status','raw_size','mailbox'];
await env.DB.prepare(`INSERT INTO messages(id,folder,created_at,updated_at,provider_id,${cols.join(',')}) VALUES(?,?,?,?,NULL,${cols.map(() => '?').join(',')})`).bind(newId, folder, stamp, stamp, ...cols.map((c) => m[c] ?? null)).run();
await env.DB.prepare('INSERT INTO message_raw(message_id,eml,stored_at) SELECT ?,eml,? FROM message_raw WHERE message_id=?').bind(newId, stamp, id).run();
const files = (await env.DB.prepare('SELECT * FROM attachments WHERE message_id=?').bind(id).all()).results;
for (const a of files) await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)').bind(uid(), newId, a.provider_id, a.filename, a.content_type, a.content_id, a.disposition, a.size, a.bytes, stamp).run();
const f = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first();
if (f) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(newId, f.flags, stamp).run();
return newId;
}
+224
View File
@@ -0,0 +1,224 @@
/**
* 메일 사용량 정보 제공 API(1.0.9, 요구사항 1) — 웹메이커 같은 외부 관리 시스템이 디스크 사용량·발송량 등을 통합 관리할 수 있게 읽기 전용 통계를 내줍니다.
*
* 주소(어느 호스트로 와도 같음, nginx 뒤 HTTPS):
* GET /api/usage 모든 도메인의 요약(기본 도메인 먼저)
* GET /api/usage/<도메인> 그 도메인의 상세(기본으로 계정 목록·일별 추이 포함)
* 쿼리: days=30(일별 추이·계정별 기간 통수의 기간, 1~366) · accounts=0|1(계정 목록 포함, 요약은 기본 0·상세는 기본 1) · series=0|1(일별 추이, 상세는 기본 1)
*
* 보안(요구사항: webmaker 의 해당 기능에서만 접근할 수 있게)
* 1) 비밀 키: 전체 설정 화면의 "사용량 API" 카드에서 만든 키(USAGE_API_KEY, 기본 도메인의 설정 파일에만 저장, 32바이트 난수)를 `Authorization: Bearer <키>`(또는 X-Usage-Key)로
* 보내야 합니다. 키가 없으면 API 자체가 꺼진 상태(404)이고, 비교는 상수 시간(safeEqual)입니다. 키는 만들 때 한 번만 보여 주며 설정 화면에는 앞뒤 일부만 보입니다. 언제든 다시 만들거나(교체) 지울 수 있습니다.
* 2) 접속 IP 제한(선택): USAGE_API_ALLOW 에 웹메이커 서버의 IP 나 CIDR(쉼표 구분)을 적으면 그 밖의 주소는 키가 맞아도 403. nginx 가 넘긴 X-Forwarded-For 의 첫 주소(또는 CF-Connecting-IP)를 봅니다.
* 3) 시도 제한: 키가 틀린 요청은 IP 마다 15분에 10회(그 뒤 429), 성공 요청도 IP 마다 1분에 120회(무차별 조회 방지). 모두 메모리 제한(env.RATE)입니다.
* 4) 읽기 전용·비밀값 없음: 메일 내용·주소록·비밀번호·API 키는 절대 들어가지 않고, 수(통·바이트)·제한·계정 이름(로그인 ID·메일함 주소·역할·상태)만 나갑니다. 호출은 감사 기록(audit_log)에 남습니다.
*
* 응답(도메인 하나)
* domain, primary, enabled, hosts, authMode, mailbox, inbound, outbound, timezone, generatedAt
* accounts : { local, external, total, limit, percent } — 게이트웨이 계정 수 / 웹메이커 등 외부 계정 수 / 시스템 제한(0 = 없음)
* storage : { bytes, limit, percent, largeFiles:{count, bytes, sent} } — 모든 메일함의 사용량(MIME 기준, 원본 .eml 크기) / 대용량 첨부파일(별도 저장)
* messages : { total, inbound, outbound, drafts, failed } — 저장된 메일 수(폴더 무관, 휴지통 포함; 영구 삭제한 것은 없음)
* traffic : { day, week, month: { sent, received, total, sentBytes, receivedBytes, start, end }, monthlyLimit, monthlyPercent } — 통계 시간대 기준(오늘·이번 주(월~)·이달)
* daily : [ { date:'YYYY-MM-DD', sent, received, sentBytes, receivedBytes } ] (최근 days 일, 오래된 날부터)
* accountList : [ { login, name, mailbox, role, source:'local'|'www'|'webmaker', status, messages, bytes, limit, percent, lastLoginAt, sent, received, sentBytes, receivedBytes } ] (기간 = days)
* limits : { system:{…}, admin:{…} } — 설치 시 시스템 제한과 관리자 제한(안내용)
*/
import { json, bad, safeEqual, now } from './util.mjs';
import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf } from './limits.mjs';
import { listUsers, externalUsers, authMode, audit, mailboxOwner, standalone } from './auth.mjs';
import { largeFileStats } from './files.mjs';
import { inboundMode, outboundMode } from './providers/index.mjs';
import { mailDomain } from './config.mjs';
export const USAGE_FAIL_LIMIT = 10, USAGE_RATE_LIMIT = 120;
const DAY_MS = 86400000;
// ------------------------------------------------------------------ 접속 IP 허용 목록
/** "1.2.3.4, 10.0.0.0/8, 2001:db8::1" 같은 문자열을 항목 배열로. 빈 문자열이면 제한 없음(빈 배열). */
export function parseAllowList(text) {
return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
}
const ipv4ToInt = (ip) => { const m = String(ip).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return null; const parts = m.slice(1).map(Number); if (parts.some((n) => n > 255)) return null; return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; };
/** IPv6 주소를 소문자 전개형(8그룹 16진수)으로. 잘못된 주소는 null. "::ffff:1.2.3.4" 꼴은 IPv4 로 비교하도록 호출자가 먼저 벗깁니다. */
function expandIpv6(ip) {
const s = String(ip).toLowerCase(); if (!/^[0-9a-f:]+$/.test(s) || s.split('::').length > 2) return null;
const [head, tail = ''] = s.split('::'); const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
if (h.length + t.length > 8 || (s.indexOf('::') < 0 && h.length !== 8)) return null;
const groups = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
if (groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.map((g) => parseInt(g, 16));
}
/**
* 접속 IP 가 허용 목록 안인지. 목록이 비어 있으면 모두 허용. IPv4 는 CIDR(/8~/32) 지원, IPv6 는 정확히 같은 주소 또는 /n 접두사.
* IPv4 주소가 "::ffff:1.2.3.4" 로 들어오면(IPv6 소켓) IPv4 로 봅니다.
*/
export function ipAllowed(ip, list) {
if (!list || !list.length) return true;
let addr = String(ip || '').trim().toLowerCase(); if (addr.startsWith('::ffff:') && addr.includes('.')) addr = addr.slice(7);
const v4 = ipv4ToInt(addr), v6 = v4 === null ? expandIpv6(addr) : null;
if (v4 === null && !v6) return false;
for (const entry of list) {
const [net, bitsRaw] = entry.split('/'); const bits = bitsRaw === undefined ? null : Number(bitsRaw);
if (v4 !== null) {
const n = ipv4ToInt(net); if (n === null) continue;
const b = bits === null ? 32 : bits; if (!Number.isInteger(b) || b < 0 || b > 32) continue;
const mask = b === 0 ? 0 : (~0 << (32 - b)) >>> 0; if (((v4 & mask) >>> 0) === ((n & mask) >>> 0)) return true;
} else {
const n = expandIpv6(net); if (!n) continue;
const b = bits === null ? 128 : bits; if (!Number.isInteger(b) || b < 0 || b > 128) continue;
let ok = true; for (let i = 0; i < 8 && ok; i++) { const take = Math.max(0, Math.min(16, b - i * 16)); if (!take) break; const m = take === 16 ? 0xffff : (0xffff << (16 - take)) & 0xffff; if ((v6[i] & m) !== (n[i] & m)) ok = false; }
if (ok) return true;
}
}
return false;
}
/**
* 허용 목록 항목 검사: 각 항목이 IPv4[/0~32] 또는 IPv6[/0~128] 이어야 합니다. 잘못된 항목 목록을 돌려줍니다(비어 있으면 통과).
* 저장 때 검사하지 않으면 오타 하나로 모든 요청이 403 이 되고 아무 데서도 알 수 없습니다.
*/
export function invalidAllowEntries(text) {
const bad = [];
for (const entry of parseAllowList(text)) {
const [net, bitsRaw, extra] = entry.split('/'); if (extra !== undefined) { bad.push(entry); continue; }
const v4 = ipv4ToInt(net), v6 = v4 === null ? expandIpv6(net) : null; if (v4 === null && !v6) { bad.push(entry); continue; }
if (bitsRaw !== undefined) { const b = Number(bitsRaw); if (!/^\d{1,3}$/.test(bitsRaw) || b > (v4 !== null ? 32 : 128)) bad.push(entry); }
}
return bad;
}
/**
* 요청의 접속 IP(허용 목록·시도 제한용). 클라이언트가 마음대로 넣을 수 있는 헤더를 믿지 않습니다:
* 1) X-Real-IP(nginx 템플릿 1.0.9 부터 $remote_addr 로 넣음) → 2) X-Forwarded-For 의 **마지막** 주소(nginx 가 $proxy_add_x_forwarded_for 로 실제 접속 주소를 끝에 덧붙임;
* 첫 주소는 클라이언트가 보낸 값일 수 있음) → 3) 소켓 주소. CF-Connecting-IP 는 Cloudflare 뒤에서만 참이므로 USAGE_TRUST_CF=1 일 때만 봅니다.
*/
export function clientIp(headers, remote = '') {
const get = (k) => String(headers.get(k) || '').trim();
if (process.env.USAGE_TRUST_CF === '1' && get('cf-connecting-ip')) return get('cf-connecting-ip');
if (get('x-real-ip')) return get('x-real-ip');
const xff = get('x-forwarded-for').split(',').map((s) => s.trim()).filter(Boolean);
if (xff.length) return xff[xff.length - 1];
return String(remote || '').trim();
}
// ------------------------------------------------------------------ 인증
/**
* 사용량 API 인증. 키는 기본 도메인(primaryEnv)의 설정에서만 읽습니다(도메인별 키가 아니라 서버 하나에 한 키 — 웹메이커가 모든 도메인을 한 번에 봄).
* @returns {{ok:true}|{ok:false, status:number, error:string}}
*/
export function authorizeUsage(primaryEnv, request, ip) {
const key = String(primaryEnv.USAGE_API_KEY || '').trim();
if (!key) return { ok: false, status: 404, error: 'usage_api_disabled' };
const rate = primaryEnv.RATE;
if (rate?.blocked('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT })) return { ok: false, status: 429, error: 'rate_limited' };
const auth = request.headers.get('authorization') || '';
const given = (auth.match(/^Bearer\s+(.+)$/i)?.[1] || request.headers.get('x-usage-key') || '').trim();
if (!given || !safeEqual(given, key)) { rate?.hit('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT, windowSec: 900 }); return { ok: false, status: 401, error: 'unauthenticated' }; }
if (!ipAllowed(ip, parseAllowList(primaryEnv.USAGE_API_ALLOW))) return { ok: false, status: 403, error: 'forbidden_ip' };
// 성공 요청 빈도(IP 마다 1분 120회). hit() 가 false 를 돌려주면 한도를 넘은 것.
if (rate && !rate.hit('usage:' + ip, { limit: USAGE_RATE_LIMIT, windowSec: 60 })) return { ok: false, status: 429, error: 'rate_limited' };
return { ok: true };
}
// ------------------------------------------------------------------ 통계
/** 통계 시간대 기준의 날짜 문자열(YYYY-MM-DD). */
const dateKey = (iso, tz) => { try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso)); } catch { return String(iso).slice(0, 10); } };
/**
* 최근 days 일의 일별 수·발신 통수와 바이트, 메일함별 기간 합계. 한 번의 조회로 기간 안의 메일(발신은 발송 완료 시각, 수신은 받은 시각)을 읽어 JS 에서 날짜별로 묶습니다
* (시간대 변환을 SQL 로 하기 어렵고, 메일 수가 많지 않아 충분히 빠름). 기간 시작은 오늘 0시(통계 시간대)에서 days-1 일을 뺀 시각입니다.
*/
async function dailySeries(env, days, tz) {
// 시작 = (오늘 0시 - (days-1)일) 이 속한 날의 0시. 24시간 단위로 빼기만 하면 서머타임 경계에서 한 시간이 밀리므로 그 날의 정오를 잡아 다시 0시를 구합니다.
const today = periodBounds('day', tz); const approx = Date.parse(today.start) - (days - 1) * DAY_MS;
const start = periodBounds('day', tz, new Date(approx + DAY_MS / 2)).start, startMs = Date.parse(start), end = today.end;
const rows = (await env.DB.prepare(`SELECT direction, mailbox, raw_size, sent_at, received_at FROM messages WHERE (direction='inbound' AND received_at>=? AND received_at<?) OR (direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?)`).bind(start, end, start, end).all()).results;
const byDate = new Map(); for (let i = 0; i < days; i++) { const d = dateKey(new Date(startMs + i * DAY_MS + DAY_MS / 2).toISOString(), tz); byDate.set(d, { date: d, sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }); }
const byMailbox = new Map();
for (const r of rows) {
const at = r.direction === 'outbound' ? r.sent_at : r.received_at; const d = dateKey(at, tz); const bucket = byDate.get(d); const size = Number(r.raw_size || 0);
const mb = byMailbox.get(r.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; byMailbox.set(r.mailbox, mb);
if (r.direction === 'outbound') { if (bucket) { bucket.sent++; bucket.sentBytes += size; } mb.sent++; mb.sentBytes += size; }
else { if (bucket) { bucket.received++; bucket.receivedBytes += size; } mb.received++; mb.receivedBytes += size; }
}
return { start, end, daily: [...byDate.values()], byMailbox };
}
/** 기간 통계에 바이트 합계를 더한 countMail. */
async function periodTraffic(env, bounds) {
const c = await countMail(env, bounds);
const s = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?").bind(bounds.start, bounds.end).first();
const r = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?").bind(bounds.start, bounds.end).first();
return { ...c, sentBytes: Number(s?.b || 0), receivedBytes: Number(r?.b || 0) };
}
/**
* 도메인(테넌트 env) 하나의 사용량 보고서.
* @param {object} o days: 일별 추이·계정별 기간의 길이, accounts: 계정 목록 포함 여부, series: 일별 추이 포함 여부, entry: 등록부 항목(hosts·enabled·primary)
*/
export async function domainUsage(env, { days = 30, accounts = true, series = true, entry = null } = {}) {
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
const counts = await env.DB.prepare("SELECT COUNT(*) total, SUM(CASE WHEN direction='inbound' THEN 1 ELSE 0 END) inbound, SUM(CASE WHEN direction='outbound' AND state='sent' THEN 1 ELSE 0 END) outbound, SUM(CASE WHEN state IN ('draft','sending') THEN 1 ELSE 0 END) drafts, SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) failed FROM messages").first();
const total = await totalUsage(env), local = await accountCount(env), ext = await externalUsers(env);
const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz));
const out = {
domain: mailDomain(env), primary: !env.TENANTS || env.TENANTS.primary.env === env, enabled: entry ? entry.enabled !== false : true, hosts: entry?.hosts || [env.PUBLIC_HOST].filter(Boolean),
authMode: authMode(env), mailbox: String(env.MAILBOX || ''), inbound: inboundMode(env), outbound: outboundMode(env), timezone: tz, generatedAt: now(),
accounts: { local, external: ext.accounts.length, total: local + ext.accounts.length, limit: sys.accounts, percent: percentOf(local, sys.accounts), externalSource: ext.accounts.length || authMode(env) === 'webmaker' ? { kind: authMode(env), ok: ext.ok !== false, error: ext.error || null } : null },
storage: { bytes: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes), largeFiles: await largeFileStats(env) },
messages: { total: Number(counts?.total || 0), inbound: Number(counts?.inbound || 0), outbound: Number(counts?.outbound || 0), drafts: Number(counts?.drafts || 0), failed: Number(counts?.failed || 0) },
traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages) },
limits: { system: sys, admin },
};
if (series || accounts) {
const d = await dailySeries(env, days, tz);
out.period = { days, start: d.start, end: d.end };
if (series) out.daily = d.daily;
if (accounts) {
const rows = [];
const push = async (u) => { const bytes = await mailboxUsage(env, u.mailbox), limit = await mailboxQuota(env, u.mailbox), n = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(u.mailbox).first(); const t = d.byMailbox.get(u.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; rows.push({ login: u.login, name: u.name || '', mailbox: u.mailbox, role: u.role, source: u.source, status: u.status, messages: Number(n?.n || 0), bytes, limit, percent: percentOf(bytes, limit), lastLoginAt: u.lastLoginAt || null, ...t }); };
for (const u of await listUsers(env)) await push({ ...u, source: 'local', lastLoginAt: u.last_login_at });
for (const a of ext.accounts) await push({ ...a, source: 'webmaker' });
// www 연동의 기본 메일함(관리자 계정 행이 없음): 메일함 주인(www 관리자) 이름으로 한 행.
if (!standalone(env) && !rows.some((r) => r.mailbox === String(env.MAILBOX || '').toLowerCase())) { let owner = null; try { owner = mailboxOwner(env); } catch { owner = null; } await push({ login: owner?.login_id || owner?.email || 'admin', name: owner?.name || '', mailbox: String(env.MAILBOX || '').toLowerCase(), role: 'admin', source: 'www', status: owner?.status || 'active', lastLoginAt: null }); }
// 계정 행이 없는데 메일이 있는 메일함(지운 계정): 주소만으로 한 행(사용량이 사라지지 않게). 메일이 있는 메일함 목록은 messages 표에서 직접 뽑습니다.
const withMail = (await env.DB.prepare('SELECT mailbox, COUNT(*) AS n FROM messages GROUP BY mailbox').all()).results;
for (const m of withMail) if (m.n && !rows.some((r) => r.mailbox === m.mailbox)) await push({ login: null, name: '', mailbox: m.mailbox, role: null, source: 'orphan', status: 'deleted', lastLoginAt: null });
out.accountList = rows;
}
}
return out;
}
// ------------------------------------------------------------------ HTTP 처리(main.mjs 가 /api/usage 로 부름)
const clampDays = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 1 && n <= 366 ? n : 30; };
const flag = (v, d) => (v === undefined || v === null || v === '' ? d : !(v === '0' || v === 'false' || v === 'no'));
/**
* GET /api/usage[/<도메인>]. 인증·IP·빈도 검사 뒤 보고서를 JSON 으로 돌려줍니다. 감사 기록은 기본 도메인 DB 에 남깁니다.
* @param {object} tenants domains.mjs 의 테넌트 관리자
* @param {Request} request
* @param {{path:string[], ip:string, query:URLSearchParams}} o path: 'usage' 뒤의 조각
*/
export async function usageApi(tenants, request, { path = [], ip = '', query = new URLSearchParams() } = {}) {
const primary = tenants.primary.env;
if (request.method !== 'GET') return bad('method_not_allowed', 405);
const auth = authorizeUsage(primary, request, ip);
// 키가 없어 꺼진 상태(404)만 빼고 모든 거부(틀린 키·허용 밖 IP·빈도 초과)를 기록합니다 — 맞는 키로 허용 밖에서 부르는 것이 가장 중요한 신호.
if (!auth.ok) { if (auth.status !== 404) console.warn(`사용량 API 거부 ${ip}: ${auth.error}(${auth.status})`); return bad(auth.error, auth.status); }
const days = clampDays(query.get('days'));
const headers = { 'Cache-Control': 'no-store' };
let domain = ''; try { domain = path[0] ? decodeURIComponent(path[0]).trim().toLowerCase() : ''; } catch { return bad('domain_not_found', 404); }
try {
if (domain) {
const t = tenants.byDomain(domain); if (!t) return bad('domain_not_found', 404);
const report = await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), true), series: flag(query.get('series'), true), entry: t.entry });
await audit(primary, 'usage-api', 'usage_read', domain, { ip, days });
return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), ...report }, 200, headers);
}
// 도메인 하나가 깨져도(DB 오류 등) 나머지 도메인의 수치는 돌려줍니다. 깨진 도메인은 { domain, error } 로 표시하고 합계에서는 뺍니다.
const domains = [];
for (const t of tenants.all()) {
try { domains.push(await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), false), series: flag(query.get('series'), false), entry: t.entry })); }
catch (e) { console.error(`사용량 API 도메인 오류(${t.entry.domain})`, e.message); domains.push({ domain: t.entry.domain, primary: t.entry.domain === tenants.registry.primary, enabled: t.entry.enabled !== false, error: 'server_error' }); }
}
await audit(primary, 'usage-api', 'usage_read', null, { ip, days, domains: domains.length });
const sum = (k, f) => domains.reduce((n, d) => n + (d.error ? 0 : f(d)), 0);
return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), primary: tenants.registry.primary, totals: { domains: domains.length, accounts: sum('a', (d) => d.accounts.total), storageBytes: sum('s', (d) => d.storage.bytes), largeFileBytes: sum('l', (d) => d.storage.largeFiles.bytes), messages: sum('m', (d) => d.messages.total), sentMonth: sum('sm', (d) => d.traffic.month.sent), receivedMonth: sum('rm', (d) => d.traffic.month.received) }, domains }, 200, headers);
} catch (e) { console.error('사용량 API 오류', e.message); return bad('server_error', 500); }
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 게이트웨이 계정 명령줄 도구(0.8.18~).
*
* 독립 실행(www 사이트 DB 없이 설치)에서는 관리자 페이지에 들어갈 첫 관리자 계정을 화면에서 만들 수 없습니다
* (관리자 페이지는 관리자 로그인이 있어야 열리므로). 이 도구가 그 첫 계정을 만들고, 이후에도 비밀번호 재설정 같은
* 운영 작업을 서버 셸에서 할 수 있게 합니다. 서비스가 돌고 있는 중에도 쓸 수 있습니다(SQLite WAL, 짧은 쓰기).
*
* 사용법(서비스 계정으로, 환경 파일을 읽어서):
* DB_FILE=... MAILBOX=admin@example.com node server/user-cli.mjs add <로그인ID> [--admin] [--name "이름"]
* DB_FILE=... node server/user-cli.mjs passwd <로그인ID>
* DB_FILE=... node server/user-cli.mjs list
*
* 비밀번호는 (1) 환경 변수 PASSWORD, (2) --password-stdin(표준 입력 한 줄), (3) 단말 입력(글자 숨김) 순으로 받습니다.
* 비밀번호 규칙은 관리자 페이지의 비밀번호 정책(기본 6자 이상)을 따릅니다(0.8.24~). 독립 실행의 첫 관리자는 서버가 시작할 때 admin / adminpasswd 로 자동 생성되므로 이 도구는 추가 계정·비밀번호 재설정에 씁니다.
* 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0)은 환경 변수로 주면 여기서도 적용됩니다(서비스의 환경 파일을 그대로 넘기면 됨).
* 명령줄 인수로는 받지 않습니다(ps·셸 이력에 남기 때문).
* MAILBOX 는 계정의 메일함 주소(로그인ID@도메인)를 정하는 데 필요하므로 add 때는 꼭 서비스와 같은 값을 주세요.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createInterface } from 'node:readline';
import { openDb, d1, migrate } from './db.mjs';
import { createUser, updateUser, listUsers, mailDomain, readPasswordPolicy } from './auth.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const [command, login, ...rest] = process.argv.slice(2);
const flag = (name) => rest.includes(name);
const option = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
function usage(code = 1) {
console.error('사용법: node server/user-cli.mjs add <login> [--admin] [--name 이름] | passwd <login> | list\n비밀번호: 환경 변수 PASSWORD, --password-stdin, 또는 단말 입력');
process.exit(code);
}
/** 표준 입력 전체(첫 줄)를 비밀번호로 읽습니다 — 스크립트에서 `printf '%s' "$PW" | node ... --password-stdin` 형태로 씁니다. */
async function readStdinLine() {
let data = ''; for await (const chunk of process.stdin) data += chunk;
return data.split(/\r?\n/)[0];
}
/** 단말에서 글자를 숨기고 비밀번호를 받습니다. 단말이 아니면(파이프) 표준 입력 한 줄을 읽습니다. */
async function prompt(label) {
if (!process.stdin.isTTY) return readStdinLine();
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
// 입력 글자를 화면에 쓰지 않도록 출력 함수를 가로챕니다. 질문 글은 먼저 직접 써 둡니다.
process.stdout.write(label);
rl._writeToOutput = () => {};
const answer = await new Promise((resolve) => rl.question('', resolve));
rl.close(); process.stdout.write('\n');
return answer;
}
/** 정책을 한 줄 안내문으로: "6자 이상, 대·소문자 혼용, 숫자 포함, 특수문자 포함" */
function policyText(p) { return [`${p.minLength}자 이상`, p.mixedCase && '대·소문자 혼용', p.digit && '숫자 포함', p.special && '특수문자 포함'].filter(Boolean).join(', '); }
async function password() {
if (process.env.PASSWORD) return process.env.PASSWORD;
if (flag('--password-stdin')) return readStdinLine();
const a = await prompt(`비밀번호(${policyText(await readPasswordPolicy(env))}): `);
if (process.stdin.isTTY) { const b = await prompt('비밀번호 확인: '); if (a !== b) { console.error('두 입력이 다릅니다'); process.exit(1); } }
return a;
}
if (!command || !['add', 'passwd', 'list'].includes(command) || (command !== 'list' && !login)) usage();
const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(file); migrate(db, join(ROOT, 'migrations'));
const env = { ...process.env, DB: d1(db) };
try {
if (command === 'list') {
for (const u of await listUsers(env)) console.log(`${u.login}\t${u.mailbox}\t${u.role}\t${u.status}\t${u.name || ''}`);
} else if (command === 'add') {
const id = await createUser(env, { login, name: option('--name') || '', password: await password(), role: flag('--admin') ? 'admin' : 'user' }, 'user-cli');
console.log(`계정을 만들었습니다: ${login} → ${login}@${mailDomain(env)} (${flag('--admin') ? '관리자' : '사용자'}, id ${id})`);
} else {
const row = db.prepare('SELECT id FROM mail_users WHERE login = ?').get(String(login).toLowerCase());
if (!row) { console.error(`계정이 없습니다: ${login}`); process.exit(1); }
await updateUser(env, row.id, { password: await password() });
console.log(`비밀번호를 바꿨습니다: ${login} (웹 세션은 모두 끝났습니다)`);
}
} catch (e) {
// createUser/updateUser 의 오류 코드를 사람이 읽을 말로 바꿉니다.
const messages = { invalid_login: '로그인 ID 는 영문 소문자·숫자·._- 2~64자여야 하고(기본 메일함 별칭은 독립 실행에서만 가능)', weak_password: `비밀번호가 정책에 맞지 않습니다(${e.policy ? policyText(e.policy) : '글자 수 부족'})`, login_taken: '이미 있는 로그인 ID 입니다', account_limit_reached: `계정 수가 시스템 제한(LIMIT_ACCOUNTS=${e.limit})에 이르렀습니다` };
console.error('실패:', messages[e.message] || e.message); process.exitCode = 1;
} finally { db.close(); }
+76
View File
@@ -0,0 +1,76 @@
export const json = (obj, status = 200, headers = {}) => new Response(JSON.stringify(obj), {
status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
});
export const bad = (error, status = 400, extra = {}) => json({ ok: false, error, ...extra }, status);
export const now = () => new Date().toISOString();
export const plus = (ms) => new Date(Date.now() + ms).toISOString();
export const uid = () => crypto.randomUUID();
export const clean = (v, max = 500) => String(v ?? '').trim().slice(0, max);
export const isEmail = (v) => /^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(v);
export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export function address(value) { const s = String(value || '').trim().toLowerCase(); return (s.match(/<([^<>]+)>$/)?.[1] || s).trim(); }
/**
* 주소 목록 문자열("A <a@x>, b@y; c@z")을 소문자 주소 배열로 바꿉니다(중복 제거).
* 쉼표·세미콜론·줄바꿈으로 나누되, 따옴표 안("Kim, Minsu" <k@x>)과 꺾쇠 안의 구분자는 무시합니다.
* 예전에는 따옴표 안의 쉼표에서도 잘라 '"Kim' 과 'Minsu" <k@x>' 두 개로 나뉘어 저장·발송이 실패했습니다.
*/
export function addresses(value) {
if (Array.isArray(value)) return [...new Set(value.map(address).filter(Boolean))];
const list = []; let current = '', quoted = false, angle = false;
for (const ch of String(value || '')) {
if (ch === '"') quoted = !quoted; else if (ch === '<' && !quoted) angle = true; else if (ch === '>' && !quoted) angle = false;
if (!quoted && !angle && /[,;\n]/.test(ch)) { list.push(current); current = ''; } else current += ch;
}
list.push(current);
return [...new Set(list.map(address).filter(Boolean))];
}
export function secret(bytes = 32) {
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('base64url');
}
export async function sha256(text) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Buffer.from(buf).toString('hex');
}
export function safeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0;
}
/**
* 프로세스 메모리 요청 제한. 단일 프로세스 전제입니다.
* - hit(): 시도를 1회 세고, 한도 안이면 true. 로그인 실패처럼 "세어야 할 사건"에만 부릅니다.
* - blocked(): 세지 않고 지금 막혀 있는지만 봅니다. 메일 서버(POP/IMAP/SMTP)는 로그인 전에 blocked() 로 확인하고 실패했을 때만 hit() 합니다.
* 예전에는 성공한 로그인까지 세어, 메일마다 새로 접속하는 메일 프로그램이 15분에 20통을 넘기면 정상 사용자도 차단됐습니다.
*/
export function memoryRateStore() {
const map = new Map();
return {
blocked(key, { limit = 5 } = {}) { const hit = map.get(key); return !!hit && hit.until >= Date.now() && hit.count >= limit; },
hit(key, { limit = 5, windowSec = 900 } = {}) {
const t = Date.now(); const hit = map.get(key);
if (!hit || hit.until < t) { map.set(key, { count: 1, until: t + windowSec * 1000 }); return true; }
if (hit.count >= limit) return false;
hit.count++; return true;
},
};
}
/** "이름 <주소>" 에서 표시 이름만(따옴표 제거). 이름이 없으면 ''. */
export function nameOf(value) { const s = String(value || '').trim(); const m = s.match(/^(.*?)\s*<[^<>]+>$/); return m ? m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(["\\])/g, '$1') : ''; }
/**
* 보내는 사람 "이름 <주소>" 를 RFC 5322 에 맞게 만듭니다(0.9.0~). 줄바꿈·따옴표·꺾쇠는 이름에서 지우고, 쉼표·괄호·@ 같은 특수문자가 있으면
* 이름을 따옴표로 감쌉니다. 이름이 비어 있으면 주소만. 예전에는 계정 이름을 그대로 이어 붙여 "Lee, Director <a@x>" 처럼 깨진 헤더가 나갔습니다.
*/
export function formatSender(name, addr) {
const n = String(name || '').replace(/[\r\n"<>\\]/g, '').trim();
if (!n) return addr;
return /[,()@;:.\[\]]/.test(n) ? `"${n}" <${addr}>` : `${n} <${addr}>`;
}
/**
* 메일 도메인에서 화면 왼쪽 위에 보일 서비스 이름을 만듭니다(0.8.25~). 계정@ 바로 뒤의 첫 이름표를 대문자로 쓰고 " 메일" 을 붙입니다.
* heart-m.com → "HEART-M 메일", cafe.heart-m.com → "CAFE 메일", arihakdang.com → "ARIHAKDANG 메일"
* 예전에는 lang/ko.json 의 appName("아리학당 메일")이 모든 설치에 그대로 보였습니다. 도메인이 비어 있으면 기본 문구(fallback)를 돌려줍니다.
*/
export function siteName(domain, fallback = '메일') {
const label = String(domain || '').trim().toLowerCase().split('.')[0];
return label ? `${label.toUpperCase()} 메일` : fallback;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Resend 웹훅 서명 검증(Svix 방식). 서명 대상은 "{svix-id}.{svix-timestamp}.{원문 본문}" 이고
* 비밀키는 "whsec_" 뒤의 base64 를 풀어 HMAC-SHA256 키로 씁니다. 헤더에는 "v1,<base64>" 가 공백으로 여러 개 올 수 있습니다.
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySvix(secret, headers, rawBody, { toleranceSec = 300, nowMs = Date.now() } = {}) {
const id = headers['svix-id'], ts = headers['svix-timestamp'], sig = headers['svix-signature'];
if (!secret || !id || !ts || !sig) return { ok: false, error: 'missing_signature' };
const t = Number(ts); if (!Number.isFinite(t) || Math.abs(nowMs / 1000 - t) > toleranceSec) return { ok: false, error: 'timestamp_out_of_range' };
const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${ts}.`).update(rawBody).digest();
for (const candidate of String(sig).split(/\s+/)) {
const [version, value] = candidate.split(',');
if (version !== 'v1' || !value) continue;
const given = Buffer.from(value, 'base64');
if (given.length === expected.length && timingSafeEqual(given, expected)) return { ok: true };
}
return { ok: false, error: 'bad_signature' };
}
/** 테스트·문서용: 같은 규칙으로 서명을 만듭니다. */
export function signSvix(secret, id, timestamp, rawBody) {
const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');
return 'v1,' + createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest('base64');
}
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* 웹메이커(WebHosting_AI_agent, 이하 webmaker) 데이터 디렉터리를 읽기 전용으로 읽어 "계정 스냅샷" 하나로 만듭니다(1.0.8, 요구사항 1).
*
* 이 파일은 두 가지로 쓰입니다.
* 1) 모듈: server/webmaker.mjs 가 readSnapshotSync(dir) 를 불러 같은 프로세스에서 바로 읽습니다(파일을 읽을 권한이 있을 때, 시험에서도 이 길).
* 2) 명령: `node webmaker-snapshot.mjs <DATA_ROOT>` 가 스냅샷을 JSON 으로 표준 출력에 씁니다. webmaker 의 데이터는 보통 그 서비스 계정(예: master)만
* 읽을 수 있는 0600/0700 이라, AriMail 실행 계정은 sudo 로 그 계정이 되어 이 명령을 부릅니다(deploy/webmaker-access.sh 가 root 소유 사본과 sudoers 규칙을 설치).
* 그래서 이 파일은 다른 모듈을 import 하지 않고 node 내장 모듈만 씁니다(root 소유 사본 하나로 혼자 돌아가야 함).
*
* webmaker 의 저장 형식(그 저장소 lib/auth.js · lib/store.js · lib/modules.js · lib/domains.js 기준)
* - <DATA_ROOT>/users.json : { <아이디>: { id, name, salt, passHash, role:'admin'|'user', accountType, … } } — 플랫폼 회원. 비밀번호는 scryptSync(비밀번호, salt, 64) 의 16진수.
* - <DATA_ROOT>/sites/<32hex>.json : { id, ownerId, title, domains:[…], webmail:{ enabled, domain }, … } — 사이트. domains 는 사이트에 연결한 도메인 목록.
* - <DATA_ROOT>/modules/<32hex>.json : { members:[ { id, loginId?, email?, name, role:'admin'|'member', approved, salt, hash, mail, mailAddress } ], … } — 사이트 회원.
* loginId 가 있는 회원이 "사이트 관리자 계정"(사이트 관리자 페이지 › 계정 추가)이고, 비밀번호는 같은 scrypt 형식(hash 필드).
* - <DATA_ROOT>/domains.json : { domains:[ { name, type:'subdomain'|'registered', siteId, userId, … } ] } — 서브 도메인·구입 도메인 등록부(사이트 연결 정보).
*
* 스냅샷에는 인증에 필요한 값(salt·해시)이 들어 있으므로 파일로 남기지 않고 메모리에서만 씁니다. 게시글·문의 같은 다른 자료는 읽지 않습니다.
*/
import { readFileSync, readdirSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const SNAPSHOT_FORMAT = 1;
const ID_RE = /^[a-f0-9]{32}$/;
const str = (v, max = 200) => String(v ?? '').trim().slice(0, max);
/**
* JSON 파일 읽기. 파일이 없으면 null(아직 만들지 않은 것), 그 밖의 실패(권한 없음, 쓰는 중이라 반쪽인 JSON 등)는 예외입니다.
* 왜 구분하나: 깨진 파일을 "회원 없음" 으로 읽으면 그 순간 모든 웹메이커 사용자의 세션이 끊깁니다. 예외로 두면 webmaker.mjs 가 마지막 정상 스냅샷으로 버팁니다.
*/
function readJson(file) {
let text; try { text = readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error(`${e.code || 'read_error'}: ${file}`); }
try { return JSON.parse(text); } catch { throw new Error(`invalid_json: ${file}`); }
}
/** 디렉터리의 <32hex>.json 파일 이름(확장자 뺀 id)들. 디렉터리가 없으면 빈 목록. */
function idsIn(dir) {
try { return readdirSync(dir).filter((f) => f.endsWith('.json') && ID_RE.test(f.slice(0, -5))).map((f) => f.slice(0, -5)); } catch { return []; }
}
/**
* webmaker 데이터 디렉터리를 읽어 스냅샷을 만듭니다.
* @param {string} dir webmaker 의 DATA_ROOT(예: /data/webmaker)
* @returns {{format:number, readAt:string, dir:string, users:object, sites:object, members:object, domainSites:object}}
* users : 아이디 → { id, name, role, salt, passHash, mustChangePassword }
* sites : 사이트 id → { id, ownerId, title, domains:string[], webmailDomain:string, webmailEnabled:boolean }
* members : 사이트 id → [ { id, loginId, email, name, role, approved, salt, hash, mail, mailAddress } ] (loginId 또는 email 이 있는 회원만)
* domainSites : 도메인 이름 → 사이트 id (domains.json 에서 사이트에 연결된 것만)
*/
export function readSnapshotSync(dir) {
const root = String(dir || '').trim(); if (!root) throw new Error('webmaker_dir_missing');
const users = {};
const rawUsers = readJson(join(root, 'users.json'));
if (rawUsers && typeof rawUsers === 'object') {
for (const [id, u] of Object.entries(rawUsers)) {
if (!u || typeof u !== 'object' || !u.salt || !u.passHash) continue;
const key = str(u.id || id, 64).toLowerCase();
users[key] = { id: key, name: str(u.name, 80), role: u.role === 'admin' ? 'admin' : 'user', salt: String(u.salt), passHash: String(u.passHash), mustChangePassword: !!u.mustChangePassword };
}
}
const sites = {}, members = {};
for (const id of idsIn(join(root, 'sites'))) {
const s = readJson(join(root, 'sites', id + '.json')); if (!s || typeof s !== 'object') continue;
const domains = (Array.isArray(s.domains) ? s.domains : []).map((d) => str(d, 253).toLowerCase().replace(/^www\./, '')).filter(Boolean);
sites[id] = { id, ownerId: str(s.ownerId, 64).toLowerCase(), title: str(s.title, 120), domains, webmailDomain: str(s.webmail?.domain, 253).toLowerCase(), webmailEnabled: !!(s.webmail && s.webmail.enabled) };
const m = readJson(join(root, 'modules', id + '.json'));
const list = m && Array.isArray(m.members) ? m.members : [];
members[id] = list.filter((x) => x && typeof x === 'object' && (x.loginId || x.email) && x.salt && x.hash).map((x) => ({
id: str(x.id, 64), loginId: str(x.loginId, 64).toLowerCase(), email: str(x.email, 200).toLowerCase(), name: str(x.name, 80), role: x.role === 'admin' ? 'admin' : 'member',
approved: x.approved !== false, salt: String(x.salt), hash: String(x.hash), mail: !!x.mail, mailAddress: str(x.mailAddress, 253).toLowerCase(),
}));
}
const domainSites = {};
const rawDomains = readJson(join(root, 'domains.json'));
for (const d of Array.isArray(rawDomains?.domains) ? rawDomains.domains : []) {
const name = str(d?.name, 253).toLowerCase(), siteId = str(d?.siteId, 32);
if (name && ID_RE.test(siteId)) domainSites[name] = siteId;
}
return { format: SNAPSHOT_FORMAT, readAt: new Date().toISOString(), dir: root, users, sites, members, domainSites };
}
// 명령줄로 직접 실행했을 때(sudo 도우미): 첫 인수가 데이터 디렉터리. 실패하면 종료 코드 2 와 오류 한 줄(표준 오류).
const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return a === b; } };
if (process.argv[1] && samePath(fileURLToPath(import.meta.url), process.argv[1])) {
try { process.stdout.write(JSON.stringify(readSnapshotSync(process.argv[2]))); }
catch (e) { process.stderr.write(`webmaker-snapshot: ${e.message}\n`); process.exit(2); }
}
+207
View File
@@ -0,0 +1,207 @@
/**
* webmaker 인증 모드(1.0.8, 요구사항 1) — 웹메이커(WebHosting_AI_agent)의 사용자 계정과 인증을 AriMail 이 그대로 씁니다.
*
* 왜
* 웹메이커는 사이트(예: site.ariweb.net)를 만들고 그 사이트의 관리자 계정·회원을 둡니다. 그 사용자들에게 메일을 열어 주려면 예전에는 웹메이커가 AriMail 관리자
* API 로 계정을 따로 만들고 메일 비밀번호를 따로 정해야 했습니다(웹메이커는 scrypt 해시만 갖고 있어 원문 비밀번호를 넘길 수 없음). 이제 AriMail 이 웹메이커의
* 사용자 파일을 **읽기 전용**으로 참조해 같은 방식(scrypt)으로 비밀번호를 검사하므로, 웹메이커 계정 그대로 웹 메일·IMAP·POP·SMTP 에 로그인합니다.
*
* 도메인 ↔ 웹메이커 계정 대응(accountsFor)
* 도메인(테넌트)의 메일 도메인(MAILBOX 의 @ 뒤)으로 웹메이커의 사이트를 찾습니다: 등록부 항목의 siteId 가 있으면 그 사이트, 없으면 domains.json(서브 도메인 등록) →
* 사이트의 domains 목록 → 사이트의 메일 도메인(webmail.domain) 순으로 같은 이름을 찾습니다.
* - 사이트가 있으면: 그 사이트의 회원 중 로그인 ID(loginId)가 있는 계정(사이트 관리자 페이지에서 추가한 "사용자") → `loginId@도메인`(역할 user),
* 사이트 소유자(users.json 의 ownerId) → `소유자ID@도메인`(역할 user; 소유자가 웹메이커 관리자이기도 하면 admin), 웹메이커 관리자(role=admin, 보통 admin) → `admin@도메인`(역할 admin).
* 같은 로그인 ID 가 겹치면 사이트 회원 → 소유자 → 플랫폼 관리자 순으로 먼저 찾은 쪽이 그 주소를 씁니다.
* - 사이트가 없으면(플랫폼 도메인 자체 등): 웹메이커의 모든 회원 → `아이디@도메인`(관리자는 admin, 나머지는 user).
* 요구사항의 예: 웹메이커에서 site.ariweb.net 을 만들고 메일을 허용하면 admin@site.ariweb.net(웹메이커 admin)으로 수·발신하고, 사이트에 사용자를 추가하면
* user@site.ariweb.net 도 같은 비밀번호로 씁니다. 이 규칙은 모든 도메인(기본·추가)에 똑같이 적용됩니다.
*
* 파일 읽기(snapshot)
* 웹메이커 데이터(users.json 0600, DATA_ROOT 0700)는 보통 웹메이커 서비스 계정만 읽을 수 있습니다. 같은 계정이거나 읽기 권한이 있으면 이 프로세스가 바로 읽고(readSnapshotSync),
* 아니면 등록부의 webmakerUser(예: master)가 적힌 도메인은 "읽기 도우미" 로 JSON 을 받습니다(deploy/webmaker-access.sh 가 설치):
* 1) systemd 소켓(/run/arimail-webmaker/<디렉터리 슬러그>.sock): 접속마다 그 계정으로 webmaker-snapshot.mjs 를 띄워 결과를 소켓에 씁니다(Accept=yes).
* AriMail 서비스는 NoNewPrivileges·CapabilityBoundingSet 로 잠겨 있어 sudo 를 쓸 수 없으므로 이것이 기본 경로입니다(ProtectSystem=strict 에서도 소켓 connect 는 됨).
* 2) sudo 도우미(`sudo -n -u <계정> /usr/local/lib/arimail/webmaker-snapshot <디렉터리>`): systemd 가 없는 서버용 대안(소켓 파일이 없을 때).
* 스냅샷은 3초 캐시하고, 로그인 실패 때는 한 번 다시 읽어
* 방금 바뀐 비밀번호·새 계정을 바로 반영합니다. 읽기에 실패하면 마지막 성공 스냅샷으로 계속 동작하며 오류를 기록합니다(웹메이커 쪽 일시 장애로 메일이 멈추지 않게).
*
* 주체(principal) id: 'wm:u:<아이디>'(플랫폼 회원), 'wm:m:<사이트 id>:<회원 id>'(사이트 회원). 세션·설정(prefs)·메일함 크기(quota)는 이 id 로 저장되며, 비밀번호·이름·역할은
* 웹메이커가 주인이므로 AriMail 에서 바꿀 수 없습니다(관리자 페이지에는 읽기 전용 행으로 보이고 POP/IMAP 허용·메일함 크기만 바꿉니다).
*/
import { execFile } from 'node:child_process';
import { connect } from 'node:net';
import { existsSync } from 'node:fs';
import { scrypt, timingSafeEqual, randomBytes } from 'node:crypto';
import { readSnapshotSync } from './webmaker-snapshot.mjs';
/** sudo 도우미 경로(deploy/webmaker-access.sh 가 설치). sudoers 규칙은 이 경로 + 데이터 디렉터리 인수 하나만 허용합니다. */
export const WEBMAKER_HELPER = '/usr/local/lib/arimail/webmaker-snapshot';
/** systemd 소켓 도우미의 소켓 파일: 데이터 디렉터리마다 하나(/data/webmaker → /run/arimail-webmaker/data-webmaker.sock). webmaker-access.sh 와 같은 규칙. */
export const webmakerSocketPath = (dir) => '/run/arimail-webmaker/' + String(dir || '').replace(/^\/+/, '').replace(/[^A-Za-z0-9]/g, '-') + '.sock';
const CACHE_MS = 3000, FRESH_MIN_MS = 2000;
/** 메일 주소의 @ 앞부분으로 쓸 수 있는 로그인 ID(웹메이커 lib/mailsys.js 의 LOCAL_RE 와 같은 규칙). */
const LOCAL_RE = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
/** 이 env(도메인)가 webmaker 인증 모드인지. LOGIN_MODE=webmaker 와 데이터 디렉터리(WEBMAKER_DIR)가 모두 있어야 합니다. */
export const isWebmaker = (env) => String(env?.LOGIN_MODE || '') === 'webmaker' && !!env?.WEBMAKER_DIR;
const mailDomainOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase().split('@')[1] || '';
// ------------------------------------------------------------------ 비밀번호(scrypt)
/**
* 웹메이커와 같은 방식으로 비밀번호를 검사합니다: scrypt(비밀번호, salt 문자열, 64바이트, 기본 매개변수 N=16384·r=8·p=1) 의 16진수를 저장값과 상수 시간 비교.
* 웹메이커(lib/auth.js hashPassword / lib/modules.js hashPw)는 crypto.scryptSync(pw, salt, 64) 를 쓰며 salt 는 16진수 "문자열" 그대로 입력합니다(디코딩하지 않음).
*/
export function verifyScrypt(password, salt, storedHex) {
return new Promise((resolve) => {
if (!salt || !storedHex) return resolve(false);
scrypt(String(password ?? ''), String(salt), 64, (err, key) => {
if (err) return resolve(false);
let stored; try { stored = Buffer.from(String(storedHex), 'hex'); } catch { return resolve(false); }
resolve(stored.length === key.length && timingSafeEqual(stored, key));
});
});
}
// 없는 계정의 로그인에도 실제 scrypt 를 한 번 돌려 걸리는 시간을 맞춥니다(계정 존재 여부가 응답 시간으로 새지 않게).
const DUMMY_SALT = randomBytes(16).toString('hex');
const dummyVerify = () => verifyScrypt(randomBytes(8).toString('hex'), DUMMY_SALT, '00'.repeat(64));
// ------------------------------------------------------------------ 스냅샷 읽기(캐시·sudo 도우미)
const cache = new Map(); // "<dir>|<user>" → { at, data, loading }
/** sudo 도우미로 스냅샷을 받습니다(JSON 표준 출력). 실패 사유(권한 없음·도우미 없음·JSON 아님)는 오류 메시지에 담습니다. */
function viaHelper(dir, user) {
return new Promise((resolve, reject) => {
execFile('sudo', ['-n', '-u', user, WEBMAKER_HELPER, dir], { timeout: 15000, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) return reject(new Error(`webmaker_helper_failed: ${String(stderr || err.message).trim().split('\n').pop()}`));
try { resolve(JSON.parse(stdout)); } catch { reject(new Error('webmaker_helper_bad_output')); }
});
});
}
/** systemd 소켓 도우미로 스냅샷을 받습니다: 접속하면 도우미가 JSON 을 쓰고 끊습니다(끝까지 읽어 파싱). */
function viaSocket(path) {
return new Promise((resolve, reject) => {
const sock = connect(path); let buf = '';
sock.setEncoding('utf8'); sock.setTimeout(15000, () => sock.destroy(new Error('timeout')));
sock.on('data', (d) => { buf += d; });
sock.on('end', () => { try { resolve(JSON.parse(buf)); } catch { reject(new Error('webmaker_socket_bad_output')); } });
sock.on('error', (e) => reject(new Error(`webmaker_socket_failed: ${e.message}`)));
});
}
async function fetchSnapshot(env) {
if (env.WEBMAKER_USER) {
// 소켓 도우미(시험에서는 WEBMAKER_SOCKET 으로 경로 지정)가 있으면 그것, 없으면 sudo 도우미.
const sock = env.WEBMAKER_SOCKET || webmakerSocketPath(env.WEBMAKER_DIR);
if (existsSync(sock)) return viaSocket(sock);
return viaHelper(env.WEBMAKER_DIR, env.WEBMAKER_USER);
}
return readSnapshotSync(env.WEBMAKER_DIR);
}
/**
* 스냅샷(캐시 3초). fresh 면 캐시를 무시하고 다시 읽습니다(로그인 실패 뒤 재확인용).
* 읽기에 실패했는데 예전 스냅샷이 있으면 그것을 돌려주고 경고를 남깁니다(한 번만). 예전 것도 없으면 오류를 던집니다 → 호출자는 "웹메이커 계정 없음" 으로 처리.
*/
export async function snapshot(env, { fresh = false } = {}) {
const key = `${env.WEBMAKER_DIR}|${env.WEBMAKER_USER || ''}`;
let c = cache.get(key); if (!c) { c = { at: 0, data: null, loading: null, warned: false, freshAt: 0 }; cache.set(key, c); }
// fresh(로그인 실패 뒤 재확인)는 디렉터리마다 FRESH_MIN_MS 에 한 번만 실제로 다시 읽습니다 — 틀린 비밀번호를 퍼부어 파일 읽기(sudo 도우미 실행)를 되풀이시키지 못하게.
if (fresh && Date.now() - c.freshAt < FRESH_MIN_MS) fresh = false;
if (!fresh && c.data && Date.now() - c.at < CACHE_MS) return c.data;
// 다시 읽기를 요구했는데 이미 읽는 중이면 그 결과(변경 전일 수 있음)를 기다린 뒤 한 번 더 읽습니다.
if (fresh && c.loading) { try { await c.loading; } catch { /* 아래에서 다시 시도 */ } }
if (fresh) c.freshAt = Date.now();
if (!c.loading) {
c.loading = fetchSnapshot(env).then((d) => { c.data = d; c.at = Date.now(); c.warned = false; return d; }).catch((e) => {
if (c.data) { if (!c.warned) { c.warned = true; console.error(` ⚠️ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽지 못해 마지막 스냅샷을 씁니다: ${e.message}`); } c.at = Date.now(); return c.data; }
throw e;
}).finally(() => { c.loading = null; });
}
return c.loading;
}
/** 시험·재시작용: 캐시 비우기. */
export function clearSnapshotCache() { cache.clear(); }
// ------------------------------------------------------------------ 도메인의 계정 목록
/** 도메인에 대응하는 웹메이커 사이트를 찾습니다(없으면 null). env.WEBMAKER_SITE(등록부의 siteId)가 우선입니다. */
export function siteFor(snap, domain, siteId = '') {
if (siteId && snap.sites[siteId]) return snap.sites[siteId];
const d = String(domain || '').toLowerCase();
if (snap.domainSites[d] && snap.sites[snap.domainSites[d]]) return snap.sites[snap.domainSites[d]];
for (const s of Object.values(snap.sites)) if (s.domains.includes(d)) return s;
for (const s of Object.values(snap.sites)) if (s.webmailDomain === d) return s;
return null;
}
/**
* 도메인의 웹메이커 계정 목록(해시 포함 — 모듈 안에서만 씀). 같은 로그인 ID 는 먼저 넣은 쪽이 이깁니다(사이트 회원 → 소유자 → 플랫폼 관리자/회원).
* @returns {Array<{id,login,name,role,mailbox,kind,salt,hash,approved}>} kind: member(사이트 회원) | owner(사이트 소유자) | admin(웹메이커 관리자) | user(플랫폼 회원)
*/
export function accountsFor(env, snap) {
const domain = mailDomainOf(env), site = siteFor(snap, domain, env.WEBMAKER_SITE || '');
const out = [], seen = new Set();
const push = (a) => { if (!LOCAL_RE.test(a.login) || seen.has(a.login)) return; seen.add(a.login); out.push({ ...a, mailbox: `${a.login}@${domain}` }); };
// 역할: 웹메이커 관리자(role=admin)만 이 도메인의 관리자(/admin·/setup — 발신 모듈·DNS 인증 정보까지 다룸). 사이트 소유자·사용자·일반 회원은 일반 계정입니다(최소 권한).
const platform = (u, kind) => push({ id: 'wm:u:' + u.id, login: u.id, name: u.name || u.id, role: u.role === 'admin' ? 'admin' : 'user', kind, salt: u.salt, hash: u.passHash, approved: true });
if (site) {
for (const m of snap.members[site.id] || []) if (m.loginId) push({ id: `wm:m:${site.id}:${m.id}`, login: m.loginId, name: m.name || m.loginId, role: 'user', kind: 'member', salt: m.salt, hash: m.hash, approved: m.approved !== false });
if (site.ownerId && snap.users[site.ownerId]) platform(snap.users[site.ownerId], 'owner');
for (const u of Object.values(snap.users)) if (u.role === 'admin') platform(u, 'admin');
} else {
for (const u of Object.values(snap.users)) platform(u, 'user');
}
return out;
}
/** 로그인 입력(아이디 또는 아이디@이 도메인)을 로그인 ID 로. 다른 도메인의 주소는 null. */
function loginKey(env, login) {
const key = String(login || '').trim().toLowerCase(); if (!key) return null;
if (!key.includes('@')) return key;
const [local, dom] = key.split('@'); return dom === mailDomainOf(env) ? local : null;
}
const principal = (a) => ({ id: a.id, source: 'webmaker', login: a.login, email: a.mailbox, name: a.name, role: a.role, mailbox: a.mailbox, kind: a.kind });
/**
* 로그인 이름과 비밀번호로 웹메이커 계정을 검사합니다. 맞으면 주체(principal), 아니면 null.
* 캐시된 스냅샷에서 실패하면 파일을 다시 읽어 한 번 더 검사합니다(웹메이커에서 방금 바꾼 비밀번호·새 계정 반영). 스냅샷을 읽을 수 없으면 null(기록만).
*/
export async function verifyWebmaker(env, login, password) {
const key = loginKey(env, login); if (!key || !password) return null;
let snap; try { snap = await snapshot(env); } catch (e) { console.error(` ✖ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽을 수 없습니다: ${e.message}`); await dummyVerify(); return null; }
// 계정이 있든 없든(승인 취소 포함) 시도마다 scrypt 를 꼭 한 번 돌려, 걸리는 시간으로 계정 존재 여부가 새지 않게 합니다(두 시도 모두 같은 횟수).
const check = async (a) => (a && a.approved ? verifyScrypt(password, a.salt, a.hash) : dummyVerify().then(() => false));
let a = accountsFor(env, snap).find((x) => x.login === key);
if (await check(a)) return principal(a);
// 두 번째 시도: 방금 바뀐 파일. 캐시가 아주 새것이어도(3초) 비밀번호 변경 직후의 로그인을 놓치지 않게 한 번은 다시 읽습니다.
try { snap = await snapshot(env, { fresh: true }); } catch { await dummyVerify(); return null; }
a = accountsFor(env, snap).find((x) => x.login === key);
if (await check(a)) return principal(a);
return null;
}
/**
* 주체 id('wm:…')로 계정을 찾습니다(세션 확인용). 웹메이커에서 지워졌거나 승인이 풀린 계정은 null → 세션 종료.
* 파일을 읽지 못한 경우(예전 스냅샷도 없음)는 예외를 그대로 던집니다 — auth.currentUser 가 "일시 장애" 로 보고 세션을 지우지 않습니다.
*/
export async function webmakerPrincipalById(env, id) {
const snap = await snapshot(env);
const a = accountsFor(env, snap).find((x) => x.id === id && x.approved);
return a ? principal(a) : null;
}
/** 메일함 주소로 웹메이커 계정을 찾습니다(자동 처리·수신 판단용). */
export async function webmakerByMailbox(env, mailbox) {
let snap; try { snap = await snapshot(env); } catch { return null; }
const a = accountsFor(env, snap).find((x) => x.mailbox === String(mailbox || '').toLowerCase() && x.approved);
return a ? principal(a) : null;
}
/** 웹메이커 계정의 메일함 주소 목록(수신 허용 판단). 승인이 풀린 회원의 메일함도 넣습니다(정지된 로컬 계정과 같은 취급: 메일은 받아 둠). */
export async function webmakerMailboxes(env) {
let snap; try { snap = await snapshot(env); } catch { return []; }
return accountsFor(env, snap).map((a) => a.mailbox);
}
/** 관리자 페이지·설정 화면용 목록(해시 없음). status 는 승인 여부로 active / suspended. */
export async function webmakerAccounts(env) {
let snap; try { snap = await snapshot(env); } catch (e) { return { ok: false, error: e.message, accounts: [] }; }
const site = siteFor(snap, mailDomainOf(env), env.WEBMAKER_SITE || '');
return { ok: true, readAt: snap.readAt, site: site ? { id: site.id, title: site.title, ownerId: site.ownerId } : null, accounts: accountsFor(env, snap).map((a) => ({ id: a.id, login: a.login, name: a.name, mailbox: a.mailbox, role: a.role, kind: a.kind, status: a.approved ? 'active' : 'suspended', source: 'webmaker' })) };
}
/** 로그인 ID 가 웹메이커 계정과 겹치는지(로컬 계정을 만들 때 login_taken 으로 막기 위해). */
export async function webmakerHasLogin(env, login) {
let snap; try { snap = await snapshot(env); } catch { return false; }
const key = String(login || '').toLowerCase();
return accountsFor(env, snap).some((a) => a.login === key);
}