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
+45
View File
@@ -0,0 +1,45 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, d1, migrate } from '../server/db.mjs';
import { hashPassword } from '../server/auth.mjs';
import { memoryRateStore } from '../server/util.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
export async function makeWwwDb({ login = 'arihakdang', password = 'admin-secret-12', email = 'admin@arihakdang.com', role = 'admin', status = 'active' } = {}) {
const db = openDb(':memory:');
db.exec('CREATE TABLE users(id TEXT PRIMARY KEY,email TEXT UNIQUE,login_id TEXT,name TEXT,role TEXT,status TEXT,password_hash TEXT)');
db.prepare('INSERT INTO users VALUES(?,?,?,?,?,?,?)').run('admin-1', email, login, '관리자', role, status, await hashPassword(password, 1000));
db.prepare('INSERT INTO users VALUES(?,?,?,?,?,?,?)').run('student-1', 'student@example.test', 'student', '학생', 'student', 'active', await hashPassword(password, 1000));
return db;
}
export async function makeEnv(over = {}) {
const db = openDb(':memory:'); migrate(db, join(HERE, '..', 'migrations'), { quiet: true });
return { DB: d1(db), RATE: memoryRateStore(), WWW_DB: await makeWwwDb(), MAILBOX: 'admin@arihakdang.com', RESEND_API_KEY: 'test', POP_AUTH_DELAY_MS: 0, ...over };
}
export function runner() {
let pass = 0, fail = 0;
const t = async (name, fn) => { try { await fn(); console.log(' ok ' + name); pass++; } catch (e) { console.log(' FAIL ' + name + ' — ' + (e.stack || e.message)); fail++; } };
const done = () => { console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); };
return { t, done };
}
export const eq = (a, b, m) => { if (a !== b) throw new Error(`${m}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); };
export const ok = (v, m) => { if (!v) throw new Error(m); };
/** Resend 흉내: 수신 목록/상세/첨부/원본 다운로드/발송. */
export function fakeResend({ received = [], sent = [], raw = {}, attachments = {}, calls = [], failStatus = 0 } = {}) {
return async (url, options = {}) => {
const u = new URL(url), path = u.pathname; const method = options.method || 'GET';
calls.push({ path, method, body: options.body ? JSON.parse(options.body) : undefined, key: options.headers?.['Idempotency-Key'] });
if (failStatus) return Response.json({ message: 'API key is invalid' }, { status: failStatus });
if (path === '/domains') return Response.json({ data: [{ name: 'arihakdang.com', status: 'verified' }] });
if (path === '/emails' && method === 'POST') { const id = 'sent-' + calls.length; sent.push({ id, ...JSON.parse(options.body), created_at: new Date().toISOString() }); return Response.json({ id }); }
if (path === '/emails/receiving') return Response.json({ data: received.map(({ html, text, headers, ...s }) => s), has_more: false });
if (path === '/emails') return Response.json({ data: sent, has_more: false });
let m;
if ((m = path.match(/^\/emails\/receiving\/([^/]+)\/attachments$/))) return Response.json({ data: (attachments[m[1]] || []), has_more: false });
if ((m = path.match(/^\/emails\/receiving\/([^/]+)$/))) { const item = received.find((r) => r.id === m[1]); return item ? Response.json({ ...item, raw: raw[item.id] ? { download_url: 'https://files.test/raw/' + item.id, expires_at: '2099-01-01' } : null }) : Response.json({ message: 'not found' }, { status: 404 }); }
if ((m = path.match(/^\/emails\/([^/]+)$/))) { const item = sent.find((r) => r.id === m[1]); return item ? Response.json(item) : Response.json({}, { status: 404 }); }
if (u.hostname === 'files.test') { const id = path.split('/').pop(); if (path.startsWith('/raw/')) return new Response(raw[id]); const a = Object.values(attachments).flat().find((x) => x.id === id); return new Response(a?.bytes || 'file-bytes'); }
return Response.json({ data: [], has_more: false });
};
}
export const request = (path, cookie, method = 'GET', body, extraHeaders = {}) => new Request('https://mail.arihakdang.com/api/' + path, { method, headers: { ...(cookie ? { Cookie: cookie } : {}), ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...extraHeaders }, body: body instanceof FormData ? body : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body) });
export const params = (path, ip = '127.0.0.1') => ({ path: path.split('?')[0].split('/').filter(Boolean), ip });
+185
View File
@@ -0,0 +1,185 @@
/**
* 1.0.0 시험: 설정 파일(config.mjs)·발송 모듈 선택(providers)·SMTP 중계 클라이언트·내장 Haraka 수신 전달 API·초기 설정 API.
* 실행: node test/config.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import net from 'node:net';
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, saveConfig, publicConfig, applyConfig, configFile, setupToken, generateDkim, dnsRecords, coerceValue, CONFIG_SCHEMA, maskSecret } from '../server/config.mjs';
import { sendMail, outboundMode, inboundMode, relaySettings, outboundStatus, usesResend, usesHaraka } from '../server/providers/index.mjs';
import { smtpSend, smtpProbe, smtpErrorCode } from '../server/providers/smtp-client.mjs';
import { payloadToEml } from '../server/providers/smtp.mjs';
import { setupApi, createSetupServer, harakaStatus } from '../server/setup.mjs';
import { sendDraft, saveDraft } from '../server/store.mjs';
import { createSession } from '../server/auth.mjs';
const { t, done } = runner();
const api = (env, path, cookie, method = 'GET', body, extraHeaders = {}) => {
const headers = { ...(cookie ? { cookie } : {}), ...extraHeaders };
let payload = body;
if (body !== undefined && !(body instanceof FormData) && typeof body !== 'string' && !Buffer.isBuffer(body)) { payload = JSON.stringify(body); headers['content-type'] = 'application/json'; }
return handle(env, new Request('https://mail.arihakdang.com/api/' + path, { method, headers, body: payload }), { path: path.split('?')[0].split('/').filter(Boolean), ip: '127.0.0.1' });
};
const adminLogin = async (env) => { const r = await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' }, { 'x-mail-scope': 'admin' }); return r.headers.get('set-cookie').split(';')[0]; };
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-cfg-'));
/**
* 시험용 SMTP 서버: EHLO/STARTTLS 없음/AUTH PLAIN/MAIL/RCPT/DATA 를 받아 기록합니다.
* @param {object} o rejectRcpt: 거부할 주소, authUser/authPass: 인증 요구, dataCode: DATA 끝 응답 코드
*/
function fakeSmtp({ rejectRcpt = [], authUser = '', authPass = '', dataCode = 250 } = {}) {
const sessions = [];
const server = net.createServer((socket) => {
const s = { from: null, rcpt: [], data: '', auth: null }; sessions.push(s); let inData = false, buf = '';
socket.write('220 fake.test ESMTP\r\n');
socket.on('data', (chunk) => {
buf += chunk.toString('latin1');
while (true) {
const i = buf.indexOf('\r\n'); if (i < 0) break; const line = buf.slice(0, i); buf = buf.slice(i + 2);
if (inData) { if (line === '.') { inData = false; socket.write(dataCode === 250 ? '250 2.0.0 Ok: queued as FAKE123\r\n' : `${dataCode} rejected by fake\r\n`); } else s.data += (line.startsWith('..') ? line.slice(1) : line) + '\r\n'; continue; }
const cmd = line.split(' ')[0].toUpperCase();
if (cmd === 'EHLO') socket.write('250-fake.test\r\n250-SIZE 10485760\r\n' + (authUser ? '250-AUTH PLAIN LOGIN\r\n' : '') + '250 8BITMIME\r\n');
else if (cmd === 'AUTH') { const [, user, pass] = Buffer.from(line.split(' ')[2] || '', 'base64').toString('utf8').split('\0'); s.auth = user; socket.write(user === authUser && pass === authPass ? '235 2.7.0 ok\r\n' : '535 5.7.8 authentication failed\r\n'); }
else if (cmd === 'MAIL') { s.from = line.match(/<([^>]*)>/)?.[1]; socket.write('250 ok\r\n'); }
else if (cmd === 'RCPT') { const a = line.match(/<([^>]*)>/)?.[1]; if (rejectRcpt.includes(a)) socket.write('550 5.1.1 no such user\r\n'); else { s.rcpt.push(a); socket.write('250 ok\r\n'); } }
else if (cmd === 'DATA') { inData = true; socket.write('354 go ahead\r\n'); }
else if (cmd === 'QUIT') { socket.write('221 bye\r\n'); socket.end(); }
else socket.write('250 ok\r\n');
}
});
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, sessions, close: () => server.close() })));
}
await t('1.0.0 설정 파일: 첫 시작에 환경 변수 값을 가져와 만들고, 파일 값이 환경 변수보다 우선하며, 저장은 즉시 env 에 적용되고 비밀값은 가려서 보여 준다', async () => {
const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', MAIL_FROM_NAME: '예전 이름', STATS_TIMEZONE: 'Asia/Seoul' });
const cfg = loadConfig(env);
ok(existsSync(configFile(env)) && configFile(env) === join(dir, 'config.json'), '설정 파일이 DB 옆에 생김');
eq(cfg.RESEND_API_KEY, 're_oldkey_from_env_1234', '환경 변수 값 가져오기'); eq(cfg.inbound, 'resend', '기본 수신 resend'); eq(cfg.outbound, 'resend', '기본 발신 resend');
ok(cfg.HARAKA_TOKEN && cfg.HARAKA_TOKEN.length > 20, '내부 토큰 자동 생성'); ok(env.CONFIG_CREATED, '새로 만듦 표시');
ok(JSON.parse(readFileSync(configFile(env), 'utf8'))._meta.importedFromEnv.includes('MAIL_FROM_NAME'), '가져온 키 기록');
// 저장 → 즉시 적용 + 알림
let notified = 0; env.ON_CONFIG_CHANGE = () => notified++;
const pub = saveConfig(env, { RESEND_API_KEY: 're_newkey_abcdefgh_9876', outbound: 'smtp', SMTP_RELAY_HOST: 'smtp.example.test', SMTP_RELAY_PORT: '2525', SITE_TITLE: '새 제목' });
eq(env.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '저장 즉시 env 반영'); eq(env.MAIL_OUTBOUND, 'smtp', '발신 방식'); eq(env.SITE_TITLE, '새 제목', '제목'); eq(notified, 1, '변경 알림');
eq(pub.values.RESEND_API_KEY, maskSecret('re_newkey_abcdefgh_9876'), '비밀값 가림'); ok(!pub.values.RESEND_API_KEY.includes('abcdefgh'), '중간 글자 숨김');
eq(pub.sources.RESEND_API_KEY, 'config', '출처 config'); eq(pub.values.SMTP_RELAY_PORT, 2525, '숫자 변환'); eq(pub.present.HARAKA_TOKEN, true, 'hidden 항목은 있음/없음만'); ok(!('HARAKA_TOKEN' in pub.values), 'hidden 값 비노출');
// 다시 읽으면 파일이 기준(환경 변수에 옛 값이 있어도)
const env2 = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', SITE_TITLE: '환경 제목' });
loadConfig(env2); eq(env2.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '파일 값이 환경 변수보다 우선'); eq(env2.SITE_TITLE, '새 제목', '제목도 파일 우선'); ok(!env2.CONFIG_CREATED, '다시 만들지 않음');
// 비우기: 파일에 '' 로 저장되면 환경 변수 값이 되살아나지 않음
saveConfig(env2, { SITE_TITLE: '' }); eq(env2.SITE_TITLE, '', '비운 값은 빈 값'); eq(publicConfig(env2).sources.SITE_TITLE, 'default', '비운 값은 환경 변수로 되살아나지 않음(출처 default)');
// 검증
let err = null; try { saveConfig(env2, { outbound: 'pigeon' }); } catch (e) { err = e; } eq(err?.message, 'validation', '모르는 발신 방식 거부'); eq(err?.field, 'outbound', '필드 이름');
err = null; try { saveConfig(env2, { HARAKA_PORT: '25a' }); } catch (e) { err = e; } eq(err?.message, 'validation', '숫자 아님 거부');
eq(coerceValue(CONFIG_SCHEMA.find((i) => i.key === 'SETUP_DONE'), '1'), true, 'bool 변환');
// 숨김 항목은 일반 저장으로 바꿀 수 없음
saveConfig(env2, { HARAKA_TOKEN: 'hacked' }); ok(env2.HARAKA_TOKEN !== 'hacked', 'hidden 항목 보호');
// 토큰 파일
const tok = setupToken(env2); eq(setupToken(env2), tok, '토큰 고정'); ok(existsSync(join(dir, 'setup-token')), '토큰 파일');
// DKIM
const dns = generateDkim(env2); ok(dns.hasDkim && dns.records.some((r) => r.name.startsWith('arimail._domainkey.') && r.value.startsWith('v=DKIM1; k=rsa; p=')), 'DKIM 레코드');
ok(env2.HARAKA_DKIM_PRIVATE.includes('BEGIN RSA PRIVATE KEY'), '비밀키 저장'); const pub1 = env2.HARAKA_DKIM_PUBLIC; generateDkim(env2); eq(env2.HARAKA_DKIM_PUBLIC, pub1, 'force 없이는 유지'); generateDkim(env2, { force: true }); ok(env2.HARAKA_DKIM_PUBLIC !== pub1, 'force 로 교체');
ok(dnsRecords(env2).records.find((r) => r.kind === 'MX').value.includes('mail.arihakdang.com'), 'MX 는 호스트 이름');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.0 발송 모듈 선택: resend 는 Resend API, smtp 는 중계 서버(봉투에 Bcc, 헤더에는 없음, 점 스터핑, 일부 거부·전체 거부·인증), haraka 는 로컬 제출 포트', async () => {
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) });
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
eq(outboundMode(env), 'resend', '기본 resend'); ok(usesResend(env) && !usesHaraka(env), '사용 여부');
const payload = { from: 'Admin <admin@arihakdang.com>', to: ['a@example.test'], cc: ['c@example.test'], bcc: ['b@example.test'], subject: '제목 한글', text: '본문\n.점 줄\n끝', html: '<p>본문</p>', attachments: [{ filename: '첨부.txt', content: Buffer.from('hello').toString('base64'), content_type: 'text/plain' }] };
const r1 = await sendMail(env, payload, 'k1'); ok(r1.id && calls.some((c) => c.path === '/emails'), 'Resend 호출');
// smtp 중계
const fake = await fakeSmtp({ authUser: 'relayuser', authPass: 'relaypass' });
applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: 'relayuser', SMTP_RELAY_PASS: 'relaypass', HARAKA_TOKEN: 'x' });
eq(relaySettings(env).port, fake.port, '중계 설정'); const before = calls.length;
const r2 = await sendMail(env, payload, 'k2'); eq(calls.length, before, 'Resend 는 부르지 않음'); ok(r2.id.includes('@arihakdang.com'), 'Message-ID 가 id');
const s = fake.sessions.at(-1); eq(s.auth, 'relayuser', 'AUTH PLAIN'); eq(s.from, 'admin@arihakdang.com', '봉투 보낸 사람'); eq(JSON.stringify(s.rcpt), JSON.stringify(['a@example.test', 'c@example.test', 'b@example.test']), '봉투 받는 사람(To·Cc·Bcc)');
ok(s.data.includes('Subject: =?UTF-8?') && s.data.includes('To: a@example.test') && s.data.includes('Cc: c@example.test') && !/^Bcc:/mi.test(s.data), '헤더에 Bcc 없음'); ok(!/\r\n\.\./.test(s.data), '점 스터핑이 풀린 뒤 이중 점 없음'); ok(s.data.includes('Content-Disposition: attachment'), '첨부 포함');
const st = await outboundStatus(env, { force: true }); eq(st.mode, 'smtp', '상태 모드'); eq(st.provider, 'ok', '연결 확인'); eq(st.relay.auth, true, '인증 표시');
// 일부 거부 → 성공(나머지), 전체 거부 → mail_rejected
const fake2 = await fakeSmtp({ rejectRcpt: ['b@example.test'] });
applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake2.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' });
const r3 = await sendMail(env, payload); eq(r3.rejected.length, 1, '일부 거부 기록'); eq(r3.accepted.length, 2, '나머지 발송');
let err = null; try { await sendMail(env, { ...payload, to: ['b@example.test'], cc: [], bcc: [] }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', '전체 거부'); ok(err.providerMessage.includes('550'), '서버 응답 보존');
// 인증 실패 / 접속 실패 / DATA 거부
const fake3 = await fakeSmtp({ authUser: 'u', authPass: 'p' });
err = null; try { await smtpSend({ host: '127.0.0.1', port: fake3.port, security: 'none', user: 'u', pass: 'wrong', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\nbody\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_auth_failed', '인증 실패 코드');
err = null; try { await smtpSend({ host: '127.0.0.1', port: 1, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n'), timeoutMs: 2000 }); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '접속 실패 코드');
const dotFake = await fakeSmtp(); await smtpSend({ host: '127.0.0.1', port: dotFake.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\n.dot line\r\nend\r\n') }); ok(dotFake.sessions[0].data.includes('.dot line\r\nend'), '점으로 시작하는 줄 전송(점 스터핑)'); dotFake.close();
const fake4 = await fakeSmtp({ dataCode: 552 }); err = null; try { await smtpSend({ host: '127.0.0.1', port: fake4.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', 'DATA 거부');
eq(smtpErrorCode(451, 'data'), 'mail_rate_limited', '4xx 는 일시 오류'); eq((await smtpProbe({ host: '127.0.0.1', port: 1, security: 'none', timeoutMs: 1000 })).ok, false, '연결 확인 실패');
// haraka 모드: 127.0.0.1:HARAKA_RELAY_PORT
const fake5 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'haraka', HARAKA_RELAY_PORT: fake5.port, HARAKA_TOKEN: 'x' });
eq(relaySettings(env).host, '127.0.0.1', '로컬 고정'); await sendMail(env, payload); eq(fake5.sessions.length, 1, 'Haraka 제출 포트로 전달'); eq(fake5.sessions[0].auth, null, '인증 없음');
// payloadToEml: 인라인 그림 cid
const { eml } = payloadToEml({ from: 'a@x.test', to: ['b@x.test'], subject: 's', html: '<img src="cid:pic">', attachments: [{ filename: 'p.png', content: Buffer.from('PNG').toString('base64'), content_type: 'image/png', content_id: 'pic' }] }, 'x.test');
ok(eml.toString().includes('Content-ID: <pic>') && eml.toString().includes('multipart/related'), '인라인 첨부');
// 웹 발송(sendDraft)도 같은 길을 씁니다
const fake6 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake6.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' });
const draftId = await saveDraft(env, { to: 'friend@example.test', subject: '웹 발송', html: '<p>안녕</p>' }); const sent = await sendDraft(env, draftId);
eq(sent.state, 'sent', '발송 완료'); eq(fake6.sessions.length, 1, '중계 서버로'); ok(sent.provider_id.includes('@arihakdang.com'), 'provider_id 는 Message-ID');
for (const f of [fake, fake2, fake3, fake4, fake5, fake6]) f.close();
});
await t('1.0.0 Haraka 수신 전달 API: 토큰·수신 방식 확인, RCPT 조회, 원본 저장(봉투 수신자로 메일함 결정)·중복·사본·용량, 상태 기록', async () => {
const env = await makeEnv({}); applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' });
const hdr = (extra = {}) => ({ 'x-arimail-token': 'tok-secret-1234567890', ...extra });
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).status, 404, '수신 방식이 resend 면 닫힘');
applyConfig(env, { inbound: 'haraka', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' });
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, { 'x-arimail-token': 'wrong' })).status, 401, '토큰 틀림');
eq((await (await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, true, '우리 메일함');
eq((await (await api(env, 'inbound/haraka/rcpt?addr=nobody@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, false, '없는 사용자');
const eml = 'From: Someone <someone@outside.test>\r\nTo: hidden@elsewhere.test\r\nSubject: =?UTF-8?B?' + Buffer.from('하라카').toString('base64') + '?=\r\nMessage-ID: <h1@outside.test>\r\nDate: Tue, 14 Oct 2026 10:00:00 +0900\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n하라카 본문\r\n';
const post = (id, rcpt, body = eml) => api(env, 'inbound/haraka', '', 'POST', Buffer.from(body), hdr({ 'content-type': 'message/rfc822', 'x-arimail-id': id, 'x-arimail-from': 'someone@outside.test', 'x-arimail-rcpt': JSON.stringify(rcpt) }));
const r1 = await (await post('tx-1', ['admin@arihakdang.com'])).json();
ok(r1.ok && r1.imported && r1.mailbox === 'admin@arihakdang.com', '봉투 수신자(Bcc 로 받은 메일)로 메일함 결정 — ' + JSON.stringify(r1));
const row = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(r1.id).first();
eq(row.folder, 'inbox', '받은 메일함'); eq(row.direction, 'inbound', '수신'); eq(row.provider_id, 'haraka:tx-1', 'provider_id'); eq(row.subject, '하라카', '제목 디코딩'); eq(row.raw_status, 'stored', '원본 그대로'); ok(!row.read_at, '읽지 않음'); eq(row.sender, 'Someone <someone@outside.test>', '보낸 사람');
const r2 = await (await post('tx-1', ['admin@arihakdang.com'])).json(); eq(r2.skipped, 'exists', '같은 트랜잭션 중복 저장 안 함');
eq((await (await post('tx-2', ['stranger@other.test'])).json()).skipped, 'other_mailbox', '우리 메일함 없음');
// 사본: 두 메일함에 함께 온 메일
const { createUser } = await import('../server/auth.mjs'); await createUser(env, { login: 'staff', name: '직원', password: 'staff-pass-123', role: 'member' });
const r3 = await (await post('tx-3', ['admin@arihakdang.com', 'staff@arihakdang.com'])).json(); eq(r3.copies?.length, 1, '다른 메일함 사본 — ' + JSON.stringify(r3)); eq(r3.copies[0].mailbox, 'staff@arihakdang.com', '사본 메일함');
// 상태 기록과 관리자 상태의 via_haraka
const events = (await env.DB.prepare("SELECT status, COUNT(*) n FROM webhook_events WHERE type='haraka.received' GROUP BY status ORDER BY status").all()).results;
eq(JSON.stringify(events), JSON.stringify([{ status: 'skipped', n: 1 }, { status: 'stored', n: 2 }]), '수신 기록(중복은 같은 id 로 덮어씀)');
const admin = await adminLogin(env); const st = await (await api(env, 'admin/status', admin, 'GET', undefined, { 'x-mail-scope': 'admin' })).json();
eq(st.storage.via_haraka, 3, 'Haraka 수신 통계(사본 포함)'); eq(st.mail.inbound, 'haraka', '상태의 수신 방식'); eq(st.mail.outbound, 'resend', '상태의 발신 방식');
eq((await api(env, 'inbound/haraka', '', 'POST', Buffer.alloc(0), hdr({ 'x-arimail-id': 'tx-4', 'x-arimail-rcpt': '["admin@arihakdang.com"]' }))).status, 400, '빈 본문 거부');
});
await t('1.0.0 초기 설정 API: 관리자 세션(/api/setup)과 전용 포트(토큰) 모두 같은 응답, 저장·연결 확인·DKIM·재시작 불가 안내, 토큰 오류 제한', async () => {
const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), APP_VERSION: '1.0.0-test', PUBLIC_HOST: 'mail.arihakdang.com' }); loadConfig(env);
// 관리자 세션 없이·웹 메일 세션으로는 403
eq((await api(env, 'setup/config')).status, 401, '로그인 없음');
const admin = await adminLogin(env); const A = { 'x-mail-scope': 'admin' };
const d = await (await api(env, 'setup/config', admin, 'GET', undefined, A)).json();
ok(d.ok && d.values && d.schema.length && d.meta.host === 'mail.arihakdang.com' && d.meta.webhookUrl.endsWith('/api/webhooks/resend'), '설정 응답'); eq(d.meta.inbound, 'resend', '메타 수신'); ok(d.meta.dns.records.length >= 3, 'DNS 안내'); ok(d.meta.haraka && d.meta.haraka.installed === false, 'Haraka 미설치 표시');
const saved = await (await api(env, 'setup/config', admin, 'PUT', { values: { MAIL_FROM_NAME: '새 발신자', SETUP_DONE: true } }, A)).json();
eq(saved.values.MAIL_FROM_NAME, '새 발신자', '저장'); eq(saved.values.SETUP_DONE, true, '완료 표시'); eq(env.MAIL_FROM_NAME, '새 발신자', '즉시 적용');
const bad = await api(env, 'setup/config', admin, 'PUT', { values: { HARAKA_PORT: 'abc' } }, A); eq(bad.status, 422, '검증 실패'); eq((await bad.json()).field, 'HARAKA_PORT', '필드');
const chk = await (await api(env, 'setup/check', admin, 'POST', { what: 'outbound' }, A)).json(); eq(chk.outboundStatus.mode, 'resend', '연결 확인 응답');
const dk = await (await api(env, 'setup/dkim', admin, 'POST', {}, A)).json(); ok(dk.dns.hasDkim, 'DKIM 생성');
eq((await api(env, 'setup/restart', admin, 'POST', {}, A)).status, 409, 'systemd 아닐 때 재시작 불가');
// 전용 포트 서버(토큰)
const webDir = join(dir, 'web'); writeFileSync; const { mkdirSync } = await import('node:fs'); mkdirSync(webDir); writeFileSync(join(webDir, 'setup.html'), '<!doctype html><title>__TITLE__</title><body data-mode="__MODE__">v__VERSION__ __HOST__</body>'); writeFileSync(join(webDir, 'setup.js'), '//'); writeFileSync(join(webDir, 'setup.css'), '');
const server = createSetupServer(env, { webDir, tlsOptions: null }); const bound = await server.listen({ port: 0, host: '127.0.0.1' });
const base = `http://127.0.0.1:${bound.port}`; const token = setupToken(env);
const page = await (await fetch(base + '/')).text(); ok(page.includes('data-mode="token"') && page.includes('v1.0.0-test'), '화면 자리표시자 치환');
eq((await fetch(base + '/api/setup/config')).status, 401, '토큰 없음');
eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'nope' } })).status, 401, '토큰 틀림');
const ok1 = await (await fetch(base + '/api/setup/config?token=' + encodeURIComponent(token))).json(); eq(ok1.values.MAIL_FROM_NAME, '새 발신자', '주소 토큰으로 읽기');
const put = await (await fetch(base + '/api/setup/config', { method: 'PUT', headers: { 'X-Setup-Token': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ values: { SITE_TITLE: '토큰 저장' } }) })).json(); eq(put.values.SITE_TITLE, '토큰 저장', '헤더 토큰으로 저장');
for (let i = 0; i < 10; i++) await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'guess' + i } });
eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': token } })).status, 429, '틀린 시도 10회 뒤 차단(맞는 토큰도)');
ok(server.urls(token)[0].includes(':' + bound.port + '/?token='), '접속 주소'); server.close();
ok(harakaStatus(env).installed === false && harakaStatus(env).status === null, 'Haraka 상태 파일 없음');
rmSync(dir, { recursive: true, force: true });
});
done();
+226
View File
@@ -0,0 +1,226 @@
/**
* 1.0.7 시험: 다중 도메인(server/domains.mjs) — 등록부 생성, 호스트·로그인 ID 로 테넌트 고르기, 도메인 추가(자기 DB·설정·초기 관리자)·중지·삭제,
* 전체 설정 API(/api/setup/domains, ?domain=), IMAP 로그인의 도메인 분리, Haraka 수신 전달의 도메인 나누기, 안내 화면, Haraka 관리 프로세스의 다중 도메인 설정 생성.
* 실행: node test/domains.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import net from 'node:net';
import http from 'node:http';
import { spawn } from 'node:child_process';
import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, saveConfig, configFile, setupToken } from '../server/config.mjs';
import { openTenants, loadRegistry, registryFile, normalizeEntry, noServicePage, isLocalHost, COPY_KEYS } from '../server/domains.mjs';
import { createImap } from '../server/imap.mjs';
import { updateUser } from '../server/auth.mjs';
import { createSetupServer, setupApi } from '../server/setup.mjs';
const { t, done } = runner();
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-dom-'));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
/** 기본 도메인 env: 파일 DB 위치(DB_FILE)만 있으면 되고 DB 자체는 메모리입니다(main.mjs 처럼 loadConfig 를 먼저 부릅니다). */
async function base(over = {}) {
const dir = tmp();
const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', RESEND_API_KEY: 're_primary_key_123456', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIA', DNS_AWS_SECRET_ACCESS_KEY: 'sekret', ...over });
loadConfig(env);
return { env, dir };
}
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
await t('등록부: 첫 시작에 환경 파일의 도메인으로 domains.json 을 만들고, 호스트·IP·localhost·하위 호스트·모르는 호스트를 규칙대로 고른다', async () => {
const { env, dir } = await base();
const tenants = open(env);
ok(existsSync(registryFile(env)) && registryFile(env) === join(dir, 'domains.json'), '등록부 파일');
const reg = loadRegistry(env); eq(reg.primary, 'arihakdang.com', '기본 도메인'); eq(reg.domains['arihakdang.com'].hosts[0], 'mail.arihakdang.com', '기본 호스트');
eq(env.DOMAIN, 'arihakdang.com', 'env.DOMAIN'); eq(env.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름');
for (const h of ['mail.arihakdang.com', 'MAIL.arihakdang.com:8790', '127.0.0.1:8790', 'localhost', '[::1]:8790', '', 'arihakdang.com', 'webmail.arihakdang.com']) eq(tenants.resolveHost(h).tenant?.entry.domain, 'arihakdang.com', '기본 도메인으로: ' + h);
eq(tenants.resolveHost('mail.other.test').tenant, null, '모르는 호스트'); eq(tenants.resolveHost('notarihakdang.com').tenant, null, '접미사만 비슷한 호스트');
ok(isLocalHost('10.0.0.5') && isLocalHost('localhost') && !isLocalHost('mail.x.com'), 'isLocalHost');
eq(tenants.resolveLogin('admin', '').DOMAIN, 'arihakdang.com', '도메인 없는 로그인 → 기본'); eq(tenants.resolveLogin('admin@arihakdang.com', ''), env, '@기본 도메인'); eq(tenants.resolveLogin('x@unknown.test', '').DOMAIN, 'arihakdang.com', '@등록되지 않은 도메인(www 관리자 이메일 로그인)은 기본 도메인에서 검사');
eq((await tenants.list()).length, 1, '목록 1개'); tenants.close();
});
await t('도메인 추가: 자기 디렉터리에 DB·설정(기본 도메인의 키·DNS 설정 복사)·초기 관리자(admin)가 생기고 계정은 도메인마다 따로이며, 중복·형식·www DB 오류를 거른다', async () => {
const { env, dir } = await base();
const tenants = open(env);
const r = await tenants.add({ domain: 'Second.test', hosts: 'mail.second.test, webmail.second.test', adminPassword: 'secondpw1', note: '둘째' });
eq(r.entry.domain, 'second.test', '소문자 정규화'); eq(r.entry.hosts.join(','), 'mail.second.test,webmail.second.test', '호스트 목록'); eq(r.initialAdmin?.login, 'admin', '초기 관리자'); eq(r.initialAdmin?.password, 'secondpw1', '지정한 비밀번호');
ok(existsSync(join(dir, 'domains', 'second.test', 'mail.sqlite')) && existsSync(join(dir, 'domains', 'second.test', 'config.json')), '도메인 디렉터리');
const sec = tenants.byDomain('second.test').env;
eq(sec.MAILBOX, 'admin@second.test', '메일함'); eq(sec.PUBLIC_HOST, 'mail.second.test', '공개 호스트'); eq(sec.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름은 기본 도메인'); eq(sec.LOGIN_MODE, 'standalone', '자체 계정'); ok(!sec.WWW_DB && !sec.WWW_DB_FILE, 'www DB 없음');
eq(sec.RESEND_API_KEY, 're_primary_key_123456', 'API 키 복사'); eq(sec.DNS_PROVIDER, 'route53', 'DNS 설정 복사'); eq(sec.DNS_AWS_SECRET_ACCESS_KEY, 'sekret', 'DNS 비밀 복사'); eq(sec.DNS_AWS_HOSTED_ZONE_ID, undefined, '존 id 는 복사 안 함(도메인으로 자동 검색)'); eq(sec.MAIL_INBOUND, 'resend', '수신 기본');
ok(COPY_KEYS.includes('RESEND_API_KEY') && !COPY_KEYS.includes('HARAKA_DKIM_PRIVATE'), '복사 키 목록');
ok(JSON.parse(readFileSync(configFile(sec), 'utf8'))._meta.copiedFrom === 'arihakdang.com', '설정 파일 메타');
// 로그인: 둘째 도메인의 admin 은 둘째 env 에서만, 기본 도메인의 www 관리자는 둘째 env 에서 거부
const login = await api(sec, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' }); eq(login.status, 200, '둘째 도메인 로그인');
const cookie = login.headers.get('set-cookie').split(';')[0]; const session = await (await api(sec, 'session', cookie)).json();
eq(session.mailbox, 'admin@second.test', '세션 메일함'); eq(session.domain, 'second.test', '세션 도메인'); eq(session.initialPassword, true, '초기 비밀번호 경고');
const st = await (await api(sec, 'settings', cookie)).json(); eq(st.imap.host, 'mail.arihakdang.com', '메일 프로그램 서버 이름은 기본 도메인'); eq(st.smtp.host, 'mail.arihakdang.com', 'SMTP 서버 이름'); eq(st.host, 'mail.second.test', '웹 메일 호스트는 자기 것'); eq(st.mailbox, 'admin@second.test', '설정 화면 메일함');
eq((await api(sec, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 401, '기본 도메인 계정은 둘째에서 거부');
eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' })).status, 401, '둘째 계정은 기본에서 거부');
eq((await api(env, 'session', cookie)).status, 200, '세션 응답'); eq((await (await api(env, 'session', cookie)).json()).user, null, '둘째 세션 쿠키는 기본 도메인에서 비로그인');
// 호스트·로그인 ID 로 고르기
eq(tenants.resolveHost('webmail.second.test').tenant.entry.domain, 'second.test', '추가 호스트'); eq(tenants.resolveHost('imap.second.test').tenant.entry.domain, 'second.test', '하위 호스트'); eq(tenants.resolveLogin('admin@second.test', '').DOMAIN, 'second.test', '@둘째');
eq(tenants.resolveLogin('admin', 'mail.second.test').DOMAIN, 'second.test', 'SNI 로 둘째'); eq(tenants.resolveLogin('admin', 'mail.arihakdang.com').DOMAIN, 'arihakdang.com', 'SNI 로 기본');
// 오류
for (const [body, code, field] of [[{ domain: 'second.test' }, 'domain_exists'], [{ domain: 'arihakdang.com' }, 'domain_exists'], [{ domain: 'third.test', hosts: 'mail.second.test' }, 'host_taken', 'hosts'], [{ domain: 'bad domain' }, 'validation', 'domain'], [{ domain: 'third.test', adminPassword: 'abc' }, 'validation', 'adminPassword'], [{ domain: 'third.test', auth: { mode: 'www', wwwDbFile: '/nonexistent/x.sqlite' } }, 'www_db_unreadable', 'auth.wwwDbFile'], [{ domain: 'third.test', auth: { mode: 'ldap' } }, 'validation', 'auth.mode']]) {
let err = null; try { await tenants.add(body); } catch (e) { err = e; }
eq(err?.message, code, JSON.stringify(body)); if (field) eq(err?.field, field, 'field ' + code);
}
eq((await tenants.list()).length, 2, '목록 2개'); const second = (await tenants.list())[1]; eq(second.accounts, 1, '계정 수'); eq(second.initialPassword, 'secondpw1', '목록의 초기 비밀번호 안내');
// 관리자가 비밀번호를 바꾸면 초기 비밀번호 안내가 사라집니다
await updateUser(sec, (await sec.DB.prepare('SELECT id FROM mail_users').first()).id, { password: 'changedpw1' }); eq((await tenants.list())[1].initialPassword, null, '비밀번호 변경 뒤 안내 없음');
// 같은 도메인을 동시에 추가하면 하나만 성공합니다
const race = await Promise.allSettled([tenants.add({ domain: 'race.test' }), tenants.add({ domain: 'race.test' })]); eq(race.filter((x) => x.status === 'fulfilled').length, 1, '동시 추가 하나만'); eq(race.find((x) => x.status === 'rejected')?.reason?.message, 'domain_exists', '다른 하나는 중복');
// 복사 없이 추가 + 등록부 다시 읽기(재시작 흉내)
await tenants.add({ domain: 'third.test', copyFromPrimary: false }); eq(tenants.byDomain('third.test').env.RESEND_API_KEY, undefined, '복사 안 함');
tenants.close();
const env2 = (await base({ DB_FILE: join(dir, 'mail-gateway.sqlite') })).env; const again = open(env2);
eq(again.all().map((x) => x.entry.domain).sort().join(','), 'arihakdang.com,race.test,second.test,third.test', '재시작 뒤 등록부'); eq(again.byDomain('second.test').env.RESEND_API_KEY, 're_primary_key_123456', '재시작 뒤 설정 유지');
eq((await api(again.byDomain('second.test').env, 'login', '', 'POST', { login: 'admin@second.test', password: 'changedpw1' })).status, 200, '재시작 뒤 로그인(바꾼 비밀번호)'); again.close();
});
await t('중지·수정·삭제: 중지한 도메인은 disabled 로 알려 로그인·수신을 막고, 기본 도메인은 지울 수 없으며, 삭제해도 데이터 디렉터리는 남는다', async () => {
const { env, dir } = await base();
const tenants = open(env);
await tenants.add({ domain: 'second.test' });
tenants.update('second.test', { enabled: false });
const r = tenants.resolveHost('mail.second.test'); ok(r.tenant && r.disabled, '중지 표시'); eq(tenants.resolveLogin('admin@second.test', ''), null, '중지 도메인 로그인 불가'); eq(tenants.envs().length, 1, '주기 작업 대상에서 제외');
tenants.update('second.test', { enabled: true, hosts: 'post.second.test', note: '메모' });
eq(tenants.resolveHost('post.second.test').disabled, false, '다시 사용'); eq(tenants.byDomain('second.test').env.PUBLIC_HOST, 'post.second.test', '호스트 변경이 env 에 반영'); eq(tenants.resolveHost('mail.second.test').tenant.entry.domain, 'second.test', '옛 호스트도 하위 호스트 규칙으로');
// 로그인 방식 바꾸기(www): 읽을 수 있는 SQLite 파일이면 허용
const www = join(dir, 'www.sqlite'); writeFileSync(www, '');
tenants.update('second.test', { auth: { mode: 'www', wwwDbFile: www, ownerLogin: 'owner' } }); eq(tenants.byDomain('second.test').env.WWW_DB_FILE, www, 'www DB 반영'); eq(tenants.byDomain('second.test').env.LOGIN_MODE, 'www', 'www 방식');
tenants.update('second.test', { auth: { mode: 'local' } }); ok(!tenants.byDomain('second.test').env.WWW_DB_FILE, '자체 계정으로 복귀');
const p = tenants.update('arihakdang.com', { hosts: 'alias.arihakdang.com', enabled: false, auth: { mode: 'local' } });
eq(p.enabled, true, '기본 도메인은 끌 수 없음'); eq(p.hosts.join(','), 'mail.arihakdang.com,alias.arihakdang.com', '기본 호스트는 항상 포함'); eq(p.auth.mode, 'www', '기본 도메인의 로그인 방식은 환경 파일');
let err = null; try { tenants.remove('arihakdang.com'); } catch (e) { err = e; } eq(err?.message, 'primary_domain', '기본 도메인 삭제 불가');
const removed = tenants.remove('second.test'); eq(tenants.resolveHost('post.second.test').tenant, null, '삭제 뒤 모르는 호스트'); ok(existsSync(join(removed.dir, 'mail.sqlite')), '데이터 디렉터리 유지');
ok(!loadRegistry(env).domains['second.test'], '등록부에서 제거'); tenants.close();
});
await t('전체 설정 API: 전용 포트에서 도메인 목록·추가·수정·삭제와 ?domain= 선택이 동작하고, 추가 도메인 호스트로 열면 안내 화면, 관리자 세션(/setup)에서는 도메인 관리가 없다', async () => {
const { env } = await base({ SETUP_PORT: '0' });
const tenants = open(env); env.APP_VERSION = '1.0.7-test';
const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tenants });
const port = 40000 + Math.floor(Math.random() * 20000); await setup.listen({ port, host: '127.0.0.1' });
const token = setupToken(env); eq(readFileSync(join(dirname(env.DB_FILE), 'setup-token'), 'utf8').trim(), token, '토큰 파일');
const call = (path, method = 'GET', body, host = '127.0.0.1') => fetch(`http://127.0.0.1:${port}${path}`, { method, headers: { 'X-Setup-Token': token, Host: host, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
try {
let r = await call('/api/setup/domains'); let d = await r.json(); eq(r.status, 200, '목록'); eq(d.primary, 'arihakdang.com', 'primary'); eq(d.domains.length, 1, '1개'); eq(d.serverHost, 'mail.arihakdang.com', '메일 서버 이름');
r = await call('/api/setup/domains', 'POST', { domain: 'second.test', adminPassword: 'secondpw1' }); d = await r.json(); eq(r.status, 201, '추가'); eq(d.initialAdmin.login, 'admin', '초기 관리자'); eq(d.domains.length, 2, '목록 갱신');
r = await call('/api/setup/domains', 'POST', { domain: 'second.test' }); eq(r.status, 409, '중복'); eq((await r.json()).error, 'domain_exists', '중복 코드');
r = await call('/api/setup/domains', 'POST', { domain: 'x y' }); eq(r.status, 422, '형식'); eq((await r.json()).field, 'domain', '필드');
// ?domain= 으로 둘째 도메인의 설정을 읽고 씁니다
r = await call('/api/setup/config?domain=second.test'); d = await r.json(); eq(d.meta.mailbox, 'admin@second.test', '둘째 설정'); eq(d.meta.primary, false, '기본 아님'); eq(d.meta.serverHost, 'mail.arihakdang.com', '서버 이름');
r = await call('/api/setup/config?domain=second.test', 'PUT', { values: { MAIL_FROM_NAME: '둘째' } }); d = await r.json(); eq(d.values.MAIL_FROM_NAME, '둘째', '둘째 저장'); eq(tenants.byDomain('second.test').env.MAIL_FROM_NAME, '둘째', '둘째 env 적용'); ok(env.MAIL_FROM_NAME !== '둘째', '기본 도메인은 그대로');
r = await call('/api/setup/config'); d = await r.json(); eq(d.meta.mailbox, 'admin@arihakdang.com', '기본 설정'); eq(d.meta.primary, true, '기본'); eq(d.meta.domains, 2, '도메인 수');
r = await call('/api/setup/config?domain=nope.test'); eq(r.status, 404, '모르는 도메인'); eq((await r.json()).error, 'domain_not_found', '코드');
r = await call('/api/setup/plan?domain=second.test'); d = await r.json(); eq(d.plan.domain, 'second.test', '둘째 도메인의 자동 설정 계획');
r = await call('/api/setup/domains/second.test', 'PATCH', { enabled: false }); d = await r.json(); eq(r.status, 200, '중지'); eq(d.domains[1].enabled, false, '중지 반영');
r = await call('/api/setup/domains/second.test', 'DELETE'); eq(r.status, 200, '삭제'); eq((await r.json()).domains.length, 1, '삭제 뒤 1개');
r = await call('/api/setup/domains/arihakdang.com', 'DELETE'); eq(r.status, 409, '기본 삭제 불가');
// 추가 도메인의 호스트 이름으로 전용 포트에 오면 안내 화면(기본 도메인 주소로 열라고)
await tenants.add({ domain: 'second.test' });
// fetch 는 Host 헤더를 바꿀 수 없으므로 http 모듈로 직접 보냅니다
const rawGet = (host) => new Promise((resolve, reject) => { http.get({ host: '127.0.0.1', port, path: '/', headers: { Host: host } }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, body })); }).on('error', reject); });
let g = await rawGet('mail.second.test'); eq(g.status, 404, '다른 호스트'); ok(g.body.includes('기본 도메인 주소로'), '안내 문구');
g = await rawGet('mail.arihakdang.com'); eq(g.status, 200, '기본 호스트'); g = await rawGet('127.0.0.1:' + port); eq(g.status, 200, 'IP'); r = await call('/api/health'); eq((await r.json()).setup, true, '상태 확인');
} finally { setup.close(); tenants.close(); }
// 관리자 세션 쪽(api.mjs → setupApi, tenants 없음): domains 는 404
const r2 = await setupApi(env, new Request('https://mail.arihakdang.com/api/setup/domains'), ['domains'], { ip: '127.0.0.1', user: { login: 'x' } }); eq(r2.status, 404, '관리자 세션에서는 도메인 관리 없음');
});
await t('IMAP: 로그인 ID 의 @도메인으로 도메인을 고르고(admin@second.test → 둘째 DB), 도메인 없는 ID 는 기본 도메인, 모르는·중지한 도메인은 거부한다', async () => {
const { env } = await base({ POP_AUTH_DELAY_MS: 0 });
const tenants = open(env);
await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' }); await tenants.add({ domain: 'off.test', adminPassword: 'offpw123' }); tenants.update('off.test', { enabled: false });
const imap = createImap(env, { hostname: 'mail.test', resolveEnv: (l, s) => tenants.resolveLogin(l, s) }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
const session = async (login, password) => { const c = imapClient(port); await c.greeting(); const r = await c.cmd(`LOGIN "${login}" "${password}"`); return { c, r }; };
try {
let { c, r } = await session('admin@second.test', 'secondpw1'); ok(/OK/.test(r), '둘째 로그인: ' + r.trim()); r = await c.cmd('SELECT INBOX'); ok(/OK/.test(r), '둘째 SELECT'); c.end();
({ c, r } = await session('admin@second.test', 'admin-secret-12')); ok(/NO/.test(r), '둘째에 기본 비밀번호 거부'); c.end();
({ c, r } = await session('arihakdang', 'admin-secret-12')); ok(/OK/.test(r), '도메인 없는 ID 는 기본 도메인'); c.end();
({ c, r } = await session('admin@off.test', 'offpw123')); ok(/NO/.test(r), '중지한 도메인 거부'); c.end();
({ c, r } = await session('admin@nowhere.test', 'secondpw1')); ok(/NO/.test(r), '모르는 도메인은 기본 도메인에서 검사 → 계정 없음'); c.end();
({ c, r } = await session('ADMIN@arihakdang.com', 'admin-secret-12')); ok(/OK/.test(r), 'www 관리자 이메일 로그인(기본 도메인)'); c.end();
} finally { imap.close(); tenants.close(); }
});
await t('Haraka 수신 전달: 받는 사람 주소의 도메인으로 저장할 DB 를 고르고, 토큰은 기본 도메인 것이며, 수신 방식이 haraka 인 도메인만 받는다', async () => {
const { env } = await base();
const tenants = open(env);
await tenants.add({ domain: 'second.test' }); await tenants.add({ domain: 'third.test' });
const sec = tenants.byDomain('second.test').env, third = tenants.byDomain('third.test').env;
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': env.HARAKA_TOKEN })).status, 404, '아무 도메인도 haraka 수신이 아니면 404');
saveConfig(sec, { inbound: 'haraka' }); // 둘째만 haraka 수신
const hdr = { 'X-AriMail-Token': env.HARAKA_TOKEN };
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': sec.HARAKA_TOKEN })).status, 401, '둘째 도메인의 토큰은 거부(토큰은 기본 도메인 것)');
const rc = async (addr) => (await (await api(env, 'inbound/haraka/rcpt?addr=' + addr, '', 'GET', undefined, hdr)).json()).local;
eq(await rc('admin@second.test'), true, '둘째 메일함'); eq(await rc('nobody@second.test'), false, '둘째 없는 사용자'); eq(await rc('admin@third.test'), false, '셋째는 haraka 수신 아님'); eq(await rc('admin@arihakdang.com'), false, '기본은 haraka 수신 아님');
const eml = Buffer.from('From: sender@example.test\r\nTo: admin@second.test\r\nSubject: hello second\r\nMessage-ID: <h1@example.test>\r\n\r\nbody\r\n');
const post = (rcpt, id) => handle(env, new Request('https://x/api/inbound/haraka', { method: 'POST', headers: { ...hdr, 'X-AriMail-Id': id, 'X-AriMail-From': 'sender@example.test', 'X-AriMail-Rcpt': JSON.stringify(rcpt), 'Content-Type': 'message/rfc822' }, body: eml }), { path: ['inbound', 'haraka'], ip: '127.0.0.1' });
let r = await post(['admin@second.test'], 'tx1'); eq(r.status, 200, '저장'); const r1 = await r.json(); ok(r1.imported && r1.mailbox === 'admin@second.test', '둘째 메일함에 저장 — ' + JSON.stringify(r1));
// 두 도메인의 주소가 한 트랜잭션에 함께 오면 각 DB 에 저장하고 합계를 돌려줍니다
saveConfig(third, { inbound: 'haraka' });
r = await post(['admin@second.test', 'admin@third.test'], 'tx2'); const r2 = await r.json(); eq(r2.imported, 2, '두 도메인 합계'); eq(r2.results.length, 2, '도메인별 결과');
eq(Number((await third.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 1, '셋째 DB 에 저장'); saveConfig(third, { inbound: 'resend' });
eq(Number((await sec.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 2, '둘째 DB 에 저장'); eq(Number((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 0, '기본 DB 에는 없음');
eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '둘째의 수신 기록');
r = await post(['admin@second.test'], 'tx1'); eq((await r.json()).skipped, 'exists', '재시도는 중복'); eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '기록 유지');
tenants.close();
});
await t('안내 화면과 항목 정규화: 모르는 도메인·중지한 도메인 문구, 호스트 기본값 mail.<도메인>, 로그인 방식 검사', async () => {
const html = noServicePage({ host: 'mail.nowhere.test', strings: {}, version: '1.0.7' });
ok(html.includes('메일 서비스가 존재하지 않습니다') && html.includes('mail.nowhere.test') && html.includes('관리자에게'), '모르는 도메인 문구');
ok(noServicePage({ disabled: true }).includes('활성화되지 않았습니다'), '중지 문구'); ok(noServicePage({ host: '<x>' }).includes('&lt;x&gt;'), '이스케이프');
const e = normalizeEntry({ domain: ' Example.COM. ' }); eq(e.domain, 'example.com', '도메인'); eq(e.hosts[0], 'mail.example.com', '기본 호스트'); eq(e.mailbox, 'admin@example.com', '기본 메일함'); eq(e.auth.mode, 'local', '기본 로그인'); eq(e.enabled, true, '기본 사용');
const e2 = normalizeEntry({ domain: 'example.com', mailbox: 'Postmaster', hosts: ['Mail.Example.com', 'mail.example.com', 'a.example.com:443'] }); eq(e2.mailbox, 'postmaster@example.com', '메일함 로컬'); eq(e2.hosts.join(','), 'mail.example.com,a.example.com', '중복·포트 제거');
let err = null; try { normalizeEntry({ domain: 'example.com', hosts: 'bad host' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '호스트 검사');
err = null; try { normalizeEntry({ domain: 'example.com', auth: { mode: 'www' } }); } catch (x) { err = x; } eq(err?.field, 'auth.wwwDbFile', 'www 는 DB 파일 필수');
});
await t('Haraka 관리 프로세스: 등록부의 켜진 도메인 중 수신 haraka 인 도메인을 domains= 에 넣고, 발신 haraka + DKIM 키가 있는 도메인마다 dkim/<도메인>/ 키 파일을 만든다', async () => {
const dir = tmp(), data = join(dir, 'data'), haraka = join(dir, 'haraka'); mkdirSync(data, { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'bin'), { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'config'), { recursive: true });
// 가짜 Haraka: 실행 파일은 그냥 대기하는 스크립트(설정 생성만 봅니다)
writeFileSync(join(haraka, 'node_modules', 'Haraka', 'bin', 'haraka'), 'setInterval(() => {}, 1000);\n'); writeFileSync(join(haraka, 'node_modules', 'Haraka', 'package.json'), '{"version":"0.0.0-test"}');
writeFileSync(join(data, 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_TOKEN: 'tok', HARAKA_DKIM_PRIVATE: 'KEY-PRIMARY', HARAKA_DKIM_SELECTOR: 'p1' }));
writeFileSync(join(data, 'domains.json'), JSON.stringify({ primary: 'a.test', domains: { 'a.test': { domain: 'a.test', enabled: true, hosts: ['mail.a.test'] }, 'b.test': { domain: 'b.test', enabled: true, hosts: ['mail.b.test'] }, 'c.test': { domain: 'c.test', enabled: false, hosts: ['mail.c.test'] }, 'd.test': { domain: 'd.test', enabled: true, hosts: ['mail.d.test'] } } }));
mkdirSync(join(data, 'domains', 'b.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'b.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-B', HARAKA_DKIM_SELECTOR: 'sel-b' }));
mkdirSync(join(data, 'domains', 'c.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'c.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-C' }));
mkdirSync(join(data, 'domains', 'd.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'd.test', 'config.json'), JSON.stringify({ inbound: 'resend', outbound: 'haraka' })); // 발신만, 키 없음
// 예전에 서명하던 도메인의 디렉터리(지워져야 함)
mkdirSync(join(haraka, 'config', 'dkim', 'old.test'), { recursive: true }); writeFileSync(join(haraka, 'config', 'dkim', 'old.test', 'private'), 'OLD'); writeFileSync(join(haraka, 'config', 'dkim.private.key'), 'OLD-SINGLE');
const child = spawn(process.execPath, [join(ROOT, 'haraka', 'run.mjs')], { env: { ...process.env, DB_FILE: join(data, 'mail-gateway.sqlite'), HARAKA_DIR: haraka, HARAKA_CHECK_MS: '200', MAILBOX: 'admin@a.test', PUBLIC_HOST: 'mail.a.test', PORT: '18990' }, stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
try {
const until = Date.now() + 8000; while (Date.now() < until && !existsSync(join(haraka, 'config', 'dkim', 'b.test', 'private'))) await new Promise((r) => setTimeout(r, 100));
const ini = readFileSync(join(haraka, 'config', 'arimail.ini'), 'utf8'); ok(ini.includes('domains=a.test,b.test\n'), 'domains= 에 켜진 haraka 수신 도메인만: ' + ini);
eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'selector'), 'utf8'), 'sel-b\n', 'b 셀렉터'); eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'private'), 'utf8'), 'KEY-B\n', 'b 키');
eq(readFileSync(join(haraka, 'config', 'dkim', 'a.test', 'selector'), 'utf8'), 'p1\n', '기본 도메인 셀렉터'); ok(!existsSync(join(haraka, 'config', 'dkim', 'c.test')) && !existsSync(join(haraka, 'config', 'dkim', 'd.test')) && !existsSync(join(haraka, 'config', 'dkim', 'old.test')), '중지·키 없음·옛 도메인 디렉터리 없음');
ok(readFileSync(join(haraka, 'config', 'plugins'), 'utf8').includes('dkim'), 'dkim 플러그인 켜짐'); ok(!readFileSync(join(haraka, 'config', 'dkim.ini'), 'utf8').includes('domain=') && !existsSync(join(haraka, 'config', 'dkim.private.key')), '단일 도메인 폴백 없음(키 없는 도메인은 서명하지 않음)');
const status = JSON.parse(readFileSync(join(data, 'haraka-status.json'), 'utf8')); eq(status.domains.join(','), 'a.test,b.test', '상태 파일 domains'); eq(status.signers.join(','), 'a.test,b.test', '상태 파일 signers'); eq(status.dkim, true, 'dkim 표시');
} catch (e) { e.message += '\n--- run.mjs 출력:\n' + out; throw e; } finally { child.kill('SIGTERM'); await new Promise((r) => child.on('exit', r)); rmSync(dir, { recursive: true, force: true }); }
});
/** IMAP 시험 클라이언트(test/imap.test.mjs 와 같은 최소 구현). */
function imapClient(port) {
const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = [];
socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); });
function flush() {
while (waiters.length) {
const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/;
const m = buffer.match(re); if (!m) return;
const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end);
}
}
let n = 0;
return { greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }), cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); }, end: () => socket.end() };
}
done();
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
import net from 'node:net';
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
import { ingestReceived, saveDraft } from '../server/store.mjs';
import { createImap, tokenize, resolveSet } from '../server/imap.mjs';
import { buildEml } from '../server/mime.mjs';
import { writeServices } from '../server/limits.mjs';
const { t, done } = runner();
const sample = (id, extra = {}) => ({ id, from: 'Sender <sender@example.test>', to: ['admin@arihakdang.com'], subject: '받은 메일 ' + id, html: '<p>내용 <b>' + id + '</b></p>', text: '내용 ' + id, created_at: new Date().toISOString(), headers: {}, message_id: '<' + id + '@example.test>', ...extra });
function client(port) {
const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = [];
socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); });
function flush() {
while (waiters.length) {
const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/;
const m = buffer.match(re); if (!m) return;
const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end);
}
}
let n = 0;
return {
greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }),
cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); },
raw: (text) => socket.write(text),
// 바이트 그대로 보내는 명령(8bit APPEND 시험용). cmd 는 문자열을 UTF-8 로 보내므로 따로 둡니다.
bytes: (tag, buf) => { socket.write(buf); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); },
untagged: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }),
end: () => socket.end(),
};
}
await t('토큰화와 집합 해석', async () => {
const tokens = tokenize('A1 FETCH 1:* (FLAGS BODY.PEEK[HEADER.FIELDS (From To)]<0.100> "quoted \\"x\\"")');
eq(tokens[2], '1:*', '집합'); eq(tokens[3][1], 'BODY.PEEK[HEADER.FIELDS (From To)]<0.100>', '대괄호 원자'); eq(tokens[3][2], 'quoted "x"', '따옴표');
const msgs = [{ uid: 3 }, { uid: 7 }, { uid: 9 }];
eq(resolveSet('2:*', msgs, false).join(','), '1,2', '시퀀스'); eq(resolveSet('7:*', msgs, true).join(','), '1,2', 'UID 범위'); eq(resolveSet('9,3', msgs, true).join(','), '0,2', 'UID 목록');
});
await t('0.9.7 IMAP 평문 로그인 스위치: 기본 허용이면 인사말에 AUTH=PLAIN, 관리자가 끄면 LOGINDISABLED 와 거절', async () => {
const env = await makeEnv();
const imap = createImap(env, { hostname: 'mail.test' }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
let c = client(port); let g = await c.greeting(); ok(/AUTH=PLAIN/.test(g) && !/LOGINDISABLED/.test(g), '기본: AUTH=PLAIN 광고: ' + g);
ok((await c.cmd('LOGIN arihakdang admin-secret-12')).includes('OK'), '평문 로그인 허용'); await c.cmd('LOGOUT'); c.end();
await writeServices(env, { plainLogin: false });
c = client(port); g = await c.greeting(); ok(/LOGINDISABLED/.test(g) && !/AUTH=PLAIN/.test(g), '끄면 LOGINDISABLED: ' + g);
ok(/NO \[PRIVACY\]/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), 'LOGIN 거절'); ok(/NO \[PRIVACY\]/.test(await c.cmd('AUTHENTICATE PLAIN')), 'AUTHENTICATE 거절'); c.end();
const fixed = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port2 = port + 1; await fixed.listen({ plainPort: port2, host: '127.0.0.1' });
try { c = client(port2); ok(/AUTH=PLAIN/.test(await c.greeting()), 'allowPlaintext:true 는 설정과 무관하게 허용'); c.end(); } finally { fixed.close(); }
} finally { imap.close(); }
});
await t('IMAP: 로그인·LIST·SELECT·FETCH·STORE·SEARCH·MOVE·EXPUNGE·APPEND·IDLE 가 웹 메일함과 같은 저장소를 쓴다', async () => {
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
const raw = { a: Buffer.from('From: Sender <sender@example.test>\r\nTo: admin@arihakdang.com\r\nSubject: raw a\r\nMessage-ID: <a@example.test>\r\nDate: Mon, 28 Sep 2026 06:00:00 +0000\r\nContent-Type: text/plain\r\n\r\nbody a\r\n') };
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a', { created_at: at(30) }), sample('b', { created_at: at(20) }), sample('c', { created_at: at(10) })], raw }), IMAP_IDLE_POLL_MS: 200 });
for (const id of ['a', 'b', 'c']) await ingestReceived(env, id);
await saveDraft(env, { to: 'x@example.test', subject: '초안', html: '<p>초안 본문</p>' });
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c = client(port); ok((await c.greeting()).includes('IMAP4rev1'), '인사');
ok((await c.cmd('CAPABILITY')).includes('IDLE'), 'CAPABILITY');
ok(/NO/.test(await c.cmd('LOGIN student admin-secret-12')), '학생 거부');
ok(/NO/.test(await c.cmd('LOGIN arihakdang wrong')), '비밀번호 오류');
ok(/A\d+ OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), '로그인');
const list = await c.cmd('LIST "" "*"'); ok(list.includes('"INBOX"') && list.includes('\\Sent') && list.includes('\\Trash'), 'LIST');
const status = await c.cmd('STATUS INBOX (MESSAGES UNSEEN)'); ok(status.includes('MESSAGES 3') && status.includes('UNSEEN 3'), 'STATUS');
const select = await c.cmd('SELECT INBOX'); ok(select.includes('* 3 EXISTS') && select.includes('UIDVALIDITY') && select.includes('READ-WRITE'), 'SELECT');
const fetch1 = await c.cmd('FETCH 1 (UID FLAGS RFC822.SIZE ENVELOPE BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (Subject)])');
ok(fetch1.includes('UID 1') && fetch1.includes('FLAGS ()') && fetch1.includes('"raw a"') && fetch1.includes('Subject: raw a'), 'FETCH 요약'); ok(!fetch1.includes('\\Seen'), 'PEEK 는 읽음 처리 없음');
const body = await c.cmd('UID FETCH 1 BODY[]'); ok(body.includes('body a') && body.includes('FLAGS (\\Seen)'), 'BODY[] 원본 + 읽음');
eq((await env.DB.prepare("SELECT read_at FROM messages WHERE provider_id='a'").first()).read_at !== null, true, '웹 읽음 상태 공유');
ok((await c.cmd('STORE 2 +FLAGS (\\Flagged \\Seen)')).includes('\\Flagged'), 'STORE'); ok((await c.cmd('SEARCH UNSEEN')).includes('* SEARCH 3'), 'SEARCH UNSEEN');
ok((await c.cmd('SEARCH FLAGGED')).includes('* SEARCH 2'), 'SEARCH FLAGGED'); ok((await c.cmd('UID SEARCH SUBJECT "raw"')).includes('* SEARCH 1'), 'SEARCH SUBJECT');
ok((await c.cmd('SEARCH OR SUBJECT "받은 메일 c" FROM "nobody"')).includes('* SEARCH 3'), 'SEARCH OR');
const move = await c.cmd('UID MOVE 3 Trash'); ok(move.includes('COPYUID') && move.includes('* 3 EXPUNGE'), 'MOVE');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='c'").first()).folder, 'trash', '웹 휴지통 공유');
await c.cmd('STORE 2 +FLAGS (\\Deleted)'); ok((await c.cmd('EXPUNGE')).includes('* 2 EXPUNGE'), 'EXPUNGE');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='b'").first()).folder, 'trash', '삭제 → 휴지통 이동');
ok((await c.cmd('SELECT Trash')).includes('* 2 EXISTS'), '휴지통 2통');
await c.cmd('STORE 1:* +FLAGS.SILENT (\\Deleted)'); await c.cmd('EXPUNGE');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages WHERE folder=\'trash\'').first()).n, 0, '휴지통 영구 삭제');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM tombstones').first()).n, 2, '재수신 차단');
const drafts = await c.cmd('SELECT Drafts'); ok(drafts.includes('* 1 EXISTS'), '초안 폴더');
const dfetch = await c.cmd('FETCH 1 (FLAGS BODY.PEEK[TEXT])'); ok(dfetch.includes('\\Draft') && /초안|7LSI7JWI/.test(dfetch), '초안 원본 즉시 생성');
const eml = buildEml({ from: 'Ari Hak Dang <admin@arihakdang.com>', to: ['friend@example.test'], subject: '클라이언트가 보낸 메일', text: '복사본', html: '<p>복사본</p>' });
const appendResp = await c.cmd(`APPEND Sent (\\Seen) {${eml.length}+}\r\n${eml.toString('latin1')}`); ok(appendResp.includes('APPENDUID'), 'APPEND 저장');
const sentRow = await env.DB.prepare("SELECT subject,folder,direction,read_at,raw_status FROM messages WHERE subject='클라이언트가 보낸 메일'").first();
eq(sentRow.folder, 'sent', '보낸 메일함'); eq(sentRow.direction, 'outbound', '발신'); ok(sentRow.read_at && sentRow.raw_status === 'stored', '읽음·원본 저장');
ok((await c.cmd('SELECT INBOX')).includes('* 1 EXISTS'), '받은 메일함 1통');
c.raw('A99 IDLE\r\n'); const idleStart = await c.untagged(); ok(idleStart.startsWith('+ '), 'IDLE 시작');
await ingestReceived(env, 'b'); // 삭제된 b 는 재수신 차단 → 변화 없음
await env.DB.prepare("INSERT INTO tombstones VALUES('never','x','deleted')").run();
const fresh = sample('d', { created_at: new Date().toISOString() }); env.RESEND_FETCH = fakeResend({ received: [fresh] }); await ingestReceived(env, 'd');
const exists = await c.untagged(); ok(exists.includes('EXISTS'), 'IDLE 로 새 메일 알림');
c.raw('DONE\r\n'); await new Promise((r) => setTimeout(r, 100));
ok(/A\d+ OK/.test(await c.cmd('LOGOUT')) || true, 'LOGOUT'); c.end();
const p = createImap(env, { hostname: 'mail.test', allowPlaintext: false }); const port2 = port + 1; await p.listen({ plainPort: port2, host: '127.0.0.1' });
const c2 = client(port2); const g = await c2.greeting(); ok(g.includes('LOGINDISABLED'), '평문 로그인 비활성'); ok(/NO/.test(await c2.cmd('LOGIN arihakdang admin-secret-12')), '평문 로그인 거부'); c2.end(); p.close();
} finally { imap.close(); }
});
await t('IMAP: 돌아온 메일은 새 UID, 괄호 묶음 OR/NOT 검색, base64 본문 검색, 8bit APPEND 원본 보존', async () => {
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a', { created_at: at(30), text: '고유단어 uniqueword 본문' }), sample('b', { created_at: at(20) })] }) });
for (const id of ['a', 'b']) await ingestReceived(env, id);
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
// 계정 설정에서 IMAP 을 끄면 비밀번호가 맞아도 거부되고, 켜면 들어갑니다(0.8.4).
const { writePrefs } = await import('../server/auth.mjs'); const admin = { id: 'www:admin-1' };
await writePrefs(env, admin, { imap: false });
const c = client(port); await c.greeting(); ok((await c.cmd('LOGIN arihakdang admin-secret-12')).includes('IMAP access is disabled'), 'IMAP 꺼진 계정 거부');
await writePrefs(env, admin, { imap: true }); ok(/A\d+ OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), 'IMAP 켜면 로그인');
ok((await c.cmd('SELECT INBOX')).includes('* 2 EXISTS'), 'SELECT');
// 괄호 묶음: 예전에는 묶음이 항상 참으로 평가되어 OR 는 전부, NOT 은 아무것도 돌려주지 않았습니다.
ok(/\* SEARCH\r\n/.test(await c.cmd('SEARCH OR (FROM nomatch) (FROM zzz)')), 'OR 묶음 둘 다 거짓 → 결과 없음');
ok((await c.cmd('SEARCH NOT (SUBJECT "받은 메일 a")')).includes('* SEARCH 2'), 'NOT 묶음');
ok((await c.cmd('SEARCH OR (SUBJECT "받은 메일 a") (FROM nomatch)')).includes('* SEARCH 1\r\n'), 'OR 묶음 한쪽 참');
// 본문 검색: 저장 원본은 base64 이므로 풀어서 찾아야 합니다.
ok((await c.cmd('SEARCH BODY uniqueword')).includes('* SEARCH 1\r\n'), 'BODY 는 base64 본문을 풀어서 찾는다');
// 휴지통에 갔다가 돌아온 메일은 예전 UID(1)가 아니라 더 큰 새 UID 를 받아야 합니다(RFC 3501 UID 단조 증가).
await c.cmd('UID MOVE 1 Trash'); await c.cmd('SELECT Trash'); const back = await c.cmd('UID MOVE 1:* INBOX');
const m = back.match(/COPYUID \d+ \S+ (\d+)/); ok(m && Number(m[1]) > 2, '돌아온 메일은 새 UID: ' + back);
const sel = await c.cmd('SELECT INBOX'); ok(sel.includes('* 2 EXISTS'), '받은 메일함 2통');
ok((await c.cmd('UID FETCH 3:* (UID)')).includes('UID ' + m[1]), 'UIDNEXT 이후 조회에 보인다');
// 웹에서 옮겼다 되돌린 경우도 같은 규칙(트리거)이 적용됩니다.
const bId = (await env.DB.prepare("SELECT id FROM messages WHERE provider_id='b'").first()).id;
const before = (await env.DB.prepare("SELECT id FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()).id;
await env.DB.prepare("UPDATE messages SET folder='trash' WHERE id=?").bind(bId).run(); await env.DB.prepare("UPDATE messages SET folder='inbox' WHERE id=?").bind(bId).run();
ok(!(await env.DB.prepare("SELECT 1 FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()), '떠난 폴더의 UID 행 삭제');
ok((await c.cmd('NOOP')) && (await env.DB.prepare("SELECT id FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()).id > before, '웹 이동 후 새 UID');
// 8bit APPEND: EUC-KR 바이트(UTF-8 이 아님)가 저장 원본에 그대로 남아야 합니다.
const head = Buffer.from('From: x@example.test\r\nTo: admin@arihakdang.com\r\nSubject: euc\r\nContent-Type: text/plain; charset=euc-kr\r\nContent-Transfer-Encoding: 8bit\r\n\r\n', 'latin1');
const eml = Buffer.concat([head, Buffer.from([0xc7, 0xd1, 0xb1, 0xdb, 0x0d, 0x0a])]);
const resp = await c.bytes('Z1', Buffer.concat([Buffer.from(`Z1 APPEND INBOX {${eml.length}+}\r\n`), eml, Buffer.from('\r\n')]));
ok(resp.includes('Z1 OK'), 'APPEND 완료');
const row = await env.DB.prepare("SELECT r.eml FROM messages m JOIN message_raw r ON r.message_id=m.id WHERE m.subject='euc'").first();
ok(Buffer.from(row.eml).equals(eml), '8bit 원본 바이트 보존');
// APPEND 의 날짜·시각·시간대가 그대로 저장되고(앞에 공백 붙은 날 포함), ENVELOPE 의 한글 제목이 UTF-8 로 나갑니다.
// 제목을 인코딩하지 않은(raw 8bit UTF-8) 헤더로 올립니다. ENVELOPE 는 이 바이트를 리터럴로 돌려줘야 합니다.
const dated = Buffer.from('From: Ari <admin@arihakdang.com>\r\nTo: x@example.test\r\nSubject: 한글 제목\r\nMessage-ID: <dt@x>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nx\r\n', 'utf8');
const ap = await c.bytes('Z2', Buffer.concat([Buffer.from(`Z2 APPEND Sent (\\Seen) " 7-Oct-2026 23:30:00 +0900" {${dated.length}+}\r\n`), dated, Buffer.from('\r\n')])); ok(ap.includes('APPENDUID'), 'APPEND 날짜: ' + ap);
eq((await env.DB.prepare("SELECT received_at FROM messages WHERE message_id='<dt@x>'").first()).received_at, '2026-10-07T14:30:00.000Z', '시각·시간대 반영');
await c.cmd('SELECT Sent'); const found = await c.cmd('UID SEARCH HEADER Message-ID dt@x'); const uid = found.match(/SEARCH (\d+)/)[1];
const fetched = await c.cmd(`UID FETCH ${uid} ENVELOPE`); ok(Buffer.from(fetched, 'latin1').toString('utf8').includes('한글 제목'), 'ENVELOPE 한글 제목 UTF-8: ' + JSON.stringify(Buffer.from(fetched, 'latin1').toString('utf8').slice(0, 160)));
c.end();
} finally { imap.close(); }
});
await t('0.8.16 오류 수정: 임시 보관함에서 EXPUNGE 한 초안은 휴지통에 남지 않고, LF 만 쓴 APPEND 도 본문이 저장된다', async () => {
const env = await makeEnv({ RESEND_FETCH: fakeResend({}) });
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c = client(port); await c.greeting(); await c.cmd('LOGIN arihakdang admin-secret-12');
// Thunderbird 자동 저장 흉내: v1 APPEND → v2 APPEND → v1 에 \Deleted → UID EXPUNGE. 예전에는 v1 이 휴지통으로 갔습니다.
const draft = (n) => Buffer.from(`From: 관리자 <admin@arihakdang.com>\r\nTo: a@example.test\r\nSubject: draft v${n}\r\nMessage-ID: <d${n}@x>\r\nContent-Type: text/plain\r\n\r\nbody v${n}\r\n`);
for (const n of [1, 2]) ok((await c.bytes('D' + n, Buffer.concat([Buffer.from(`D${n} APPEND Drafts (\\Draft \\Seen) {${draft(n).length}+}\r\n`), draft(n), Buffer.from('\r\n')]))).includes('APPENDUID'), 'APPEND ' + n);
await c.cmd('SELECT Drafts'); const uid = (await c.cmd('UID SEARCH HEADER Message-ID d1@x')).match(/SEARCH (\d+)/)[1];
await c.cmd(`UID STORE ${uid} +FLAGS.SILENT (\\Deleted)`); ok(/OK/.test(await c.cmd(`UID EXPUNGE ${uid}`)), 'UID EXPUNGE');
const rows = (await env.DB.prepare('SELECT subject,folder FROM messages ORDER BY subject').all()).results;
eq(rows.map((r) => r.subject + ':' + r.folder).join(','), 'draft v2:drafts', '옛 초안은 영구 삭제, 휴지통 비어 있음');
// LF 만 쓴 multipart 메시지도 본문을 해석합니다.
const lf = Buffer.from('From: a@example.test\nTo: admin@arihakdang.com\nSubject: lf only\nMessage-ID: <lf@x>\nContent-Type: multipart/alternative; boundary="b1"\n\n--b1\nContent-Type: text/plain\n\nplain body\n--b1\nContent-Type: text/html\n\n<p>html body</p>\n--b1--\n');
ok((await c.bytes('L1', Buffer.concat([Buffer.from(`L1 APPEND INBOX {${lf.length}+}\r\n`), lf, Buffer.from('\r\n')]))).includes('APPENDUID'), 'LF APPEND');
const m = await env.DB.prepare("SELECT text,html FROM messages WHERE message_id='<lf@x>'").first(); eq(m.text, 'plain body', 'LF 텍스트'); ok(m.html.includes('html body'), 'LF HTML');
c.end();
} finally { imap.close(); }
});
await t('0.8.24 오류 수정: 짝 없는 NUL 한 줄에 멈추지 않고, 읽기 전용(EXAMINE) 세션의 UID EXPUNGE 는 거부하며, 받은 메일은 Drafts 로 옮길 수 없다', async () => {
eq(JSON.stringify(tokenize('a1 \0')), '["a1"]', '짝 없는 NUL 은 버림(예전에는 무한 루프)');
eq(JSON.stringify(tokenize('a1 \0zzz')), '["a1"]', '짝 없는 NUL 뒤 글자도 버림');
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b')] }) });
await ingestReceived(env, 'a'); await ingestReceived(env, 'b');
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 43000 + Math.floor(Math.random() * 1000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c1 = client(port), c2 = client(port); await c1.greeting(); await c2.greeting();
await c1.cmd('LOGIN arihakdang admin-secret-12'); await c2.cmd('LOGIN arihakdang admin-secret-12');
await c1.cmd('SELECT INBOX');
// 두 메일의 received_at 이 같아 UID 순서가 id(무작위)에 따라 바뀔 수 있으므로 UID 를 DB 에서 찾습니다.
const uidOf = async (pid) => (await env.DB.prepare("SELECT u.id FROM imap_uids u JOIN messages m ON m.id=u.message_id WHERE m.provider_id=? AND u.folder='inbox'").bind(pid).first()).id;
const ua = await uidOf('a'), ub = await uidOf('b');
await c1.cmd(`UID STORE ${ua} +FLAGS (\\Deleted)`);
await c2.cmd('EXAMINE INBOX');
ok(/NO .*read-only/.test(await c2.cmd(`UID EXPUNGE ${ua}`)), '읽기 전용 UID EXPUNGE 거부');
ok(/NO .*read-only/.test(await c2.cmd('EXPUNGE')), '읽기 전용 EXPUNGE 거부');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='a'").first()).folder, 'inbox', '메일은 그대로');
ok(/NO \[CANNOT\]/.test(await c1.cmd(`UID MOVE ${ub} Drafts`)), '받은 메일 → Drafts 거부');
ok(/NO \[CANNOT\]/.test(await c1.cmd(`UID COPY ${ub} Drafts`)), '받은 메일 COPY → Drafts 거부');
ok(/OK/.test((await c1.cmd(`UID MOVE ${ub} Trash`)).split('\r\n').filter(Boolean).pop()), '휴지통으로는 이동');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='b'").first()).folder, 'trash', '휴지통 반영');
// 짝 없는 NUL 을 회선으로 보내도 서버가 계속 응답합니다.
const c3 = client(port); await c3.greeting(); const raw = await c3.cmd('\0'); ok(/BAD|NO|OK/.test(raw), 'NUL 줄에도 응답'); ok(/OK/.test(await c3.cmd('CAPABILITY')), '이후 명령 정상');
c1.end(); c2.end(); c3.end();
} finally { imap.close(); }
});
await t('0.9.0: 관리자가 IMAP 서비스를 끄면 로그인을 거부하고, 메일함 최대 크기를 넘는 APPEND 는 OVERQUOTA 로 거부한다', async () => {
const env = await makeEnv({ LIMIT_MAILBOX_BYTES: '2K' });
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
await writeServices(env, { imap: false });
let c = client(port); await c.greeting(); const r = await c.cmd('LOGIN arihakdang admin-secret-12'); ok(/NO \[UNAVAILABLE\].*administrator/.test(r), '서비스 꺼짐: ' + r); c.end();
await writeServices(env, { imap: true });
c = client(port); await c.greeting(); ok(/OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), '다시 켜면 로그인');
const small = buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 's', text: 'short' });
ok(/OK \[APPENDUID/.test(await c.cmd(`APPEND Drafts {${small.length}+}\r\n${small.toString('latin1')}`)), '작은 초안 저장');
const big = buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 'big', text: 'x'.repeat(3000) });
ok(/NO \[OVERQUOTA\]/.test(await c.cmd(`APPEND Drafts {${big.length}+}\r\n${big.toString('latin1')}`)), '용량 초과 APPEND 거부');
c.end();
} finally { imap.close(); }
});
done();
+255
View File
@@ -0,0 +1,255 @@
/**
* 1.0.5 시험: DNS 모듈(Google Cloud DNS·Route 53·공개 조회), 필요한 레코드 계산(SPF 병합), Resend 도메인·웹훅 모듈, 자동 설정 계획(buildPlan/runPlan).
* 외부 서비스는 모두 가짜 fetch(env.DNS_FETCH / env.RESEND_FETCH)와 가짜 리졸버로 대신합니다.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { generateKeyPairSync } from 'node:crypto';
import { makeEnv, runner, eq, ok } from './_helpers.mjs';
import { loadConfig, applyConfig, saveConfig } from '../server/config.mjs';
import { mergeSpf, requiredRecords, applyRecords, checkRecords, probeDns } from '../server/dns/index.mjs';
import { txtChunks, lookup, reverseCheck } from '../server/dns/common.mjs';
import * as google from '../server/dns/google.mjs';
import * as route53 from '../server/dns/route53.mjs';
import { resendDnsRecords, findDomain, providerStatus, providerError, clearStatusCache, autoVerifyDomain } from '../server/providers/resend.mjs';
import { buildPlan, runPlan } from '../server/provision.mjs';
const { t, done } = runner();
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-prov-'));
const envFor = async (dir, over = {}) => { const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), MAILBOX: 'admin@example.test', PUBLIC_HOST: 'mail.example.test', RESEND_API_KEY: '', ...over }); loadConfig(env); return env; };
/** 가짜 리졸버: 표에 있는 값만 돌려주고 없으면 ENOTFOUND. */
function fakeResolver(table = {}) {
const get = (name, type) => { const v = table[`${type} ${name.toLowerCase()}`]; if (v === undefined) { const e = new Error('nf'); e.code = 'ENOTFOUND'; throw e; } return v; };
return { resolveTxt: async (n) => get(n, 'TXT').map((v) => txtChunks(v)), resolveMx: async (n) => get(n, 'MX'), resolveCname: async (n) => get(n, 'CNAME'), resolve4: async (n) => get(n, 'A'), reverse: async (ip) => get(ip, 'PTR'), table };
}
/** 가짜 Resend(도메인·웹훅 관리 부분만) — 상태를 기억합니다. */
function fakeResendAdmin(state = {}) {
state.domains = state.domains || []; state.webhooks = state.webhooks || []; state.calls = [];
return async (url, options = {}) => {
const u = new URL(url), path = u.pathname, method = options.method || 'GET', body = options.body ? JSON.parse(options.body) : null; state.calls.push({ path, method, body });
if (!options.headers?.Authorization?.includes('re_good')) return Response.json({ message: 'API key is invalid' }, { status: 401 });
let m;
if (path === '/domains' && method === 'GET') return Response.json({ data: state.domains.map(({ records, ...d }) => d) });
if (path === '/domains' && method === 'POST') { const d = { id: 'dom-' + (state.domains.length + 1), name: body.name, status: 'not_started', region: body.region, capabilities: body.capabilities, records: [{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=PUBKEY', status: 'not_started' }, { record: 'SPF', name: 'send', type: 'CNAME', value: 'send.forge.rmta.net', status: 'not_started' }, ...(body.capabilities.receiving === 'enabled' ? [{ record: 'Receiving', name: '', type: 'MX', value: `inbound-smtp.${body.region}.amazonaws.com`, priority: 9, status: 'not_started' }] : [])] }; state.domains.push(d); return Response.json(d); }
if ((m = path.match(/^\/domains\/([^/]+)\/verify$/))) { const d = state.domains.find((x) => x.id === m[1]); if (!d) return Response.json({}, { status: 404 }); d.status = state.verifyResult || 'pending'; return Response.json({ object: 'domain', id: d.id }); }
if ((m = path.match(/^\/domains\/([^/]+)$/))) { const d = state.domains.find((x) => x.id === m[1]); if (!d) return Response.json({ message: 'not found' }, { status: 404 }); if (method === 'PATCH') { d.capabilities = body.capabilities; if (body.capabilities.receiving === 'enabled' && !d.records.some((r) => r.type === 'MX')) d.records.push({ record: 'Receiving', name: '', type: 'MX', value: `inbound-smtp.${d.region}.amazonaws.com`, priority: 9 }); if (body.capabilities.receiving === 'disabled') d.records = d.records.filter((r) => r.type !== 'MX'); return Response.json({ object: 'domain', id: d.id }); } return Response.json(d); }
if (path === '/webhooks' && method === 'GET') return Response.json({ data: state.webhooks });
if (path === '/webhooks' && method === 'POST') { state.seq = (state.seq || 0) + 1; const w = { id: 'wh-' + state.seq, endpoint: body.endpoint, events: body.events, status: 'enabled' }; state.webhooks.push(w); return Response.json({ object: 'webhook', id: w.id, signing_secret: 'whsec_' + w.id }); }
if ((m = path.match(/^\/webhooks\/([^/]+)$/)) && method === 'DELETE') { state.webhooks = state.webhooks.filter((w) => w.id !== m[1]); return Response.json({ object: 'webhook', id: m[1], deleted: true }); }
return Response.json({ message: 'unexpected ' + method + ' ' + path }, { status: 500 });
};
}
/** 가짜 Google Cloud DNS + OAuth. zone 의 rrsets 를 기억합니다. */
function fakeGoogle(state = {}) {
state.rrsets = state.rrsets || {}; state.calls = []; state.changes = [];
return async (url, options = {}) => {
const u = new URL(url), method = options.method || 'GET', body = options.body && !(options.body instanceof URLSearchParams) ? JSON.parse(options.body) : null; state.calls.push({ host: u.hostname, path: u.pathname, method, body });
if (u.hostname === 'oauth2.googleapis.com') { const p = options.body; ok(String(p.get('assertion')).split('.').length === 3, 'JWT 3 부분'); return Response.json({ access_token: 'ya29.test', expires_in: 3600 }); }
if (!options.headers?.Authorization?.includes('ya29.test')) return Response.json({ error: { message: 'unauth' } }, { status: 401 });
if (u.pathname.endsWith('/managedZones') && method === 'GET') return Response.json({ managedZones: [{ name: 'other-zone', dnsName: 'other.test.', visibility: 'public' }, { name: 'example-zone', dnsName: 'example.test.', visibility: 'public' }, { name: 'priv', dnsName: 'example.test.', visibility: 'private' }] });
if (u.pathname.includes('/rrsets')) { const key = `${u.searchParams.get('type')} ${u.searchParams.get('name')}`; return Response.json({ rrsets: state.rrsets[key] ? [state.rrsets[key]] : [] }); }
if (u.pathname.endsWith('/changes') && method === 'POST') { state.changes.push(body); for (const d of body.deletions || []) delete state.rrsets[`${d.type} ${d.name}`]; for (const a of body.additions || []) state.rrsets[`${a.type} ${a.name}`] = a; return Response.json({ status: 'pending' }); }
return Response.json({ error: { message: 'unexpected ' + u.pathname } }, { status: 500 });
};
}
/** 가짜 Route 53: 서명 헤더만 확인하고 XML 로 답합니다. */
function fakeRoute53(state = {}) {
state.calls = []; state.rrsets = state.rrsets || {};
return async (url, options = {}) => {
const u = new URL(url), method = options.method || 'GET'; state.calls.push({ path: u.pathname, query: u.search, method, headers: options.headers, body: options.body });
const auth = options.headers?.Authorization || '';
if (!/^AWS4-HMAC-SHA256 Credential=AKIATEST\/\d{8}\/us-east-1\/route53\/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/.test(auth) && !/SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date/.test(auth)) return new Response('<ErrorResponse><Error><Code>InvalidSignature</Code><Message>bad</Message></Error></ErrorResponse>', { status: 403 });
if (u.pathname === '/2013-04-01/hostedzonesbyname') { const name = u.searchParams.get('dnsname'); return new Response(`<ListHostedZonesByNameResponse><HostedZones>${name === 'example.test.' ? '<HostedZone><Id>/hostedzone/Z123</Id><Name>example.test.</Name><Config><PrivateZone>false</PrivateZone></Config></HostedZone>' : ''}</HostedZones></ListHostedZonesByNameResponse>`); }
if (u.pathname.endsWith('/rrset') && method === 'GET') { const key = `${u.searchParams.get('type')} ${u.searchParams.get('name')}`; const r = state.rrsets[key]; return new Response(`<ListResourceRecordSetsResponse><ResourceRecordSets>${r ? `<ResourceRecordSet><Name>${r.name}</Name><Type>${r.type}</Type><TTL>300</TTL><ResourceRecords>${r.values.map((v) => `<ResourceRecord><Value>${v.replace(/"/g, '&quot;')}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet>` : ''}</ResourceRecordSets></ListResourceRecordSetsResponse>`); }
if (u.pathname.endsWith('/rrset') && method === 'POST') { state.lastBody = options.body; for (const m of options.body.matchAll(/<ResourceRecordSet><Name>([^<]+)<\/Name><Type>([^<]+)<\/Type><TTL>\d+<\/TTL><ResourceRecords>(.*?)<\/ResourceRecords>/g)) state.rrsets[`${m[2]} ${m[1]}`] = { name: m[1], type: m[2], values: [...m[3].matchAll(/<Value>([^<]*)<\/Value>/g)].map((x) => x[1].replace(/&quot;/g, '"')) }; return new Response('<ChangeResourceRecordSetsResponse><ChangeInfo><Id>/change/C999</Id><Status>PENDING</Status></ChangeInfo></ChangeResourceRecordSetsResponse>'); }
return new Response('<ErrorResponse><Error><Code>Unexpected</Code></Error></ErrorResponse>', { status: 500 });
};
}
const saKey = () => generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } }).privateKey;
await t('1.0.5 DNS 공통: SPF 병합, TXT 255자 분할, 공개 조회 비교(TXT/MX/CNAME, 없음=not_found), PTR 확인', async () => {
eq(mergeSpf('', ['mx', 'a:mail.example.test']), 'v=spf1 mx a:mail.example.test ~all', '새 SPF');
eq(mergeSpf('v=spf1 include:_spf.google.com -all', ['mx', 'a:mail.example.test']), 'v=spf1 include:_spf.google.com mx a:mail.example.test -all', '기존 SPF 에 끼워 넣기(all 유지)');
eq(mergeSpf('v=spf1 mx a:mail.example.test ~all', ['mx', 'a:mail.example.test']), 'v=spf1 mx a:mail.example.test ~all', '이미 있으면 그대로');
eq(txtChunks('x'.repeat(600)).map((c) => c.length).join(','), '255,255,90', '분할'); eq(txtChunks('').length, 1, '빈 값도 조각 하나');
const r = fakeResolver({ 'TXT example.test': ['v=spf1 mx ~all', 'other'], 'MX example.test': [{ priority: 10, exchange: 'mail.example.test' }], 'CNAME send.example.test': ['send.forge.rmta.net'], 'A mail.example.test': ['203.0.113.5'], 'PTR 203.0.113.5': ['mail.example.test'] });
eq((await lookup({ name: 'example.test', type: 'TXT', values: ['v=spf1 mx ~all'] }, { resolver: r })).ok, true, 'TXT 일치');
eq((await lookup({ name: 'example.test', type: 'TXT', values: ['v=spf1 -all'] }, { resolver: r })).ok, false, 'TXT 불일치');
eq((await lookup({ name: 'example.test', type: 'MX', values: ['mail.example.test'], priority: 10 }, { resolver: r })).ok, true, 'MX 일치(우선순위 포함)');
eq((await lookup({ name: 'example.test', type: 'MX', values: ['mail.example.test'], priority: 9 }, { resolver: r })).ok, false, 'MX 우선순위 다름');
eq((await lookup({ name: 'send.example.test', type: 'CNAME', values: ['send.forge.rmta.net.'] }, { resolver: r })).ok, true, 'CNAME 끝 점 무시');
const nf = await lookup({ name: 'nope.example.test', type: 'TXT', values: ['x'] }, { resolver: r }); eq(nf.ok, false, '없음'); eq(nf.error, 'not_found', 'not_found 코드');
eq((await reverseCheck('mail.example.test', { resolver: r })).ok, true, 'PTR 일치'); eq((await reverseCheck('mail.example.test', { resolver: fakeResolver({ 'A mail.example.test': ['203.0.113.5'], 'PTR 203.0.113.5': ['vps-1.hosting.test'] }) })).ok, false, 'PTR 다름');
});
await t('1.0.5 필요한 레코드: 수·발신 방식별 구성(Haraka MX·SPF·DKIM·DMARC, Resend 레코드 변환, 수신이 Resend 가 아니면 Resend MX 제외)', async () => {
const dir = tmp(); const env = await envFor(dir);
applyConfig(env, { inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PUBLIC: 'PUB', HARAKA_DKIM_SELECTOR: 'arimail', HARAKA_TOKEN: 'x' });
const recs = requiredRecords(env, { existingSpf: 'v=spf1 include:spf.protection.outlook.com -all' });
eq(recs.map((r) => `${r.type} ${r.name}${r.optional ? '?' : ''}`).join(' | '), 'MX example.test | TXT example.test | TXT arimail._domainkey.example.test | TXT _dmarc.example.test?', 'Haraka 수·발신 레코드');
eq(recs[0].values[0], 'mail.example.test', 'MX 는 호스트 이름'); eq(recs[1].values[0], 'v=spf1 include:spf.protection.outlook.com mx a:mail.example.test -all', '기존 SPF 병합'); ok(recs[2].values[0].startsWith('v=DKIM1; k=rsa; p=PUB'), 'DKIM');
const resendDomain = { name: 'Example.test', records: [{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=K' }, { record: 'SPF', name: 'send', type: 'CNAME', value: 'send.forge.rmta.net' }, { record: 'Receiving', name: '', type: 'MX', value: 'inbound-smtp.ap-northeast-1.amazonaws.com', priority: 9 }] };
eq(resendDnsRecords(resendDomain).map((r) => `${r.type} ${r.name}`).join(' | '), 'TXT resend._domainkey.example.test | CNAME send.example.test | MX example.test', 'Resend 레코드 → FQDN');
applyConfig(env, { inbound: 'haraka', outbound: 'resend', HARAKA_TOKEN: 'x' });
const mixed = requiredRecords(env, { resendDomain });
eq(mixed.map((r) => `${r.type} ${r.name}`).join(' | '), 'MX example.test | TXT resend._domainkey.example.test | CNAME send.example.test', '수신 Haraka + 발신 Resend: Resend MX 는 빠지고 우리 MX 만');
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
eq(requiredRecords(env, { resendDomain }).filter((r) => r.type === 'MX')[0].priority, 9, '수신 Resend: Resend MX(우선순위 9)');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 Google Cloud DNS 모듈: 서비스 계정 JWT 인증, 존 자동 선택(공개·가장 긴 일치), rrdatas 변환(TXT 따옴표·분할, MX 우선순위), UPSERT(기존 삭제+추가, 같으면 생략), 오류 코드', async () => {
const dir = tmp(); const env = await envFor(dir); const g = {}; env.DNS_FETCH = fakeGoogle(g);
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'sa@p.iam.gserviceaccount.com', private_key: saKey(), project_id: 'proj-1' }), HARAKA_TOKEN: 'x' });
const zone = await google.findZone(env, 'example.test'); eq(zone.name, 'example-zone', '공개 존 선택(사설 제외)');
const longTxt = 'v=DKIM1; p=' + 'A'.repeat(300), rr = google.rrdata({ type: 'TXT', values: [longTxt] })[0];
eq(rr, txtChunks(longTxt).map((c) => `"${c}"`).join(' '), 'TXT 255자 분할·따옴표'); eq(rr.split('" "').length, 2, '두 조각'); eq(rr.replace(/"/g, '').replace(/ /g, ''), longTxt.replace(/ /g, ''), '이어 붙이면 원본');
eq(google.rrdata({ type: 'MX', values: ['mail.example.test'], priority: 10 })[0], '10 mail.example.test.', 'MX 형식'); eq(google.rrdata({ type: 'CNAME', values: ['send.forge.rmta.net'] })[0], 'send.forge.rmta.net.', 'CNAME 끝 점');
g.rrsets['TXT example.test.'] = { name: 'example.test.', type: 'TXT', ttl: 300, rrdatas: ['"v=spf1 include:old ~all"'] };
const r1 = await google.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 include:old mx ~all'] }, { name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]);
eq(r1.changed.length, 2, '두 레코드 변경'); const ch = g.changes[0]; eq(ch.deletions.length, 1, '기존 TXT 삭제'); eq(ch.additions.length, 2, '추가 2'); eq(ch.additions[0].rrdatas[0], '"v=spf1 include:old mx ~all"', '새 SPF');
const r2 = await google.upsert(env, zone, [{ name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]); eq(r2.unchanged.length, 1, '같으면 생략'); eq(g.changes.length, 1, '변경 요청 없음');
eq((await probeDns(env)).ok, true, '연결 확인'); eq((await probeDns(env)).project, 'proj-1', '프로젝트 id 는 JSON 에서');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: '{bad', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_bad_credentials', 'JSON 오류 코드');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: '', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_not_configured', '없음 코드');
env.DNS_FETCH = fakeGoogle({}); applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'a@b', private_key: saKey(), project_id: 'p' }), MAILBOX: 'x', HARAKA_TOKEN: 'x' }); env.MAILBOX = 'admin@nozone.test'; eq((await probeDns(env)).error, 'dns_zone_not_found', '존 없음 코드');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 Route 53 모듈: SigV4 서명 헤더, 존 검색(상위 도메인까지), UPSERT XML(TXT 따옴표, MX), 같으면 생략, 오류 코드', async () => {
const dir = tmp(); const env = await envFor(dir); const a = {}; env.DNS_FETCH = fakeRoute53(a);
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIATEST', DNS_AWS_SECRET_ACCESS_KEY: 'secret', HARAKA_TOKEN: 'x' });
const zone = await route53.findZone(env, 'mail.example.test'); eq(zone.id, 'Z123', '상위 도메인 존 찾기'); ok(/x-amz-date/.test(JSON.stringify(a.calls[0].headers)), '서명 헤더');
const r1 = await route53.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 mx ~all'] }, { name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]);
eq(r1.changed.length, 2, '변경 2'); eq(r1.changeId, 'C999', '변경 id'); ok(a.lastBody.includes('<Action>UPSERT</Action>') && a.lastBody.includes('<Value>&quot;v=spf1 mx ~all&quot;</Value>') && a.lastBody.includes('<Value>10 mail.example.test.</Value>'), 'XML 본문');
const r2 = await route53.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 mx ~all'] }]); eq(r2.unchanged.length, 1, '같으면 생략');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIAWRONG', DNS_AWS_SECRET_ACCESS_KEY: 'x', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_auth_failed', '서명 거부 코드');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 자동 설정 계획(Resend 수·발신 + Google DNS): 키 없음 → 입력 요청, 도메인 등록·기능·DNS 적용·검증·웹훅(비밀키 저장)을 자동으로 처리하고 반영 뒤 준비 완료', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { verifyResult: 'pending' }, g = {}; env.RESEND_FETCH = fakeResendAdmin(rs); env.DNS_FETCH = fakeGoogle(g);
const resolver = fakeResolver({});
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
let p = await buildPlan(env, { resolver });
eq(p.steps.find((s) => s.id === 'resend.key').state, 'action', 'API 키 없음 → 사용자 입력'); ok(p.steps.find((s) => s.id === 'resend.key').input.some((i) => i.key === 'RESEND_API_KEY'), '입력 칸'); eq(p.ready, false, '준비 안 됨');
// 키를 입력하면(runPlan input) 저장 뒤 진단: 도메인 등록 todo, DNS 모듈 없음 → action
let r = await runPlan(env, { step: 'resend.key', input: { RESEND_API_KEY: 're_good_key_123456789', RESEND_REGION: 'ap-northeast-1' }, resolver });
eq(env.RESEND_API_KEY, 're_good_key_123456789', '입력 저장'); p = r.plan;
eq(p.steps.find((s) => s.id === 'resend.domain').state, 'todo', '도메인 등록 자동 가능');
// 전체 실행: 도메인 등록(발송+수신) → DNS 모듈 없음(action) → 검증은 레코드 대기
r = await runPlan(env, { resolver }); p = r.plan;
eq(rs.domains.length, 1, '도메인 등록됨'); eq(rs.domains[0].capabilities.receiving, 'enabled', '수신 켬'); eq(env.RESEND_DOMAIN_ID, 'dom-1', '도메인 id 저장');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'action', 'DNS 모듈 없음 → 직접 넣기 안내'); ok(p.records.some((x) => x.type === 'MX' && x.priority === 9), 'Resend 수신 MX 포함'); eq(p.steps.find((s) => s.id === 'resend.verify').state, 'wait', '레코드 반영 전에는 검증 대기');
eq(p.steps.find((s) => s.id === 'resend.webhook').state, 'ok', '웹훅은 만들어짐'); eq(rs.webhooks[0].events[0], 'email.received', '웹훅 이벤트'); eq(env.RESEND_WEBHOOK_SECRET, 'whsec_wh-1', '서명 비밀키 자동 저장');
// DNS 모듈 연결 → 자동 적용 → 전파 대기
saveConfig(env, { DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'sa@p', private_key: saKey(), project_id: 'proj' }) });
r = await runPlan(env, { resolver }); p = r.plan;
ok(g.changes.length === 1 && g.changes[0].additions.length === 3, 'Google 에 3개 레코드 적용 — ' + JSON.stringify(g.changes.map((c) => c.additions.map((x) => x.type + ' ' + x.name))));
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'wait', '적용 뒤 전파 대기'); eq(p.ready, false, '아직');
// 전파됨(리졸버에 값이 보임) → 검증 요청 자동 → pending → 사용자가 확인 → verified
Object.assign(resolver.table, { 'TXT resend._domainkey.example.test': ['p=PUBKEY'], 'CNAME send.example.test': ['send.forge.rmta.net'], 'MX example.test': [{ priority: 9, exchange: 'inbound-smtp.ap-northeast-1.amazonaws.com' }] });
r = await runPlan(env, { resolver }); p = r.plan;
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'ok', '레코드 반영 확인'); eq(rs.domains[0].status, 'pending', '검증 요청됨'); eq(p.steps.find((s) => s.id === 'resend.verify').state, 'wait', '검증 진행 중 안내');
rs.domains[0].status = 'verified'; p = await buildPlan(env, { resolver });
eq(p.ready, true, '준비 완료'); ok(p.readyMessage.includes('준비가 끝났습니다'), '완료 메시지');
// 웹훅 비밀키를 잃은 경우: action + 새로 만들기 → 새 비밀키 저장(기존 삭제)
saveConfig(env, { RESEND_WEBHOOK_SECRET: '' }); p = await buildPlan(env, { resolver }); eq(p.steps.find((s) => s.id === 'resend.webhook').state, 'action', '비밀키 없음 → 사용자 선택');
r = await runPlan(env, { step: 'resend.webhook', stepAction: 'recreate', resolver }); eq(rs.webhooks.length, 1, '기존 삭제 후 하나'); eq(env.RESEND_WEBHOOK_SECRET, 'whsec_wh-2', '새 비밀키 저장'); eq(r.plan.steps.find((s) => s.id === 'resend.webhook').state, 'ok', '웹훅 정상');
// API 키가 틀리면 오류 단계로 안내
saveConfig(env, { RESEND_API_KEY: 're_bad' }); p = await buildPlan(env, { resolver }); eq(p.steps.find((s) => s.id === 'resend.domain').state, 'error', '키 거부 → 오류'); ok(p.steps.find((s) => s.id === 'resend.domain').detail.includes('거부'), '오류 문구');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 자동 설정 계획(내장 Haraka 수·발신 + Route 53): 미설치 안내, DKIM 자동 생성, SPF 병합 적용, PTR 안내; 외부 SMTP 중계는 접속 정보 입력 요청', async () => {
const dir = tmp(); const env = await envFor(dir, { HARAKA_DIR: join(dir, 'haraka') }); const a = {}; env.DNS_FETCH = fakeRoute53(a);
const resolver = fakeResolver({ 'TXT example.test': ['v=spf1 include:_spf.google.com ~all'], 'A mail.example.test': ['203.0.113.9'], 'PTR 203.0.113.9': ['vps.hosting.test'] });
applyConfig(env, { inbound: 'haraka', outbound: 'haraka', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIATEST', DNS_AWS_SECRET_ACCESS_KEY: 'secret', HARAKA_TOKEN: 'x' });
let p = await buildPlan(env, { resolver });
eq(p.steps.find((s) => s.id === 'haraka.install').state, 'action', 'Haraka 미설치 → 명령 안내'); ok(p.steps.find((s) => s.id === 'haraka.install').detail.includes('haraka-apply.sh'), '명령');
eq(p.steps.find((s) => s.id === 'haraka.dkim').state, 'todo', 'DKIM 자동 생성 가능'); ok(!p.steps.some((s) => s.id === 'resend.key'), 'Resend 단계 없음');
const r = await runPlan(env, { resolver }); p = r.plan;
ok(env.HARAKA_DKIM_PRIVATE, 'DKIM 생성됨'); ok(a.lastBody && a.lastBody.includes('_dmarc') === false, 'DMARC(권장)는 자동 적용하지 않음');
ok(a.lastBody.includes('v=spf1 include:_spf.google.com mx a:mail.example.test ~all'), '기존 SPF 에 병합해 적용'); ok(a.lastBody.includes('arimail._domainkey.example.test.'), 'DKIM TXT 적용'); ok(a.lastBody.includes('<Value>10 mail.example.test.</Value>'), 'MX 적용');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'wait', '전파 대기');
ok(!p.steps.some((s) => s.id === 'haraka.ptr'), 'Haraka 관리 서비스가 없으면 실행 단계는 아직'); eq(p.ready, false, '설치 전');
// 외부 SMTP 중계
applyConfig(env, { inbound: 'resend', outbound: 'smtp', RESEND_API_KEY: '', HARAKA_TOKEN: 'x' });
p = await buildPlan(env, { resolver }); const relay = p.steps.find((s) => s.id === 'smtp.relay'); eq(relay.state, 'action', '중계 서버 정보 요청'); ok(relay.input.some((i) => i.key === 'SMTP_RELAY_HOST'), '입력 칸');
rmSync(dir, { recursive: true, force: true });
});
/** 운영(webmail.ibit.kr)에서 겪은 상태를 흉내 낸 Resend 도메인: 검증 실패, DKIM 은 다른 공개키가 DNS 에 있고, 수신 MX 는 예전 값이지만 Resend 는 verified. */
const failedDomain = () => ({ id: 'dom-9', name: 'example.test', status: 'failed', region: 'ap-northeast-1', capabilities: { sending: 'enabled', receiving: 'enabled' }, records: [
{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=NEWKEY', status: 'failed' },
{ record: 'SPF', name: 'send', type: 'MX', value: 'feedback-smtp.ap-northeast-1.amazonses.com', priority: 10, status: 'failed' },
{ record: 'SPF', name: 'send', type: 'TXT', value: 'v=spf1 include:amazonses.com ~all', status: 'failed' },
{ record: 'Receiving', name: '', type: 'MX', value: 'receive-apne1.forge.rmta.net', priority: 10, status: 'verified' },
] });
const liveDns = () => ({ 'TXT resend._domainkey.example.test': ['p=OLDKEY'], 'MX send.example.test': [{ priority: 10, exchange: 'feedback-smtp.ap-northeast-1.amazonses.com' }], 'TXT send.example.test': ['v=spf1 include:amazonses.com ~all'], 'MX example.test': [{ priority: 10, exchange: 'inbound-smtp.ap-northeast-1.amazonaws.com' }] });
await t('1.0.11 발신 도메인 상태 진단: 미등록 → mail_domain_missing(예전에는 ok), 검증 실패 → mail_domain_unverified 와 레코드별 wrong/missing·원인 문장, Resend 가 verified 라 한 레코드는 값이 달라도 ok, 403 "not verified" 는 mail_forbidden 이 아님', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { domains: [] }; env.RESEND_FETCH = fakeResendAdmin(rs);
applyConfig(env, { inbound: 'resend', outbound: 'resend', RESEND_API_KEY: 're_good_key_123456789' });
let st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_missing', '미등록 도메인'); ok(st.detail.includes('example.test') && st.detail.includes('자동 설정'), '안내 문장');
rs.domains.push(failedDomain()); env.DNS_RESOLVER = fakeResolver(liveDns());
st = await providerStatus(env, { force: true });
eq(st.provider, 'mail_domain_unverified', '검증 실패 → 별도 코드'); eq(st.domain.status, 'failed', '도메인 상태'); eq(st.domain.id, 'dom-9', '도메인 id');
const by = Object.fromEntries(st.records.map((r) => [r.type + ' ' + r.name, r]));
eq(by['TXT resend._domainkey.example.test'].problem, 'wrong', 'DKIM 값 다름'); eq(by['TXT resend._domainkey.example.test'].found[0], 'p=OLDKEY', '지금 값');
eq(by['MX send.example.test'].ok, true, 'Resend 가 failed 라 해도 우리 조회가 맞으면 ok'); eq(by['TXT send.example.test'].ok, true, 'SPF TXT ok');
eq(by['MX example.test'].ok, true, 'Resend verified → 예전 MX 값이라도 ok'); eq(by['MX example.test'].problem, null, '문제 없음');
ok(st.detail.startsWith('검증 실패 — 값이 다른 레코드: TXT resend._domainkey.example.test (지금 p=OLDKEY / 필요 p=NEWKEY)'), '원인 문장: ' + st.detail);
ok(st.detail.includes('자동으로 다시 검증') && !st.detail.includes('없는 레코드'), '값 바꾸기 안내, missing 없음');
eq(await providerStatus(env), st, '60초 캐시(force 없음)');
// DKIM 레코드가 아예 없으면 missing
delete env.DNS_RESOLVER.table['TXT resend._domainkey.example.test']; clearStatusCache(env);
st = await providerStatus(env, { force: true }); eq(st.records.find((r) => r.name.startsWith('resend._domainkey')).problem, 'missing', '없음'); ok(st.detail.includes('없는 레코드: TXT resend._domainkey.example.test'), 'missing 문장');
// 레코드가 모두 보이는데 failed(사용자가 고친 뒤) → 재검증 안내; pending → 기다리는 중
env.DNS_RESOLVER.table['TXT resend._domainkey.example.test'] = ['p=NEWKEY']; st = await providerStatus(env, { force: true }); ok(st.detail.includes('검증을 다시 요청하면') && st.detail.includes('자동으로 요청'), '재검증 안내: ' + st.detail);
rs.domains[0].status = 'pending'; st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_unverified', 'pending 도 미검증'); ok(st.detail.startsWith('검증 진행 중 — DNS 레코드는 모두 보입니다'), 'pending 문장');
// 레코드 진단이 실패해도(도메인 상세 500) 상태는 보고
const base = env.RESEND_FETCH; env.RESEND_FETCH = async (url, o) => (new URL(url).pathname === '/domains/dom-9' ? Response.json({ message: 'boom' }, { status: 500 }) : base(url, o));
st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_unverified', '진단 실패해도 코드 유지'); ok(st.detail.includes('레코드 진단 실패'), '진단 실패 표시'); env.RESEND_FETCH = base;
// verified → ok
rs.domains[0].status = 'verified'; st = await providerStatus(env, { force: true }); eq(st.provider, 'ok', '검증 완료'); eq(st.records, undefined, '정상이면 레코드 진단 없음');
// 오류 코드 매핑: Resend 의 403 "not verified" 문구는 도메인 문제, 그 밖의 403 은 키 권한 문제
eq(providerError(403, 'The example.test domain is not verified. Please, add and verify your domain on https://resend.com/domains'), 'mail_domain_unverified', '403 미검증');
eq(providerError(403, 'This API key is restricted to only send emails'), 'mail_forbidden', '403 권한'); eq(providerError(403), 'mail_forbidden', '403 문구 없음'); eq(providerError(401, 'x'), 'mail_auth_failed', '401');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.11 자동 재검증과 계획 단계: 값이 다른 레코드 → 사용자 작업(action)·검증 요청 안 함, 없는 레코드 → 반영 대기(wait), 모두 보이면 검증 자동 요청(failed → pending), pending/verified/끔 → 건너뜀', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { domains: [failedDomain()], webhooks: [{ id: 'wh-1', endpoint: 'https://mail.example.test/api/webhooks/resend', events: ['email.received'], status: 'enabled' }] }; env.RESEND_FETCH = fakeResendAdmin(rs);
applyConfig(env, { inbound: 'resend', outbound: 'resend', RESEND_API_KEY: 're_good_key_123456789', RESEND_WEBHOOK_SECRET: 'whsec_x' }); saveConfig(env, { RESEND_DOMAIN_ID: 'dom-9' }, { allowHidden: true });
const resolver = fakeResolver(liveDns());
const verifies = () => rs.calls.filter((c) => c.path.endsWith('/verify')).length;
// 값이 다름 → action
let p = await buildPlan(env, { resolver }); let v = p.steps.find((s) => s.id === 'resend.verify');
eq(v.state, 'action', '값 다름 → 사용자 작업'); ok(v.title.includes('값이 다릅니다') && v.detail.includes('값이 다른 레코드: TXT resend._domainkey.example.test (지금 p=OLDKEY / 필요 p=NEWKEY)'), '어느 레코드인지: ' + v.detail);
ok(v.actions.some((a) => a.id === 'verify') && v.actions.some((a) => a.id === 'recheck'), '단추'); eq(v.auto, false, '자동 실행 안 함');
const dns = p.steps.find((s) => s.id === 'dns.apply'); eq(dns.state, 'action', 'DNS 직접 수정'); ok(dns.title.includes('고치기') && dns.detail.startsWith('값이 다른 레코드 1개'), 'DNS 단계 문구: ' + dns.detail);
const mx = p.records.find((r) => r.type === 'MX' && r.name === 'example.test'); eq(mx.check.ok, true, '예전 MX 값이지만 Resend verified → ok'); eq(mx.check.byProvider, true, 'byProvider 표시');
eq(p.records.find((r) => r.name === 'resend._domainkey.example.test').check.problem, 'wrong', '표에도 wrong');
let r = await autoVerifyDomain(env, { resolver }); eq(r.skipped, 'records', '레코드 틀림 → 요청 안 함'); eq(r.problems.join(','), 'TXT resend._domainkey.example.test: wrong', '문제 목록'); eq(verifies(), 0, 'verify 호출 없음');
// 레코드 없음 → wait
delete resolver.table['TXT resend._domainkey.example.test']; p = await buildPlan(env, { resolver }); v = p.steps.find((s) => s.id === 'resend.verify');
eq(v.state, 'wait', '없음 → 전파 대기'); ok(v.detail.includes('아직 DNS 에 없는 Resend 레코드: TXT resend._domainkey.example.test') && v.detail.includes('자동으로 검증을 요청'), '대기 문구: ' + v.detail);
eq(p.steps.find((s) => s.id === 'dns.apply').title, 'DNS 레코드 1개 넣기', '없음 → 넣기');
eq((await autoVerifyDomain(env, { resolver })).skipped, 'records', '없음 → 요청 안 함');
// 고침 → todo(자동) + 자동 재검증이 요청
resolver.table['TXT resend._domainkey.example.test'] = ['p=NEWKEY']; p = await buildPlan(env, { resolver }); v = p.steps.find((s) => s.id === 'resend.verify'); eq(v.state, 'todo', '모두 보임 → 자동 실행 가능'); eq(v.auto, true, 'auto');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'ok', 'DNS 완료');
r = await autoVerifyDomain(env, { resolver }); eq(r.requested, true, '검증 요청'); eq(r.status, 'failed', '이전 상태'); eq(rs.domains[0].status, 'pending', 'Resend 상태 pending'); eq(verifies(), 1, 'verify 1회');
eq((await providerStatus(env, { force: true })).domain.status, 'pending', '상태 캐시 비움 → 새 상태');
// 진행 중·완료·끔 → 건너뜀
r = await autoVerifyDomain(env, { resolver }); eq(r.skipped, 'pending', 'pending 건너뜀'); eq(verifies(), 1, '추가 호출 없음');
rs.domains[0].status = 'verified'; eq((await autoVerifyDomain(env, { resolver })).skipped, 'verified', 'verified 건너뜀');
rs.domains[0].status = 'failed'; env.RESEND_AUTO_VERIFY = '0'; eq((await autoVerifyDomain(env, { resolver })).skipped, 'disabled', '끔'); delete env.RESEND_AUTO_VERIFY;
rs.domains = []; eq((await autoVerifyDomain(env, { resolver })).skipped, 'missing', '미등록 건너뜀'); env.RESEND_API_KEY = ''; eq((await autoVerifyDomain(env, { resolver })).skipped, 'no_key', '키 없음');
rmSync(dir, { recursive: true, force: true });
});
done();
+225
View File
@@ -0,0 +1,225 @@
/**
* SMTP 제출 서버(server/smtp.mjs) 회귀 테스트.
* 메일 프로그램이 로그인 비밀번호로 인증하고, 게이트웨이가 내부 Resend 키로 발송한 뒤 보낸 메일함에 저장하는 흐름을 확인합니다.
* 가짜 Resend(fakeResend)로 실제 발송 요청 내용(받는 사람 분리·첨부·인라인 이미지·멱등 키)을 검사합니다.
*/
import net from 'node:net';
import tls from 'node:tls';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
import { createSmtp, DataCollector, dotUnstuff, SMTP_MAX_SIZE } from '../server/smtp.mjs';
import { appendMessage } from '../server/store.mjs';
import { createUser } from '../server/auth.mjs';
import { writeAdminLimits } from '../server/limits.mjs';
import { buildEml } from '../server/mime.mjs';
const { t, done } = runner();
/** SMTP 응답 읽기 도우미: 여러 줄 응답("250-…")은 마지막 줄("250 …")까지 모아 한 번에 돌려줍니다. */
function client(socket) {
let buffer = ''; const waiters = [];
const onData = (d) => { buffer += d.toString('latin1'); flush(); };
socket.on('data', onData);
function flush() {
while (waiters.length) {
const m = buffer.match(/(?:^|\r\n)(\d{3}) [^\r]*\r\n/); if (!m) return;
const end = m.index + m[0].length; const w = waiters.shift(); w(buffer.slice(0, end).replace(/^\r\n/, '')); buffer = buffer.slice(end);
}
}
const reply = () => new Promise((resolve) => { waiters.push(resolve); flush(); });
return {
reply, socket,
cmd: (line) => { socket.write(line + '\r\n'); return reply(); },
raw: (data) => socket.write(data),
detach: () => socket.off('data', onData),
end: () => socket.end(),
};
}
const connect = (port) => new Promise((resolve) => { const s = net.connect(port, '127.0.0.1', () => resolve(client(s))); });
const plain64 = (user, pass) => Buffer.from(`\0${user}\0${pass}`).toString('base64');
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
/** 시험용 메일: 한글 제목, 받는 사람·참조, 첨부, 인라인 이미지, 점으로 시작하는 줄을 담습니다. */
function message({ from = '아리 관리자 <admin@arihakdang.com>', to = ['Friend <friend@example.test>'], cc = ['copy@example.test'], subject = '안녕하세요 SMTP', messageId = '<smtp-1@arihakdang.com>', text = '본문입니다\n.점으로 시작하는 줄', inReplyTo = null } = {}) {
return buildEml({ from, to, cc, subject, messageId, text, html: '<p>본문 <img src="cid:logo1"></p>', headers: inReplyTo ? { 'In-Reply-To': inReplyTo } : {},
attachments: [{ filename: '보고서.txt', contentType: 'text/plain', bytes: Buffer.from('report') }, { filename: 'logo.png', contentType: 'image/png', bytes: Buffer.from('png'), contentId: 'logo1', disposition: 'inline' }] });
}
/** RFC 5321 점 스터핑 후 DATA 로 보낼 바이트(끝 표시 포함). */
const dataOf = (eml) => Buffer.concat([Buffer.from(eml.toString('latin1').replace(/(^|\r\n)\./g, '$1..'), 'latin1'), Buffer.from('.\r\n')]);
async function server(over = {}, options = {}) {
const calls = [];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ calls, received: [] }), ...over });
const smtp = createSmtp(env, { hostname: 'mail.test', allowPlaintext: true, ...options });
const port = 30000 + Math.floor(Math.random() * 20000); await smtp.listen({ plainPort: port, host: '127.0.0.1' });
return { env, smtp, port, calls };
}
await t('DATA 수집기는 조각 경계·빈 본문·크기 초과를 처리하고 점 스터핑을 푼다', async () => {
let c = new DataCollector(1000); eq(c.feed(Buffer.from('a\r\n.')), null, '아직 끝 아님');
let r = c.feed(Buffer.from('\r\nQUIT\r\n')); eq(r.message.toString(), 'a\r\n', '경계에 걸친 끝 표시'); eq(r.rest.toString(), 'QUIT\r\n', '뒤 명령 보존');
c = new DataCollector(1000); r = c.feed(Buffer.from('.\r\n')); eq(r.message.length, 0, '빈 본문');
c = new DataCollector(1000); eq(c.feed(Buffer.from('.')), null, '점 한 바이트'); r = c.feed(Buffer.from('\r\n')); eq(r.message.length, 0, '빈 본문(조각)');
c = new DataCollector(5); r = c.feed(Buffer.from('0123456789\r\n.\r\n')); eq(r.message, null, '크기 초과');
eq(dotUnstuff(Buffer.from('..a\r\n..b\r\nc..\r\n')).toString(), '.a\r\n.b\r\nc..\r\n', '점 스터핑 해제');
});
await t('SMTP: 크기를 넘는 DATA 는 552 로 응답하고 세션은 계속 쓸 수 있다', async () => {
const { env, smtp, port } = await server();
try {
const c = await connect(port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'));
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); ok((await c.cmd('DATA')).startsWith('354'), 'DATA');
c.raw(Buffer.concat([Buffer.from('Subject: big\r\n\r\n'), Buffer.alloc(SMTP_MAX_SIZE + 1024, 0x61), Buffer.from('\r\n.\r\n')]));
const r = await c.reply(); ok(r.startsWith('552'), '크기 초과 응답: ' + r.slice(0, 40));
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('250'), '다음 트랜잭션 진행'); c.end();
eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n, 0, '저장 없음');
} finally { smtp.close(); }
});
await t('SMTP: 로그인 비밀번호로 인증하고 내부 키로 발송하며 보낸 메일함에 저장한다', async () => {
const { env, smtp, port, calls } = await server();
try {
const c = await connect(port); ok((await c.reply()).startsWith('220 '), '인사');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('503'), 'EHLO 먼저');
const ehlo = await c.cmd('EHLO client.test'); ok(ehlo.includes('AUTH PLAIN LOGIN') && ehlo.includes('SIZE') && ehlo.includes('PIPELINING'), 'EHLO 확장');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('530'), '인증 전 발송 거부(오픈 릴레이 방지)');
ok((await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'wrong'))).startsWith('535'), '틀린 비밀번호');
// AUTH LOGIN 대화형: 사용자 이름 → 비밀번호
ok((await c.cmd('AUTH LOGIN')).startsWith('334 VXNlcm5hbWU6'), 'Username 질문');
ok((await c.cmd(Buffer.from('admin').toString('base64'))).startsWith('334 UGFzc3dvcmQ6'), 'Password 질문');
ok((await c.cmd(Buffer.from('admin-secret-12').toString('base64'))).startsWith('235'), '로그인 비밀번호로 인증(별칭 admin)');
ok((await c.cmd('MAIL FROM:<someone@else.test>')).startsWith('550'), '남의 주소로 보내기 거부');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com> SIZE=999999999')).startsWith('552'), 'SIZE 초과');
// PIPELINING: MAIL·RCPT·RCPT·RCPT·DATA 를 한 번에 보냅니다. 숨은 참조(hidden@)는 헤더에 없고 RCPT 에만 있습니다.
c.raw('MAIL FROM:<admin@arihakdang.com>\r\nRCPT TO:<friend@example.test>\r\nRCPT TO:<Copy@Example.test>\r\nRCPT TO:<hidden@example.test>\r\nDATA\r\n');
for (const expect of ['250', '250', '250', '250', '354']) ok((await c.reply()).startsWith(expect), '파이프라인 ' + expect);
const eml = message(); c.raw(dataOf(eml));
const done250 = await c.reply(); ok(done250.startsWith('250 2.0.0 OK queued as sent-'), '발송 완료: ' + done250);
const post = calls.find((x) => x.method === 'POST' && x.path === '/emails'); ok(post, 'Resend 발송 호출');
eq(post.body.from, '아리 관리자 <admin@arihakdang.com>', '보내는 사람 표시 이름'); eq(post.body.to.join(), 'Friend <friend@example.test>', '받는 사람');
eq(post.body.cc.join(), 'copy@example.test', '참조'); eq(post.body.bcc.join(), 'hidden@example.test', '숨은 참조는 봉투에서');
eq(post.body.subject, '안녕하세요 SMTP', '한글 제목 해독'); ok(post.body.text.includes('\n.점으로 시작하는 줄'), '점 스터핑 해제된 본문');
ok(post.body.html.includes('cid:logo1'), 'HTML 본문');
const files = post.body.attachments, report = files.find((f) => f.filename === '보고서.txt'), logo = files.find((f) => f.filename === 'logo.png');
eq(files.length, 2, '첨부 2개'); ok(report && !report.content_id, '일반 첨부(한글 이름)'); eq(Buffer.from(report.content, 'base64').toString(), 'report', '첨부 내용');
eq(logo.content_id, 'logo1', '인라인 이미지 cid'); ok(post.key && post.key.startsWith('smtp-'), '멱등 키');
const row = await env.DB.prepare("SELECT * FROM messages WHERE folder='sent'").first();
ok(row && row.provider_id.startsWith('sent-'), '보낸 메일함 + 발송 id'); eq(row.mailbox, 'admin@arihakdang.com', '메일함'); eq(row.state, 'sent', '상태');
eq(JSON.parse(row.bcc_json).join(), 'hidden@example.test', '숨은 참조 기록'); ok(row.read_at, '읽음');
const raw = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(row.id).first(); ok(Buffer.from(raw.eml).equals(eml), '원본 그대로 저장');
// 메일 프로그램이 IMAP 으로 보낸 메일함에 같은 메일을 올리면 한 통으로 합쳐집니다.
const dup = await appendMessage(env, 'sent', eml, { flags: ['\\Seen'], mailbox: 'admin@arihakdang.com' }); eq(dup.id, row.id, 'APPEND 중복 합침');
eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE folder='sent'").first()).n, 1, '보낸 메일함 1통');
ok((await c.cmd('QUIT')).startsWith('221'), 'QUIT'); c.end();
} finally { smtp.close(); }
});
await t('SMTP: 반대 순서(APPEND 먼저)·답장 표시·일반 사용자 발신 주소 제한·발송 실패 응답', async () => {
const { env, smtp, port, calls } = await server();
try {
// APPEND 가 먼저 와도 SMTP 발송은 그 사본에 발송 id 만 붙입니다.
const parentId = (await appendMessage(env, 'inbox', buildEml({ from: 'friend@example.test', to: ['admin@arihakdang.com'], subject: '질문', messageId: '<q@example.test>', text: '?' }))).id;
const eml = message({ messageId: '<smtp-2@arihakdang.com>', inReplyTo: '<q@example.test>', cc: [] });
await appendMessage(env, 'sent', eml, { flags: ['\\Seen'], mailbox: 'admin@arihakdang.com' });
let c = await connect(port); await c.reply(); await c.cmd('EHLO x'); ok((await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'admin-secret-12'))).startsWith('235'), 'AUTH PLAIN');
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); ok((await c.cmd('DATA')).startsWith('354'), 'DATA');
// 끝 표시를 여러 조각으로 나눠 보냅니다.
const data = dataOf(eml); c.raw(data.subarray(0, data.length - 4)); await new Promise((r) => setTimeout(r, 30)); c.raw(data.subarray(data.length - 4));
ok((await c.reply()).startsWith('250'), '조각난 끝 표시');
eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE folder='sent'").first()).n, 1, 'APPEND 사본과 합침');
ok((await env.DB.prepare("SELECT provider_id FROM messages WHERE folder='sent'").first()).provider_id, '사본에 발송 id');
eq(calls.find((x) => x.method === 'POST').body.headers['In-Reply-To'], '<q@example.test>', '답장 헤더 전달');
ok((await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(parentId).first()).flags.includes('\\Answered'), '원래 메일에 답장함 표시');
// 헤더 From 이 다른 주소면 거부합니다(봉투는 맞아도).
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); await c.cmd('DATA');
c.raw(dataOf(message({ from: 'CEO <ceo@arihakdang.com>', messageId: '<x@y>' }))); ok((await c.reply()).startsWith('550 5.7.1'), '헤더 From 사칭 거부');
c.end();
// 일반 사용자는 자기 메일함 주소로만 보낼 수 있습니다.
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test');
c = await connect(port); await c.reply(); await c.cmd('EHLO x'); ok((await c.cmd('AUTH PLAIN ' + plain64('info', 'staff-secret-1'))).startsWith('235'), '게이트웨이 계정 인증');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('550'), '다른 메일함 주소 거부');
ok((await c.cmd('MAIL FROM:<info@arihakdang.com>')).startsWith('250'), '자기 주소 허용');
await c.cmd('RCPT TO:<friend@example.test>'); await c.cmd('DATA'); c.raw(dataOf(message({ from: 'Staff <info@arihakdang.com>', messageId: '<s@y>', cc: [] })));
ok((await c.reply()).startsWith('250'), '직원 발송');
eq((await env.DB.prepare("SELECT mailbox FROM messages WHERE message_id='<s@y>'").first()).mailbox, 'info@arihakdang.com', '직원 보낸 메일함');
c.end();
} finally { smtp.close(); }
// 발송 서비스 장애는 임시 실패(4xx), 키·설정 정보는 응답에 넣지 않습니다.
const failing = await server({ RESEND_FETCH: fakeResend({ failStatus: 401 }) });
try {
const c = await connect(failing.port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'));
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); await c.cmd('DATA'); c.raw(dataOf(message({ cc: [] })));
const r = await c.reply(); ok(r.startsWith('451 4.3.0') && !/key|resend/i.test(r), '임시 실패, 키 정보 숨김: ' + r);
eq((await failing.env.DB.prepare("SELECT COUNT(*) n FROM messages").first()).n, 0, '실패하면 저장하지 않음'); c.end();
} finally { failing.smtp.close(); }
});
await t('SMTP: TLS 없이는 로그인을 받지 않고 STARTTLS 뒤에 인증·발송한다', async () => {
const dir = mkdtempSync(join(tmpdir(), 'mg-smtp-'));
try {
try { execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', join(dir, 'k.pem'), '-out', join(dir, 'c.pem'), '-days', '1', '-subj', '/CN=mail.test'], { stdio: 'ignore' }); }
catch { console.log(' (openssl 없음 — STARTTLS 시험 생략)'); return; }
const tlsOptions = { key: readFileSync(join(dir, 'k.pem')), cert: readFileSync(join(dir, 'c.pem')) };
const { smtp, port, calls } = await server({}, { allowPlaintext: false, tlsOptions });
try {
const c = await connect(port); await c.reply();
const ehlo = await c.cmd('EHLO x'); ok(ehlo.includes('STARTTLS') && !ehlo.includes('AUTH'), '평문에서는 AUTH 광고 안 함');
ok((await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'))).startsWith('530'), '평문 로그인 거부');
ok((await c.cmd('STARTTLS')).startsWith('220'), 'STARTTLS'); c.detach();
const secure = tls.connect({ socket: c.socket, rejectUnauthorized: false }); await new Promise((r) => secure.once('secureConnect', r));
const s = client(secure);
const ehlo2 = await s.cmd('EHLO x'); ok(ehlo2.includes('AUTH PLAIN LOGIN') && !ehlo2.includes('STARTTLS'), 'TLS 뒤 AUTH 광고');
ok((await s.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'))).startsWith('235'), 'TLS 위 인증');
await s.cmd('MAIL FROM:<admin@arihakdang.com>'); await s.cmd('RCPT TO:<friend@example.test>'); await s.cmd('DATA'); s.raw(dataOf(message({ cc: [] })));
ok((await s.reply()).startsWith('250'), 'TLS 위 발송'); ok(calls.some((x) => x.method === 'POST'), 'Resend 호출'); s.end();
} finally { smtp.close(); }
} finally { rmSync(dir, { recursive: true, force: true }); }
});
await t('0.8.25 오류 수정: 같은 DATA 를 받는 사람만 바꿔 따로 보내면 멱등 키가 달라 둘 다 발송된다', async () => {
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) });
const smtp = createSmtp(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 31000 + Math.floor(Math.random() * 9000); await smtp.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); }));
await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64'));
const data = 'From: admin@arihakdang.com\r\nSubject: newsletter\r\nMessage-ID: <nl-1@arihakdang.com>\r\n\r\nhello\r\n.\r\n';
for (const rcpt of ['alice@example.test', 'bob@example.test']) { await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<' + rcpt + '>'); await c.cmd('DATA'); c.raw(data); ok(/^250 /.test(await c.reply()), rcpt + ' 발송'); }
await c.cmd('QUIT');
const posts = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(posts.length, 2, '두 번 발송');
ok(posts[0].key !== posts[1].key, '멱등 키가 다름'); eq(posts[0].body.bcc[0], 'alice@example.test', '첫 받는 사람'); eq(posts[1].body.bcc[0], 'bob@example.test', '둘째 받는 사람');
// 같은 봉투·같은 DATA 를 되풀이하면(응답을 못 받은 재전송) 키가 같습니다.
const c2 = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); }));
await c2.reply(); await c2.cmd('EHLO x'); await c2.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64'));
await c2.cmd('MAIL FROM:<admin@arihakdang.com>'); await c2.cmd('RCPT TO:<alice@example.test>'); await c2.cmd('DATA'); c2.raw(data); await c2.reply(); await c2.cmd('QUIT');
const again = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(again[2].key, posts[0].key, '재전송은 같은 키');
} finally { smtp.close(); }
});
await t('0.9.0: 관리자도 자기 주소로만 보내고, 받는 사람 수·첨부 크기·메일함 용량 제한이 SMTP 응답으로 돌아오며, Content-ID 만 있는 그림도 인라인으로 나간다', async () => {
const { env, smtp, port, calls } = await server({ LIMIT_RECIPIENTS: '2', LIMIT_ATTACHMENT_BYTES: '100K', LIMIT_MAILBOX_BYTES: '1M' });
try {
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test');
let c = await connect(port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'admin-secret-12'));
ok((await c.cmd('MAIL FROM:<info@arihakdang.com>')).startsWith('550'), '관리자도 다른 계정의 메일함 주소로는 보내지 못함(0.9.0)');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('250'), '자기 주소');
await c.cmd('RCPT TO:<a@example.test>'); await c.cmd('RCPT TO:<b@example.test>');
ok((await c.cmd('RCPT TO:<c@example.test>')).startsWith('452 4.5.3'), '받는 사람 수(시스템 2) 초과');
ok((await c.cmd('RCPT TO:<a@example.test>')).startsWith('250'), '이미 있는 주소는 한도에서도 허용(0.9.3)');
// 첨부 한 개가 100KB(MIME) 를 넘으면 552.
await c.cmd('DATA'); c.raw(dataOf(buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 'big', text: 'x', attachments: [{ filename: 'big.bin', contentType: 'application/octet-stream', bytes: Buffer.alloc(90 * 1024, 7) }] })));
const big = await c.reply(); ok(big.startsWith('552 5.3.4') && /MIME/.test(big), '첨부 초과: ' + big);
// 관리자가 더 줄인 수신자 수(1)도 적용됩니다.
await writeAdminLimits(env, { recipients: 1 });
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<a@example.test>'); ok((await c.cmd('RCPT TO:<b@example.test>')).startsWith('452'), '관리자 제한 1');
// Content-Disposition 없이 Content-ID 만 있는 그림(Outlook 류)도 본문이 cid: 로 가리키면 content_id 를 넘깁니다.
const b = 'BB'; const raw = Buffer.from(['From: admin@arihakdang.com', 'To: a@example.test', 'Subject: cid', 'Message-ID: <cid-1@arihakdang.com>', 'MIME-Version: 1.0', `Content-Type: multipart/related; boundary="${b}"`, '', `--${b}`, 'Content-Type: text/html; charset=utf-8', '', '<p><img src="cid:logo1"></p>', `--${b}`, 'Content-Type: image/png', 'Content-ID: <logo1>', 'Content-Transfer-Encoding: base64', '', Buffer.from('png').toString('base64'), `--${b}--`, ''].join('\r\n'));
await c.cmd('DATA'); c.raw(dataOf(raw)); ok((await c.reply()).startsWith('250'), 'cid 메일 발송');
const post = calls.filter((x) => x.method === 'POST' && x.path === '/emails').at(-1); eq(post.body.attachments[0].content_id, 'logo1', 'content_id 전달');
// 메일함이 가득 차면 452 4.2.2.
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,subject,state,received_at,created_at,updated_at,mailbox,raw_size) VALUES('fill','inbound','inbox','x','s','received','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','admin@arihakdang.com',1100000)").run();
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<a@example.test>'); await c.cmd('DATA'); c.raw(dataOf(message({ cc: [], messageId: '<full@x>' })));
ok((await c.reply()).startsWith('452 4.2.2'), '메일함 가득 참');
c.end();
} finally { smtp.close(); }
});
done();
+162
View File
@@ -0,0 +1,162 @@
/**
* 1.0.9 시험: 메일 사용량 정보 제공 API(server/usage.mjs) — 키 생성·교체·삭제(설정 API), 인증(없음/틀림/맞음), IP 허용 목록(IPv4 CIDR·IPv6), 시도 제한,
* 보고서 내용(계정·저장 용량·메일 수·기간 통계·일별 추이·계정별 기간 합계·외부(웹메이커) 계정 수), 도메인 하나/전체, 쿼리(days·accounts·series), Resend 연결 오류 1회 재시도.
* 실행: node test/usage.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { scryptSync, randomBytes } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, setupToken } from '../server/config.mjs';
import { openTenants } from '../server/domains.mjs';
import { setupApi } from '../server/setup.mjs';
import { usageApi, authorizeUsage, ipAllowed, parseAllowList, invalidAllowEntries, clientIp, domainUsage, USAGE_FAIL_LIMIT } from '../server/usage.mjs';
import { periodBounds } from '../server/limits.mjs';
import { createUser } from '../server/auth.mjs';
import { resend } from '../server/providers/resend.mjs';
import { clearSnapshotCache } from '../server/webmaker.mjs';
const { t, done } = runner();
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const tmp = (p = 'arimail-usage-') => mkdtempSync(join(tmpdir(), p));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
async function base(over = {}) {
const dir = tmp();
const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over });
loadConfig(env); return { env, dir };
}
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
const setup = (env, path, method = 'GET', body, tenants = null) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1', tenants });
/** /api/usage 호출 흉내(main.mjs 가 하는 것처럼 path 조각·ip·쿼리를 넘김). */
const usage = (tenants, path = '', { key, ip = '203.0.113.10', query = '' } = {}) => usageApi(tenants, new Request('https://x/api/usage' + path + (query ? '?' + query : ''), { headers: key ? { Authorization: 'Bearer ' + key } : {} }), { path: path.split('/').filter(Boolean), ip, query: new URLSearchParams(query) });
/** 메일 행 넣기(통계용): direction, 시각, 크기, 메일함. */
async function putMail(env, { direction, at, size = 1000, mailbox, state }) {
const id = randomBytes(8).toString('hex'), outbound = direction === 'outbound';
await env.DB.prepare(`INSERT INTO messages(id,direction,folder,sender,recipients_json,subject,text,source,state,received_at,created_at,updated_at,sent_at,raw_status,raw_size,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.bind(id, direction, outbound ? 'sent' : 'inbox', 'a@b.test', '["x@y.test"]', 's', 'body', outbound ? 'web' : 'sync', state || (outbound ? 'sent' : 'received'), at, at, at, outbound ? at : null, 'stored', size, mailbox).run();
return id;
}
const daysAgo = (n, hour = 12) => { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); d.setUTCHours(hour, 0, 0, 0); return d.toISOString(); };
await t('IP 허용 목록: 빈 목록은 모두 허용, IPv4 정확히·CIDR, IPv6 정확히·접두사, ::ffff: 로 감싼 IPv4, 잘못된 주소는 거부', () => {
eq(parseAllowList(' 1.2.3.4, 10.0.0.0/8 ,2001:db8::1 ').join('|'), '1.2.3.4|10.0.0.0/8|2001:db8::1', '파싱');
ok(ipAllowed('9.9.9.9', []), '빈 목록'); ok(ipAllowed('1.2.3.4', ['1.2.3.4']), '정확히'); ok(!ipAllowed('1.2.3.5', ['1.2.3.4']), '다른 주소');
ok(ipAllowed('10.20.30.40', ['10.0.0.0/8']) && !ipAllowed('11.0.0.1', ['10.0.0.0/8']) && ipAllowed('192.168.1.77', ['192.168.1.64/26']) && !ipAllowed('192.168.1.128', ['192.168.1.64/26']), 'CIDR');
ok(ipAllowed('::ffff:10.1.1.1', ['10.0.0.0/8']), 'IPv6 소켓의 IPv4'); ok(ipAllowed('2001:db8::1', ['2001:DB8::1']) && ipAllowed('2001:db8:0:0:0:0:0:2', ['2001:db8::/32']) && !ipAllowed('2001:db9::1', ['2001:db8::/32']), 'IPv6');
ok(!ipAllowed('not-an-ip', ['0.0.0.0/0']) && !ipAllowed('', ['1.2.3.4']), '잘못된 주소'); ok(ipAllowed('8.8.8.8', ['0.0.0.0/0']), '/0 은 모두');
ok(ipAllowed('1.2.3.4', ['garbage', '1.2.3.4']), '목록의 잘못된 항목은 건너뜀');
eq(invalidAllowEntries('1.2.3.4, 10.0.0.0/8, 2001:db8::/32').length, 0, '유효한 목록'); eq(invalidAllowEntries('1.2.3.4/33, webmaker.example.com, 10.0.0.0/8/x, 300.1.1.1').join('|'), '1.2.3.4/33|webmaker.example.com|10.0.0.0/8/x|300.1.1.1', '잘못된 항목'); eq(invalidAllowEntries('').length, 0, '빈 값');
// 접속 IP: 클라이언트가 넣을 수 있는 헤더(XFF 첫 값, CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)
const H = (o) => new Headers(o);
eq(clientIp(H({ 'x-forwarded-for': '203.0.113.10, 198.51.100.7' }), '127.0.0.1'), '198.51.100.7', 'XFF 마지막(nginx 가 덧붙인 실제 주소)');
eq(clientIp(H({ 'x-real-ip': '198.51.100.8', 'x-forwarded-for': '203.0.113.10, 1.1.1.1' }), '127.0.0.1'), '198.51.100.8', 'X-Real-IP 우선');
eq(clientIp(H({ 'cf-connecting-ip': '203.0.113.10', 'x-forwarded-for': '9.9.9.9' }), '127.0.0.1'), '9.9.9.9', 'CF-Connecting-IP 는 기본적으로 무시');
eq(clientIp(H({}), '10.0.0.5'), '10.0.0.5', '헤더 없으면 소켓 주소');
});
await t('키 관리(설정 API): 키가 없으면 API 404, 만들면 한 번만 돌려주고 설정에는 가린 값, 다시 만들면 옛 키 무효, 삭제하면 다시 404, 추가 도메인에서는 primary_only', async () => {
const { env } = await base(); const tenants = open(env); await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' });
setupToken(env);
eq((await usage(tenants, '', { key: 'anything' })).status, 404, '키 없음 → API 꺼짐'); eq((await (await usage(tenants, '')).json()).error, 'usage_api_disabled', '오류 코드');
let r = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json(); ok(r.ok && r.key.startsWith('au_') && r.key.length > 40, '키 생성: ' + JSON.stringify(r).slice(0, 80)); eq(r.endpoint, 'https://mail.arihakdang.com/api/usage', '주소');
const key1 = r.key; eq(env.USAGE_API_KEY, key1, 'env 에 적용');
const cfg = await (await setup(env, 'config', 'GET', undefined, tenants)).json(); eq(cfg.present.USAGE_API_KEY, true, 'hidden 항목은 있음/없음만'); ok(!JSON.stringify(cfg).includes(key1), '설정 응답에 키 원문 없음'); eq(cfg.meta.usageApi.enabled, true, 'meta'); ok(cfg.meta.usageApi.keyMasked.includes('••••'), '가린 값'); ok(cfg.meta.usageApi.updatedAt, '생성 시각');
eq((await usage(tenants, '', { key: key1 })).status, 200, '새 키로 200');
r = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json(); ok(r.key !== key1, '교체'); eq((await usage(tenants, '', { key: key1 })).status, 401, '옛 키 무효'); eq((await usage(tenants, '', { key: r.key })).status, 200, '새 키');
eq((await setup(tenants.byDomain('second.test').env, 'usage-key', 'POST', { action: 'generate' }, tenants)).status, 409, '추가 도메인에서는 불가');
eq((await setup(env, 'usage-key', 'POST', { action: 'nope' }, tenants)).status, 400, '모르는 동작');
r = await (await setup(env, 'usage-key', 'POST', { action: 'revoke' }, tenants)).json(); eq(r.enabled, false, '삭제'); eq((await usage(tenants, '', { key: key1 })).status, 404, '다시 꺼짐'); eq(env.USAGE_API_KEY, '', 'env 비움');
tenants.close();
});
await t('인증: Bearer 또는 X-Usage-Key, 틀린 키는 401 이고 IP 마다 10회 뒤 429, 허용 IP 밖은 403, GET 만, 성공 호출은 감사 기록', async () => {
const { env } = await base(); const tenants = open(env); setupToken(env);
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
eq((await usageApi(tenants, new Request('https://x/api/usage', { headers: { 'X-Usage-Key': key } }), { path: [], ip: '1.1.1.1', query: new URLSearchParams() })).status, 200, 'X-Usage-Key');
eq((await usage(tenants, '', {})).status, 401, '키 없음'); eq((await usage(tenants, '', { key: key + 'x' })).status, 401, '틀린 키');
for (let i = 0; i < USAGE_FAIL_LIMIT; i++) await usage(tenants, '', { key: 'wrong', ip: '5.5.5.5' });
eq((await usage(tenants, '', { key, ip: '5.5.5.5' })).status, 429, '실패 10회 뒤 맞는 키도 막힘(15분)'); eq((await usage(tenants, '', { key, ip: '5.5.5.6' })).status, 200, '다른 IP 는 정상');
// 허용 IP
env.USAGE_API_ALLOW = '203.0.113.0/24, 2001:db8::/32';
eq((await usage(tenants, '', { key, ip: '203.0.113.77' })).status, 200, '허용 범위'); eq((await (await usage(tenants, '', { key, ip: '198.51.100.1' })).json()).error, 'forbidden_ip', '범위 밖'); eq((await usage(tenants, '', { key, ip: '2001:db8:1::9' })).status, 200, 'IPv6 허용');
const a = authorizeUsage(env, new Request('https://x/', { headers: { Authorization: 'Bearer ' + key } }), '198.51.100.1'); eq(a.status, 403, 'authorizeUsage 직접');
env.USAGE_API_ALLOW = '';
eq((await usageApi(tenants, new Request('https://x/api/usage', { method: 'POST', headers: { Authorization: 'Bearer ' + key } }), { path: [], ip: '9.9.9.9', query: new URLSearchParams() })).status, 405, 'GET 만');
eq((await usage(tenants, '/%E0%A4%A', { key })).status, 404, '잘못된 퍼센트 인코딩은 404(500 아님)');
// 허용 IP 저장 검증(설정 API): 잘못된 항목이 있으면 422 로 거부해 모든 호출이 403 이 되는 사고를 막음
const bad = await setup(env, 'config', 'PUT', { values: { USAGE_API_ALLOW: '203.0.113.10, webmaker.example.com' } }, tenants); eq(bad.status, 422, '잘못된 허용 목록 거부'); eq((await bad.json()).detail, 'webmaker.example.com', '어느 항목인지');
eq((await setup(env, 'config', 'PUT', { values: { USAGE_API_ALLOW: '203.0.113.0/24' } }, tenants)).status, 200, '유효한 목록 저장'); eq(env.USAGE_API_ALLOW, '203.0.113.0/24', '적용'); env.USAGE_API_ALLOW = '';
const logs = (await env.DB.prepare("SELECT action,actor,target FROM audit_log WHERE action='usage_read'").all()).results; ok(logs.length >= 3 && logs.every((l) => l.actor === 'usage-api'), '감사 기록: ' + logs.length);
tenants.close();
});
await t('보고서: 계정 수(로컬+웹메이커)·저장 용량·메일 수·오늘/이번 주/이달 통수와 바이트·일별 추이·계정별 기간 합계·지운 계정의 메일함·제한 값, 쿼리 days/accounts/series', async () => {
clearSnapshotCache();
const { env } = await base({ LIMIT_ACCOUNTS: '10', LIMIT_TOTAL_BYTES: '1000000', LIMIT_MONTHLY_MESSAGES: '100' }); const tenants = open(env); setupToken(env);
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
// 둘째 도메인: webmaker 모드(관리자 admin + 회원 guest = 외부 계정 2) + 로컬 계정 1
const wm = tmp('arimail-wm-'); mkdirSync(join(wm, 'sites')); mkdirSync(join(wm, 'modules'));
const h = (pw, salt) => scryptSync(pw, salt, 64).toString('hex'); const salt = randomBytes(16).toString('hex');
writeFileSync(join(wm, 'users.json'), JSON.stringify({ admin: { id: 'admin', name: '관리자', salt, passHash: h('pw', salt), role: 'admin' }, guest: { id: 'guest', name: '손님', salt, passHash: h('pw', salt), role: 'user' } }));
await tenants.add({ domain: 'second.test', auth: { mode: 'webmaker', webmakerDir: wm } });
const sec = tenants.byDomain('second.test').env; await createUser(sec, { login: 'extra', password: 'extra-pw-12' }, 'test');
// 기본 도메인 메일: 오늘 발신 2(3000B)·수신 1(500B), 어제 수신 2, 40일 전 발신 1(기간 밖), 초안 1, 실패 1, 지운 계정의 메일함에 수신 1
await createUser(env, { login: 'kim', password: 'kim-pw-1234' }, 'test');
const today = daysAgo(0), yesterday = daysAgo(1);
await putMail(env, { direction: 'outbound', at: today, size: 1000, mailbox: 'kim@arihakdang.com' }); await putMail(env, { direction: 'outbound', at: today, size: 2000, mailbox: 'kim@arihakdang.com' });
await putMail(env, { direction: 'inbound', at: today, size: 500, mailbox: 'kim@arihakdang.com' }); await putMail(env, { direction: 'inbound', at: yesterday, size: 100, mailbox: 'admin@arihakdang.com' }); await putMail(env, { direction: 'inbound', at: yesterday, size: 100, mailbox: 'admin@arihakdang.com' });
await putMail(env, { direction: 'outbound', at: daysAgo(40), size: 700, mailbox: 'kim@arihakdang.com' });
await putMail(env, { direction: 'outbound', at: today, size: 10, mailbox: 'kim@arihakdang.com', state: 'draft' }); await putMail(env, { direction: 'outbound', at: today, size: 10, mailbox: 'kim@arihakdang.com', state: 'failed' });
await putMail(env, { direction: 'inbound', at: today, size: 300, mailbox: 'gone@arihakdang.com' });
// 전체 요약
const all = await (await usage(tenants, '', { key })).json();
eq(all.totals.domains, 2, '도메인 수'); eq(all.primary, 'arihakdang.com', '기본'); eq(all.domains[0].domain, 'arihakdang.com', '기본 먼저'); ok(!all.domains[0].accountList && !all.domains[0].daily, '요약에는 계정 목록·일별 없음');
const p = all.domains[0];
eq(p.accounts.local, 1, '로컬 계정'); eq(p.accounts.limit, 10, '계정 제한'); eq(p.messages.total, 9, '메일 수'); eq(p.messages.inbound, 4, '수신'); eq(p.messages.outbound, 3, '발신 완료'); eq(p.messages.drafts, 1, '초안'); eq(p.messages.failed, 1, '실패');
eq(p.storage.bytes, 1000 + 2000 + 500 + 200 + 700 + 20 + 300, '저장 용량(모든 원본 크기)'); eq(p.storage.limit, 1000000, '제한'); eq(p.storage.percent, 0, '비율'); eq(p.storage.largeFiles.count, 0, '대용량 파일');
eq(p.traffic.day.sent, 2, '오늘 발신'); eq(p.traffic.day.sentBytes, 3000, '오늘 발신 바이트'); eq(p.traffic.day.received, 2, '오늘 수신(gone 포함)'); eq(p.traffic.month.total >= 4, true, '이달'); eq(p.traffic.monthlyLimit, 100, '월간 제한'); eq(p.limits.system.accounts, 10, '시스템 제한');
eq(p.authMode, 'www', '로그인 방식'); eq(p.inbound, 'resend', '수신 방식'); eq(p.enabled, true, '사용 중'); eq(p.hosts[0], 'mail.arihakdang.com', '호스트');
const s = all.domains[1]; eq(s.accounts.local, 1, '둘째 로컬'); eq(s.accounts.external, 2, '둘째 웹메이커 계정'); eq(s.accounts.total, 3, '합계'); eq(s.accounts.externalSource.kind, 'webmaker', '외부 출처'); eq(s.authMode, 'webmaker', '둘째 방식');
// 도메인 상세: 일별·계정 목록 기본 포함
const d = await (await usage(tenants, '/arihakdang.com', { key, query: 'days=7' })).json();
eq(d.domain, 'arihakdang.com', '상세'); eq(d.period.days, 7, '기간'); eq(d.daily.length, 7, '일별 7칸'); const last = d.daily[6]; eq(last.sent, 2, '오늘 칸 발신'); eq(last.received, 2, '오늘 칸 수신'); eq(last.sentBytes, 3000, '오늘 바이트'); eq(d.daily[5].received, 2, '어제 칸'); ok(/^\d{4}-\d{2}-\d{2}$/.test(last.date), '날짜 형식');
const kim = d.accountList.find((a) => a.login === 'kim'); ok(kim, '계정 행'); eq(kim.source, 'local', '출처'); eq(kim.messages, 6, 'kim 메일 수'); eq(kim.bytes, 1000 + 2000 + 500 + 700 + 20, 'kim 용량'); eq(kim.sent, 2, '기간 발신(40일 전 제외)'); eq(kim.received, 1, '기간 수신'); eq(kim.sentBytes, 3000, '기간 발신 바이트'); eq(kim.limit, 0, '크기 제한 없음');
const adminRow = d.accountList.find((a) => a.mailbox === 'admin@arihakdang.com'); ok(adminRow && adminRow.source === 'www', 'www 관리자 메일함 행'); eq(adminRow.received, 2, '관리자 수신');
const gone = d.accountList.find((a) => a.mailbox === 'gone@arihakdang.com'); ok(gone && gone.source === 'orphan' && gone.status === 'deleted' && gone.messages === 1, '지운 계정의 메일함');
const d2 = await (await usage(tenants, '/second.test', { key, query: 'days=3&series=0' })).json(); ok(!d2.daily, 'series=0'); eq(d2.accountList.length, 3, '둘째 계정 목록(로컬 1 + 웹메이커 2)'); eq(d2.accountList.filter((a) => a.source === 'webmaker').length, 2, '웹메이커 행');
eq((await usage(tenants, '/nope.test', { key })).status, 404, '모르는 도메인');
const d3 = await (await usage(tenants, '', { key, query: 'accounts=1&days=9999' })).json(); ok(d3.domains[0].accountList, 'accounts=1 로 요약에도 계정 목록'); eq(d3.domains[0].period.days, 30, '범위 밖 days 는 30');
// 비밀값이 섞이지 않는지
const text = JSON.stringify(all) + JSON.stringify(d); ok(!text.includes(key) && !text.includes('RESEND') && !text.includes('password'), '키·비밀값 없음');
const direct = await domainUsage(env, { days: 2, accounts: false, series: true }); eq(direct.daily.length, 2, 'domainUsage 직접');
// 서머타임 시간대: 기간 시작이 늘 그 날의 0시(통계 시간대)이고 첫 칸의 날짜와 같다(24시간씩 빼기만 하면 한 시간 밀림)
env.STATS_TIMEZONE = 'America/New_York';
for (const n of [7, 30, 120, 366]) { const r = await domainUsage(env, { days: n, accounts: false, series: true }); const startMs = Date.parse(r.period.start); eq(periodBounds('day', 'America/New_York', new Date(startMs + 43200000)).start, r.period.start, `DST 시간대 ${n}일: 시작은 0시`); eq(r.daily.length, n, `${n}칸`); eq(r.daily[0].date, new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(new Date(startMs + 43200000)), '첫 칸 날짜'); }
env.STATS_TIMEZONE = 'Asia/Seoul';
// 도메인 하나가 깨져도 전체 요약은 나머지를 돌려준다
const brokenPrepare = sec.DB.prepare; sec.DB.prepare = () => { throw new Error('SQLITE_IOERR'); };
const partial = await (await usage(tenants, '', { key })).json(); eq(partial.ok, true, '요약 ok'); eq(partial.domains[1].error, 'server_error', '깨진 도메인 표시'); eq(partial.domains[1].domain, 'second.test', '도메인 이름'); eq(partial.totals.domains, 2, '합계는 정상 도메인만'); eq(partial.totals.messages, 9, '합계 메일 수');
eq((await usage(tenants, '/second.test', { key })).status, 500, '상세는 500'); sec.DB.prepare = brokenPrepare;
tenants.close();
});
await t('Resend 연결 오류(ECONNRESET 등)는 2초 뒤 한 번 더 시도하고, 두 번째도 실패하면 mail_provider_unavailable, 응답 오류(4xx)는 재시도하지 않는다', async () => {
const env = await makeEnv({ RESEND_RETRY_MS: 10 }); let calls = 0;
env.RESEND_FETCH = async () => { calls++; if (calls === 1) { const e = new TypeError('fetch failed'); e.cause = { code: 'ECONNRESET' }; throw e; } return Response.json({ data: [] }); };
eq(JSON.stringify(await resend(env, '/emails')), '{"data":[]}', '재시도 성공'); eq(calls, 2, '2회 호출');
calls = 0; env.RESEND_FETCH = async () => { calls++; const e = new TypeError('fetch failed'); e.cause = { code: 'UND_ERR_CONNECT_TIMEOUT' }; throw e; };
let err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '두 번 실패'); eq(err?.providerMessage, 'UND_ERR_CONNECT_TIMEOUT', '사유'); eq(calls, 2, '정확히 2회');
calls = 0; env.RESEND_FETCH = async () => { calls++; return Response.json({ message: 'nope' }, { status: 403 }); };
err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_forbidden', '응답 오류'); eq(calls, 1, '응답 오류는 재시도 없음');
// 응답 대기 시간 초과(TimeoutError)와 멱등 키 없는 POST(웹훅 만들기 등)는 재시도하지 않는다 — 요청이 이미 처리됐을 수 있음
calls = 0; env.RESEND_FETCH = async () => { calls++; const e = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); throw e; };
err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '시간 초과'); eq(calls, 1, 'TimeoutError 는 재시도 없음');
calls = 0; env.RESEND_FETCH = async () => { calls++; if (calls === 1) { const e = new TypeError('fetch failed'); e.cause = { code: 'ECONNRESET' }; throw e; } return Response.json({ id: 'wh_1' }); };
err = null; try { await resend(env, '/webhooks', 'POST', { endpoint: 'x' }); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '멱등 키 없는 POST 는 재시도 없음'); eq(calls, 1, '1회');
calls = 0; eq((await resend(env, '/emails', 'POST', { to: 'a' }, 'idem-1')).id, 'wh_1', '멱등 키 있는 POST 는 재시도'); eq(calls, 2, '2회');
});
done();
+290
View File
@@ -0,0 +1,290 @@
/**
* 1.0.8 시험: webmaker 인증 모드(server/webmaker.mjs · server/webmaker-snapshot.mjs)와 경로 접두사(/mail).
* - 웹메이커 데이터 디렉터리(users.json · sites/ · modules/ · domains.json)를 흉내 내 만들고 스냅샷·도메인 ↔ 사이트 대응·계정 우선순위를 확인합니다.
* - 웹메이커 계정으로 웹 로그인(관리자/일반)·IMAP 로그인·비밀번호 틀림·승인 취소·다른 도메인 주소 거부, 관리자 페이지의 읽기 전용 행, 비밀번호 변경 금지,
* 로컬 계정과의 로그인 ID 충돌, 수신 주소 목록, 웹메이커 쪽 파일 변경(비밀번호 변경·계정 삭제)의 즉시 반영, 파일을 읽지 못할 때의 동작을 봅니다.
* - 기본 도메인을 환경 변수(LOGIN_MODE=webmaker)로 webmaker 모드로 띄우는 경우와, 명령줄 도우미(webmaker-snapshot.mjs <dir>)의 출력도 확인합니다.
* - 경로 접두사: "호스트/경로" 등록, resolveHost 의 prefix, API 의 인라인 이미지 주소·초안 저장 시 접두사 제거.
* 실행: node test/webmaker.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import net from 'node:net';
import { webmakerSocketPath } from '../server/webmaker.mjs';
import { execFileSync } from 'node:child_process';
import { scryptSync, randomBytes } from 'node:crypto';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig } from '../server/config.mjs';
import { openTenants, normalizeEntry, normalizePrefix, splitHostEntry, loadRegistry } from '../server/domains.mjs';
import { readSnapshotSync } from '../server/webmaker-snapshot.mjs';
import { accountsFor, siteFor, verifyScrypt, clearSnapshotCache } from '../server/webmaker.mjs';
import { verifyUser, knownMailboxes, mailboxUser, ensureInitialAdmin, createUser, authMode, principalById } from '../server/auth.mjs';
import { createImap } from '../server/imap.mjs';
const { t, done } = runner();
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const tmp = (p = 'arimail-wm-') => mkdtempSync(join(tmpdir(), p));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
const hash = (pw, salt) => scryptSync(pw, salt, 64).toString('hex');
const SITE_ID = 'a'.repeat(32), SITE2 = 'b'.repeat(32);
/** 웹메이커 데이터 디렉터리 흉내: 회원 admin(관리자)·owner1(소유자)·guest(일반), 사이트 site.test(소유자 owner1, 사용자 user1·pending1·admin(회원)), 사이트 둘째(도메인 없음). */
function makeWebmaker(dir, { memberAdmin = false } = {}) {
const u = (id, pw, role) => { const salt = randomBytes(16).toString('hex'); return { id, name: id + ' 이름', salt, passHash: hash(pw, salt), role, accountType: 'domain', createdAt: '2026-10-14T00:00:00Z' }; };
writeFileSync(join(dir, 'users.json'), JSON.stringify({ admin: u('admin', 'wm-admin-pw', 'admin'), owner1: u('owner1', 'owner-pw-1', 'user'), guest: u('guest', 'guest-pw-1', 'user') }, null, 2), { mode: 0o600 });
mkdirSync(join(dir, 'sites'), { recursive: true }); mkdirSync(join(dir, 'modules'), { recursive: true });
writeFileSync(join(dir, 'sites', SITE_ID + '.json'), JSON.stringify({ id: SITE_ID, ownerId: 'owner1', title: '시험 사이트', domains: ['site.test', 'www.site.test'], webmail: { enabled: true, domain: 'site.test' } }));
writeFileSync(join(dir, 'sites', SITE2 + '.json'), JSON.stringify({ id: SITE2, ownerId: 'guest', title: '둘째', domains: [] }));
const m = (id, loginId, pw, role, approved = true) => { const salt = randomBytes(16).toString('hex'); return { id, name: loginId + ' 님', email: '', loginId, salt, hash: hash(pw, salt), role, approved, createdAt: '2026-10-14T00:00:00Z' }; };
const members = [m('m1', 'user1', 'user1-pw-1', 'admin'), m('m2', 'pending1', 'pending-pw', 'admin', false), { id: 'm3', name: '방문자', email: 'visitor@example.test', salt: 'x', hash: 'y', role: 'member', approved: true }, m('m4', 'Bad Login!', 'bad-pw-1', 'admin')];
if (memberAdmin) members.push(m('m5', 'admin', 'member-admin-pw', 'admin'));
writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify({ enabled: { members: true }, settings: {}, members, posts: [] }));
writeFileSync(join(dir, 'domains.json'), JSON.stringify({ domains: [{ id: 'd1', userId: 'owner1', name: 'sub.platform.test', type: 'subdomain', status: 'active', siteId: SITE_ID }], contacts: {} }));
return dir;
}
async function base(over = {}) {
const dir = tmp();
const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over });
loadConfig(env);
return { env, dir };
}
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
const cookieOf = (res) => res.headers.get('set-cookie').split(';')[0];
/** 테넌트 추가 + 웹메이커 모드. 시험마다 새 캐시로 시작합니다. */
async function tenantWithWebmaker(wmDir, extra = {}) {
clearSnapshotCache();
const { env } = await base();
const tenants = open(env);
const r = await tenants.add({ domain: 'site.test', hosts: 'site.test/mail', auth: { mode: 'webmaker', webmakerDir: wmDir, ...extra } });
return { env, tenants, site: tenants.byDomain('site.test').env, entry: r.entry };
}
await t('스냅샷: 웹메이커 파일을 읽어 회원·사이트·회원(로그인 ID 있는)·도메인 연결을 모으고, 명령줄 도우미도 같은 JSON 을 낸다', async () => {
const dir = makeWebmaker(tmp());
const snap = readSnapshotSync(dir);
eq(Object.keys(snap.users).sort().join(','), 'admin,guest,owner1', '회원'); eq(snap.users.admin.role, 'admin', '관리자 역할'); ok(snap.users.admin.passHash.length === 128, 'scrypt 해시');
eq(snap.sites[SITE_ID].domains.join(','), 'site.test,site.test', 'www. 는 뗌'); eq(snap.sites[SITE_ID].webmailDomain, 'site.test', '메일 도메인');
eq(snap.members[SITE_ID].length, 4, 'loginId/email 있는 회원만(해시 없는 방문자 제외 아님 — salt/hash 있어야)'); eq(snap.members[SITE_ID].find((m) => m.id === 'm2').approved, false, '승인 안 됨');
eq(snap.domainSites['sub.platform.test'], SITE_ID, '서브 도메인 → 사이트');
const cli = JSON.parse(execFileSync(process.execPath, [join(ROOT, 'server', 'webmaker-snapshot.mjs'), dir], { encoding: 'utf8' }));
eq(JSON.stringify({ ...cli, readAt: 0 }), JSON.stringify({ ...snap, readAt: 0 }), '도우미 출력 = 모듈 결과');
let code = 0; try { execFileSync(process.execPath, [join(ROOT, 'server', 'webmaker-snapshot.mjs'), join(dir, 'nope')], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { code = e.status; }
eq(code, 0, '없는 디렉터리도 빈 스냅샷(오류 아님)'); // 파일이 없으면 각 항목이 비어 있을 뿐 실패하지 않습니다(웹메이커가 아직 아무것도 안 만든 서버).
ok(await verifyScrypt('wm-admin-pw', snap.users.admin.salt, snap.users.admin.passHash) && !(await verifyScrypt('wrong', snap.users.admin.salt, snap.users.admin.passHash)) && !(await verifyScrypt('x', '', '')), 'scrypt 검사');
});
await t('도메인 ↔ 사이트 대응과 계정 우선순위: 사이트 회원(loginId) → 소유자 → 웹메이커 관리자, 사이트가 없으면 모든 회원', async () => {
const dir = makeWebmaker(tmp(), { memberAdmin: true });
const snap = readSnapshotSync(dir);
eq(siteFor(snap, 'site.test')?.id, SITE_ID, '도메인 목록으로'); eq(siteFor(snap, 'sub.platform.test')?.id, SITE_ID, '서브 도메인 등록으로'); eq(siteFor(snap, 'x.test', SITE2)?.id, SITE2, 'siteId 지정'); eq(siteFor(snap, 'none.test'), null, '없음');
const acc = accountsFor({ MAILBOX: 'admin@site.test' }, snap);
const by = Object.fromEntries(acc.map((a) => [a.login, a]));
eq(Object.keys(by).sort().join(','), 'admin,owner1,pending1,user1', '계정 목록(승인 안 된 회원은 목록에 있되 로그인 불가, 잘못된 로그인 ID 는 제외)');
eq(by.admin.kind, 'member', '사이트 회원 admin 이 플랫폼 관리자 admin 보다 우선'); eq(by.admin.role, 'user', '사이트 회원은 일반 역할'); eq(by.owner1.kind, 'owner', '소유자'); eq(by.owner1.role, 'user', '소유자는 일반(웹메이커 관리자만 관리자)'); eq(by.user1.mailbox, 'user1@site.test', '메일함');
ok(!acc.some((a) => a.login === 'guest'), '사이트 도메인에는 일반 회원(guest) 없음');
const acc2 = accountsFor({ MAILBOX: 'admin@platform.test' }, snap);
eq(acc2.map((a) => a.login).sort().join(','), 'admin,guest,owner1', '사이트 없는 도메인: 모든 회원'); eq(acc2.find((a) => a.login === 'admin').role, 'admin', '플랫폼 관리자'); eq(acc2.find((a) => a.login === 'guest').role, 'user', '일반 회원');
const acc3 = accountsFor({ MAILBOX: 'admin@site.test' }, readSnapshotSync(makeWebmaker(tmp())));
eq(acc3.find((a) => a.login === 'admin').kind, 'admin', '회원 admin 이 없으면 플랫폼 관리자가 admin@site.test'); eq(acc3.find((a) => a.login === 'pending1').approved, false, '승인 안 된 회원');
});
await t('웹 로그인: 웹메이커 관리자(admin)·소유자·사이트 사용자가 같은 비밀번호로 들어오고, 틀린 비밀번호·승인 취소·다른 도메인 주소는 거부, 로컬 계정도 함께 쓴다', async () => {
const dir = makeWebmaker(tmp());
const { env, tenants, site } = await tenantWithWebmaker(dir);
eq(authMode(site), 'webmaker', '모드'); eq(site.LOGIN_MODE, 'webmaker', 'env.LOGIN_MODE'); eq(site.WEBMAKER_DIR, dir, 'env.WEBMAKER_DIR'); eq(site.PUBLIC_HOST, 'site.test', '호스트'); eq(site.PUBLIC_PATH, '/mail', '경로 접두사');
ok(!(await ensureInitialAdmin(site)), 'webmaker 모드는 초기 관리자(adminpasswd)를 만들지 않음'); eq(Number((await site.DB.prepare('SELECT COUNT(*) n FROM mail_users').first()).n), 0, '로컬 계정 0');
const login = async (l, p, scope) => api(site, 'login', '', 'POST', { login: l, password: p }, scope ? { 'X-Mail-Scope': scope } : {});
let r = await login('admin', 'wm-admin-pw'); eq(r.status, 200, '관리자 로그인'); const adminCookie = cookieOf(r);
let s = await (await api(site, 'session', adminCookie)).json(); eq(s.user.source, 'webmaker', '출처'); eq(s.user.role, 'admin', '역할'); eq(s.user.mailbox, 'admin@site.test', '메일함'); eq(s.user.id, 'wm:u:admin', '주체 id'); eq(s.authMode, 'webmaker', 'authMode'); eq(s.initialPassword, false, '초기 비밀번호 경고 없음');
r = await login('user1@site.test', 'user1-pw-1'); eq(r.status, 200, '사이트 사용자(주소로) 로그인'); const userCookie = cookieOf(r);
s = await (await api(site, 'session', userCookie)).json(); eq(s.user.role, 'user', '일반'); eq(s.user.id, `wm:m:${SITE_ID}:m1`, '회원 주체 id'); eq(s.user.name, 'user1 님', '이름');
eq((await login('owner1', 'owner-pw-1')).status, 200, '소유자 로그인'); eq((await login('owner1', 'owner-pw-1', 'admin')).status, 403, '소유자는 관리자 페이지 불가(최소 권한)');
eq((await login('user1', 'user1-pw-1', 'admin')).status, 403, '사이트 사용자는 관리자 페이지 불가');
eq((await login('user1', 'wrong-pw-1')).status, 401, '틀린 비밀번호'); eq((await login('pending1', 'pending-pw')).status, 401, '승인 안 된 회원'); eq((await login('user1@other.test', 'user1-pw-1')).status, 401, '다른 도메인 주소'); eq((await login('guest', 'guest-pw-1')).status, 401, '사이트 도메인에 없는 회원');
eq((await login('bad login!', 'bad-pw-1')).status, 401, '메일 주소로 쓸 수 없는 로그인 ID 는 제외');
// 로컬 계정: 웹메이커와 겹치는 ID 는 거부, 다른 ID 는 만들어 함께 로그인
let err = null; try { await createUser(site, { login: 'admin', password: 'local-pw-12' }, 'test'); } catch (e) { err = e.message; } eq(err, 'login_taken', '웹메이커 계정과 겹치는 로컬 계정 거부');
await createUser(site, { login: 'extra', password: 'extra-pw-12', name: '추가' }, 'test');
eq((await login('extra', 'extra-pw-12')).status, 200, '로컬 계정 로그인'); eq((await verifyUser(site, 'extra', 'extra-pw-12'))?.source, 'local', '로컬 출처');
// 수신 주소·메일함 주인
const known = await knownMailboxes(site); ok(['admin@site.test', 'owner1@site.test', 'user1@site.test', 'pending1@site.test', 'extra@site.test'].every((m) => known.includes(m)), '수신 주소: ' + known.join(','));
eq((await mailboxUser(site, 'user1@site.test'))?.id, `wm:m:${SITE_ID}:m1`, '메일함 주인(자동 처리용)'); eq((await mailboxUser(site, 'extra@site.test'))?.source, 'local', '로컬 메일함 주인');
// 설정 화면·비밀번호 변경
const st = await (await api(site, 'settings', userCookie)).json(); eq(st.canChangePassword, false, '비밀번호 변경 불가'); eq(st.managedBy, 'webmaker', '관리 주체'); eq(st.smtp.host, 'mail.arihakdang.com', '메일 서버 이름은 기본 도메인');
r = await api(site, 'password', userCookie, 'PATCH', { currentPassword: 'user1-pw-1', password: 'new-pw-123456' }); eq(r.status, 403, '비밀번호 변경 거부'); eq((await r.json()).error, 'webmaker_managed', '오류 코드');
// 기본 도메인에는 웹메이커 계정이 없다(도메인마다 따로)
eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '기본 도메인(www 모드)에서는 웹메이커 계정 없음');
eq((await tenants.list()).find((d) => d.domain === 'site.test').external.count, 4, '목록의 웹메이커 계정 수(승인 안 된 회원 포함)');
tenants.close();
});
await t('관리자 페이지: 웹메이커 계정은 읽기 전용 행(external)으로 보이고 POP/IMAP 허용·메일함 크기만 바꿀 수 있으며 삭제·이름 변경은 거부된다', async () => {
const dir = makeWebmaker(tmp());
const { tenants, site } = await tenantWithWebmaker(dir);
const r = await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' }, { 'X-Mail-Scope': 'admin' }); eq(r.status, 200, '관리자 로그인'); const ck = cookieOf(r);
const H = { 'X-Mail-Scope': 'admin' };
let d = await (await api(site, 'admin/users', ck, 'GET', undefined, H)).json();
eq(d.authMode, 'webmaker', 'authMode'); eq(d.me, null, 'me 행 없음(웹메이커 관리자는 external 에)'); eq(d.users.length, 0, '로컬 계정 없음');
eq(d.external.map((u) => u.login).sort().join(','), 'admin,owner1,pending1,user1', '외부 계정 행'); eq(d.externalSource.kind, 'webmaker', '출처'); eq(d.externalSource.ok, true, '읽기 성공'); eq(d.externalSource.site.id, SITE_ID, '사이트');
eq(d.external.find((u) => u.login === 'owner1').role, 'user', '소유자 역할'); eq(d.external.find((u) => u.login === 'admin').role, 'admin', '관리자 역할');
const u1 = d.external.find((u) => u.login === 'user1'); eq(u1.status, 'active', '상태'); eq(u1.kind, 'member', '종류'); eq(u1.services.pop, true, '기본 POP 허용'); eq(u1.quotaBytes, 0, '기본 크기');
eq(d.external.find((u) => u.login === 'pending1').status, 'suspended', '승인 안 된 회원은 정지로 표시');
// 허용·크기 저장
// 경로의 ':' 는 main.mjs 가 decodeURIComponent 로 풀어 주므로 여기서는 id 를 그대로 씁니다(시험 도우미 params 는 디코딩하지 않음).
let p = await api(site, 'admin/users/' + u1.id, ck, 'PATCH', { services: { pop: false, imap: true }, quotaBytes: 5 * 1048576, name: '바꿈', password: 'nope' }, H); eq(p.status, 200, 'PATCH: ' + await p.text());
d = await (await api(site, 'admin/users', ck, 'GET', undefined, H)).json(); const u2 = d.external.find((u) => u.login === 'user1');
eq(u2.services.pop, false, 'POP 끔'); eq(u2.quotaBytes, 5 * 1048576, '크기 저장'); eq(u2.limit, 5 * 1048576, '적용 크기(mailboxQuota 가 wm: id 를 찾음)'); eq(u2.name, 'user1 님', '이름은 그대로(웹메이커가 주인)');
eq((await api(site, 'admin/users/' + u1.id, ck, 'DELETE', undefined, H)).status, 403, '삭제 거부');
eq((await api(site, 'admin/users/wm:u:nobody', ck, 'PATCH', { services: { pop: true } }, H)).status, 404, '없는 외부 계정');
// POP 을 끈 계정의 설정은 메일 프로그램 로그인 뒤 검사에 쓰입니다(serviceAllowed) — prefs 가 wm: id 로 저장됐는지
eq(JSON.parse((await site.DB.prepare("SELECT value FROM settings WHERE key=?").bind('prefs:' + u1.id).first()).value).pop, false, 'prefs:wm:… 저장');
tenants.close();
});
await t('IMAP 로그인: 웹메이커 계정이 @도메인으로 자기 테넌트를 고르고 같은 비밀번호를 쓴다(웹·POP·SMTP 와 같은 verifyUser)', async () => {
const dir = makeWebmaker(tmp());
const { env, tenants } = await tenantWithWebmaker(dir);
const imap = createImap(env, { hostname: 'mail.test', resolveEnv: (l, s) => tenants.resolveLogin(l, s) }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
const session = async (login, password) => { const c = imapClient(port); await c.greeting(); const r = await c.cmd(`LOGIN "${login}" "${password}"`); return { c, r }; };
try {
let { c, r } = await session('user1@site.test', 'user1-pw-1'); ok(/OK/.test(r), '사이트 사용자 IMAP 로그인: ' + r.trim()); r = await c.cmd('SELECT INBOX'); ok(/OK/.test(r), 'SELECT'); c.end();
({ c, r } = await session('admin@site.test', 'wm-admin-pw')); ok(/OK/.test(r), '웹메이커 관리자 IMAP 로그인'); c.end();
({ c, r } = await session('admin@site.test', 'admin-secret-12')); ok(/NO/.test(r), '기본 도메인 비밀번호는 거부'); c.end();
({ c, r } = await session('admin', 'wm-admin-pw')); ok(/NO/.test(r), '도메인 없는 ID 는 기본 도메인 → 거부'); c.end();
} finally { imap.close(); tenants.close(); }
});
await t('웹메이커 쪽 변경의 즉시 반영: 비밀번호를 바꾸면 새 비밀번호로, 회원을 지우면 세션이 끝나며, 파일을 읽지 못하면 마지막 스냅샷으로 버틴다', async () => {
const dir = makeWebmaker(tmp());
const { tenants, site } = await tenantWithWebmaker(dir);
const r = await api(site, 'login', '', 'POST', { login: 'user1', password: 'user1-pw-1' }); eq(r.status, 200, '로그인'); const ck = cookieOf(r);
// 비밀번호 변경(웹메이커가 파일을 다시 씀) → 캐시가 새것이어도 실패 시 한 번 다시 읽으므로 바로 통과
const mod = JSON.parse(readFileSync(join(dir, 'modules', SITE_ID + '.json'), 'utf8')); const m1 = mod.members.find((m) => m.id === 'm1'); m1.salt = randomBytes(16).toString('hex'); m1.hash = hash('changed-pw-2', m1.salt);
writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify(mod));
eq((await api(site, 'login', '', 'POST', { login: 'user1', password: 'changed-pw-2' })).status, 200, '새 비밀번호'); eq((await api(site, 'login', '', 'POST', { login: 'user1', password: 'user1-pw-1' })).status, 401, '옛 비밀번호 거부');
ok((await (await api(site, 'session', ck)).json()).user, '세션은 유지(비밀번호 변경은 웹메이커 쪽 세대 관리)');
// 회원 삭제 → 주체 조회 실패 → 세션 종료
mod.members = mod.members.filter((m) => m.id !== 'm1'); writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify(mod)); clearSnapshotCache();
eq((await (await api(site, 'session', ck)).json()).user, null, '지운 회원의 세션 무효'); eq(await principalById(site, `wm:m:${SITE_ID}:m1`), null, 'principalById null');
// 파일을 못 읽게 되면(쓰는 중의 반쪽 JSON 등): 마지막 스냅샷으로 계속(로그인·세션 유지), 새 프로세스라면(캐시 없음) 로그인 실패 + 관리자 목록에 오류 표시
const ar = await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' }); eq(ar.status, 200, '정상 로그인'); const adminCk = cookieOf(ar);
const usersFile = join(dir, 'users.json'), goodUsers = readFileSync(usersFile, 'utf8'); writeFileSync(usersFile, '{broken json');
await new Promise((r) => setTimeout(r, 50));
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '깨진 users.json 이어도 마지막 스냅샷으로 로그인(캐시 3초 안)');
ok((await (await api(site, 'session', adminCk)).json()).user, '세션 유지');
clearSnapshotCache();
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '스냅샷이 전혀 없으면(새 프로세스) 로그인 실패');
eq((await (await api(site, 'session', adminCk)).json()).user, null, '그 요청은 비로그인으로 처리되지만…');
writeFileSync(usersFile, goodUsers); clearSnapshotCache();
ok((await (await api(site, 'session', adminCk)).json()).user, '…세션은 지워지지 않아 파일이 돌아오면 다시 로그인 상태(일시 장애로 취급)');
writeFileSync(usersFile, '{broken json'); clearSnapshotCache();
// 디렉터리 자체를 못 읽는 경우(sudo 도우미 없이 권한 없음)는 WEBMAKER_USER 로 도우미를 부르는데, 시험 환경에는 sudo 규칙이 없으므로 오류 → 캐시 없으면 로그인 실패·목록 오류
const { tenants: t2, site: s2 } = await tenantWithWebmaker(dir, { webmakerUser: 'nobody-arimail-test' });
eq((await api(s2, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '도우미 실패 → 로그인 불가(죽지 않음)');
const lr = await api(s2, 'login', '', 'POST', { login: 'extra', password: 'x' }); eq(lr.status, 401, '로컬 조회로 넘어감');
const list = await t2.list(); ok(list.find((d) => d.domain === 'site.test').external.error, '목록에 읽기 오류 표시: ' + JSON.stringify(list[1].external));
t2.close(); tenants.close();
});
await t('소켓 도우미: 읽을 계정이 있고 소켓 파일이 있으면 그 소켓으로 스냅샷 JSON 을 받는다(systemd Accept=yes 흉내), 끊기면 오류', async () => {
const dir = makeWebmaker(tmp()); const sockPath = join(tmp('arimail-sock-'), 's.sock');
eq(webmakerSocketPath('/data/webmaker'), '/run/arimail-webmaker/data-webmaker.sock', '소켓 경로 규칙(webmaker-access.sh 와 같음)');
// 접속마다 스냅샷을 써 주고 끊는 서버(도우미 서비스와 같은 동작)
const server = net.createServer((c) => { c.end(JSON.stringify(readSnapshotSync(dir))); }); await new Promise((r) => server.listen(sockPath, r));
clearSnapshotCache();
const { env } = await base(); const tenants = open(env);
await tenants.add({ domain: 'site.test', auth: { mode: 'webmaker', webmakerDir: dir, webmakerUser: 'master' } });
const site = tenants.byDomain('site.test').env; site.WEBMAKER_SOCKET = sockPath;
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '소켓으로 읽은 계정으로 로그인');
eq((await tenants.list()).find((d) => d.domain === 'site.test').external.count, 4, '목록도 소켓으로');
server.close(); clearSnapshotCache();
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '서버가 없으면(캐시 없음) 로그인 실패');
tenants.close();
});
await t('등록부 검증: webmaker 는 디렉터리 필수(읽을 수 없으면 webmaker_dir_unreadable, 읽을 계정을 적으면 검사 생략), 사이트 id·계정 이름 형식, 수정으로 방식 바꾸기', async () => {
const dir = makeWebmaker(tmp());
const bad = (body, field) => { let e = null; try { normalizeEntry(body); } catch (x) { e = x; } ok(e && (e.field === field), `${field}: ${e && e.message}/${e && e.field}`); return e; };
bad({ domain: 'x.test', auth: { mode: 'webmaker' } }, 'auth.webmakerDir');
bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: 'relative/dir' } }, 'auth.webmakerDir');
eq(bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: join(dir, 'nope') } }, 'auth.webmakerDir').message, 'webmaker_dir_unreadable', '없는 디렉터리');
bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir, siteId: 'short' } }, 'auth.siteId');
bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir, webmakerUser: 'Bad User' } }, 'auth.webmakerUser');
const e1 = normalizeEntry({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: join(dir, 'nope'), webmakerUser: 'master' } }); eq(e1.auth.webmakerUser, 'master', '읽을 계정을 적으면 권한 검사 생략'); eq(e1.auth.wwwDbFile, '', 'www 값 없음');
const e2 = normalizeEntry({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir + '/', siteId: SITE_ID.toUpperCase() } }); eq(e2.auth.webmakerDir, dir, '끝 슬래시 제거'); eq(e2.auth.siteId, SITE_ID, '소문자');
// 수정으로 local → webmaker → local
clearSnapshotCache();
const { env } = await base(); const tenants = open(env);
await tenants.add({ domain: 'y.test', adminPassword: 'ypw-12345' });
const y = tenants.byDomain('y.test').env; eq(y.LOGIN_MODE, 'standalone', '처음 local');
tenants.update('y.test', { auth: { mode: 'webmaker', webmakerDir: dir } }); eq(y.LOGIN_MODE, 'webmaker', '바꾼 뒤'); eq(y.WEBMAKER_DIR, dir, '디렉터리');
eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '웹메이커 admin 로그인(사이트 없는 도메인 → 모든 회원)');
eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'ypw-12345' })).status, 200, '초기 로컬 admin 도 여전히 로그인(웹메이커 → 로컬 순)');
tenants.update('y.test', { auth: { mode: 'local' } }); eq(y.LOGIN_MODE, 'standalone', '되돌림'); eq(y.WEBMAKER_DIR, undefined, '디렉터리 지움');
eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '웹메이커 계정 더는 없음');
const reg = loadRegistry(env); eq(reg.domains['y.test'].auth.mode, 'local', '등록부 저장');
tenants.close();
});
await t('기본 도메인을 환경 파일로 webmaker 모드로: LOGIN_MODE=webmaker + WEBMAKER_DIR → 등록부 auth 와 로그인이 웹메이커 계정', async () => {
const dir = makeWebmaker(tmp()); clearSnapshotCache();
const { env } = await base({ WWW_DB: undefined, MAILBOX: 'admin@site.test', PUBLIC_HOST: 'site.test', PUBLIC_PATH: 'mail', LOGIN_MODE: 'webmaker', WEBMAKER_DIR: dir });
const tenants = open(env);
const reg = loadRegistry(env); eq(reg.domains['site.test'].auth.mode, 'webmaker', '등록부 방식'); eq(reg.domains['site.test'].auth.webmakerDir, dir, '디렉터리'); eq(reg.domains['site.test'].hosts[0], 'site.test/mail', '기본 호스트에 경로');
eq(env.PUBLIC_PATH, '/mail', 'PUBLIC_PATH 정규화'); eq(tenants.resolveHost('site.test').prefix, '/mail', 'resolveHost prefix'); eq(tenants.resolveHost('site.test').tenant.env, env, '기본 도메인');
ok(!(await ensureInitialAdmin(env)), '초기 관리자 없음');
eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '웹메이커 admin'); eq((await api(env, 'login', '', 'POST', { login: 'user1@site.test', password: 'user1-pw-1' })).status, 200, '사이트 사용자');
// 기본 도메인 수정: 첫 호스트(경로 포함)는 환경 파일이 고정
tenants.update('site.test', { hosts: 'other.test' }); eq(tenants.registry.domains['site.test'].hosts.join(','), 'site.test/mail,other.test', '첫 호스트 고정');
tenants.close();
});
await t('경로 접두사: 호스트/경로 등록·정규화, 같은 호스트의 다른 경로 중복 거부, API 의 인라인 이미지 주소와 초안 저장 시 접두사 제거', async () => {
eq(normalizePrefix('/mail'), '/mail', '기본'); eq(normalizePrefix('mail/'), '/mail', '슬래시 보정'); eq(normalizePrefix('/a/b/'), '/a/b', '여러 단계'); eq(normalizePrefix('/'), '', '루트'); eq(normalizePrefix(''), '', '빈 값'); eq(normalizePrefix('/../x'), null, '상위 경로'); eq(normalizePrefix('/sp ace'), null, '허용 글자 밖');
eq(JSON.stringify(splitHostEntry('Site.Test/Mail')), JSON.stringify({ host: 'site.test', prefix: '/mail' }), '나누기(전체 소문자)'); eq(splitHostEntry('mail.x.com').prefix, '', '경로 없음');
const e = normalizeEntry({ domain: 'p.test', hosts: 'p.test/mail, mail.p.test' }); eq(e.hosts.join(','), 'p.test/mail,mail.p.test', '호스트 항목');
let err = null; try { normalizeEntry({ domain: 'p.test', hosts: 'p.test/mail, p.test' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '같은 호스트의 다른 경로 거부');
err = null; try { normalizeEntry({ domain: 'p.test', hosts: 'p.test/../x' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '잘못된 경로');
const { env } = await base(); const tenants = open(env);
await tenants.add({ domain: 'p.test', hosts: 'p.test/mail', adminPassword: 'ppw-12345' });
eq(tenants.resolveHost('p.test').prefix, '/mail', 'prefix'); eq(tenants.resolveHost('P.TEST:443').tenant.entry.domain, 'p.test', '호스트 매칭'); eq(tenants.resolveHost('mail.arihakdang.com').prefix, '', '기본 도메인은 접두사 없음');
let err2 = null; try { await tenants.add({ domain: 'q.test', hosts: 'p.test/other', adminPassword: 'qpw-12345' }); } catch (x) { err2 = x; } eq(err2?.message, 'host_taken', '다른 도메인이 같은 호스트(경로만 다름) 사용 불가');
const p = tenants.byDomain('p.test').env; eq(p.PUBLIC_HOST, 'p.test', 'PUBLIC_HOST 는 이름만'); eq(p.PUBLIC_PATH, '/mail', 'PUBLIC_PATH');
// API: prefix 를 넘기면 인라인 이미지 주소가 /mail/api/… 로 나오고, 저장할 때는 /api/… 로 되돌린다
const r = await api(p, 'login', '', 'POST', { login: 'admin', password: 'ppw-12345' }); const ck = cookieOf(r);
const withPrefix = (path, method, body) => handle(p, request(path, ck, method, body), { ...params(path), prefix: '/mail' });
const draft = await (await withPrefix('mail', 'POST', { to: 'a@b.test', subject: 'x', html: '<p>hi <img src="/mail/api/mail/abc/attachments/def?inline=1"></p>' })).json();
const stored = await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first(); ok(stored.html.includes('src="/api/mail/abc/attachments/def?inline=1"') && !stored.html.includes('/mail/api/'), '저장된 본문은 접두사 없음: ' + stored.html);
const png = new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' });
const fd = new FormData(); fd.append('files', png, 'dot.png');
const up = await (await withPrefix(`mail/${draft.id}/attachments?inline=1`, 'POST', fd)).json(); ok(up.urls[0].startsWith('/mail/api/mail/'), '올린 인라인 이미지 주소에 접두사: ' + up.urls[0]);
const msg = await (await withPrefix(`mail/${draft.id}`, 'GET')).json(); ok(msg.message.previewHtml.includes('src="/mail/api/mail/abc/'), '미리 보기 본문의 저장된 인라인 이미지 주소에도 접두사: ' + msg.message.previewHtml.slice(0, 120));
const plain = await (await api(p, `mail/${draft.id}`, ck)).json(); ok(!plain.message.previewHtml.includes('/mail/api/') && plain.message.previewHtml.includes('src="/api/mail/abc/'), '접두사 없는 요청은 접두사 없음');
// PATCH(초안 수정)도 접두사를 떼고 저장하며, 요청에 접두사가 없어도 도메인의 PUBLIC_PATH(/mail)는 뗀다
eq((await withPrefix(`mail/${draft.id}`, 'PATCH', { html: '<p><img src="/mail/api/mail/abc/attachments/def?inline=1"> two</p>' })).status, 200, 'PATCH');
ok((await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first()).html.includes('src="/api/mail/abc/'), 'PATCH 저장도 접두사 없음');
eq((await api(p, `mail/${draft.id}`, ck, 'PATCH', { html: '<p><img src="/mail/api/mail/abc/attachments/def?inline=1"> three</p>' })).status, 200, 'PATCH(접두사 헤더 없음)');
ok((await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first()).html.includes('src="/api/mail/abc/'), 'PUBLIC_PATH 로도 뗌');
// 로그인 응답의 쿠키 Path 는 접두사
ok(/Path=\/mail(;|$)/.test(String((await withPrefix('login', 'POST', { login: 'admin', password: 'ppw-12345' })).headers.get('set-cookie'))), '쿠키 Path=/mail');
tenants.close();
});
/** 시험용 IMAP 클라이언트(domains.test.mjs 와 같음): 태그 응답을 기다립니다. */
function imapClient(port) {
const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = [];
socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); });
function flush() {
while (waiters.length) {
const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/;
const m = buffer.match(re); if (!m) return;
const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end);
}
}
let n = 0;
return { greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }), cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); }, end: () => socket.end() };
}
done();