Files
AriWeb-Suite/modules/arimail/test/gateway.test.mjs
T

1159 lines
157 KiB
JavaScript

import net from 'node:net';
import tls from 'node:tls';
import { execFileSync } from 'node:child_process';
import { makeEnv, runner, eq, ok, fakeResend, request, params } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { verifyAdmin, createSession, SESSION_COOKIE, ADMIN_COOKIE, createUser, readPrefs } from '../server/auth.mjs';
import { sanitizeHtml } from '../server/sanitize.mjs';
import { verifySvix, signSvix } from '../server/webhook.mjs';
import { ingestReceived, syncMailbox, cleanupSessions, isoTime, saveDraft, sendDraft, addAttachment, appendMessage, ingestSent } from '../server/store.mjs';
import { openDb, d1, migrate } from '../server/db.mjs';
import { mkdtempSync, copyFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createPop3, maildrop } from '../server/pop3.mjs';
import { buildEml } from '../server/mime.mjs';
import { parseMime, parseParams, summarize, parseAddresses } from '../server/imap-mime.mjs';
import { addresses, siteName, formatSender, nameOf } from '../server/util.mjs';
import { parseSize, systemLimits, periodBounds, mailStats, readAdminLimits, mailboxQuota, mailboxUsage, countMail, mimeSize } from '../server/limits.mjs';
import { pendingLogin, listUsers, isPending, deleteUser } from '../server/auth.mjs';
import { attachmentLimit, recipientLimit, writeAdminLimits as writeLimits } from '../server/limits.mjs';
import { fixUtf8 } from '../server/imap-mime.mjs';
import { systemLargeFiles, defaultAdminLarge, readLargeFileSettings, writeLargeFileSettings, uploadLargeFile, listLargeFiles, removeLargeFile, finalizeLargeFiles, largeFileInfo, claimDownload, finishDownload, cleanupLargeFiles, largeFileStats, filesDir, checkLargeFileTerms } from '../server/files.mjs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { uid } from '../server/util.mjs';
import { updateUser, writePrefs, mailboxUser, standalone, knownMailboxes, readPasswordPolicy, writePasswordPolicy, passwordProblems, ensureInitialAdmin, INITIAL_ADMIN_PASSWORD, usesInitialPassword } from '../server/auth.mjs';
import { autoReplyActive, automatedReason, autoProcess, FORWARD_MARKER } from '../server/automation.mjs';
const { t, done } = runner();
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
const login = async (env) => { const user = await verifyAdmin(env, 'arihakdang', 'admin-secret-12'); return SESSION_COOKIE + '=' + await createSession(env, user, new Request('https://x')); };
// 관리자 페이지(/admin)는 별도 세션(범위 admin, 쿠키 mg_admin)과 X-Mail-Scope 헤더를 씁니다.
const adminLogin = async (env) => { const user = await verifyAdmin(env, 'arihakdang', 'admin-secret-12'); return ADMIN_COOKIE + '=' + await createSession(env, user, new Request('https://x'), { scope: 'admin' }); };
const adminApi = (env, path, cookie, method, body) => handle(env, request(path, cookie, method, body, { 'X-Mail-Scope': 'admin' }), params(path));
const sample = (id, extra = {}) => ({ id, from: 'Sender <sender@example.test>', to: ['admin@arihakdang.com'], subject: '받은 메일 ' + id, html: '<p>내용 <b>' + id + '</b><script>x()</script></p>', text: '내용 ' + id, created_at: new Date().toISOString(), headers: { 'x-test': '1' }, message_id: '<' + id + '@example.test>', ...extra });
await t('인증은 www 관리자 계정만 통과하고 학생·정지·오타를 거부한다', async () => {
const env = await makeEnv();
ok(await verifyAdmin(env, 'arihakdang', 'admin-secret-12'), 'login_id');
ok(await verifyAdmin(env, 'ADMIN@arihakdang.com', 'admin-secret-12'), 'email 대소문자');
eq(await verifyAdmin(env, 'arihakdang', 'wrong'), null, '비밀번호');
eq(await verifyAdmin(env, 'student', 'admin-secret-12'), null, '학생 거부');
env.WWW_DB.prepare("UPDATE users SET status='suspended' WHERE id='admin-1'").run();
eq(await verifyAdmin(env, 'arihakdang', 'admin-secret-12'), null, '정지 계정 거부');
});
await t('메일 주소·admin 별칭으로도 메일함 소유 관리자 계정에 인증된다', async () => {
const env = await makeEnv();
ok(await verifyAdmin(env, 'admin', 'admin-secret-12'), '별칭 admin (이메일 일치)'); ok(await verifyAdmin(env, 'ADMIN@arihakdang.com', 'admin-secret-12'), '메일함 주소');
eq(await verifyAdmin(env, 'admin', 'wrong'), null, '비밀번호는 www 그대로');
env.WWW_DB.prepare("UPDATE users SET email='owner@example.test' WHERE id='admin-1'").run();
ok(await verifyAdmin(env, 'admin', 'admin-secret-12'), '이메일 불일치 → 유일한 활성 관리자');
env.WWW_DB.prepare("INSERT INTO users VALUES('admin-2','second@example.test','second','둘째','admin','active',?)").run(env.WWW_DB.prepare("SELECT password_hash FROM users WHERE id='admin-1'").get().password_hash);
eq(await verifyAdmin(env, 'admin', 'admin-secret-12'), null, '관리자 둘 이상이면 지정 필요');
env.MAILBOX_OWNER_LOGIN = 'second'; eq((await verifyAdmin(env, 'admin@arihakdang.com', 'admin-secret-12')).id, 'www:admin-2', 'MAILBOX_OWNER_LOGIN 우선');
const r = await api(env, 'login', '', 'POST', { login: 'admin', password: 'admin-secret-12' }); eq(r.status, 200, '웹 로그인도 별칭 허용');
});
await t('웹 로그인은 세션 쿠키를 주고 잘못된 자격은 401·요청 제한을 받는다', async () => {
const env = await makeEnv();
eq((await api(env, 'mail')).status, 401, '로그인 필요');
const r = await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' });
eq(r.status, 200, '로그인'); const cookie = r.headers.get('set-cookie').split(';')[0];
eq((await api(env, 'session', cookie)).status, 200, '세션');
ok(JSON.stringify(await (await api(env, 'session', cookie)).json()).includes('"login":"arihakdang"'), '사용자');
for (let i = 0; i < 10; i++) await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'bad' });
eq((await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 429, '요청 제한');
});
await t('HTML 정제는 실행 요소를 제거하고 서식·cid 이미지를 유지한다', async () => {
const c = sanitizeHtml('<script>alert(1)</script><p onclick="bad()">본문<img src="javascript:bad" onerror="bad()"><img src="cid:img1"></p><a href="jav&#97;script:bad">링크</a><strong>굵게</strong><style>p{}</style>');
ok(!/script|onclick|onerror|javascript|<style/.test(c.html), '실행 코드 제거'); ok(c.html.includes('<strong>굵게</strong>') && c.html.includes('src="cid:img1"'), '서식·cid 유지'); ok(c.text.includes('본문'), '텍스트');
});
await t('웹훅 서명은 Svix 규칙으로 검증하고 시각 허용 범위를 벗어나면 거부한다', async () => {
const secret = 'whsec_' + Buffer.from('supersecretkey123').toString('base64'), body = '{"type":"email.received"}', ts = String(Math.floor(Date.now() / 1000));
const headers = { 'svix-id': 'msg_1', 'svix-timestamp': ts, 'svix-signature': 'v1,AAAA ' + signSvix(secret, 'msg_1', ts, body) };
ok(verifySvix(secret, headers, Buffer.from(body)).ok, '정상'); ok(!verifySvix(secret, { ...headers, 'svix-signature': 'v1,AAAA' }, Buffer.from(body)).ok, '위조');
ok(!verifySvix(secret, headers, Buffer.from(body + ' ')).ok, '본문 변조'); ok(!verifySvix(secret, { ...headers, 'svix-timestamp': String(Number(ts) - 1000) }, Buffer.from(body)).ok, '오래된 서명');
});
await t('웹훅 수신은 원본과 첨부를 저장하고 다른 메일함·중복·서명 없음은 처리하지 않는다', async () => {
const secret = 'whsec_' + Buffer.from('k').toString('base64'), raw = { a: Buffer.from('From: sender@example.test\r\nSubject: raw\r\n\r\nbody\r\n') };
const env = await makeEnv({ RESEND_WEBHOOK_SECRET: secret, RESEND_FETCH: fakeResend({ received: [sample('a', { attachments: [{ id: 'att1', filename: 'f.txt' }] }), sample('b', { to: ['other@arihakdang.com'] })], raw, attachments: { a: [{ id: 'att1', filename: 'f.txt', content_type: 'text/plain', size: 5, download_url: 'https://files.test/att/att1', bytes: 'hello' }] } }) });
const send = async (payload, id = 'evt_' + Math.random()) => { const body = JSON.stringify(payload), ts = String(Math.floor(Date.now() / 1000)); return api(env, 'webhooks/resend', '', 'POST', body, { 'svix-id': id, 'svix-timestamp': ts, 'svix-signature': signSvix(secret, id, ts, body) }); };
eq((await api(env, 'webhooks/resend', '', 'POST', '{}')).status, 401, '서명 없음 거부');
const r = await send({ type: 'email.received', data: { email_id: 'a', to: ['admin@arihakdang.com'], subject: '받은 메일 a' } }, 'evt_a');
eq(r.status, 200, '수신'); const row = await env.DB.prepare("SELECT * FROM messages WHERE provider_id='a'").first();
eq(row.folder, 'inbox', '받은 메일함'); eq(row.source, 'webhook', '경로'); eq(row.raw_status, 'stored', '원본 그대로'); ok(!row.html.includes('script'), '정제');
eq(Buffer.from((await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(row.id).first()).eml).toString(), raw.a.toString(), '원본 바이트');
eq(Buffer.from((await env.DB.prepare('SELECT bytes FROM attachments WHERE message_id=?').bind(row.id).first()).bytes).toString(), 'hello', '첨부 바이트');
eq(row.expires_at, null, '보관 기한 없음(만료일을 두지 않음)');
eq((await (await send({ type: 'email.received', data: { email_id: 'a', to: ['admin@arihakdang.com'] } }, 'evt_a')).json()).duplicate, true, '같은 이벤트 중복');
eq((await (await send({ type: 'email.received', data: { email_id: 'b', to: ['other@arihakdang.com'] } })).json()).ignored, 'other_mailbox', '다른 메일함');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n, 1, '한 통만');
});
await t('정기 동기화는 웹훅이 놓친 메일을 보충하고 이미 있는 메일·삭제된 메일은 다시 가져오지 않는다', async () => {
const sent = [{ id: 's1', from: 'Ari Hak Dang <admin@arihakdang.com>', to: ['x@example.test'], subject: '보낸 메일', html: '<p>hi</p>', text: 'hi', created_at: new Date().toISOString() }, { id: 's2', from: 'Ari Hakdang <hello@arihakdang.com>', to: ['y@example.test'], subject: '다른 발신자', created_at: new Date().toISOString() }];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b'), sample('old', { created_at: new Date(Date.now() - 40 * 86400000).toISOString() })], sent }) });
await ingestReceived(env, 'a', { source: 'webhook' });
const r = await syncMailbox(env, { trigger: 'test' }); eq(r.imported, 3, '놓친 b + 40일 지난 old + 보낸 s1');
eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE direction='inbound'").first()).n, 3, '보관 기한이 없으므로 오래된 메일도 가져온다');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='s1'").first()).folder, 'sent', '보낸 메일함');
eq((await env.DB.prepare("SELECT source FROM messages WHERE provider_id='a'").first()).source, 'webhook', '기존 유지');
const cookie = await login(env); const b = await env.DB.prepare("SELECT id FROM messages WHERE provider_id='b'").first();
eq((await api(env, 'mail/' + b.id, cookie, 'DELETE')).status, 409, '휴지통 먼저');
await api(env, 'mail/' + b.id, cookie, 'PATCH', { folder: 'trash' }); eq((await api(env, 'mail/' + b.id, cookie, 'DELETE')).status, 200, '영구 삭제');
eq((await syncMailbox(env, { trigger: 'test' })).imported, 0, '재수신 없음');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM sync_runs').first()).n, 2, '실행 기록');
});
await t('메일은 보관 기한 없이 남고 주기 정리는 만료된 세션만 지운다', async () => {
// 예전 설정(RETENTION_DAYS=30)이 환경에 남아 있어도 무시되어야 합니다.
const env = await makeEnv({ RETENTION_DAYS: '30', RESEND_FETCH: fakeResend({ received: [sample('a', { created_at: new Date(Date.now() - 400 * 86400000).toISOString() })] }) });
await ingestReceived(env, 'a'); const cookie = await login(env);
await env.DB.prepare("INSERT INTO sessions(id,user_id,login,expires_at,created_at) VALUES('old','admin-1','arihakdang','2000-01-01T00:00:00.000Z','2000-01-01T00:00:00.000Z')").run();
eq((await cleanupSessions(env)).sessions, 1, '만료 세션만 삭제');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n + (await env.DB.prepare('SELECT COUNT(*) n FROM message_raw').first()).n, 2, '400일 지난 메일·원본 유지');
eq((await api(env, 'mail?folder=inbox', cookie)).status, 200, '유효 세션은 그대로');
const s = await (await api(env, 'settings', cookie)).json(); eq(s.retention, 'unlimited', '설정 화면 보관 정책'); eq(s.retentionDays, undefined, '일수 표시 없음');
});
await t('0004 마이그레이션은 기존 만료일을 비우고 Resend 형식 시각을 ISO 로 바꾼다', async () => {
// 0.7 까지의 스키마(0001~0003)로 DB 를 만든 뒤 예전 형식 데이터를 넣고, 전체 마이그레이션을 다시 돌립니다.
const all = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), old = mkdtempSync(join(tmpdir(), 'mg-mig-'));
try {
for (const f of readdirSync(all).filter((x) => x < '0004')) copyFileSync(join(all, f), join(old, f));
const db = openDb(':memory:'); migrate(db, old, { quiet: true });
db.prepare("INSERT INTO messages(id,direction,folder,received_at,sent_at,created_at,updated_at,expires_at) VALUES('m1','outbound','sent','2026-09-28 06:20:26.514000+00','2026-09-28 06:20:26.514000+00','x','x','2026-10-28T06:20:26.514Z'),('m2','inbound','inbox','2026-09-28T07:00:00.000Z',NULL,'x','x','2026-10-28T07:00:00.000Z')").run();
eq(migrate(db, all, { quiet: true }), 6, '0004·0005·0006·0007·0008·0009 적용'); // [Ariweb Suite 변경] 0009 발송 대기열
const rows = db.prepare('SELECT id,received_at,sent_at,expires_at FROM messages ORDER BY received_at DESC').all();
eq(rows.map((r) => r.id).join(','), 'm2,m1', '정렬이 실제 시각 순서');
eq(rows[1].received_at, '2026-09-28T06:20:26.514Z', 'received_at ISO'); eq(rows[1].sent_at, '2026-09-28T06:20:26.514Z', 'sent_at ISO');
eq(rows[0].received_at, '2026-09-28T07:00:00.000Z', 'ISO 값은 그대로'); ok(rows.every((r) => r.expires_at === null), '만료일 비움');
ok(!db.prepare("SELECT 1 FROM sqlite_master WHERE name='messages_expires'").get(), '만료 색인 삭제');
db.close();
} finally { rmSync(old, { recursive: true, force: true }); }
});
await t('보낸 메일 동기화는 Resend 의 공백·짧은 시간대 시각을 ISO 로 저장한다', async () => {
eq(isoTime('2026-09-28 06:20:26.514000+00'), '2026-09-28T06:20:26.514Z', '공백 형식'); eq(isoTime('2026-09-28T06:20:26.514Z'), '2026-09-28T06:20:26.514Z', 'ISO 그대로');
eq(isoTime('2026-09-28'), '2026-09-28T00:00:00.000Z', '날짜만'); eq(isoTime('nonsense', 'F'), 'F', '해석 불가 → 대체값');
const sent = [{ id: 's1', from: 'Ari Hak Dang <admin@arihakdang.com>', to: ['x@example.test'], subject: '보낸 메일', html: '<p>hi</p>', text: 'hi', created_at: '2026-09-28 06:20:26.514000+00' }];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ sent }) }); await syncMailbox(env, { trigger: 'test' });
const row = await env.DB.prepare("SELECT received_at,sent_at FROM messages WHERE provider_id='s1'").first();
eq(row.received_at, '2026-09-28T06:20:26.514Z', 'received_at'); eq(row.sent_at, '2026-09-28T06:20:26.514Z', 'sent_at');
});
await t('빠른 확인 동기화는 이미 저장한 메일에서 멈추고 전체 동기화는 그 뒤의 빠진 메일까지 채운다', async () => {
// Resend 목록은 최신순: new(새 메일) → known(웹훅으로 이미 저장) → missed(예전에 놓친 메일)
const at = (h) => new Date(Date.now() - h * 3600000).toISOString(), calls = [];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ calls, received: [sample('new', { created_at: at(1) }), sample('known', { created_at: at(2) }), sample('missed', { created_at: at(3) })] }) });
await ingestReceived(env, 'known', { source: 'webhook' });
eq((await syncMailbox(env, { trigger: 'poll', quick: true })).imported, 1, '새 메일만');
ok(!(await env.DB.prepare("SELECT 1 FROM messages WHERE provider_id='missed'").first()), '빠른 확인은 이미 아는 메일 뒤로 내려가지 않는다');
eq((await syncMailbox(env, { trigger: 'timer' })).imported, 1, '전체 동기화가 놓친 메일 보충');
eq((await env.DB.prepare("SELECT trigger FROM sync_runs ORDER BY id LIMIT 1").first()).trigger, 'poll', '실행 계기 기록');
// API: 목록 자동 새로 고침은 { quick: true } 로 부릅니다.
const cookie = await login(env); const r = await (await api(env, 'mail/sync', cookie, 'POST', { quick: true })).json(); eq(r.started, true, 'API 시작');
await new Promise((res) => setTimeout(res, 50)); eq((await env.DB.prepare("SELECT trigger FROM sync_runs ORDER BY id DESC LIMIT 1").first()).trigger, 'poll', 'API 빠른 확인 계기');
});
await t('0.8.0 오류 수정: 실패한 웹훅 재시도, 관리자 인라인 이미지, 이동 규칙, 첨부 필터, 동기화 잠김', async () => {
// 1) 웹훅 저장이 실패(500)한 이벤트는 Resend 가 같은 svix-id 로 다시 보내면 처리되어야 합니다.
const secret = 'whsec_' + Buffer.from('k').toString('base64'); let down = true; const base = fakeResend({ received: [sample('w')] });
const env = await makeEnv({ RESEND_WEBHOOK_SECRET: secret, RESEND_FETCH: (u, o) => down ? Promise.resolve(Response.json({ message: 'boom' }, { status: 500 })) : base(u, o) });
const send = async (id) => { const body = JSON.stringify({ type: 'email.received', data: { email_id: 'w', to: ['admin@arihakdang.com'] } }), ts = String(Math.floor(Date.now() / 1000)); return api(env, 'webhooks/resend', '', 'POST', body, { 'svix-id': id, 'svix-timestamp': ts, 'svix-signature': signSvix(secret, id, ts, body) }); };
eq((await send('evt_retry')).status, 500, '첫 시도 실패 → 500');
down = false; const retry = await (await send('evt_retry')).json(); ok(!retry.duplicate && retry.imported, '같은 id 재시도는 다시 처리');
eq((await (await send('evt_retry')).json()).duplicate, true, '성공한 뒤의 재전송은 중복');
// 2) 관리자가 다른 메일함을 열었을 때 인라인 이미지 주소(?inline=1, mailbox 없음)도 열려야 합니다.
const env2 = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('i', { to: ['info@arihakdang.com'] })] }) }), cookie = await login(env2);
await createUser(env2, { login: 'info', password: 'staff-secret-1' }, 'test'); await syncMailbox(env2, { trigger: 'test' });
const info = await env2.DB.prepare("SELECT id FROM messages WHERE provider_id='i'").first();
await env2.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES('img1',?,'p.png','image/png','p1','inline',3,?,'x')").bind(info.id, Buffer.from('png')).run();
const staff = (await api(env2, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0];
eq((await api(env2, `mail/${info.id}/attachments/img1?inline=1`, staff)).status, 200, '자기 메일함 인라인 이미지');
eq((await api(env2, `mail/${info.id}/attachments/img1?inline=1`, cookie)).status, 404, '0.9.0: 관리자도 다른 메일함은 보지 못함');
const adminMail = (await saveDraft(env2, { to: 'x@example.test', subject: '관리자 초안', html: '<p>x</p>' }));
eq((await api(env2, 'mail/' + adminMail, staff)).status, 404, '일반 사용자는 여전히 남의 메일 차단');
// 3) 이동 규칙: 받은 메일 → 임시 보관함·보낸 메일함 금지, 초안 → 보낸 메일함 금지.
eq((await api(env2, 'mail/' + info.id, staff, 'PATCH', { action: 'move', target: 'drafts' })).status, 409, '받은 메일 → 임시 보관함 거부');
eq((await (await api(env2, 'mail/bulk', cookie, 'POST', { ids: [adminMail], action: 'move', target: 'sent' })).json()).done, 0, '초안 → 보낸 메일함 거부');
eq((await (await api(env2, 'mail/bulk', cookie, 'POST', { ids: [adminMail], action: 'move', target: 'trash' })).json()).done, 1, '초안 → 휴지통 허용');
eq((await api(env2, 'mail/' + info.id, staff, 'PATCH', { action: 'move', target: 'trash' })).status, 200, '받은 메일 → 휴지통 허용');
// 4) 첨부 필터는 서버에서 걸러 합계가 맞아야 합니다.
const env3 = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('x1'), sample('x2'), sample('x3')] }) }), c3 = await login(env3); await syncMailbox(env3, { trigger: 'test' });
const x2 = await env3.DB.prepare("SELECT id FROM messages WHERE provider_id='x2'").first();
await env3.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,disposition,size,bytes,created_at) VALUES('f1',?,'a.txt','text/plain','attachment',1,?,'x')").bind(x2.id, Buffer.from('a')).run();
const filtered = await (await api(env3, 'mail?folder=inbox&attach=1', c3)).json(); eq(filtered.total, 1, '첨부 있는 메일만 합계'); eq(filtered.messages[0].id, x2.id, '첨부 메일');
// 5) 동기화 시작 단계에서 예외가 나도 "진행 중" 표시가 풀려 다음 동기화가 돌아야 합니다.
const env4 = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('s1')] }) }); const prepare = env4.DB.prepare; let broken = true;
env4.DB.prepare = (sql) => { if (broken && sql.startsWith('INSERT INTO sync_runs')) { broken = false; throw new Error('SQLITE_BUSY'); } return prepare(sql); };
let threw = false; try { await syncMailbox(env4, { trigger: 'test' }); } catch { threw = true; } ok(threw, '첫 동기화 예외');
eq((await syncMailbox(env4, { trigger: 'test' })).imported, 1, '잠기지 않고 다음 동기화 진행');
});
await t('0.8.1 오류 수정: 큰 HTML 수신, 미완성 주소 초안, 인라인 이미지 발송, RFC 2231, 경계 접두사, STLS 주입, 메일함 이름 변경', async () => {
// 1) 40만 자가 넘는 HTML 메일도 저장됩니다(예전에는 예외로 수신 실패 → 웹훅 무한 재시도).
const big = '<div style="color:red">' + 'x'.repeat(480000) + '</div>';
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('big', { html: big, text: '큰 메일' })] }) }), cookie = await login(env);
ok((await ingestReceived(env, 'big')).imported, '큰 HTML 메일 저장');
ok((await env.DB.prepare("SELECT html FROM messages WHERE provider_id='big'").first()).html.length > 400000, 'HTML 그대로 정제');
// 2) 초안은 미완성·따옴표 안 쉼표 주소도 저장하고, 발송 때 검사합니다.
eq(addresses('"Kim, Minsu" <k@x.test>, b@y.test').join(), 'k@x.test,b@y.test', '따옴표 안 쉼표');
const draft = (await (await api(env, 'mail', cookie, 'POST', { to: 'bob', subject: '미완성', html: '<p>본문</p>' })).json()).id; ok(draft, '미완성 주소 초안 저장');
eq((await api(env, 'mail/' + draft, cookie, 'PATCH', { to: 'bob@example', subject: '미완성', html: '<p>본문</p>' })).status, 200, '자동 저장 성공');
eq((await api(env, 'mail/' + draft + '/send', cookie, 'POST', {})).status, 422, '발송 때 주소 검사');
eq((await (await api(env, 'mail/' + draft + '/send', cookie, 'POST', {})).json()).error, 'invalid_email', '오류 코드');
// 3) 본문에서 지운 인라인 이미지는 보내지 않고, 4) 인용한 원래 메일의 인라인 이미지는 cid 첨부로 복사해 보냅니다.
const calls = []; const env2 = await makeEnv({ RESEND_FETCH: fakeResend({ calls, received: [sample('orig', { html: '<p><img src="cid:pic1"></p>' })] }) }), c2 = await login(env2);
await ingestReceived(env2, 'orig'); const orig = await env2.DB.prepare("SELECT id FROM messages WHERE provider_id='orig'").first();
await env2.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES('pic',?,'p.png','image/png','pic1','inline',3,?,'x')").bind(orig.id, Buffer.from('PNG')).run();
const reply = await saveDraft(env2, { to: 'friend@example.test', subject: 'Re', html: `<p>답장</p><blockquote><img src="/api/mail/${orig.id}/attachments/pic?inline=1"></blockquote>` });
await addAttachment(env2, reply, { filename: 'gone.png', contentType: 'image/png', bytes: Buffer.from('GONE'), inline: true });
eq((await api(env2, 'mail/' + reply + '/send', c2, 'POST', {})).status, 200, '답장 발송');
const post = calls.find((x) => x.method === 'POST' && x.path === '/emails').body;
eq(post.attachments.length, 1, '지운 인라인 이미지는 빠짐'); eq(Buffer.from(post.attachments[0].content, 'base64').toString(), 'PNG', '원래 메일 이미지 복사');
ok(post.html.includes('cid:' + post.attachments[0].content_id) && !post.html.includes('/api/mail/'), '받는 사람에게는 cid 로');
// 7) RFC 2231 파라미터
eq(parseParams(`attachment; filename*=UTF-8''%ED%95%9C%EA%B8%80.pdf`).filename, '한글.pdf', '퍼센트 인코딩');
eq(parseParams(`x; filename*0*=UTF-8''%ED%95%9C; filename*1="-doc.txt"`).filename, '한-doc.txt', '이어 쓰기 조각');
eq(parseParams('x; name="plain \\"q\\".txt"').name, 'plain "q".txt', '일반 따옴표 값');
// 10) 경계 접두사: 바깥 경계 b1 이 안쪽 경계 b1x 와 섞이지 않아야 합니다.
const nested = Buffer.from('Content-Type: multipart/mixed; boundary="b1"\r\n\r\n--b1\r\nContent-Type: multipart/alternative; boundary="b1x"\r\n\r\n--b1x\r\nContent-Type: text/plain\r\n\r\nplain\r\n--b1x\r\nContent-Type: text/html\r\n\r\n<p>html</p>\r\n--b1x--\r\n--b1\r\nContent-Type: application/pdf; name="a.pdf"\r\n\r\nPDF\r\n--b1--\r\n');
const tree = parseMime(nested); eq(tree.root.parts.length, 2, '바깥 파트 2개'); eq(tree.root.parts[0].parts.length, 2, '안쪽 파트 2개');
const info = summarize(tree); eq(info.text.trim(), 'plain', '텍스트'); eq(info.attachments.length, 1, '첨부 1개');
// 첨부로 전달한 메일(message/rfc822)도 첨부로 남습니다.
const fwd = summarize(parseMime(Buffer.from('Content-Type: multipart/mixed; boundary="z"\r\n\r\n--z\r\nContent-Type: text/plain\r\n\r\nsee\r\n--z\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\n\r\nhi\r\n--z--\r\n')));
eq(fwd.attachments.length, 1, '전달 메일 첨부'); eq(fwd.attachments[0].filename, 'message.eml', '전달 메일 이름');
// 9) STLS 뒤에 붙여 보낸 평문 명령은 실행되지 않습니다(TLS 없는 서버에서는 STLS 가 거절되고 붙은 명령은 버려짐).
const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' });
try {
const out = await new Promise((resolve) => { const sock = net.connect(port, '127.0.0.1'); let buf = ''; sock.on('data', (d) => { buf += d; if (buf.includes('-ERR TLS')) { setTimeout(() => { sock.end(); resolve(buf); }, 150); } }); sock.once('data', () => sock.write('STLS\r\nUSER injected\r\n')); });
ok(out.includes('-ERR TLS not available') && !out.includes('name accepted'), 'STLS 뒤 주입 명령 버림: ' + JSON.stringify(out));
} finally { pop.close(); }
// 11) 계정의 메일함 주소를 바꾸면 메일도 따라갑니다.
const env3 = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('m1', { to: ['info@arihakdang.com'] })] }) });
const uidStaff = await createUser(env3, { login: 'info', password: 'staff-secret-1' }, 'test'); await syncMailbox(env3, { trigger: 'test' });
// 0.8.4 부터 메일함 주소는 로그인 ID 에 묶여 바뀌지 않습니다(옛 주소의 메일이 고아가 되던 문제의 근본 해결).
await updateUser(env3, uidStaff, { mailbox: 'office@arihakdang.com' });
eq((await verifyAdmin(env3, 'info', 'staff-secret-1')).mailbox, 'info@arihakdang.com', '메일함 주소 고정'); eq((await env3.DB.prepare("SELECT mailbox FROM messages WHERE provider_id='m1'").first()).mailbox, 'info@arihakdang.com', '메일 그대로');
});
await t('0.8.4: 메일함 자동 지정, 계정별 POP/IMAP 허용, 관리자 페이지 별도 세션', async () => {
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a')] }) }), cookie = await login(env), admin = await adminLogin(env);
// 1) 계정의 메일함은 항상 로그인 ID@도메인. 수정 때 mailbox 를 보내도 바뀌지 않습니다.
const r = await adminApi(env, 'admin/users', admin, 'POST', { login: 'sales', password: 'sales-secret-1', mailbox: 'anything@arihakdang.com', services: { pop: false, imap: true } }); eq(r.status, 201, '생성');
const sid = (await r.json()).id; eq((await verifyAdmin(env, 'sales', 'sales-secret-1')).mailbox, 'sales@arihakdang.com', '자동 메일함');
eq((await adminApi(env, 'admin/users/' + sid, admin, 'PATCH', { mailbox: 'x@arihakdang.com', name: '영업' })).status, 200, '수정');
eq((await verifyAdmin(env, 'sales', 'sales-secret-1')).mailbox, 'sales@arihakdang.com', '메일함 주소는 바뀌지 않음');
const list = await (await adminApi(env, 'admin/users', admin)).json(); eq(list.users[0].services.pop, false, '목록에 서비스 표시'); eq(list.me.services.imap, true, '관리자 자신 서비스');
// 2) 서비스 허용: 계정 설정(prefs)으로 저장되고, POP 서버는 꺼진 계정의 로그인을 거부합니다.
const sales = await verifyAdmin(env, 'sales', 'sales-secret-1'); eq((await readPrefs(env, sales)).pop, false, 'prefs 저장');
const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' });
try {
const talk = (lines) => new Promise((resolve) => { const sock = net.connect(port, '127.0.0.1'); let buf = ''; sock.on('data', (d) => { buf += d; }); sock.on('close', () => resolve(buf)); sock.once('data', () => { sock.write(lines.join('\r\n') + '\r\n'); setTimeout(() => sock.end(), 300); }); });
ok((await talk(['USER sales', 'PASS sales-secret-1'])).includes('POP access is disabled'), 'POP 꺼진 계정 거부');
await adminApi(env, 'admin/users/' + sid, admin, 'PATCH', { services: { pop: true, imap: false } });
ok((await talk(['USER sales', 'PASS sales-secret-1', 'STAT'])).includes('+OK 0 0'), 'POP 켜면 로그인');
} finally { pop.close(); }
// 사용자도 자기 설정 화면에서 바꿀 수 있습니다.
const sc = (await api(env, 'login', '', 'POST', { login: 'sales', password: 'sales-secret-1' })).headers.get('set-cookie').split(';')[0];
const p = await (await api(env, 'prefs', sc, 'PATCH', { imap: true, remoteImages: true })).json(); eq(p.prefs.imap, true, '사용자 설정'); eq(p.prefs.pop, true, '다른 값 유지');
// 3) 관리자 페이지 세션 분리: 메일 세션으로는 /api/admin 불가, 관리자 세션으로는 메일 API 불가, 일반 계정은 관리자 로그인 불가.
eq((await api(env, 'admin/users', cookie)).status, 403, '메일 세션으로 계정 관리 금지');
eq((await (await api(env, 'admin/users', cookie)).json()).error, 'admin_login_required', '안내 코드');
eq((await adminApi(env, 'mail?folder=inbox', admin)).status, 403, '관리자 세션으로 메일 API 금지');
eq((await adminApi(env, 'session', admin)).status, 200, '관리자 세션 조회'); ok((await (await adminApi(env, 'session', admin)).json()).user, '관리자 세션 사용자');
eq((await api(env, 'session', admin)).status === 200 && !(await (await api(env, 'session', admin)).json()).user, true, '관리자 쿠키는 메일 범위에서 무효');
const denied = await handle(env, request('login', '', 'POST', { login: 'sales', password: 'sales-secret-1' }, { 'X-Mail-Scope': 'admin' }), params('login')); eq(denied.status, 403, '일반 계정 관리자 로그인 거부'); eq((await denied.json()).error, 'admin_only', '거부 코드');
const granted = await handle(env, request('login', '', 'POST', { login: 'admin', password: 'admin-secret-12' }, { 'X-Mail-Scope': 'admin' }), params('login')); eq(granted.status, 200, '관리자 로그인'); ok(granted.headers.get('set-cookie').startsWith(ADMIN_COOKIE + '='), '관리자 쿠키');
const st = await (await adminApi(env, 'admin/status', admin)).json(); ok(st.webhook && st.sync && st.ports.submission === 587, '서버 상태');
const s = await (await api(env, 'settings', cookie)).json(); ok(!s.webhook && s.prefs && s.smtp, '웹 메일 설정에는 운영 정보 없음');
eq((await adminApi(env, 'admin/sync', admin, 'POST', {})).status, 200, '관리자 동기화 시작');
});
await t('0.8.4 오류 수정: 로그인 제한은 실패만 세고, 올린 초안의 헤더는 답장용만 발송하며, 이름 붙은 텍스트 파트는 첨부다', async () => {
const { memoryRateStore } = await import('../server/util.mjs'); const rate = memoryRateStore();
for (let i = 0; i < 25; i++) ok(!rate.blocked('k', { limit: 20 }), '성공만으로는 막히지 않음 ' + i);
for (let i = 0; i < 20; i++) rate.hit('k', { limit: 20, windowSec: 900 }); ok(rate.blocked('k', { limit: 20 }), '실패 20회 뒤 차단');
// IMAP 으로 올린 초안(모든 원본 헤더를 가짐)을 웹에서 보내면 In-Reply-To/References 만 넘깁니다.
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) });
const eml = buildEml({ from: 'Ari <admin@arihakdang.com>', to: ['x@example.test'], subject: '올린 초안', text: '본문', messageId: '<d1@x>', headers: { 'In-Reply-To': '<p@x>', 'X-Mozilla-Draft-Info': 'internal/draft; vcard=0' } });
const { appendMessage } = await import('../server/store.mjs'); const { id } = await appendMessage(env, 'drafts', eml, { mailbox: 'admin@arihakdang.com' });
await sendDraft(env, id); const post = calls.find((c) => c.method === 'POST' && c.path === '/emails').body;
eq(Object.keys(post.headers).sort().join(), 'In-Reply-To', '답장 헤더만'); ok(!JSON.stringify(post).includes('Mozilla') && !('Content-Type' in post.headers), '원본 MIME 헤더 제외');
// 이름이 붙은 텍스트 파트(Apple Mail 의 inline; filename=)는 첨부로 남습니다.
const parsed = parseMime(Buffer.from('Content-Type: multipart/mixed; boundary="q"\r\n\r\n--q\r\nContent-Type: text/plain\r\n\r\nbody\r\n--q\r\nContent-Type: text/plain; name="notes.txt"\r\nContent-Disposition: inline; filename="notes.txt"\r\n\r\nnotes\r\n--q\r\nContent-Type: text/csv\r\n\r\na,b\r\n--q--\r\n'));
const info = summarize(parsed); eq(info.text.trim(), 'body', '본문'); eq(info.attachments.map((a) => a.filename).join(), 'notes.txt,attachment', '텍스트 첨부 2개');
});
await t('0.8.12 오류 수정: Chrome 밑줄·취소선 보존, 웹 로그인 제한은 실패만, 게이트웨이 관리자에게는 admin 행 없음, 설정 응답', async () => {
eq(sanitizeHtml('<span style="text-decoration-line: underline;">u</span><span style="text-decoration-line: line-through">s</span>').html, '<span style="text-decoration-line:underline">u</span><span style="text-decoration-line:line-through">s</span>', 'text-decoration-line 유지');
const env = await makeEnv({ RESEND_FETCH: fakeResend({}) });
for (let i = 0; i < 12; i++) eq((await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 200, '성공 로그인은 제한에 들지 않음 ' + i);
for (let i = 0; i < 10; i++) await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'wrong' });
eq((await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 429, '실패 10회 뒤 차단');
// 역할이 관리자인 게이트웨이 계정으로 /admin 에 들어오면 www 관리자 행(me)이 없고 me 수정도 거부됩니다.
await createUser(env, { login: 'boss', password: 'boss-secret-123', role: 'admin' }, 'test');
const boss = await verifyAdmin(env, 'boss', 'boss-secret-123'); const bossCookie = ADMIN_COOKIE + '=' + await createSession(env, boss, new Request('https://x'), { scope: 'admin' });
const d = await (await adminApi(env, 'admin/users', bossCookie)).json(); eq(d.me, null, 'me 없음'); eq(d.users.some((u) => u.login === 'boss'), true, '자기 행은 목록에');
eq((await adminApi(env, 'admin/users/me', bossCookie, 'PATCH', { services: { pop: false } })).status, 404, 'me 수정 거부');
const www = await (await adminApi(env, 'admin/users', await adminLogin(env))).json(); eq(www.me.source, 'www', 'www 관리자에게는 me');
// 웹 메일 설정 응답은 화면이 쓰는 필드를 모두 갖습니다(pop.deliveries 는 관리자 페이지로 옮겨져 화면도 더는 쓰지 않음).
const s = await (await api(env, 'settings', await login(env))).json(); ok(s.pop.host && s.pop.ports && s.imap.ports && s.smtp.ports && s.prefs, '설정 필드');
});
await t('발송은 Resend API 로 한 번만 보내고 보낸 메일함에 원본을 만들며 오류는 원인 코드로 돌려준다', async () => {
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }), cookie = await login(env);
const id = (await (await api(env, 'mail', cookie, 'POST', { to: 'Member <member@example.test>', subject: '테스트', html: '<p>본문</p>' })).json()).id;
const form = new FormData(); form.append('files', new Blob(['attached'], { type: 'text/plain' }), '첨부.txt');
eq((await api(env, 'mail/' + id + '/attachments', cookie, 'POST', form)).status, 201, '첨부 업로드');
const results = await Promise.allSettled([sendDraft(env, id), sendDraft(env, id)]); ok(results.some((r) => r.status === 'fulfilled'), '발송');
const posts = calls.filter((c) => c.method === 'POST' && c.path === '/emails'); eq(posts.length, 1, '동시 발송 중복 방지');
ok(posts[0].body.from.includes('admin@arihakdang.com'), '고정 발신자'); eq(posts[0].body.attachments[0].filename, '첨부.txt', '첨부 전송'); eq(Buffer.from(posts[0].body.attachments[0].content, 'base64').toString(), 'attached', '첨부 내용');
const row = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); eq(row.folder, 'sent', '보낸 메일함'); eq(row.raw_status, 'built', '원본 생성');
ok(Buffer.from((await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first()).eml).toString().includes('Content-Disposition: attachment'), 'eml 첨부');
const failing = await makeEnv({ RESEND_FETCH: fakeResend({ failStatus: 401 }) }), c2 = await login(failing);
const d2 = (await (await api(failing, 'mail', c2, 'POST', { to: 'member@example.test', subject: '실패', html: '<p>본문</p>' })).json()).id;
const r = await api(failing, 'mail/' + d2 + '/send', c2, 'POST', {}); const body = await r.json();
eq(body.error, 'mail_auth_failed', '키 오류 코드'); ok(body.detail.includes('invalid'), '상세'); eq((await api(failing, 'mail/status', c2)).status, 200, '상태 API');
eq((await (await api(failing, 'mail/status', c2)).json()).provider, 'mail_auth_failed', '상태 표시');
});
await t('메일 API 는 목록·상세·원본 다운로드·첨부 다운로드·읽음 처리를 제공한다', async () => {
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a', { attachments: [{ id: 'att1' }] })], attachments: { a: [{ id: 'att1', filename: '사진.png', content_type: 'image/png', size: 3, content_id: 'img1', download_url: 'https://files.test/att/att1', bytes: 'png' }] } }) }), cookie = await login(env);
await ingestReceived(env, 'a'); const list = await (await api(env, 'mail?folder=inbox', cookie)).json(); eq(list.total, 1, '목록'); eq(list.messages[0].attachment_count, 1, '첨부 수'); ok(!list.messages[0].read_at, '읽지 않음');
const detail = await (await api(env, 'mail/' + list.messages[0].id, cookie)).json(); eq(detail.message.attachments[0].filename, '사진.png', '첨부 목록'); ok(detail.message.previewHtml !== undefined, '미리보기');
const orig = await api(env, 'mail/' + list.messages[0].id + '/original', cookie); eq(orig.headers.get('content-type'), 'message/rfc822', '원본'); ok((await orig.text()).includes('Subject:'), 'eml');
const att = await api(env, 'mail/' + list.messages[0].id + '/attachments/' + detail.message.attachments[0].id, cookie); eq(await att.text(), 'png', '첨부 바이트');
ok((await (await api(env, 'mail?folder=inbox', cookie)).json()).messages[0].read_at, '읽음 처리');
eq((await api(env, 'settings', cookie)).status, 200, '설정'); const smtpInfo = (await (await api(env, 'settings', cookie)).json()).smtp;
eq(smtpInfo.host, 'mail.arihakdang.com', 'SMTP 는 이 서버'); eq(smtpInfo.ports.smtps + '/' + smtpInfo.ports.starttls, '465/587', 'SMTP 포트');
ok(!('username' in smtpInfo) && !('apiKeyConfigured' in smtpInfo) && !JSON.stringify(smtpInfo).includes('resend'), 'SMTP 안내에 발송 서비스·키 정보 없음');
});
// ------------------------------------------------------------------ POP3
function popClient(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 idx = w.multi ? buffer.indexOf('\r\n.\r\n') : buffer.indexOf('\r\n'); if (idx < 0) { if (w.multi && buffer.startsWith('-ERR')) { const e = buffer.indexOf('\r\n'); if (e < 0) return; waiters.shift(); w.resolve(buffer.slice(0, e)); buffer = buffer.slice(e + 2); continue; } return; } waiters.shift(); const end = w.multi ? idx + 5 : idx + 2; w.resolve(buffer.slice(0, w.multi ? idx + 2 : idx)); buffer = buffer.slice(end); } }
const read = (multi = false) => new Promise((resolve) => { waiters.push({ resolve, multi }); flush(); });
return { greeting: () => read(), cmd: (line, multi = false) => { socket.write(line + '\r\n'); return read(multi); }, end: () => socket.end() };
}
await t('POP3 는 www 관리자 계정으로 로그인해 UIDL·RETR·DELE 를 처리하고 한 번 내려준 메일은 다시 주지 않는다', async () => {
const raw = { a: Buffer.from('From: sender@example.test\r\nSubject: raw a\r\n\r\nline\r\n.dot line\r\n'), b: Buffer.from('From: sender@example.test\r\nSubject: raw b\r\n\r\nbody b\r\n') };
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
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 }) });
for (const id of ['a', 'b', 'c']) await ingestReceived(env, id);
const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' });
try {
let c = popClient(port); ok((await c.greeting()).startsWith('+OK'), '인사');
ok((await c.cmd('CAPA', true)).includes('UIDL'), 'CAPA'); ok((await c.cmd('STAT')).startsWith('-ERR'), '로그인 전 거부');
await c.cmd('USER arihakdang'); ok((await c.cmd('PASS wrong')).startsWith('-ERR'), '비밀번호 오류');
await c.cmd('USER student'); ok((await c.cmd('PASS admin-secret-12')).startsWith('-ERR'), '학생 거부');
await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '관리자 로그인');
eq((await c.cmd('STAT')).split(' ')[1], '3', '3통');
const uidl = await c.cmd('UIDL', true); eq(uidl.split('\r\n').filter((l) => /^\d+ /.test(l)).length, 3, 'UIDL 목록'); ok(/^\+OK/.test(uidl), 'UIDL 형식');
const retr = await c.cmd('RETR 1', true); ok(retr.includes('Subject: raw a') && retr.includes('\r\n..dot line'), '원본 그대로 + 점 스터핑');
const top = await c.cmd('TOP 2 0', true); ok(top.includes('Subject: raw b') && !top.includes('body b'), 'TOP');
ok((await c.cmd('DELE 2')).startsWith('+OK'), 'DELE'); eq((await c.cmd('STAT')).split(' ')[1], '2', '삭제 반영');
ok((await c.cmd('RSET')).startsWith('+OK'), 'RSET'); eq((await c.cmd('STAT')).split(' ')[1], '3', '복원');
await c.cmd('DELE 2'); ok((await c.cmd('QUIT')).startsWith('+OK'), 'QUIT'); c.end();
await new Promise((r) => setTimeout(r, 50));
eq((await maildrop(env, 'www:admin-1')).length, 1, 'RETR·DELE 한 메일 제외');
c = popClient(port); await c.greeting(); await c.cmd('USER admin@arihakdang.com'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '이메일 로그인');
eq((await c.cmd('STAT')).split(' ')[1], '1', '두 번째 세션은 남은 1통만');
const list = await c.cmd('LIST', true); ok(list.includes('1 '), 'LIST');
const retr3 = await c.cmd('RETR 1', true); ok(retr3.includes('Subject: =?UTF-8?B?') || retr3.includes('Subject: 받은 메일 c'), '재구성 원본');
await c.cmd('QUIT'); c.end(); await new Promise((r) => setTimeout(r, 50));
eq((await maildrop(env, 'www:admin-1')).length, 0, '모두 전달됨');
eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE direction='inbound'").first()).n, 3, '서버 저장본 유지');
const cookie = await login(env); const c3 = (await env.DB.prepare("SELECT id FROM messages WHERE provider_id='c'").first()).id;
eq((await api(env, 'mail/' + c3, cookie, 'PATCH', { popReset: true })).status, 200, '전달 기록 초기화'); eq((await maildrop(env, 'www:admin-1')).length, 1, '다시 내려받기 가능');
const p = createPop3(env, { hostname: 'mail.test', allowPlaintext: false }); const port2 = port + 1; await p.listen({ plainPort: port2, host: '127.0.0.1' });
const c4 = popClient(port2); await c4.greeting(); await c4.cmd('USER arihakdang'); ok((await c4.cmd('PASS admin-secret-12')).startsWith('-ERR'), '평문 로그인 거부(STLS 필요)'); c4.end(); p.close();
} finally { pop.close(); }
});
await t('MIME 생성은 한글 헤더·첨부·인라인 이미지를 표준 형식으로 만든다', async () => {
const eml = buildEml({ from: '아리학당 <admin@arihakdang.com>', to: ['x@example.test'], subject: '한글 제목', text: '본문', html: '<p>본문<img src="cid:img1"></p>', headers: { 'X-Custom': 'v', 'Bad\r\nHeader': 'x' }, attachments: [{ filename: '파일.txt', contentType: 'text/plain', bytes: Buffer.from('hi') }, { filename: 'a.png', contentType: 'image/png', contentId: 'img1', bytes: Buffer.from([1]) }] }).toString();
ok(eml.includes('Subject: =?UTF-8?B?') && eml.includes('multipart/mixed') && eml.includes('multipart/related') && eml.includes('Content-ID: <img1>') && eml.includes('X-Custom: v') && !eml.includes('Bad'), '구조');
ok(/\r\n\r\n/.test(eml) && !/[^\r]\n/.test(eml), 'CRLF');
});
await t('관리자는 게이트웨이 계정을 만들고 수정·삭제할 수 있으며 일반 사용자는 관리 API 에 접근할 수 없다', async () => {
const env = await makeEnv(), cookie = await adminLogin(env);
eq((await adminApi(env, 'admin/users', cookie)).status, 200, '목록');
let r = await adminApi(env, 'admin/users', cookie, 'POST', { login: 'info', name: '직원', mailbox: 'info@arihakdang.com', password: 'staff-secret-1', role: 'user' }); eq(r.status, 201, '생성'); const sid = (await r.json()).id;
eq((await adminApi(env, 'admin/users', cookie, 'POST', { login: 'info', mailbox: 'x@arihakdang.com', password: 'staff-secret-1' })).status, 409, '중복 로그인');
const ignored = await adminApi(env, 'admin/users', cookie, 'POST', { login: 'other', mailbox: 'x@other.test', password: 'staff-secret-1' }); eq(ignored.status, 201, '메일함 주소 입력은 무시(자동 지정)');
eq((await verifyAdmin(env, 'other', 'staff-secret-1')).mailbox, 'other@arihakdang.com', '메일함 = 로그인 ID@도메인');
eq((await adminApi(env, 'admin/users', cookie, 'POST', { login: 'admin', mailbox: 'a@arihakdang.com', password: 'staff-secret-1' })).status, 422, '별칭 이름 거부');
eq((await adminApi(env, 'admin/users', cookie, 'POST', { login: 'weak', mailbox: 'w@arihakdang.com', password: 'short' })).status, 422, '짧은 비밀번호 거부');
const staff = await verifyAdmin(env, 'info', 'staff-secret-1'); eq(staff.mailbox, 'info@arihakdang.com', '계정 메일함'); eq(staff.role, 'user', '역할');
ok(await verifyAdmin(env, 'info@arihakdang.com', 'staff-secret-1'), '메일함 주소로 로그인');
const sc = await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' }); const staffCookie = sc.headers.get('set-cookie').split(';')[0];
eq((await api(env, 'admin/users', staffCookie)).status, 403, '일반 사용자(메일 세션) 관리 금지');
const session = await (await api(env, 'session', staffCookie)).json(); eq(session.mailbox, 'info@arihakdang.com', '세션 메일함'); eq(session.mailboxes.length, 1, '자기 메일함만');
eq((await adminApi(env, 'admin/users/' + sid, cookie, 'PATCH', { password: 'new-secret-123', name: '직원2' })).status, 200, '수정');
eq((await api(env, 'session', staffCookie)).status === 200 && !(await (await api(env, 'session', staffCookie)).json()).user, true, '비밀번호 변경 시 세션 종료');
ok(await verifyAdmin(env, 'info', 'new-secret-123'), '새 비밀번호');
eq((await adminApi(env, 'admin/users/' + sid, cookie, 'PATCH', { status: 'suspended' })).status, 200, '정지'); eq(await verifyAdmin(env, 'info', 'new-secret-123'), null, '정지 계정 거부');
eq((await adminApi(env, 'admin/users/' + sid, cookie, 'DELETE')).status, 200, '삭제'); eq((await (await adminApi(env, 'admin/users', cookie)).json()).users.length, 1, '삭제 반영(other 계정만 남음)');
});
await t('메일은 메일함별로 분리되고 ?mailbox= 는 누구에게나 무시된다(0.9.0: 관리자의 다른 메일함 열람 삭제)', async () => {
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b', { to: ['info@arihakdang.com'] }), sample('c', { to: ['nobody@arihakdang.com'] })] }) }), cookie = await login(env);
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test');
const r = await syncMailbox(env, { trigger: 'test' }); eq(r.imported, 2, '알려진 메일함 두 곳만 수신');
eq((await env.DB.prepare("SELECT mailbox FROM messages WHERE provider_id='b'").first()).mailbox, 'info@arihakdang.com', '라우팅');
eq((await (await api(env, 'mail?folder=inbox', cookie)).json()).total, 1, '관리자 기본 메일함 1통');
const adminList = await (await api(env, 'mail?folder=inbox&mailbox=info@arihakdang.com', cookie)).json(); eq(adminList.mailbox, 'admin@arihakdang.com', '관리자도 ?mailbox= 무시(자기 메일함)');
eq((await (await api(env, 'session', cookie)).json()).mailboxes.length, 1, '세션의 메일함 목록도 하나뿐');
const staffCookie = (await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0];
const list = await (await api(env, 'mail?folder=inbox&mailbox=admin@arihakdang.com', staffCookie)).json(); eq(list.mailbox, 'info@arihakdang.com', '사용자는 자기 메일함만'); eq(list.total, 1, '자기 메일함 1통');
const a = await env.DB.prepare("SELECT id FROM messages WHERE provider_id='a'").first(); eq((await api(env, 'mail/' + a.id, staffCookie)).status, 404, '남의 메일 접근 차단');
const draft = (await (await api(env, 'mail', staffCookie, 'POST', { to: 'x@example.test', subject: '직원 초안', html: '<p>본문</p>' })).json()).id;
const row = await env.DB.prepare('SELECT sender,mailbox FROM messages WHERE id=?').bind(draft).first(); eq(row.mailbox, 'info@arihakdang.com', '초안 메일함'); ok(row.sender.includes('info@arihakdang.com'), '발신 주소');
eq((await (await api(env, 'mail/bulk', staffCookie, 'POST', { ids: [a.id, draft], action: 'trash' })).json()).done, 1, '일괄 작업은 자기 메일만');
eq((await maildrop(env, 'x', 'info@arihakdang.com')).length, 1, 'POP 메일함 범위');
});
await t('인라인 이미지는 cid 첨부로 발송되고 별표·읽음 일괄 처리가 동작한다', async () => {
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }), cookie = await login(env);
const id = (await (await api(env, 'mail', cookie, 'POST', { to: 'member@example.test', subject: '이미지', html: '<p>본문</p>' })).json()).id;
const form = new FormData(); form.append('files', new Blob([Buffer.from([137, 80, 78, 71])], { type: 'image/png' }), 'pic.png');
const up = await (await api(env, 'mail/' + id + '/attachments?inline=1', cookie, 'POST', form)).json(); ok(up.urls[0].includes('?inline=1'), '인라인 주소');
eq((await api(env, 'mail/' + id, cookie, 'PATCH', { to: 'member@example.test', subject: '이미지', html: `<p>본문 <img src="${up.urls[0]}"></p>` })).status, 200, '본문에 이미지 저장');
ok((await env.DB.prepare('SELECT html FROM messages WHERE id=?').bind(id).first()).html.includes('/api/mail/'), '정제기가 자체 첨부 주소 허용');
eq((await api(env, 'mail/' + id + '/send', cookie, 'POST', {})).status, 200, '발송');
const post = calls.find((c) => c.method === 'POST' && c.path === '/emails'); ok(post.body.html.includes('src="cid:'), 'cid 치환'); eq(post.body.attachments[0].content_id, post.body.html.match(/cid:([^"]+)/)[1], '첨부 content_id 일치');
const eml = Buffer.from((await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first()).eml).toString(); ok(eml.includes('multipart/related') && eml.includes('Content-ID:'), '원본에 인라인 파트');
eq((await api(env, 'mail/' + id, cookie, 'PATCH', { flagged: true })).status, 200, '별표'); ok((await (await api(env, 'mail?folder=sent', cookie)).json()).messages[0].flagged, '목록 별표 표시');
ok((await (await api(env, 'mail?folder=sent&flagged=1', cookie)).json()).total === 1, '별표 필터');
eq((await (await api(env, 'mail/bulk', cookie, 'POST', { ids: [id], action: 'unread' })).json()).done, 1, '일괄 읽지 않음'); ok(!(await (await api(env, 'mail?folder=sent&unread=1', cookie)).json()).messages[0].read_at, '읽지 않음 필터');
});
await t('화면 기본값(외부 이미지 표시)은 계정별로 저장되고 세션 응답에 실린다', async () => {
const env = await makeEnv(), cookie = await login(env);
eq((await (await api(env, 'session', cookie)).json()).prefs.remoteImages, false, '기본 차단');
eq((await (await api(env, 'prefs', cookie, 'PATCH', { remoteImages: true })).json()).prefs.remoteImages, true, '저장');
eq((await (await api(env, 'session', cookie)).json()).prefs.remoteImages, true, '세션 반영');
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test');
const sc = (await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0];
eq((await (await api(env, 'session', sc)).json()).prefs.remoteImages, false, '다른 계정은 별도');
});
await t('0.8.16 메일 전달: 받은 메일을 지정 주소로 첨부·원본 HTML 과 함께 전달하고 고리·옛 메일·전달 대상 발신은 건너뛴다', async () => {
const calls = [];
const received = [
sample('f1', { html: '<p>안녕 <img src="cid:pic1"></p>', attachments: [{ id: 'att-1', filename: 'a.pdf', content_type: 'application/pdf', size: 3, content_disposition: 'attachment' }, { id: 'att-2', filename: 'pic.png', content_type: 'image/png', size: 3, content_id: '<pic1>', content_disposition: 'inline' }] }),
sample('f2', { headers: { [FORWARD_MARKER]: 'other@arihakdang.com' } }), // 우리 전달 표시가 있는 메일 → 다시 전달하지 않음
sample('f3', { from: 'Me Elsewhere <me@gmail.test>' }), // 전달 대상이 보낸 메일 → 돌려보내지 않음
sample('f4', { created_at: new Date(Date.now() - 4 * 86400000).toISOString() }), // 72시간 지난 메일 → 건너뜀
];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received, calls, attachments: { f1: [{ id: 'att-1', filename: 'a.pdf', content_type: 'application/pdf', size: 3, download_url: 'https://files.test/a/att-1', bytes: 'pdf' }, { id: 'att-2', filename: 'pic.png', content_type: 'image/png', size: 3, content_id: '<pic1>', content_disposition: 'inline', download_url: 'https://files.test/a/att-2', bytes: 'png' }] } }) }), cookie = await login(env);
// 설정 저장 검사: 켜면서 주소가 없거나, 자기 메일함이거나, 6개 이상이면 거부. 꺼진 상태의 잘못된 주소도 거부.
eq((await api(env, 'prefs', cookie, 'PATCH', { forward: { enabled: true, to: '' } })).status, 422, '주소 없음');
eq((await (await api(env, 'prefs', cookie, 'PATCH', { forward: { enabled: true, to: 'admin@arihakdang.com' } })).json()).error, 'forward_invalid', '자기 메일함 금지');
eq((await api(env, 'prefs', cookie, 'PATCH', { forward: { enabled: true, to: 'a@x.test,b@x.test,c@x.test,d@x.test,e@x.test,f@x.test' } })).status, 422, '최대 5개');
eq((await api(env, 'prefs', cookie, 'PATCH', { forward: { enabled: false, to: 'not-an-address' } })).status, 422, '형식 검사');
// 설정이 꺼져 있으면 아무것도 하지 않습니다.
await ingestReceived(env, 'f4'); eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').length, 0, '꺼짐: 발송 없음');
const r = await (await api(env, 'prefs', cookie, 'PATCH', { forward: { enabled: true, to: 'Me <me@gmail.test>, boss@example.test' } })).json();
eq(r.prefs.forward.to, 'me@gmail.test, boss@example.test', '주소 정리 저장'); eq((await (await api(env, 'session', cookie)).json()).prefs.forward.enabled, true, '세션에 실림');
const r1 = await ingestReceived(env, 'f1', { source: 'webhook' }); eq(r1.auto.forward.status, 'sent', '전달됨');
const fwd = calls.find((c) => c.path === '/emails' && c.method === 'POST'); ok(fwd, '발송 호출');
eq(fwd.body.to.join(','), 'me@gmail.test,boss@example.test', '전달 주소'); eq(fwd.body.from, '"Sender (via admin@arihakdang.com)" <admin@arihakdang.com>', '보내는 사람은 메일함, 이름은 원래 발신자');
eq(fwd.body.reply_to[0], 'Sender <sender@example.test>', '답장은 원래 발신자에게'); eq(fwd.body.subject, '받은 메일 f1', '제목 유지');
ok(fwd.body.html.includes('cid:pic1'), '원본 HTML'); eq(fwd.body.headers[FORWARD_MARKER], 'admin@arihakdang.com', '전달 표시 헤더');
eq(fwd.body.attachments.length, 2, '첨부·인라인 포함'); ok(fwd.body.attachments.some((a) => a.content_id === 'pic1' && a.content === Buffer.from('png').toString('base64')), '인라인 cid'); eq(fwd.key, 'auto-forward-' + r1.id, '멱등 키');
// 같은 메일을 다시 처리해도(원본 없이 저장했다가 다시 채우는 경우) 두 번 보내지 않습니다.
eq((await autoProcess(env, r1.id)).forward.detail, 'claimed', '중복 처리 방지'); eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').length, 1, '발송 1회');
eq((await ingestReceived(env, 'f2')).auto.forward.detail, 'loop', '전달 고리 차단'); eq((await ingestReceived(env, 'f3')).auto.forward.detail, 'self', '전달 대상이 보낸 메일');
await env.DB.prepare("DELETE FROM messages WHERE provider_id='f4'").run(); eq((await ingestReceived(env, 'f4')).auto.forward.detail, 'stale', '옛 메일');
eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').length, 1, '건너뛴 경우 발송 없음');
// 메일 상세에 처리 기록이 실리고, 원본은 받은 메일함에 그대로 남습니다.
const m = (await (await api(env, 'mail/' + r1.id, cookie)).json()).message; eq(m.autoActions[0].kind, 'forward', '기록'); eq(m.autoActions[0].status, 'sent', '상태'); eq(m.folder, 'inbox', '원본 유지');
eq((await (await api(env, 'mail?folder=sent', cookie)).json()).total, 0, '전달 메일은 보낸 메일함에 쌓지 않음');
// 발송 실패는 수신 저장을 막지 않고 failed 로 기록됩니다.
const inner = fakeResend({ received: [sample('f5')], calls }); env.RESEND_FETCH = (url, options = {}) => options.method === 'POST' && new URL(url).pathname === '/emails' ? Response.json({ message: 'boom' }, { status: 500 }) : inner(url, options);
const bad = await ingestReceived(env, 'f5'); ok(bad.imported, '수신은 저장'); eq(bad.auto.forward.status, 'failed', '실패 기록'); ok(bad.auto.forward.detail.includes('boom'), '실패 사유 기록');
// 다른 계정의 메일함은 그 계정의 설정을 따릅니다(mailboxUser).
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test'); eq((await mailboxUser(env, 'info@arihakdang.com')).login, 'info', '계정 메일함 주인'); eq((await mailboxUser(env, 'admin@arihakdang.com')).source, 'www', '기본 메일함 주인'); eq(await mailboxUser(env, 'nobody@arihakdang.com'), null, '주인 없음');
});
await t('0.8.16 자동 응답: 기간·기한 없음 판단, 자동 발송 메일 제외, 상대별 하루 한 번, 보낸 메일함 저장', async () => {
const h = (ms) => new Date(Date.now() + ms).toISOString();
eq(autoReplyActive({ enabled: true, noLimit: true, message: 'x' }), true, '기한 없음'); eq(autoReplyActive({ enabled: false, noLimit: true, message: 'x' }), false, '꺼짐'); eq(autoReplyActive({ enabled: true, noLimit: true, message: ' ' }), false, '메시지 없음');
eq(autoReplyActive({ enabled: true, noLimit: false, start: h(-3600000), end: h(3600000), message: 'x' }), true, '기간 안'); eq(autoReplyActive({ enabled: true, noLimit: false, start: h(3600000), end: h(7200000), message: 'x' }), false, '시작 전');
eq(autoReplyActive({ enabled: true, noLimit: false, start: '', end: h(-1000), message: 'x' }), false, '종료 뒤'); eq(autoReplyActive({ enabled: true, noLimit: false, start: '', end: h(1000), message: 'x' }), true, '시작 비우면 지금부터'); eq(autoReplyActive({ enabled: true, noLimit: false, start: '', end: '', message: 'x' }), false, '종료 없으면 동작 안 함');
eq(automatedReason({ sender: 'a@b.test' }, { 'Auto-Submitted': 'auto-replied' }), 'auto-submitted', 'RFC 3834'); eq(automatedReason({ sender: 'a@b.test' }, { precedence: 'bulk' }), 'bulk', 'Precedence'); eq(automatedReason({ sender: 'a@b.test' }, { 'List-Id': '<x.list>' }), 'list', '메일링 리스트');
eq(automatedReason({ sender: 'MAILER-DAEMON@mx.test' }, {}), 'noreply', '반송'); eq(automatedReason({ sender: 'No Reply <noreply@shop.test>' }, {}), 'noreply', 'no-reply'); eq(automatedReason({ sender: 'Kim <kim@b.test>' }, { 'Auto-Submitted': 'no' }), null, '사람');
const calls = [];
const received = [sample('r1'), sample('r2'), sample('r3', { from: 'Other <other@example.test>', reply_to: ['Desk <desk@example.test>'] }), sample('r4', { headers: { 'Auto-Submitted': 'auto-generated' } }), sample('r5', { from: 'noreply@shop.test' })];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received, calls }) }), cookie = await login(env);
// 설정 검사: 켜는데 메시지가 없거나, 기한 제한인데 종료가 없거나 종료가 시작보다 앞서면 거부. 시각은 ISO 로 정리해 저장.
eq((await (await api(env, 'prefs', cookie, 'PATCH', { autoReply: { enabled: true, noLimit: true, message: '' } })).json()).error, 'autoreply_message', '메시지 필요');
eq((await (await api(env, 'prefs', cookie, 'PATCH', { autoReply: { enabled: true, noLimit: false, end: '', message: '부재중' } })).json()).error, 'autoreply_period', '종료 필요');
eq((await (await api(env, 'prefs', cookie, 'PATCH', { autoReply: { enabled: true, noLimit: false, start: h(7200000), end: h(3600000), message: '부재중' } })).json()).error, 'autoreply_period', '종료가 시작보다 앞');
eq((await (await api(env, 'prefs', cookie, 'PATCH', { autoReply: { enabled: true, noLimit: false, start: 'garbage', end: h(3600000), message: '부재중' } })).json()).error, 'autoreply_period', '시각 형식');
let r = await (await api(env, 'prefs', cookie, 'PATCH', { autoReply: { enabled: true, noLimit: false, start: '2026-10-01T09:00', end: h(3600000), subject: '', message: '휴가 중입니다.\r\n10일에 돌아옵니다.' } })).json();
eq(r.prefs.autoReply.start, new Date('2026-10-01T09:00').toISOString(), 'ISO 정리'); // 오프셋 없는 시각은 서버의 시간대로 해석되므로 기대값도 같은 방식으로 만듭니다(UTC·KST 서버 모두 통과) eq(r.prefs.autoReply.message, '휴가 중입니다.\n10일에 돌아옵니다.', '줄바꿈 정리'); eq(r.autoReplyActive, true, '지금 동작 중'); eq((await (await api(env, 'session', cookie)).json()).autoReplyActive, true, '세션 응답');
const r1 = await ingestReceived(env, 'r1', { source: 'webhook' }); eq(r1.auto.reply.status, 'sent', '응답 보냄');
const sent = calls.filter((c) => c.path === '/emails' && c.method === 'POST'); eq(sent.length, 1, '발송 1회');
eq(sent[0].body.to[0], 'sender@example.test', '보낸 사람에게'); eq(sent[0].body.subject, 'Re: 받은 메일 r1', '제목 비우면 Re: 원제목'); eq(sent[0].body.text, '휴가 중입니다.\n10일에 돌아옵니다.', '본문');
ok(sent[0].body.html.includes('휴가 중입니다.<br>10일에'), 'HTML 본문'); eq(sent[0].body.headers['Auto-Submitted'], 'auto-replied', 'RFC 3834 표시'); eq(sent[0].body.headers['In-Reply-To'], '<r1@example.test>', '스레드'); eq(sent[0].body.from, '관리자 <admin@arihakdang.com>', '보내는 사람');
const box = await (await api(env, 'mail?folder=sent', cookie)).json(); eq(box.total, 1, '보낸 메일함에 저장'); eq(box.messages[0].subject, 'Re: 받은 메일 r1', '보낸 메일 제목');
const m = (await (await api(env, 'mail/' + r1.id, cookie)).json()).message; eq(m.answered, true, '원래 메일에 답장함 표시'); eq(m.autoActions[0].kind, 'reply', '기록'); eq(m.autoActions[0].sent_message_id, box.messages[0].id, '보낸 메일 연결');
eq((await ingestReceived(env, 'r2')).auto.reply.detail, 'recent', '같은 상대 하루 한 번');
const r3 = await ingestReceived(env, 'r3'); eq(r3.auto.reply.status, 'sent', '다른 상대'); eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').at(-1).body.to[0], 'desk@example.test', 'Reply-To 우선');
eq((await ingestReceived(env, 'r4')).auto.reply.detail, 'auto-submitted', '자동 발송 메일 제외'); eq((await ingestReceived(env, 'r5')).auto.reply.detail, 'noreply', 'no-reply 제외');
eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').length, 2, '발송은 2회뿐');
// 사용자 지정 제목, 기간이 끝나면 멈춤, 기한 없음이면 계속.
await api(env, 'prefs', cookie, 'PATCH', { autoReply: { subject: '[자동 응답] 부재중', start: '', end: h(3600000) } });
env.RESEND_FETCH = fakeResend({ received: [sample('r6', { from: 'Lee <lee@example.test>' }), sample('r7', { from: 'Park <park@example.test>' }), sample('r8', { from: 'Choi <choi@example.test>' })], calls });
await ingestReceived(env, 'r6'); eq(calls.at(-1).body.subject, '[자동 응답] 부재중', '지정 제목');
r = await (await api(env, 'prefs', cookie, 'PATCH', { autoReply: { enabled: true, noLimit: false, start: h(-7200000), end: h(-3600000) } })).json(); eq(r.autoReplyActive, false, '기간 지남');
eq((await ingestReceived(env, 'r7')).auto, undefined, '기간 밖: 처리 없음');
await api(env, 'prefs', cookie, 'PATCH', { autoReply: { noLimit: true } }); eq((await ingestReceived(env, 'r8')).auto.reply.status, 'sent', '기한 없음');
eq((await (await api(env, 'prefs', cookie, 'PATCH', { autoReply: { enabled: false } })).json()).autoReplyActive, false, '끄기');
// 게이트웨이 계정 메일함도 자기 설정으로 동작합니다. 전달과 자동 응답을 함께 켜면 둘 다 합니다.
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test'); const info = await verifyAdmin(env, 'info', 'staff-secret-1');
await writePrefs(env, info, { forward: { enabled: true, to: 'archive@example.test' }, autoReply: { enabled: true, noLimit: true, message: '문의 감사합니다.' } });
env.RESEND_FETCH = fakeResend({ received: [sample('i1', { to: ['info@arihakdang.com'] })], calls });
const i1 = await ingestReceived(env, 'i1'); eq(i1.auto.forward.status, 'sent', '계정 전달'); eq(i1.auto.reply.status, 'sent', '계정 자동 응답');
eq(calls.at(-1).body.from, 'Ari Hak Dang <info@arihakdang.com>', '이름 없는 계정은 기본 발신 이름'); eq(calls.at(-2).body.to[0], 'archive@example.test', '전달 주소');
});
await t('0.8.16 오류 수정: 웹 발송은 초안의 From 을 믿지 않고, cid 그림 초안을 보존·발송하며, 수정한 초안의 옛 원본을 버리고, 빈 PATCH 는 거부하고, 주소 이름을 바르게 나눈다', async () => {
const calls = [];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }), cookie = await login(env);
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test'); const ic = (await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0];
// 1) 일반 계정이 IMAP 으로 From: 관리자 메일함인 초안을 올려 웹에서 보내도 보내는 주소는 자기 메일함입니다.
const forged = Buffer.from('From: Boss <admin@arihakdang.com>\r\nTo: victim@example.test\r\nSubject: forged\r\nContent-Type: text/plain\r\n\r\nhello\r\n');
const { id: fid } = await appendMessage(env, 'drafts', forged, { mailbox: 'info@arihakdang.com' });
eq((await api(env, 'mail/' + fid + '/send', ic, 'POST', {})).status, 200, '발송'); eq(calls.at(-1).body.from, 'Ari Hak Dang <info@arihakdang.com>', 'From 은 자기 메일함');
// 2) IMAP 으로 올린 cid 그림 초안: 그림 첨부가 지워지지 않고 cid 그대로 발송에 실립니다. 상세 응답의 previewHtml 은 편집기용 주소로 바뀝니다.
const related = buildEml({ from: 'info@arihakdang.com', to: ['x@example.test'], subject: 'pic', html: '<p>pic: <img src="cid:part1@x"></p>', text: 'pic', attachments: [{ filename: 'p.png', contentType: 'image/png', contentId: 'part1@x', disposition: 'inline', bytes: Buffer.from('png') }] });
const { id: pid } = await appendMessage(env, 'drafts', related, { mailbox: 'info@arihakdang.com' });
const detail = (await (await api(env, 'mail/' + pid, ic)).json()).message; ok(detail.previewHtml.includes('/api/mail/' + pid + '/attachments/'), '편집기용 그림 주소');
await sendDraft(env, pid); const sentPic = calls.at(-1).body; eq(sentPic.attachments.length, 1, '인라인 그림 발송'); eq(sentPic.attachments[0].content_id, 'part1@x', 'cid'); ok(sentPic.html.includes('src="cid:part1@x"'), '본문 cid 유지');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM attachments WHERE message_id=?').bind(pid).first()).n, 1, '첨부 보존');
// 3) IMAP 으로 올린 초안을 웹에서 고치면 옛 원본(.eml)은 버리고 raw_status 가 none 이 됩니다(IMAP/POP 이 새 내용을 재구성).
const plain = Buffer.from('From: info@arihakdang.com\r\nTo: a@example.test\r\nSubject: old subject\r\nContent-Type: text/plain\r\n\r\nold body\r\n');
const { id: did } = await appendMessage(env, 'drafts', plain, { mailbox: 'info@arihakdang.com' }); eq((await env.DB.prepare('SELECT raw_status FROM messages WHERE id=?').bind(did).first()).raw_status, 'stored', '올린 원본');
eq((await api(env, 'mail/' + did, ic, 'PATCH', { to: 'b@example.test', subject: 'NEW subject', html: '<p>new</p>' })).status, 200, '수정');
eq((await env.DB.prepare('SELECT raw_status,raw_size FROM messages WHERE id=?').bind(did).first()).raw_status, 'none', '옛 원본 폐기'); eq(await env.DB.prepare('SELECT 1 FROM message_raw WHERE message_id=?').bind(did).first(), null, 'message_raw 삭제');
await addAttachment(env, did, { filename: 'f.txt', contentType: 'text/plain', bytes: Buffer.from('f') }); eq((await env.DB.prepare('SELECT raw_status FROM messages WHERE id=?').bind(did).first()).raw_status, 'none', '첨부 뒤에도 none');
// 4) 동작도 내용도 없는 PATCH {} 는 400 이고 초안은 그대로입니다.
eq((await api(env, 'mail/' + did, ic, 'PATCH', {})).status, 400, '빈 PATCH 거부'); eq((await env.DB.prepare('SELECT subject FROM messages WHERE id=?').bind(did).first()).subject, 'NEW subject', '초안 유지');
// 5) 주소 이름: 따옴표 안 콜론·"Re:"·부호화된 쉼표가 이름을 자르거나 쪼개지 않고, 그룹 구문은 여전히 구성원만 남깁니다.
const addrs = parseAddresses('"Meeting 10:30" <a@b.test>, Re: invoice <c@b.test>, =?UTF-8?B?6rmALCDrr7zsiJg=?= <k@x.test>, team: d@b.test, "E, F" <e@b.test>;');
eq(addrs.map((a) => (a.name || '') + '|' + a.mailbox + '@' + a.host).join(' / '), 'Meeting 10:30|a@b.test / Re: invoice|c@b.test / 김, 민수|k@x.test / |d@b.test / E, F|e@b.test', '주소 이름 해석');
void cookie;
});
await t('개수 조회 API 는 읽음 처리 직후의 읽지 않음 수를 돌려준다', async () => {
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b')] }) }), cookie = await login(env);
await ingestReceived(env, 'a'); await ingestReceived(env, 'b');
let c = await (await api(env, 'mail/counts', cookie)).json(); eq(c.counts.find((x) => x.folder === 'inbox').unread, 2, '읽지 않음 2');
const a = await env.DB.prepare("SELECT id FROM messages WHERE provider_id='a'").first(); await api(env, 'mail/' + a.id, cookie);
c = await (await api(env, 'mail/counts', cookie)).json(); eq(c.counts.find((x) => x.folder === 'inbox').unread, 1, '읽은 뒤 1');
await api(env, 'mail/bulk', cookie, 'POST', { ids: [a.id], action: 'trash' });
c = await (await api(env, 'mail/counts', cookie)).json(); eq(c.counts.find((x) => x.folder === 'inbox').n, 1, '이동 반영'); eq(c.counts.find((x) => x.folder === 'trash').n, 1, '휴지통 반영');
});
await t('0.8.18 독립 실행: www DB 없이 기본 메일함 별칭 계정을 만들고 그 계정이 로그인·관리자 페이지·메일함 설정의 주인이 된다', async () => {
// WWW_DB 를 빼면 독립 실행입니다. 기본 메일함(admin@…)의 앞부분을 로그인 ID 로 하는 게이트웨이 계정을 만들 수 있어야 합니다.
const env = await makeEnv({ WWW_DB: undefined, MAILBOX: 'admin@heart.test' });
delete env.WWW_DB;
ok(standalone(env), '독립 실행 판정');
const id = await createUser(env, { login: 'admin', name: '관리자', password: 'standalone-pw-1', role: 'admin' }, 'cli');
const me = await verifyAdmin(env, 'admin', 'standalone-pw-1');
eq(me?.source, 'local', '게이트웨이 계정으로 로그인'); eq(me.mailbox, 'admin@heart.test', '메일함 = 기본 메일함'); eq(me.role, 'admin', '관리자');
ok(await verifyAdmin(env, 'admin@heart.test', 'standalone-pw-1'), '메일함 주소로도 로그인');
eq(await verifyAdmin(env, 'admin', 'wrong-password-1'), null, '틀린 비밀번호');
// 기본 메일함의 설정 주인(전달·자동 응답)이 이 계정입니다. www 연동에서는 www 관리자만 주인이 될 수 있었습니다.
eq((await mailboxUser(env, 'admin@heart.test'))?.id, id, '기본 메일함의 주인');
// 관리자 페이지: 별도 세션으로 들어가 계정 목록을 보면 자신이 목록에 있고 www 행(me)은 없습니다.
const cookie = ADMIN_COOKIE + '=' + await createSession(env, me, new Request('https://x'), { scope: 'admin' });
const list = await (await adminApi(env, 'admin/users', cookie)).json();
eq(list.me, null, 'www 행 없음'); eq(list.users.map((u) => u.login).join(), 'admin', '계정 목록'); eq(list.defaultMailbox, 'admin@heart.test', '기본 메일함');
// www 연동 환경에서는 별칭 로그인 ID 를 여전히 막습니다(www 관리자 몫).
const linked = await makeEnv({ MAILBOX: 'admin@heart.test' });
await createUser(linked, { login: 'admin', password: 'standalone-pw-1' }, 'cli').then(() => ok(false, '연동 환경은 거부'), (e) => eq(e.message, 'invalid_login', '연동 환경은 거부'));
});
await t('0.8.24 비밀번호 정책: 기본 6자, 관리자가 길이·대소문자·숫자·특수문자 규칙을 정하고 계정 추가·수정·API 가 그 규칙으로 검사한다', async () => {
const env = await makeEnv();
eq((await readPasswordPolicy(env)).minLength, 6, '기본 최소 6자');
// 예전의 10자 규칙이 사라졋는지: 6자 비밀번호로 계정 생성이 됩니다. 5자는 거부.
await createUser(env, { login: 'six', password: 'abcdef' }, 'test');
await createUser(env, { login: 'five', password: 'abcde' }, 'test').then(() => ok(false, '5자 거부'), (e) => { eq(e.message, 'weak_password', '5자 거부'); eq(e.problems.join(), 'length', '어긋난 항목'); });
// 규칙 검사 함수
eq(passwordProblems('abcdef', { minLength: 8, mixedCase: true, digit: true, special: true }).join(), 'length,mixedCase,digit,special', '모든 항목');
eq(passwordProblems('Abcdef1!', { minLength: 8, mixedCase: true, digit: true, special: true }).length, 0, '모두 만족');
eq(passwordProblems('한글비밀번호', { minLength: 6, mixedCase: false, digit: false, special: false }).length, 0, '글자 수는 코드 포인트 기준');
// 관리자 API 로 정책 변경: 6 미만·65 이상·정수 아님은 거부(422 policy_invalid)
const cookie = await adminLogin(env);
eq((await adminApi(env, 'admin/policy', cookie)).status, 200, '정책 조회');
eq((await (await adminApi(env, 'admin/policy', cookie, 'PATCH', { minLength: 5 })).json()).error, 'policy_invalid', '5 거부');
eq((await (await adminApi(env, 'admin/policy', cookie, 'PATCH', { minLength: 65 })).json()).error, 'policy_invalid', '65 거부');
eq((await (await adminApi(env, 'admin/policy', cookie, 'PATCH', { minLength: '8.5' })).json()).error, 'policy_invalid', '정수 아님 거부');
let r = await (await adminApi(env, 'admin/policy', cookie, 'PATCH', { minLength: 8, mixedCase: true, digit: 1, special: false })).json();
eq(JSON.stringify(r.policy), JSON.stringify({ minLength: 8, mixedCase: true, digit: true, special: false }), '정책 저장');
eq((await (await adminApi(env, 'admin/users', cookie)).json()).policy.minLength, 8, '계정 목록에 정책 포함');
// 새 규칙으로 계정 추가: 어긋나면 422 + problems/policy, 맞으면 201
r = await adminApi(env, 'admin/users', cookie, 'POST', { login: 'weak', password: 'abcdefgh' });
eq(r.status, 422, '규칙 위반 422'); const body = await r.json(); eq(body.error, 'weak_password', '코드'); eq(body.problems.join(), 'mixedCase,digit', '어긋난 항목'); eq(body.policy.minLength, 8, '정책 동봉');
eq((await adminApi(env, 'admin/users', cookie, 'POST', { login: 'strong', password: 'Abcdefg1' })).status, 201, '규칙 만족');
// 수정(비밀번호 변경)도 같은 규칙. 기존 계정(six, 6자)은 소급 적용되지 않아 그대로 로그인됩니다.
const six = (await (await adminApi(env, 'admin/users', cookie)).json()).users.find((u) => u.login === 'six');
eq((await adminApi(env, 'admin/users/' + six.id, cookie, 'PATCH', { password: 'short1A' })).status, 422, '수정도 검사');
eq((await adminApi(env, 'admin/users/' + six.id, cookie, 'PATCH', { password: 'Longer1x' })).status, 200, '수정 통과');
ok(await verifyAdmin(env, 'strong', 'Abcdefg1'), '새 계정 로그인');
// 비관리자 세션으로는 정책을 바꿀 수 없습니다.
eq((await api(env, 'admin/policy', await login(env), 'PATCH', { minLength: 6 })).status, 403, '메일 세션 거부');
// 정책 완화(기본으로 되돌림)도 됩니다.
eq((await (await adminApi(env, 'admin/policy', cookie, 'PATCH', { minLength: 6, mixedCase: false, digit: false })).json()).policy.mixedCase, false, '완화');
});
await t('0.8.24 초기 관리자: 독립 실행에 계정이 없으면 admin / adminpasswd 가 자동으로 생기고, 비밀번호를 바꾸면 초기 비밀번호 경고가 사라진다', async () => {
const env = await makeEnv({ MAILBOX: 'admin@heart.test' }); delete env.WWW_DB;
eq(INITIAL_ADMIN_PASSWORD, 'adminpasswd', '초기 비밀번호 값');
const made = await ensureInitialAdmin(env);
eq(made?.login, 'admin', '초기 계정'); eq(made.password, 'adminpasswd', '초기 비밀번호');
eq(await ensureInitialAdmin(env), null, '두 번째 호출은 만들지 않음');
const me = await verifyAdmin(env, 'admin', 'adminpasswd'); eq(me?.role, 'admin', '초기 비밀번호로 로그인'); eq(me.mailbox, 'admin@heart.test', '기본 메일함 주인');
ok(await usesInitialPassword(env, me), '초기 비밀번호 표시');
// 세션 조회에 initialPassword 가 실려 화면이 경고를 띄웁니다.
const cookie = SESSION_COOKIE + '=' + await createSession(env, me, new Request('https://x'));
eq((await (await api(env, 'session', cookie)).json()).initialPassword, true, '세션에 경고 표시');
// 정책을 올린 뒤에도(초기 비밀번호는 정책 검사를 받지 않음) 바꾸는 새 비밀번호는 정책을 따릅니다.
await writePasswordPolicy(env, { minLength: 8, digit: true });
await updateUser(env, me.id, { password: 'nodigits' }).then(() => ok(false, '정책 위반 거부'), (e) => eq(e.message, 'weak_password', '정책 위반 거부'));
await updateUser(env, me.id, { password: 'changed12' });
eq(await usesInitialPassword(env, me), false, '바꾸면 표시 해제');
eq(await verifyAdmin(env, 'admin', 'adminpasswd'), null, '옛 비밀번호 무효');
// www 연동 환경에서는 초기 관리자를 만들지 않습니다(www 관리자가 있음).
eq(await ensureInitialAdmin(await makeEnv()), null, 'www 연동은 생성 안 함');
// 독립 실행이라도 계정이 이미 있으면 만들지 않습니다.
const env2 = await makeEnv({ MAILBOX: 'admin@heart.test' }); delete env2.WWW_DB;
await createUser(env2, { login: 'someone', password: 'whatever1' }, 'test');
eq(await ensureInitialAdmin(env2), null, '계정이 있으면 생성 안 함');
});
await t('0.8.24 오류 수정: 정지 계정의 메일도 저장되고, 우리 메일함 둘에 온 메일은 둘 다 받으며, 자동 전달 사본은 보낸 메일함에 쌓이지 않고, 없는 계정의 로그인도 해시 시간을 쓴다', async () => {
const stamp = new Date().toISOString();
const received = [
{ id: 'both', from: 'S <s@ext.test>', to: ['info@arihakdang.com', 'admin@arihakdang.com'], subject: '둘에게', text: 'x', created_at: stamp, headers: {} },
{ id: 'susp', from: 'S <s@ext.test>', to: ['info@arihakdang.com'], subject: '정지 계정에게', text: 'x', created_at: stamp, headers: {} },
{ id: 'f1', from: 'Sender <s@ext.test>', to: ['admin@arihakdang.com'], subject: '전달될 메일', text: 'body', html: '<p>body</p>', created_at: stamp, headers: {} },
];
const sent = [], calls = [];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received, sent, calls }) });
const infoId = await createUser(env, { login: 'info', password: 'password-1234' }, 'admin');
// 1) 두 메일함 모두에 저장: 주 저장본(provider_id 그대로) + 사본(provider_id#메일함). 다시 가져와도 중복되지 않습니다.
const r = await ingestReceived(env, 'both'); eq(r.copies?.length, 1, '사본 1개'); eq(r.copies[0].mailbox, 'admin@arihakdang.com', '사본 메일함');
const rows = (await env.DB.prepare("SELECT mailbox, provider_id, folder FROM messages WHERE provider_id = 'both' OR provider_id LIKE 'both#%' ORDER BY mailbox").all()).results;
eq(rows.map((x) => x.mailbox).join(), 'admin@arihakdang.com,info@arihakdang.com', '두 메일함'); eq(rows.map((x) => x.provider_id).join(), 'both#admin@arihakdang.com,both', '사본 provider_id'); ok(rows.every((x) => x.folder === 'inbox'), '받은 메일함');
eq((await ingestReceived(env, 'both')).skipped, 'exists', '재수신은 건너뜀');
eq((await env.DB.prepare("SELECT COUNT(*) AS n FROM messages WHERE provider_id LIKE 'both%'").first()).n, 2, '중복 없음');
ok((await env.DB.prepare("SELECT COUNT(*) AS n FROM message_raw WHERE message_id IN (SELECT id FROM messages WHERE provider_id LIKE 'both%')").first()).n === 2, '원본도 사본에');
// 2) 정지 계정의 메일함은 수신 대상에 남습니다(예전에는 other_mailbox 로 버려져 유실).
await updateUser(env, infoId, { status: 'suspended' });
ok((await knownMailboxes(env)).includes('info@arihakdang.com'), '정지 계정 메일함 유지');
ok((await ingestReceived(env, 'susp')).imported, '정지 계정 메일 저장');
eq(await verifyAdmin(env, 'info', 'password-1234'), null, '정지 계정은 로그인 불가');
// 3) 자동 전달로 나간 메일은 동기화가 보낸 메일함으로 가져오지 않습니다.
const owner = await mailboxUser(env, 'admin@arihakdang.com');
await writePrefs(env, owner, { forward: { enabled: true, to: 'archive@ext.test' } });
const f = await ingestReceived(env, 'f1', { source: 'webhook' }); eq(f.auto.forward.status, 'sent', '전달됨');
eq(sent.length, 1, '발송 목록에 전달 사본'); eq((await ingestSent(env, sent[0].id)).skipped, 'auto_forward', '전달 사본은 건너뜀');
const s = await syncMailbox(env, { trigger: 'timer' }); eq((await env.DB.prepare("SELECT COUNT(*) AS n FROM messages WHERE folder='sent'").first()).n, 0, '보낸 메일함 비어 있음 ' + JSON.stringify(s));
// 4) 없는 계정의 비밀번호 검사도 실제 해시를 돌려 시간으로 계정 존재가 드러나지 않습니다(있는 계정의 절반 이상 걸려야 함).
const time = async (login) => { const t0 = process.hrtime.bigint(); for (let i = 0; i < 3; i++) await verifyAdmin(env, login, 'wrong-password-xx'); return Number(process.hrtime.bigint() - t0) / 3e6; };
await updateUser(env, infoId, { status: 'active' }); await time('info');
const existing = await time('info'), missing = await time('nobody-here');
ok(missing > existing * 0.5, `없는 계정도 해시 시간 사용(${missing.toFixed(0)}ms vs ${existing.toFixed(0)}ms)`);
});
await t('0.8.25→0.9.0 비밀번호: 관리자 페이지는 다른 계정만 한 번 입력으로 재설정하고, 자기 비밀번호는 설정(PATCH /api/password)에서 현재 비밀번호·2회 입력으로 바꾼다. 서비스 이름은 도메인에서 정한다', async () => {
eq(siteName('heart-m.com'), 'HEART-M 메일', '도메인 → 이름'); eq(siteName('cafe.heart-m.com'), 'CAFE 메일', '하위 도메인은 첫 이름표'); eq(siteName('arihakdang.com'), 'ARIHAKDANG 메일', '아리학당'); eq(siteName('', '기본'), '기본', '도메인 없음');
// 독립 실행에서 게이트웨이 관리자(me)와 직원(staff)을 만들고 me 로 관리자 페이지에 들어갑니다.
const env = await makeEnv({ MAILBOX: 'admin@heart.test' }); delete env.WWW_DB;
const meId = await createUser(env, { login: 'admin', password: 'adminpasswd', role: 'admin' }, 'cli');
const otherId = await createUser(env, { login: 'staff', password: 'staff-pw-1', role: 'user' }, 'cli');
const me = await verifyAdmin(env, 'admin', 'adminpasswd');
const cookie = ADMIN_COOKIE + '=' + await createSession(env, me, new Request('https://x'), { scope: 'admin' });
eq((await (await adminApi(env, 'session', cookie)).json()).version, null, '세션 응답에 version(테스트 환경엔 APP_VERSION 없음 → null)');
// 1) 관리자 페이지에서 자기 비밀번호는 바꿀 수 없습니다(설정 화면으로 안내). 다른 항목(이름)은 바뀝니다.
let r = await adminApi(env, 'admin/users/' + meId, cookie, 'PATCH', { password: 'newpass-99' });
eq(r.status, 422, '자기 비밀번호 거부'); eq((await r.json()).error, 'self_password_settings', '코드'); ok(await verifyAdmin(env, 'admin', 'adminpasswd'), '비밀번호 유지');
eq((await adminApi(env, 'admin/users/' + meId, cookie, 'PATCH', { name: '관리자' })).status, 200, '이름만 수정');
// 2) 다른 계정은 한 번 입력으로 재설정(현재 비밀번호·확인 입력 불필요, 보조 필드는 무시).
eq((await adminApi(env, 'admin/users/' + otherId, cookie, 'PATCH', { password: 'reset-pw-77', passwordConfirm: 'ignored', currentPassword: '' })).status, 200, '다른 계정 재설정');
ok(await verifyAdmin(env, 'staff', 'reset-pw-77'), '재설정된 비밀번호');
// 3) 설정 화면의 비밀번호 변경: 웹 메일 세션(mail 범위)으로 PATCH /api/password.
const sc = (await api(env, 'login', '', 'POST', { login: 'staff', password: 'reset-pw-77' })).headers.get('set-cookie').split(';')[0];
r = await api(env, 'password', sc, 'PATCH', { password: 'my-new-pw-1', passwordConfirm: 'my-new-pw-2', currentPassword: 'reset-pw-77' }); eq((await r.json()).error, 'password_mismatch', '확인 불일치');
r = await api(env, 'password', sc, 'PATCH', { password: 'my-new-pw-1', passwordConfirm: 'my-new-pw-1', currentPassword: 'wrong' }); eq(r.status, 422, '현재 비밀번호 틀림'); eq((await r.json()).error, 'current_password_invalid', '코드');
r = await api(env, 'password', sc, 'PATCH', { password: 'short', passwordConfirm: 'short', currentPassword: 'reset-pw-77' }); eq((await r.json()).error, 'weak_password', '정책 적용');
r = await api(env, 'password', sc, 'PATCH', { password: 'my-new-pw-1', passwordConfirm: 'my-new-pw-1', currentPassword: 'reset-pw-77' }); eq(r.status, 200, '변경 성공');
ok(await verifyAdmin(env, 'staff', 'my-new-pw-1'), '새 비밀번호'); eq(await verifyAdmin(env, 'staff', 'reset-pw-77'), null, '옛 비밀번호 무효');
eq((await api(env, 'settings', sc)).status, 401, '옛 세션 종료(다시 로그인)');
ok((await env.DB.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action='password_change_denied'").first()).n >= 1, '거부 기록');
ok((await env.DB.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action='password_change'").first()).n >= 1, '변경 기록');
// 4) www 관리자는 설정 화면에서 바꿀 수 없습니다(www 사이트에서 관리).
const www = await makeEnv(); const wc = await login(www);
eq((await api(www, 'password', wc, 'PATCH', { password: 'x-pw-1234', passwordConfirm: 'x-pw-1234', currentPassword: 'admin-secret-12' })).status, 403, 'www 관리자 거부');
eq((await (await api(www, 'settings', wc)).json()).canChangePassword, false, '설정 응답의 canChangePassword');
eq((await (await api(env, 'settings', (await api(env, 'login', '', 'POST', { login: 'staff', password: 'my-new-pw-1' })).headers.get('set-cookie').split(';')[0])).json()).canChangePassword, true, '게이트웨이 계정은 가능');
});
await t('0.8.25 오류 수정: Content-ID 없는 inline 첨부는 일반 첨부로 발송되고, IMAP 초안의 Bcc 가 발송되며, 전달 첨부 복사 API, 헤더 없는 MIME 파트, <embed>·닫히지 않은 <title> 뒤 본문 보존', async () => {
// 1) sanitize: embed(빈 요소)·닫히지 않은 title 뒤의 본문이 남습니다. 닫힌 script 는 내용만 사라집니다.
eq(sanitizeHtml('<p>Intro</p><embed src="x.swf"><p>After</p>').text.replace(/\s+/g, ' ').trim(), 'Intro After', 'embed 뒤 본문');
eq(sanitizeHtml('<title>no close<p>Body</p>').text.trim(), 'Body', '닫히지 않은 title');
ok(!sanitizeHtml('<p>A</p><script>alert(1)</script><p>B</p>').html.includes('alert') && sanitizeHtml('<p>A</p><script>alert(1)</script><p>B</p>').text.includes('B'), 'script 내용 제거·뒤 본문 보존');
ok(!sanitizeHtml('<p>A</p><style>p{color:red}</style><p>B</p>').html.includes('color') && sanitizeHtml('<p>A</p><style>p{color:red}</style><p>B</p>').text.includes('B'), 'style 내용 제거');
// 2) 헤더 없는 MIME 파트(RFC 2046 5.1)
const eml = Buffer.from('From: a@x.test\r\nTo: b@x.test\r\nSubject: s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary="b1"\r\n\r\n--b1\r\n\r\nHello headerless\r\n--b1\r\nContent-Type: text/plain\r\n\r\nsecond\r\n--b1--\r\n');
const parsed = parseMime(eml); eq(parsed.text.slice(parsed.root.parts[0].bodyStart, parsed.root.parts[0].end).trim(), 'Hello headerless', '헤더 없는 파트 본문'); eq(summarize(parsed).text.trim(), 'Hello headerless', '요약 본문');
// 3) IMAP 으로 올린 초안: inline; filename=report.pdf(Content-ID 없음) + Bcc 헤더 → 웹 발송에서 첨부·숨은 참조가 모두 나갑니다.
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) });
const draftEml = buildEml({ from: 'admin@arihakdang.com', to: ['friend@example.test'], subject: '보고서', text: '첨부 확인', bcc: ['boss@example.test', 'audit@example.test'], attachments: [{ filename: 'report.pdf', contentType: 'application/pdf', bytes: Buffer.from('%PDF-1.4 fake'), disposition: 'inline' }] });
const draft = await appendMessage(env, 'drafts', draftEml);
const stored = await env.DB.prepare('SELECT bcc_json FROM messages WHERE id=?').bind(draft.id).first(); eq(stored.bcc_json, '["boss@example.test","audit@example.test"]', 'Bcc 저장');
eq((await env.DB.prepare('SELECT disposition FROM attachments WHERE message_id=?').bind(draft.id).first()).disposition, 'attachment', 'Content-ID 없는 inline 은 일반 첨부');
await sendDraft(env, draft.id);
const post = calls.find((c) => c.path === '/emails' && c.method === 'POST');
eq(JSON.stringify(post.body.bcc), '["boss@example.test","audit@example.test"]', 'bcc 발송'); eq(post.body.attachments?.[0]?.filename, 'report.pdf', '첨부 발송'); ok(!post.body.attachments[0].content_id, '일반 첨부로');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM attachments WHERE message_id=?').bind(draft.id).first()).n, 1, '첨부가 지워지지 않음');
// 4) 전달 첨부 복사 API: 받은 메일의 일반 첨부를 새 초안으로 복사(인라인·다른 메일함은 제외).
const inbound = await appendMessage(env, 'inbox', buildEml({ from: 'friend@example.test', to: ['admin@arihakdang.com'], subject: '청구서', text: 'x', html: '<p><img src="cid:logo"></p>', attachments: [{ filename: 'invoice.pdf', contentType: 'application/pdf', bytes: Buffer.from('pdf'), disposition: 'attachment' }, { filename: 'logo.png', contentType: 'image/png', bytes: Buffer.from('png'), contentId: 'logo', disposition: 'inline' }] }));
const cookie = await login(env);
const origAtt = (await (await api(env, 'mail/' + inbound.id + '/attachments', cookie)).json()).attachments;
const newDraft = (await (await api(env, 'mail', cookie, 'POST', { to: 'x@example.test', subject: 'Fwd: 청구서', html: '<p>fwd</p>' })).json()).id;
const copy = await api(env, 'mail/' + newDraft + '/attachments', cookie, 'POST', { from: inbound.id, ids: origAtt.map((a) => a.id) }, { 'Content-Type': 'application/json' });
eq(copy.status, 201, '복사 201'); eq((await copy.json()).ids.length, 1, '일반 첨부만 복사');
const copied = (await (await api(env, 'mail/' + newDraft + '/attachments', cookie)).json()).attachments; eq(copied.map((a) => a.filename).join(), 'invoice.pdf', '복사된 첨부');
// 다른 메일함의 메일을 원본으로 지정하면 거부됩니다(일반 계정 세션).
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test');
const sc = (await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0];
const theirs = (await (await api(env, 'mail', sc, 'POST', { to: 'x@example.test', subject: 's', html: '<p>x</p>' })).json()).id;
eq((await api(env, 'mail/' + theirs + '/attachments', sc, 'POST', { from: inbound.id, ids: origAtt.map((a) => a.id) }, { 'Content-Type': 'application/json' })).status, 404, '다른 메일함 원본 거부');
eq((await api(env, 'mail/' + newDraft + '/attachments', cookie, 'POST', { from: inbound.id }, { 'Content-Type': 'application/json' })).status, 400, 'ids 없으면 400');
});
await t('0.9.0 회원 가입: 끄면 404, 켜면 승인 대기/즉시 사용, 대기 계정 로그인은 pending_approval, 승인·거절, 계정 수 제한, IP 제한', async () => {
const env = await makeEnv({ MAILBOX: 'admin@heart.test', LIMIT_ACCOUNTS: '3' }); delete env.WWW_DB;
await createUser(env, { login: 'admin', password: 'adminpasswd', role: 'admin' }, 'cli');
const me = await verifyAdmin(env, 'admin', 'adminpasswd'); const ac = ADMIN_COOKIE + '=' + await createSession(env, me, new Request('https://x'), { scope: 'admin' });
eq((await (await api(env, 'session', '')).json()).signup, false, '세션 응답: 가입 꺼짐');
eq((await api(env, 'signup', '', 'POST', { login: 'kim', password: 'kim-pass-1', passwordConfirm: 'kim-pass-1' })).status, 404, '꺼져 있으면 404');
// 관리자가 켭니다(기본: 승인 후 사용).
let cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { signup: { enabled: true } })).json(); eq(cfg.signup.enabled, true, '켬'); eq(cfg.signup.approval, true, '기본 승인제'); eq(cfg.accounts.limit, 3, '시스템 계정 수');
eq((await (await api(env, 'session', '')).json()).signup, true, '세션 응답: 가입 켜짐');
eq((await (await api(env, 'signup', '', 'POST', { login: 'kim', password: 'kim-pass-1', passwordConfirm: 'kim-pass-2' })).json()).error, 'password_mismatch', '확인 불일치');
eq((await (await api(env, 'signup', '', 'POST', { login: 'kim', password: 'short' })).json()).error, 'weak_password', '정책');
let r = await api(env, 'signup', '', 'POST', { login: 'Kim', name: '김', password: 'kim-pass-1', passwordConfirm: 'kim-pass-1', role: 'admin' }); eq(r.status, 201, '가입'); const kim = await r.json(); eq(kim.pending, true, '승인 대기');
const users = await listUsers(env); const row = users.find((u) => u.login === 'kim'); eq(row.status, 'pending', '목록 상태 pending'); eq(row.role, 'user', '역할은 항상 사용자'); ok(await isPending(env, kim.id), '대기 표시');
r = await api(env, 'login', '', 'POST', { login: 'kim', password: 'kim-pass-1' }); eq(r.status, 403, '대기 계정 로그인 거부'); eq((await r.json()).error, 'pending_approval', '코드');
eq((await api(env, 'login', '', 'POST', { login: 'kim', password: 'wrong' })).status, 401, '비밀번호 틀리면 일반 실패(존재 여부 비노출)');
ok(await pendingLogin(env, 'kim@heart.test', 'kim-pass-1'), '메일함 주소로도 대기 판정');
eq((await (await adminApi(env, 'admin/users', ac)).json()).users.find((u) => u.login === 'kim').status, 'pending', '관리자 목록에 승인 대기');
// 승인: 상태를 사용으로.
eq((await adminApi(env, 'admin/users/' + kim.id, ac, 'PATCH', { status: 'active' })).status, 200, '승인'); eq(await isPending(env, kim.id), false, '대기 표시 해제');
eq((await api(env, 'login', '', 'POST', { login: 'kim', password: 'kim-pass-1' })).status, 200, '승인 뒤 로그인');
// 즉시 사용 모드
await adminApi(env, 'admin/config', ac, 'PATCH', { signup: { approval: false } });
r = await api(env, 'signup', '', 'POST', { login: 'lee', password: 'lee-pass-1', passwordConfirm: 'lee-pass-1' }); eq((await r.json()).pending, false, '즉시 사용');
eq((await api(env, 'login', '', 'POST', { login: 'lee', password: 'lee-pass-1' })).status, 200, '바로 로그인');
// 계정 수 제한(3): admin, kim, lee 로 가득 → 거절. 관리자 페이지의 계정 추가도 같은 제한.
r = await api(env, 'signup', '', 'POST', { login: 'park', password: 'park-pass-1' }); eq(r.status, 409, '계정 수 제한'); eq((await r.json()).limit, 3, '상한 전달');
eq((await (await adminApi(env, 'admin/users', ac, 'POST', { login: 'park', password: 'park-pass-1' })).json()).error, 'account_limit_reached', '관리자 추가도 제한');
// 거절 = 삭제(대기 표시도 지움)
await adminApi(env, 'admin/config', ac, 'PATCH', { signup: { approval: true } });
await adminApi(env, 'admin/users/' + kim.id, ac, 'DELETE');
r = await api(env, 'signup', '', 'POST', { login: 'choi', password: 'choi-pass-1' }); const choi = await r.json(); eq(r.status, 201, '자리 생김');
eq((await adminApi(env, 'admin/users/' + choi.id, ac, 'DELETE')).status, 200, '거절(삭제)'); eq(await isPending(env, choi.id), false, '대기 키 정리');
eq((await env.DB.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action='signup'").first()).n, 3, '가입 기록(kim·lee·choi; park 은 거절돼 기록 없음)');
// IP 제한: 1시간 5회(성공·실패 모두 셈)
for (let i = 0; i < 2; i++) await api(env, 'signup', '', 'POST', { login: 'x' + i, password: 'short' });
eq((await api(env, 'signup', '', 'POST', { login: 'y', password: 'y-pass-1' })).status, 429, 'IP 당 5회');
});
await t('0.9.0 제한: 시스템(환경)·관리자·계정별 메일함 크기, 첨부 크기(MIME 기준), 받는 사람 수, 월간 통수, 전체 용량과 현황(일·주·월 집계)', async () => {
eq(parseSize('500M'), 500 * 1024 * 1024, '크기 접미사'); eq(parseSize('2g'), 2 * 1024 ** 3, '소문자'); eq(parseSize('1.5K'), 1536, '소수'); eq(parseSize('100MB'), 100 * 1024 * 1024, 'MB 표기'); eq(parseSize(''), 0, '빈 값 = 제한 없음'); eq(parseSize('abc'), 0, '잘못된 값');
eq(mimeSize(3), 4, 'base64 크기'); eq(mimeSize(1000), 1336, 'base64 크기 2');
// 기간 경계는 Asia/Seoul 기준: 2026-10-12(월) 09:30 KST
const at = new Date('2026-10-12T00:30:00Z');
eq(periodBounds('day', 'Asia/Seoul', at).start, '2026-10-11T15:00:00.000Z', '오늘 0시 KST'); eq(periodBounds('day', 'Asia/Seoul', at).end, '2026-10-12T15:00:00.000Z', '내일 0시');
eq(periodBounds('week', 'Asia/Seoul', at).start, '2026-10-11T15:00:00.000Z', '이번 주 월요일'); eq(periodBounds('week', 'Asia/Seoul', at).end, '2026-10-18T15:00:00.000Z', '다음 월요일');
eq(periodBounds('month', 'Asia/Seoul', at).start, '2026-09-30T15:00:00.000Z', '이달 1일'); eq(periodBounds('month', 'Asia/Seoul', at).end, '2026-10-31T15:00:00.000Z', '다음 달 1일(= 말일까지)');
eq(periodBounds('month', 'UTC', new Date('2026-12-31T23:00:00Z')).end, '2027-01-01T00:00:00.000Z', '해 넘김');
// 시스템: 메일함 1MB, 첨부 200KB(MIME), 받는 사람 3, 월 5통, 전체 3MB. 관리자가 시스템 값을 넘기면 거부.
const env = await makeEnv({ LIMIT_MAILBOX_BYTES: '1M', LIMIT_ATTACHMENT_BYTES: '200K', LIMIT_RECIPIENTS: '3', LIMIT_MONTHLY_MESSAGES: '5', LIMIT_TOTAL_BYTES: '3M', RESEND_FETCH: fakeResend({ received: [sample('q1')] }) });
const sys = systemLimits(env); eq(sys.mailboxBytes, 1048576, '시스템 메일함'); eq(sys.recipients, 3, '시스템 수신자'); eq(sys.accounts, 0, '계정 제한 없음');
const ac = await adminLogin(env), cookie = await login(env);
let r = await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { recipients: 10 } }); eq(r.status, 422, '시스템 초과 거부'); const err = await r.json(); eq(err.error, 'limit_exceeds_system', '코드'); eq(err.field, 'recipients', '항목'); eq(err.limit, 3, '상한');
eq((await (await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { recipients: -1 } })).json()).error, 'limit_invalid', '음수 거부');
let cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { recipients: 2, attachmentBytes: 100 * 1024, mailboxBytes: 0 } })).json(); eq(cfg.limits.recipients, 2, '관리자 수신자 2'); eq(cfg.system.recipients, 3, '시스템 값 함께');
// 계정별 메일함 크기: 시스템(1MB) 이내만. 적용값 = 계정별 → 관리자 → 시스템 중 가장 작은 0 아닌 값.
const sid = (await (await adminApi(env, 'admin/users', ac, 'POST', { login: 'info', password: 'staff-secret-1', quotaBytes: 2 * 1048576 })).json()); eq(sid.error, 'limit_exceeds_system', '계정 크기 시스템 초과');
const info = await (await adminApi(env, 'admin/users', ac, 'POST', { login: 'info', password: 'staff-secret-1', quotaBytes: 300 * 1024 })).json(); eq(typeof info.id, 'string', '계정 생성');
eq(await mailboxQuota(env, 'info@arihakdang.com'), 300 * 1024, '계정별 적용'); eq(await mailboxQuota(env, 'admin@arihakdang.com'), 1048576, '기본 메일함은 시스템 값');
await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { mailboxBytes: 500 * 1024 } }); eq(await mailboxQuota(env, 'admin@arihakdang.com'), 500 * 1024, '관리자 기본값 적용');
const list = await (await adminApi(env, 'admin/users', ac)).json(); eq(list.users[0].quotaBytes, 300 * 1024, '목록에 계정 크기'); eq(list.users[0].limit, 300 * 1024, '적용값'); eq(list.system.mailboxBytes, 1048576, '시스템 값');
eq((await adminApi(env, 'admin/users/' + info.id, ac, 'PATCH', { quotaBytes: 0 })).status, 200, '0 = 기본값'); eq(await mailboxQuota(env, 'info@arihakdang.com'), 500 * 1024, '관리자 기본값으로');
// 받는 사람 수(관리자 2)
const d1 = await saveDraft(env, { to: 'a@example.test, b@example.test, c@example.test', subject: '많은 수신자', html: '<p>x</p>' });
r = await api(env, 'mail/' + d1 + '/send', cookie, 'POST', {}); eq(r.status, 422, '수신자 초과'); const e1 = await r.json(); eq(e1.error, 'too_many_recipients', '코드'); eq(e1.limit, 2, '상한');
eq((await (await api(env, 'mail/' + d1, cookie)).json()).message.state, 'draft', '검사는 발송 전이라 초안 유지');
// 첨부 크기: 관리자 100KB(MIME) → 파일 80KB 는 MIME 107KB 라 거부, 70KB 는 통과.
const d2 = await saveDraft(env, { to: 'a@example.test', subject: '첨부', html: '<p>x</p>' });
let threw = null; try { await addAttachment(env, d2, { filename: 'big.bin', contentType: 'application/octet-stream', bytes: Buffer.alloc(80 * 1024, 1) }); } catch (e) { threw = e; }
eq(threw?.message, 'attachment_too_large', '첨부 초과'); eq(threw?.limit, 100 * 1024, '상한(MIME)');
ok(await addAttachment(env, d2, { filename: 'ok.bin', contentType: 'application/octet-stream', bytes: Buffer.alloc(70 * 1024, 1) }), '허용 크기');
// 메일함 용량: info 메일함을 500KB 로 두고 600KB 짜리 받은 메일을 넣으면 가득 → 다음 수신은 quota_exceeded 로 건너뜀, 발송도 거부.
const infoUser = await verifyAdmin(env, 'info', 'staff-secret-1');
await appendMessage(env, 'inbox', buildEml({ from: 'x@example.test', to: ['info@arihakdang.com'], subject: 'big', text: 'x'.repeat(600 * 1024) }), { mailbox: 'info@arihakdang.com', enforceQuota: false });
ok((await mailboxUsage(env, 'info@arihakdang.com')) > 500 * 1024, '사용량 집계(MIME 기준)');
eq((await ingestReceived(env, 'q1', { summary: { to: ['info@arihakdang.com'] } })).skipped, undefined, '다른 메일함(admin) 수신은 영향 없음');
env.RESEND_FETCH = fakeResend({ received: [sample('q2', { to: ['info@arihakdang.com'] })] });
eq((await ingestReceived(env, 'q2')).skipped, 'quota_exceeded', '가득 찬 메일함 수신 건너뜀');
const sc = (await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0];
const sl = await (await api(env, 'mail?folder=inbox', sc)).json(); eq(sl.quotaBytes, 500 * 1024, '목록 응답에 적용 크기'); ok(sl.usageBytes > 500 * 1024, '목록 응답 사용량');
const d3 = (await (await api(env, 'mail', sc, 'POST', { to: 'a@example.test', subject: 's', html: '<p>x</p>' })).json()).id;
eq((await (await api(env, 'mail/' + d3 + '/send', sc, 'POST', {})).json()).error, 'quota_exceeded', '가득 찬 메일함에서 발송 거부');
threw = null; try { await appendMessage(env, 'drafts', buildEml({ from: 'info@arihakdang.com', to: ['a@example.test'], subject: 'd', text: 'x' }), { mailbox: 'info@arihakdang.com' }); } catch (e) { threw = e; } eq(threw?.message, 'quota_exceeded', 'IMAP APPEND 도 거부');
// 월간 통수(5): 이달에 보낸 4 + 받은 1 = 5 → 발송 거부. 자동 전달도 건너뜀(limit).
const { start } = periodBounds('month', 'Asia/Seoul'); const inMonth = new Date(Date.parse(start) + 3600000).toISOString();
for (let i = 0; i < 4; i++) await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,subject,state,received_at,created_at,updated_at,sent_at,mailbox) VALUES(?,'outbound','sent','a','s','sent',?,?,?,?,'admin@arihakdang.com')").bind('m' + i, inMonth, inMonth, inMonth, inMonth).run();
const month = await countMail(env, periodBounds('month', 'Asia/Seoul')); eq(month.sent, 4, '월 발신'); ok(month.received >= 1, '월 수신'); ok(month.total >= 5, '합계');
const d4 = await saveDraft(env, { to: 'a@example.test', subject: '월간', html: '<p>x</p>' });
r = await api(env, 'mail/' + d4 + '/send', cookie, 'POST', {}); const e4 = await r.json(); eq(e4.error, 'monthly_limit_exceeded', '월간 초과'); eq(e4.limit, 5, '상한');
await writePrefs(env, { id: 'www:admin-1', mailbox: 'admin@arihakdang.com' }, { forward: { enabled: true, to: 'fw@example.test' } });
// [Ariweb Suite 변경] 월간 통수 100% 에서는 새 수신도 임시 거부(skipped='monthly_limit', 보낸 서버가 재시도)합니다(Dev-Order §13.4). 예전에는 받되 자동 전달만 건너뛰었습니다.
env.RESEND_FETCH = fakeResend({ received: [sample('q3')] }); const q3 = await ingestReceived(env, 'q3'); eq(q3.skipped, 'monthly_limit', '월간 한도에서 새 수신 임시 거부');
// 현황
const st = await mailStats(env, await knownMailboxes(env), await listUsers(env));
eq(st.timezone, 'Asia/Seoul', '시간대'); ok(st.periods.month.total >= 5, '월간 합계'); ok(st.periods.day.received >= 1, '일일 수신');
const infoBox = st.mailboxes.find((m) => m.mailbox === 'info@arihakdang.com'); eq(infoBox.login, 'info', '메일함 주인'); eq(infoBox.limit, 500 * 1024, '적용 크기'); ok(infoBox.percent > 100, '사용 비율(초과)');
eq(st.usage.monthly.limit, 5, '월간 제한'); ok(st.usage.monthly.percent >= 100, '월간 비율'); eq(st.usage.total.limit, 3 * 1048576, '전체 제한'); eq(st.usage.accounts.limit, 0, '계정 제한 없음'); eq(st.usage.accounts.percent, null, '제한 없으면 비율 없음');
const status = await (await adminApi(env, 'admin/status', ac)).json(); eq(status.stats.usage.monthly.used, st.usage.monthly.used, '서버 상태 응답에 현황'); eq(status.services.pop, true, '서비스 상태');
// 전체 용량(3MB): admin 메일함에 2.5MB 를 넣어 가득 채우면 수신·발송 모두 거부.
await env.DB.prepare("UPDATE messages SET raw_size=?, mailbox='archive@arihakdang.com' WHERE id='m0'").bind(2.6 * 1048576).run(); // 다른 메일함의 용량도 전체 합계에 듭니다
env.RESEND_FETCH = fakeResend({ received: [sample('q4')] }); eq((await ingestReceived(env, 'q4')).skipped, 'storage_full', '전체 용량 초과 수신 건너뜀');
});
await t('0.9.0 POP/IMAP 서비스 스위치: 관리자가 끄면 계정 설정과 무관하게 로그인이 거부되고, 설정 화면에 알려진다', async () => {
const env = await makeEnv(), ac = await adminLogin(env), cookie = await login(env);
const cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { services: { pop: false } })).json(); eq(cfg.services.pop, false, 'POP 끔'); eq(cfg.services.imap, true, 'IMAP 유지');
eq((await (await api(env, 'settings', cookie)).json()).services.pop, false, '설정 응답에 반영');
const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); const r = await c.cmd('PASS admin-secret-12'); ok(r.startsWith('-ERR [SYS/PERM]') && /administrator/.test(r), 'POP 거부: ' + r); c.end();
await adminApi(env, 'admin/config', ac, 'PATCH', { services: { pop: true } });
const c2 = popClient(port); await c2.greeting(); await c2.cmd('USER arihakdang'); ok((await c2.cmd('PASS admin-secret-12')).startsWith('+OK'), '다시 켜면 로그인'); c2.end();
} finally { pop.close(); }
ok((await env.DB.prepare("SELECT detail FROM audit_log WHERE action='config_update' ORDER BY created_at DESC LIMIT 1").first()).detail.includes('services'), '설정 변경 기록');
});
await t('0.9.7 평문 로그인 허용 스위치: 기본 허용이라 110 에서 STLS 없이 로그인되고, 관리자가 끄면 CAPA 에서 USER 가 빠지며 거절되고, POP_ALLOW_PLAINTEXT=1 은 항상 허용', async () => {
const env = await makeEnv(), ac = await adminLogin(env), cookie = await login(env);
// 1) allowPlaintext 를 넘기지 않으면(운영 기본) 관리자 설정을 따르고, 기본값은 허용입니다. Gmail 처럼 CAPA 없이 바로 USER/PASS 를 보내는 흐름.
const pop = createPop3(env, { hostname: 'mail.test' }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' });
try {
let c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '기본값: 평문 로그인 허용'); c.end();
c = popClient(port); await c.greeting(); let capa = await c.cmd('CAPA', true); ok(/\r\nUSER\r\n/.test(capa) && /SASL PLAIN/.test(capa) && !/STLS/.test(capa), 'CAPA 에 USER 광고(인증서 없음 → STLS 없음): ' + capa); c.end();
let s = await (await api(env, 'settings', cookie)).json(); eq(s.pop.plaintext, true, '설정 응답 pop.plaintext'); eq(s.imap.plaintext, true, '설정 응답 imap.plaintext');
// 2) 관리자가 끄면: 다음 접속부터 CAPA 에 USER 가 없고 USER/PASS/AUTH 는 [SYS/PERM] 로 거절. 비밀번호 검사(인증 실패 횟수)에는 들지 않습니다.
const cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: false } })).json(); eq(cfg.services.plainLogin, false, '끔'); eq(cfg.services.pop, true, 'POP 스위치는 그대로');
c = popClient(port); await c.greeting(); capa = await c.cmd('CAPA', true); ok(!/\r\nUSER\r\n/.test(capa) && !/SASL/.test(capa), 'USER 광고 안 함: ' + capa);
const r = await c.cmd('USER arihakdang'); ok(r.startsWith('-ERR [SYS/PERM]') && /STLS/.test(r), '거절: ' + r); ok((await c.cmd('AUTH PLAIN')).startsWith('-ERR [SYS/PERM]'), 'AUTH 도 거절'); c.end();
s = await (await api(env, 'settings', cookie)).json(); eq(s.pop.plaintext, false, '설정 응답에 반영');
eq((await (await adminApi(env, 'admin/status', ac)).json()).plainLogin, false, '서버 상태 응답');
eq((await (await adminApi(env, 'admin/config', ac)).json()).services.plainLogin, false, '관리자 설정 응답');
// 3) 다시 켜면 바로(다음 접속부터) 허용. 설정은 접속마다 읽습니다.
await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: true } });
c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '다시 켜면 로그인'); c.end();
} finally { pop.close(); }
// 4) 환경 변수 POP_ALLOW_PLAINTEXT=1 은 관리자 설정이 꺼져 있어도 허용(진단용, 예전 의미 유지). 0 은 아무 뜻이 없습니다(관리자 설정을 따름).
await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: false } });
const env1 = Object.assign(Object.create(Object.getPrototypeOf(env)), env, { POP_ALLOW_PLAINTEXT: '1' });
const pop1 = createPop3(env1, { hostname: 'mail.test', allowPlaintext: env1.POP_ALLOW_PLAINTEXT === '1' ? true : null }); const port1 = port + 1; await pop1.listen({ plainPort: port1, host: '127.0.0.1' });
try { const c = popClient(port1); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), 'env=1 이면 항상 허용'); c.end(); } finally { pop1.close(); }
eq((await (await api(env1, 'settings', cookie)).json()).pop.plaintext, true, 'env=1 은 설정 응답에도 허용으로');
const env0 = Object.assign(Object.create(Object.getPrototypeOf(env)), env, { POP_ALLOW_PLAINTEXT: '0' });
const pop0 = createPop3(env0, { hostname: 'mail.test', allowPlaintext: env0.POP_ALLOW_PLAINTEXT === '1' ? true : null }); const port0 = port + 2; await pop0.listen({ plainPort: port0, host: '127.0.0.1' });
try { const c = popClient(port0); await c.greeting(); ok((await c.cmd('USER arihakdang')).startsWith('-ERR [SYS/PERM]'), 'env=0 + 관리자 끔 → 거절'); c.end(); } finally { pop0.close(); }
await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: true } });
const pop2 = createPop3(env0, { hostname: 'mail.test', allowPlaintext: null }); const port2 = port + 3; await pop2.listen({ plainPort: port2, host: '127.0.0.1' });
try { const c = popClient(port2); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), 'env=0 + 관리자 켬 → 허용(설치 템플릿 기본값 0 에서 Gmail 110 이 되는 핵심)'); c.end(); } finally { pop2.close(); }
});
await t('0.9.7 POP3 메일함 잠금: 같은 계정의 두 번째 세션은 [IN-USE] 로 거절되고 기록되며, 첫 세션이 끝나면 바로 들어갈 수 있고 종료 요약이 남는다', async () => {
const env = await makeEnv();
const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' });
const logs = []; const orig = console.log; console.log = (...a) => { logs.push(a.join(' ')); };
try {
const c1 = popClient(port); await c1.greeting(); await c1.cmd('USER arihakdang'); ok((await c1.cmd('PASS admin-secret-12')).startsWith('+OK'), '첫 세션');
const c2 = popClient(port); await c2.greeting(); await c2.cmd('USER arihakdang'); const r = await c2.cmd('PASS admin-secret-12'); ok(r.startsWith('-ERR [IN-USE]'), '두 번째 세션 거절: ' + r);
ok(logs.some((l) => l.includes('POP3 접속 거부') && l.includes('다른 세션')), 'IN-USE 가 로그에 남음: ' + logs.filter((l) => l.includes('거부')).join(' | '));
// 거절된 세션은 인증 상태로 남아 다시 시도할 수 있습니다(비밀번호 실패로 세지 않음).
ok((await c1.cmd('QUIT')).startsWith('+OK'), '첫 세션 종료');
await new Promise((r) => setTimeout(r, 50));
await c2.cmd('USER arihakdang'); ok((await c2.cmd('PASS admin-secret-12')).startsWith('+OK'), '잠금이 풀리면 바로 로그인'); ok((await c2.cmd('QUIT')).startsWith('+OK'), '종료');
await new Promise((r) => setTimeout(r, 50));
const ends = logs.filter((l) => l.startsWith('POP3 종료')); eq(ends.length, 2, '세션마다 종료 요약 한 줄: ' + ends.join(' | ')); ok(/RETR 0·DELE 0 [\d.]+s \(quit\)/.test(ends[0]), '요약 형식: ' + ends[0]);
} finally { console.log = orig; pop.close(); }
});
await t('0.9.7 오류 수정: 세션 중 영구 삭제된 메일이 있어도 QUIT 이 성공하고 나머지 전달 기록이 남으며, 비 ASCII 비밀번호가 USER/PASS 로 로그인된다', async () => {
const raw = { a: Buffer.from('From: s@example.test\r\nSubject: raw a\r\n\r\nA\r\n'), b: Buffer.from('From: s@example.test\r\nSubject: raw b\r\n\r\nB\r\n') };
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b')], raw }) });
for (const id of ['a', 'b']) await ingestReceived(env, id);
const cookie = await login(env), admin = await adminLogin(env);
const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' });
try {
// 1) RETR 두 통 → 세션이 열린 채 웹에서 첫 통을 영구 삭제 → QUIT. 예전에는 외래 키 위반으로 묶음 전체가 되돌아가 -ERR 였고 둘째 통도 기록되지 않았습니다.
const c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '로그인');
const uidl = (await c.cmd('UIDL', true)).split('\r\n').filter((l) => /^\d+ /.test(l)).map((l) => l.split(' ')[1]);
ok((await c.cmd('RETR 1', true)).startsWith('+OK'), 'RETR 1'); ok((await c.cmd('RETR 2', true)).startsWith('+OK'), 'RETR 2');
await api(env, 'mail/' + uidl[0], cookie, 'PATCH', { folder: 'trash' }); eq((await api(env, 'mail/' + uidl[0], cookie, 'DELETE')).status, 200, '세션 중 영구 삭제');
const q = await c.cmd('QUIT'); ok(q.startsWith('+OK'), 'QUIT 성공: ' + q); c.end(); await new Promise((r) => setTimeout(r, 50));
eq((await env.DB.prepare('SELECT COUNT(*) n FROM pop_deliveries WHERE message_id=?').bind(uidl[1]).first()).n, 1, '남은 메일의 전달 기록'); eq((await maildrop(env, 'www:admin-1')).length, 0, '다시 내려가지 않음');
// 2) 한글 비밀번호: 웹·AUTH PLAIN 과 같은 UTF-8 값으로 검사해야 합니다. 예전에는 latin1 그대로 검사해 USER/PASS 만 항상 실패했습니다.
eq((await adminApi(env, 'admin/users', admin, 'POST', { login: 'hangul', password: '비밀번호-1234', mailbox: 'hangul@arihakdang.com' })).status, 201, '한글 비밀번호 계정');
const c2 = popClient(port); await c2.greeting(); await c2.cmd('USER hangul'); ok((await c2.cmd('PASS 비밀번호-1234')).startsWith('+OK'), 'USER/PASS 한글 비밀번호'); await c2.cmd('QUIT'); c2.end();
const c3 = popClient(port); await c3.greeting(); ok((await c3.cmd('AUTH PLAIN ' + Buffer.from('\0hangul\0비밀번호-1234').toString('base64'))).startsWith('+OK'), 'AUTH PLAIN 도 여전히 됨'); await c3.cmd('QUIT'); c3.end();
} finally { pop.close(); }
});
await t('0.9.7 오류 수정: 시작 때 인증서가 없으면 TLS 포트를 기억해 두고 setTls 로 인증서가 들어올 때 연다', async () => {
const env = await makeEnv();
const dir = mkdtempSync(join(tmpdir(), 'mg-tls-'));
try { execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem'), '-days', '2', '-subj', '/CN=mail.test'], { stdio: 'ignore' }); }
catch { console.log(' (openssl 없음 — TLS 지연 열기 시험 건너뜀)'); rmSync(dir, { recursive: true, force: true }); return; }
const { readFileSync } = await import('node:fs'); const key = readFileSync(join(dir, 'key.pem')), cert = readFileSync(join(dir, 'cert.pem'));
const pop = createPop3(env, { hostname: 'mail.test', tlsOptions: null, allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000), sport = port + 1;
const tlsGreeting = () => new Promise((resolve) => { const s = tls.connect({ port: sport, host: '127.0.0.1', rejectUnauthorized: false }, () => {}); let buf = ''; s.setTimeout(3000, () => { s.destroy(); resolve('timeout'); }); s.on('data', (d) => { buf += d; if (buf.includes('\r\n')) { s.destroy(); resolve(buf.trim()); } }); s.on('error', (e) => resolve('error:' + e.code)); });
try {
const bound = await pop.listen({ plainPort: port, securePort: sport, host: '127.0.0.1' }); eq(bound.length, 1, '인증서 없음 → 평문만 열림: ' + bound.join(','));
eq(await tlsGreeting(), 'error:ECONNREFUSED', '995 는 아직 닫혀 있음');
pop.setTls({ key, cert }); await new Promise((r) => setTimeout(r, 150));
const g = await tlsGreeting(); ok(g.startsWith('+OK') && g.includes('POP3 ready'), '인증서가 들어오면 TLS 포트가 열려 인사함: ' + g);
pop.setTls({ key, cert }); await new Promise((r) => setTimeout(r, 50)); ok((await tlsGreeting()).startsWith('+OK'), '다시 setTls 해도(갱신) 그대로 동작');
} finally { pop.close(); rmSync(dir, { recursive: true, force: true }); }
});
await t('0.9.0 오류 수정: form/head/iframe 뒤 본문 보존, 재구성 원본의 옛 Cc·Reply-To·Bcc 제거, Cc 에만 있는 메일 동기화, LIKE 와일드카드, 8비트 UTF-8 헤더, 보내는 사람 이름 따옴표', async () => {
// 1) 정제기: <form> 으로 감싼 본문·닫히지 않은 <form>/<iframe>/<head> 뒤의 본문이 남습니다.
eq(sanitizeHtml('<p>안녕하세요</p><form action=x><p>본문 1</p>').html, '<p>안녕하세요</p><p>본문 1</p>', '닫히지 않은 form');
eq(sanitizeHtml('<form><table><tr><td><p>뉴스레터 본문</p></td></tr></table></form><p>꼬리말</p>').text, '뉴스레터 본문\n\n꼬리말', 'form 안의 내용 유지');
eq(sanitizeHtml('<p>머리말</p><iframe src=x><p>본문 2</p>').text, '머리말\n본문 2', '닫히지 않은 iframe');
eq(sanitizeHtml('<html><head><meta charset=utf-8><body><p>본문 5</p>').text, '본문 5', '닫히지 않은 head');
eq(sanitizeHtml('<svg><script>x()</script><p>svg 안</p><body><p>본문 6</p>').text, 'svg 안\n본문 6', 'svg 범위는 <p> 같은 HTML 태그에서 닫힘(브라우저와 같음, 0.9.3)');
eq(sanitizeHtml('<object data=x><p>본문 7</p></object><p>뒤</p>').text, '본문 7\n뒤', 'object 껍데기 제거');
// 2) 재구성 원본: headers_json 에 남은 옛 Cc/Reply-To/Bcc/Subject 가 되살아나지 않습니다.
const eml = buildEml({ from: 'a@x.test', to: [], subject: '', headers: { To: 'leak@x.test', Subject: 'leaked', Bcc: 'hidden@x.test', Cc: 'old-cc@x.test', 'Reply-To': 'old-reply@x.test', 'X-Keep': 'yes' } }).toString();
ok(!/^(Cc|Reply-To|Bcc): /m.test(eml) && !/leaked|leak@x.test/.test(eml), '옛 값 제거: ' + eml.split('\r\n\r\n')[0]); ok(/^X-Keep: yes/m.test(eml), '다른 사용자 헤더는 유지');
ok(/^Bcc: b@x.test/m.test(buildEml({ from: 'a@x.test', to: ['t@x.test'], subject: 's', bcc: ['b@x.test'] }).toString()), '현재 bcc 는 기록');
// 3) 동기화: 우리 메일함이 Cc 에만 있는 메일도 가져옵니다.
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('cc1', { to: ['other@external.test'], cc: ['admin@arihakdang.com'] })] }) });
eq((await syncMailbox(env, { trigger: 'test' })).imported, 1, 'Cc 전용 메일 동기화');
// 4) 검색의 % _ 는 글자 그대로.
const cookie = await login(env);
for (const [id, subject] of [['s1', 'alpha'], ['s2', 'beta'], ['s3', '100% sale'], ['s4', 'a_b']]) await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,subject,text,state,received_at,created_at,updated_at,mailbox) VALUES(?,'inbound','inbox','s@x.test',?,'','received',?,?,?,'admin@arihakdang.com')").bind(id, subject, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z').run();
eq((await (await api(env, 'mail?folder=inbox&q=_____', cookie)).json()).total, 0, '_ 는 와일드카드 아님'); eq((await (await api(env, 'mail?folder=inbox&q=%25', cookie)).json()).total, 1, '% 는 글자 그대로(100% sale)'); eq((await (await api(env, 'mail?folder=inbox&q=a_b', cookie)).json()).total, 1, '밑줄 검색');
// 5) 인코딩되지 않은 UTF-8 제목·이름(RFC 6532)이 깨지지 않습니다.
eq(fixUtf8(Buffer.from('한글 제목', 'utf8').toString('latin1')), '한글 제목', 'latin1 → utf8 복원'); eq(fixUtf8('plain'), 'plain', 'ASCII 그대로'); eq(fixUtf8('café'), 'café', '유효하지 않은 UTF-8 은 유지');
const raw8 = Buffer.concat([Buffer.from('From: 홍길동 <hong@example.test>\r\nTo: admin@arihakdang.com\r\nSubject: 한글 제목\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n한글 본문\r\n', 'utf8')]);
const info = summarize(parseMime(raw8)); eq(info.subject, '한글 제목', '8비트 제목'); eq(info.from, '홍길동 <hong@example.test>', '8비트 이름'); eq(info.text.trim(), '한글 본문', '본문');
// 6) 보내는 사람 이름의 특수문자는 따옴표로 감쌉니다.
eq(formatSender('Lee, Director', 'a@x.test'), '"Lee, Director" <a@x.test>', '쉼표 이름'); eq(formatSender('Kim "Boss" <x@y>', 'a@x.test'), '"Kim Boss x@y" <a@x.test>', '따옴표·꺾쇠 제거, @ 는 따옴표로'); eq(formatSender('', 'a@x.test'), 'a@x.test', '이름 없음'); eq(nameOf('"Lee, D" <a@x>'), 'Lee, D', '이름 추출');
const env2 = await makeEnv({ RESEND_FETCH: fakeResend({ calls: [] }) }); const user = { ...(await verifyAdmin(env2, 'arihakdang', 'admin-secret-12')), name: 'Lee, Director' };
const did = await saveDraft(env2, { to: 'f@example.test', subject: 's', html: '<p>x</p>' }, undefined, user);
eq((await env2.DB.prepare('SELECT sender FROM messages WHERE id=?').bind(did).first()).sender, '"Lee, Director" <admin@arihakdang.com>', '초안 발신 표시');
const calls = []; env2.RESEND_FETCH = fakeResend({ calls }); await sendDraft(env2, did); eq(calls.find((c) => c.method === 'POST').body.from, '"Lee, Director" <admin@arihakdang.com>', '발송 요청의 from');
});
await t('0.9.3 대용량 첨부파일: 시스템 상한(무제한 불가)·관리자 설정(기본 50%)·스트리밍 업로드·발송 조건·링크 블록·랜딩/다운로드 횟수·만료 삭제·되돌리기·정리', async () => {
// 1) 시스템 상한: 없거나 0 이면 기본값(1GB/30일/20회). 관리자 기본값은 그 50%.
eq(systemLargeFiles({}).bytes, 1024 ** 3, '기본 1GB'); eq(systemLargeFiles({ LIMIT_LARGE_FILE_DAYS: '0', LIMIT_LARGE_FILE_DOWNLOADS: 'x' }).days, 30, '0 → 기본'); eq(systemLargeFiles({ LIMIT_LARGE_FILE_BYTES: '200M', LIMIT_LARGE_FILE_DAYS: '10', LIMIT_LARGE_FILE_DOWNLOADS: '4' }).downloads, 4, '지정값');
const def = defaultAdminLarge({ bytes: 1024 ** 3, days: 30, downloads: 20 }); eq(def.maxBytes, 512 * 1024 ** 2, '50% 크기'); eq(def.maxDays, 15, '50% 일'); eq(def.maxDownloads, 10, '50% 횟수'); eq(def.enabled, false, '기본은 사용 안 함');
const dir = mkdtempSync(join(tmpdir(), 'mg-large-'));
const env = await makeEnv({ LIMIT_LARGE_FILE_BYTES: '100K', LIMIT_LARGE_FILE_DAYS: '10', LIMIT_LARGE_FILE_DOWNLOADS: '4', DB_FILE: join(dir, 'db.sqlite'), PUBLIC_HOST: 'mail.test', RESEND_FETCH: fakeResend({ calls: [] }) });
eq(filesDir(env), join(dir, 'files'), '파일 디렉터리 = DB 옆 files/');
let cfg = await readLargeFileSettings(env); eq(cfg.maxBytes, 50 * 1024, '관리자 기본 50%'); eq(cfg.maxDays, 5, '5일'); eq(cfg.maxDownloads, 2, '2회'); eq(cfg.enabled, false, '꺼짐');
// 2) 관리자 설정 검사: 0/무제한 불가, 시스템 초과 불가.
let threw = null; try { await writeLargeFileSettings(env, { maxDays: 0 }); } catch (e) { threw = e; } eq(threw?.message, 'limit_invalid', '0 거부'); eq(threw?.field, 'maxDays', '항목');
threw = null; try { await writeLargeFileSettings(env, { maxBytes: 200 * 1024 }); } catch (e) { threw = e; } eq(threw?.message, 'limit_exceeds_system', '시스템 초과'); eq(threw?.limit, 100 * 1024, '상한');
cfg = await writeLargeFileSettings(env, { enabled: true, maxBytes: 60 * 1024, maxDays: 7, maxDownloads: 3 }); eq(cfg.maxDownloads, 3, '저장');
const ac = await adminLogin(env), cookie = await login(env);
const conf = await (await adminApi(env, 'admin/config', ac)).json(); eq(conf.largeFiles.maxDays, 7, 'config 응답'); eq(conf.systemLargeFiles.days, 10, '시스템 값'); eq(conf.largeFileUsage.count, 0, '사용량');
eq((await (await adminApi(env, 'admin/config', ac, 'PATCH', { largeFiles: { maxDownloads: 9 } })).json()).error, 'limit_exceeds_system', 'API 도 거부');
const sess = await (await api(env, 'session', cookie)).json(); eq(sess.largeFiles.enabled, true, '세션에 설정'); ok(sess.attachmentBytes > 0, '일반 첨부 상한');
// 3) 업로드: 초안 소유 확인, 상한 초과는 중단·삭제, 정상은 디스크에 저장.
const user = await verifyAdmin(env, 'arihakdang', 'admin-secret-12');
const draft = await saveDraft(env, { to: 'friend@example.test', subject: '큰 파일', html: '<p>받으세요</p>' }, undefined, user);
const chunks = (n, size = 10 * 1024) => (async function* () { for (let i = 0; i < n; i++) yield Buffer.alloc(size, 65 + i); })();
threw = null; try { await uploadLargeFile(env, user, chunks(7), { draftId: draft, filename: 'big.zip' }); } catch (e) { threw = e; } eq(threw?.message, 'file_too_large', '70KB > 60KB'); eq(threw?.limit, 60 * 1024, '상한');
eq((await env.DB.prepare('SELECT COUNT(*) AS n FROM large_files').first()).n, 0, '초과 파일은 기록 없음'); eq(existsSync(join(dir, 'files')) ? readdirSync(join(dir, 'files')).length : 0, 0, '디스크에도 없음');
threw = null; try { await uploadLargeFile(env, user, chunks(1), { draftId: draft, filename: 'x', declaredSize: 999999 }); } catch (e) { threw = e; } eq(threw?.message, 'file_too_large', 'Content-Length 로 미리 거부');
threw = null; try { await uploadLargeFile(env, { ...user, mailbox: 'other@arihakdang.com' }, chunks(1), { draftId: draft, filename: 'x' }); } catch (e) { threw = e; } eq(threw?.message, 'mail_locked', '남의 초안 거부');
const f1 = await uploadLargeFile(env, user, chunks(3), { draftId: draft, filename: 'report/v1.zip', contentType: 'application/zip; charset=x' });
eq(f1.size, 30 * 1024, '크기'); eq(f1.filename, 'report_v1.zip', '이름 정리'); eq(f1.content_type, 'application/zip', '형식'); eq(f1.state, 'draft', '초안 상태'); eq(readFileSync(join(dir, 'files', f1.id)).length, 30 * 1024, '디스크 저장');
const f2 = await uploadLargeFile(env, user, chunks(2), { draftId: draft, filename: 'photos.zip' });
eq((await listLargeFiles(env, draft)).length, 2, '목록'); eq((await largeFileStats(env)).bytes, 50 * 1024, '통계');
await removeLargeFile(env, f2.id, user.mailbox); eq(existsSync(join(dir, 'files', f2.id)), false, '초안 파일 떼기 = 삭제'); eq((await listLargeFiles(env, draft)).length, 1, '목록 갱신');
const lst = await (await api(env, 'mail/' + draft + '/files', cookie)).json(); eq(lst.files.length, 1, '목록 API'); eq(lst.files[0].id, f1.id, '목록 항목');
// 4) 발송 조건: 없거나 상한을 넘으면 거부(초안은 그대로), 맞으면 링크 블록이 본문에 붙고 파일은 sent.
let r = await api(env, 'mail/' + draft + '/send', cookie, 'POST', {}); eq(r.status, 422, '조건 없음'); let e = await r.json(); eq(e.error, 'large_file_terms', '코드'); eq(e.field, 'days', '항목');
r = await api(env, 'mail/' + draft + '/send', cookie, 'POST', { largeFiles: { days: 7, downloads: 4 } }); e = await r.json(); eq(e.error, 'large_file_terms', '횟수 초과'); eq(e.field, 'downloads', '항목'); eq(e.limit, 3, '상한');
eq((await env.DB.prepare('SELECT state FROM messages WHERE id=?').bind(draft).first()).state, 'draft', '검사는 발송 전');
eq(await checkLargeFileTerms(env, draft, { days: 1, downloads: 1 }), true, '조건 검사 통과');
// 발송 실패(Resend 401)에도 링크는 그대로 남고(0.9.5), 재시도는 같은 토큰·같은 멱등 키·같은 본문으로 나갑니다. 조건을 다시 묻지 않습니다.
const failCalls = []; env.RESEND_FETCH = fakeResend({ failStatus: 401, calls: failCalls });
r = await api(env, 'mail/' + draft + '/send', cookie, 'POST', { largeFiles: { days: 3, downloads: 2 } }); eq(r.status, 422, '발송 실패');
let row = await env.DB.prepare('SELECT * FROM large_files WHERE id=?').bind(f1.id).first(); eq(row.state, 'sent', '링크 유지'); ok(row.token, '토큰 유지'); const firstToken = row.token;
await env.DB.prepare("UPDATE messages SET state='draft' WHERE id=?").bind(draft).run();
const calls = []; env.RESEND_FETCH = fakeResend({ calls });
r = await api(env, 'mail/' + draft + '/send', cookie, 'POST', {}); eq(r.status, 200, '재시도(조건 없이)');
row = await env.DB.prepare('SELECT * FROM large_files WHERE id=?').bind(f1.id).first(); eq(row.state, 'sent', '보냄'); eq(row.max_downloads, 2, '횟수'); eq(row.token, firstToken, '같은 토큰'); ok(Date.parse(row.expires_at) > Date.now() + 2.9 * 86400000, '기한 3일');
const post = calls.find((c) => c.method === 'POST' && c.path === '/emails'); const failPost = failCalls.find((c) => c.method === 'POST' && c.path === '/emails');
eq(post.key, failPost.key, '재시도의 멱등 키 동일'); eq(JSON.stringify(post.body), JSON.stringify(failPost.body), '재시도 본문 동일'); ok(post.body.html.includes('/dl/' + row.token) && post.body.html.includes('report_v1.zip'), 'HTML 링크 블록'); ok(post.body.text.includes('https://mail.test/dl/' + row.token), '텍스트 링크'); ok(post.body.html.includes('2회'), '횟수 표시'); ok(!post.body.attachments, '첨부로는 가지 않음');
const sentMsg = await (await api(env, 'mail/' + draft, cookie)).json(); ok(sentMsg.message.html.includes('/dl/' + row.token), '보낸 메일 저장본에도 링크'); eq(sentMsg.message.largeFiles[0].remaining, 2, '상세의 남은 횟수');
// 본문이 비어 있어도 대용량 파일이 있으면 보낼 수 있습니다.
const d2 = await saveDraft(env, { to: 'friend@example.test', subject: '파일만', html: '' }, undefined, user);
await uploadLargeFile(env, user, chunks(1), { draftId: d2, filename: 'only.bin' });
eq((await api(env, 'mail/' + d2 + '/send', cookie, 'POST', { largeFiles: { days: 1, downloads: 1 } })).status, 200, '본문 없이 발송');
// 5) 랜딩(횟수 안 셈) → 다운로드 차지(횟수 1 감소) → 마지막이면 파일 삭제·만료.
eq((await largeFileInfo(env, 'nope')).status, 'missing', '없는 토큰'); let info = await largeFileInfo(env, row.token); eq(info.status, 'ok', '살아 있음'); eq(info.remaining, 2, '남은 2');
info = await largeFileInfo(env, row.token); eq(info.remaining, 2, '랜딩 조회는 세지 않음');
let c1 = await claimDownload(env, row.token); eq(c1.row.downloads, 1, '1회 차지'); eq(c1.last, false, '마지막 아님'); eq(readFileSync(c1.path).length, 30 * 1024, '파일 경로');
await finishDownload(env, c1); ok(existsSync(c1.path), '아직 파일 유지');
const c2 = await claimDownload(env, row.token); eq(c2.last, true, '마지막 허용 횟수'); eq(await claimDownload(env, row.token), null, '초과 거부');
await finishDownload(env, c2); eq(existsSync(c2.path), false, '마지막 뒤 파일 삭제'); eq((await largeFileInfo(env, row.token)).status, 'expired', '만료');
eq((await listLargeFiles(env, draft))[0].expired, true, '상세에 만료');
// 기한 만료는 정리 작업이 지웁니다. 보낸 사람의 "링크 끊기" 도 즉시 삭제.
const d2file = (await listLargeFiles(env, d2))[0]; await env.DB.prepare("UPDATE large_files SET expires_at=? WHERE id=?").bind(new Date(Date.now() - 1000).toISOString(), d2file.id).run();
eq(await claimDownload(env, (await env.DB.prepare('SELECT token FROM large_files WHERE id=?').bind(d2file.id).first()).token), null, '기한 지난 파일 거부');
const cleaned = await cleanupLargeFiles(env); eq(cleaned.expired, 1, '정리: 만료 1'); eq(existsSync(join(dir, 'files', d2file.id)), false, '파일 삭제');
const d3 = await saveDraft(env, { to: 'friend@example.test', subject: '끊기', html: '<p>x</p>' }, undefined, user); const f3 = await uploadLargeFile(env, user, chunks(1), { draftId: d3, filename: 'rev.bin' });
await api(env, 'mail/' + d3 + '/send', cookie, 'POST', { largeFiles: { days: 2, downloads: 3 } });
eq((await api(env, 'mail/' + d3 + '/files/' + f3.id, cookie, 'DELETE')).status, 200, '링크 끊기'); eq(existsSync(join(dir, 'files', f3.id)), false, '파일 삭제'); eq((await largeFileInfo(env, (await env.DB.prepare('SELECT token FROM large_files WHERE id=?').bind(f3.id).first()).token)).status, 'expired', '끊긴 링크 만료');
// 버려진 초안(휴지통·삭제)의 파일은 하루 뒤 정리됩니다.
const d4 = await saveDraft(env, { to: 'a@example.test', subject: 'd4', html: '<p>x</p>' }, undefined, user); const f4 = await uploadLargeFile(env, user, chunks(1), { draftId: d4, filename: 'orphan.bin' });
await env.DB.prepare("UPDATE large_files SET created_at=? WHERE id=?").bind('2026-01-01T00:00:00.000Z', f4.id).run(); await env.DB.prepare('DELETE FROM messages WHERE id=?').bind(d4).run();
eq((await env.DB.prepare('SELECT message_id FROM large_files WHERE id=?').bind(f4.id).first()).message_id, null, '메일 삭제 시 NULL'); eq((await cleanupLargeFiles(env)).removed, 1, '고아 정리'); eq(existsSync(join(dir, 'files', f4.id)), false, '고아 파일 삭제');
// 휴지통 유예는 버린 시점 기준(0.9.5): 하루 넘게 쓰던 초안을 방금 버리면 파일이 남고, 버린 지 하루가 지나면 지워집니다.
const d5 = await saveDraft(env, { to: 'a@example.test', subject: 'd5', html: '<p>x</p>' }, undefined, user); const f5 = await uploadLargeFile(env, user, chunks(1), { draftId: d5, filename: 'old.bin' });
await env.DB.prepare("UPDATE large_files SET created_at=? WHERE id=?").bind('2026-01-01T00:00:00.000Z', f5.id).run();
eq((await api(env, 'mail/' + d5, cookie, 'PATCH', { action: 'trash' })).status, 200, '휴지통'); eq((await cleanupLargeFiles(env)).removed, 0, '방금 버린 초안의 파일은 유지'); ok(existsSync(join(dir, 'files', f5.id)), '파일 있음');
await env.DB.prepare("UPDATE messages SET updated_at=? WHERE id=?").bind('2026-01-01T00:00:00.000Z', d5).run(); eq((await cleanupLargeFiles(env)).removed, 1, '버린 지 하루 지나면 삭제');
// 다른 메일의 파일 id 를 끼워 넣은 경로로는 지울 수 없습니다(0.9.5).
const d6 = await saveDraft(env, { to: 'a@example.test', subject: 'd6', html: '<p>x</p>' }, undefined, user); const f6 = await uploadLargeFile(env, user, chunks(1), { draftId: d6, filename: 'mine.bin' });
eq((await api(env, 'mail/' + d3 + '/files/' + f6.id, cookie, 'DELETE')).status, 404, '다른 메일 경로로 삭제 거부'); eq((await listLargeFiles(env, d6)).length, 1, '파일 유지');
// 저장 실패(디렉터리를 만들 수 없음)는 프로세스를 죽이지 않고 upload_write_failed 로 끝납니다(0.9.5).
writeFileSync(join(dir, 'blocker'), 'x'); const badEnv = { ...env, FILES_DIR: join(dir, 'blocker', 'files') }; threw = null; try { await uploadLargeFile(badEnv, user, chunks(1), { draftId: d6, filename: 'x' }); } catch (e2) { threw = e2; } eq(threw?.message, 'upload_write_failed', '쓰기 실패 코드'); ok(threw?.detail, '원인 코드');
// 기능을 끄면 업로드가 거부됩니다.
await writeLargeFileSettings(env, { enabled: false }); threw = null; try { await uploadLargeFile(env, user, chunks(1), { draftId: d3, filename: 'x' }); } catch (e2) { threw = e2; } eq(threw?.message, 'large_files_disabled', '꺼짐');
rmSync(dir, { recursive: true, force: true });
});
await t('0.9.3 오류 수정: 자기 역할·상태 변경 거부, 비밀번호 변경 제한, email 필드 로그인 제한 키, APPEND 전체 용량·중복 순서, 크기 저장 순서, 상태 값 검사, 내장 상한, prefs 정리, 닫히지 않은 svg', async () => {
// 1) 유일한 관리자가 자기 역할을 내리거나 정지할 수 없습니다.
const env = await makeEnv({ MAILBOX: 'admin@heart.test', LIMIT_TOTAL_BYTES: '4K' }); delete env.WWW_DB;
const meId = await createUser(env, { login: 'admin', password: 'adminpasswd', role: 'admin' }, 'cli');
const me = await verifyAdmin(env, 'admin', 'adminpasswd'); const ac = ADMIN_COOKIE + '=' + await createSession(env, me, new Request('https://x'), { scope: 'admin' });
let r = await adminApi(env, 'admin/users/' + meId, ac, 'PATCH', { role: 'user' }); eq(r.status, 409, '자기 역할 강등 거부'); eq((await r.json()).error, 'cannot_modify_self', '코드');
eq((await adminApi(env, 'admin/users/' + meId, ac, 'PATCH', { status: 'suspended' })).status, 409, '자기 정지 거부');
eq((await adminApi(env, 'admin/users/' + meId, ac, 'PATCH', { role: 'admin', status: 'active', name: '나' })).status, 200, '같은 값은 허용');
// 2) 설정 화면의 비밀번호 변경은 10회 실패 뒤 429.
const sc = (await api(env, 'login', '', 'POST', { login: 'admin', password: 'adminpasswd' })).headers.get('set-cookie').split(';')[0];
for (let i = 0; i < 10; i++) eq((await api(env, 'password', sc, 'PATCH', { currentPassword: 'wrong' + i, password: 'new-pass-123', passwordConfirm: 'new-pass-123' })).status, 422, '틀린 현재 비밀번호 ' + i);
eq((await api(env, 'password', sc, 'PATCH', { currentPassword: 'adminpasswd', password: 'new-pass-123', passwordConfirm: 'new-pass-123' })).status, 429, '11번째는 제한');
// 3) email 필드 로그인도 계정별 키를 씁니다(빈 공용 키 없음).
const env2 = await makeEnv();
for (let i = 0; i < 10; i++) await api(env2, 'login', '', 'POST', { email: 'nobody@arihakdang.com', password: 'x' }, undefined, undefined);
eq((await handle(env2, request('login', '', 'POST', { email: 'admin@arihakdang.com', password: 'admin-secret-12' }), params('login', '10.0.0.9'))).status, 200, '다른 계정의 email 로그인은 막히지 않음');
ok(env2.RATE.blocked('login:nobody@arihakdang.com', { limit: 10 }), '계정별 키에 쌓임'); ok(!env2.RATE.blocked('login:', { limit: 10 }), '빈 키 없음');
// 4·5) APPEND: 전체 용량 초과는 storage_full, 보낸 메일함의 같은 Message-ID 사본은 용량이 가득 차도 중복으로 처리.
const user = await verifyAdmin(env, 'admin', 'adminpasswd');
const eml = buildEml({ from: 'admin@heart.test', to: ['a@example.test'], subject: 's', messageId: '<dup@heart.test>', text: 'x'.repeat(3000) });
const first = await appendMessage(env, 'sent', eml, { mailbox: user.mailbox, enforceQuota: false }); ok(first.id, '발송 사본 저장(용량 무시)');
const dup = await appendMessage(env, 'sent', eml, { mailbox: user.mailbox }); eq(dup.duplicate, true, '가득 찬 뒤 같은 사본은 중복 처리');
let threw = null; try { await appendMessage(env, 'drafts', buildEml({ from: 'admin@heart.test', to: ['a@example.test'], subject: 'd', text: 'y'.repeat(2000) }), { mailbox: user.mailbox }); } catch (e) { threw = e; } eq(threw?.message, 'storage_full', '전체 용량 초과 APPEND 거부');
// 6) 계정 수정: 실패하면 메일함 크기도 저장되지 않습니다.
const env3 = await makeEnv(), ac3 = await adminLogin(env3);
eq((await adminApi(env3, 'admin/users/does-not-exist', ac3, 'PATCH', { quotaBytes: 1048576 })).status, 404, '없는 계정');
eq((await env3.DB.prepare("SELECT COUNT(*) AS n FROM settings WHERE key LIKE 'quota:%'").first()).n, 0, '크기 행 없음');
const bob = await (await adminApi(env3, 'admin/users', ac3, 'POST', { login: 'bob', password: 'bob-pass-1', quotaBytes: 2048 })).json();
eq((await adminApi(env3, 'admin/users/' + bob.id, ac3, 'PATCH', { password: 'short', quotaBytes: 4096 })).status, 422, '정책 위반'); eq((await env3.DB.prepare('SELECT value FROM settings WHERE key=?').bind('quota:' + bob.id).first()).value, '2048', '크기 유지');
// 9) 상태 값은 active/suspended 만, 대기 계정을 정지로 바꾸면 대기가 풀립니다.
await adminApi(env3, 'admin/config', ac3, 'PATCH', { signup: { enabled: true, approval: true } });
const kim = await (await api(env3, 'signup', '', 'POST', { login: 'kim', password: 'kim-pass-1' })).json();
eq((await adminApi(env3, 'admin/users/' + kim.id, ac3, 'PATCH', { status: 'pending' })).status, 422, "'pending' 거부"); eq(await isPending(env3, kim.id), true, '대기 유지');
eq((await adminApi(env3, 'admin/users/' + kim.id, ac3, 'PATCH', { status: 'suspended' })).status, 200, '정지로'); eq(await isPending(env3, kim.id), false, '대기 표시 제거'); eq((await listUsers(env3)).find((u) => u.login === 'kim').status, 'suspended', '목록 상태 정지');
eq((await adminApi(env3, 'admin/users/' + kim.id, ac3, 'PATCH', { role: 'root' })).status, 422, '모르는 역할 거부');
// 10) 관리자 제한은 내장 상한을 넘지 못하고 불리언은 거부합니다.
threw = null; try { await writeLimits(env3, { recipients: 500 }); } catch (e) { threw = e; } eq(threw?.message, 'limit_exceeds_system', '내장 상한 50 초과'); eq(threw?.limit, 50, '상한값');
threw = null; try { await writeLimits(env3, { attachmentBytes: 100 * 1048576 }); } catch (e) { threw = e; } eq(threw?.limit, 25 * 1048576, '첨부 내장 상한');
threw = null; try { await writeLimits(env3, { recipients: true }); } catch (e) { threw = e; } eq(threw?.message, 'limit_invalid', '불리언 거부');
eq((await writeLimits(env3, { recipients: 50 })).recipients, 50, '상한값은 허용'); eq(await recipientLimit(env3), 50, '적용'); eq(await attachmentLimit(env3), 25 * 1048576, '첨부 적용');
// 11) 계정 삭제는 prefs 도 지웁니다.
await writePrefs(env3, { id: bob.id }, { pop: false }); await deleteUser(env3, bob.id);
eq((await env3.DB.prepare('SELECT COUNT(*) AS n FROM settings WHERE key=?').bind('prefs:' + bob.id).first()).n, 0, 'prefs 삭제');
// 8) 닫히지 않은 svg 뒤의 본문은 <p>/<div> 에서 범위를 닫아 남습니다.
eq(sanitizeHtml('<p>Hi</p><svg viewBox="0 0 1 1"><circle r="4"/><p>Rest</p><div>Footer</div>').text, 'Hi\nRest\nFooter', '닫히지 않은 svg');
eq(sanitizeHtml('<div><svg class="icon"><use href="#x"/></div><p>Body text</p>').text, 'Body text', 'div 안 svg');
});
await t('0.9.5 참조·숨은 참조: 웹 작성 창의 숨은 참조가 초안에 저장되고 발송·재구성 원본에 반영되며, 보내지 않은 키는 유지된다', async () => {
const calls = []; const env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }), cookie = await login(env);
const id = (await (await api(env, 'mail', cookie, 'POST', { to: 'a@example.test', cc: 'c@example.test', bcc: 'Hidden <b@example.test>, b2@example.test', subject: '숨은 참조', html: '<p>x</p>' })).json()).id;
let row = await env.DB.prepare('SELECT bcc_json,cc_json FROM messages WHERE id=?').bind(id).first(); eq(row.bcc_json, '["b@example.test","b2@example.test"]', '초안 저장'); eq(row.cc_json, '["c@example.test"]', '참조');
eq((await api(env, 'mail/' + id, cookie, 'PATCH', { subject: '제목만' })).status, 200, 'bcc 없이 수정'); row = await env.DB.prepare('SELECT bcc_json,recipients_json,subject,text FROM messages WHERE id=?').bind(id).first(); eq(row.bcc_json, '["b@example.test","b2@example.test"]', '보내지 않은 키는 유지');
eq(row.recipients_json, '["a@example.test"]', '받는 사람 유지(부분 수정)'); eq(row.subject, '제목만', '제목 갱신'); eq(row.text, 'x', '본문 유지');
eq((await api(env, 'mail/' + id, cookie, 'PATCH', { bcc: 'b3@example.test' })).status, 200, 'bcc 만 수정(PATCH 허용 키)'); eq((await env.DB.prepare('SELECT bcc_json FROM messages WHERE id=?').bind(id).first()).bcc_json, '["b3@example.test"]', '갱신');
eq((await api(env, 'mail/' + id, cookie, 'PATCH', { bcc: '' })).status, 200, '비우기'); eq((await env.DB.prepare('SELECT bcc_json FROM messages WHERE id=?').bind(id).first()).bcc_json, '[]', '빈 목록');
await api(env, 'mail/' + id, cookie, 'PATCH', { bcc: 'b@example.test' });
eq((await api(env, 'mail/' + id + '/send', cookie, 'POST', {})).status, 200, '발송');
const post = calls.find((c) => c.method === 'POST' && c.path === '/emails'); eq(post.body.bcc.join(), 'b@example.test', '발송 요청의 bcc'); eq(post.body.cc.join(), 'c@example.test', 'cc');
const raw = Buffer.from((await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first()).eml).toString(); ok(/^Bcc: b@example.test/m.test(raw), '보낸 메일 원본에 Bcc');
// 0.9.5: 전달·답장 인용문의 인라인 그림 복사가 보낸 메일 저장본(html)에도 반영됩니다(0.9.3 은 복사 전 html 을 다시 써서 원본을 지우면 그림이 깨졌음).
const origin = uid(); await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,subject,html,text,state,received_at,created_at,updated_at,mailbox) VALUES(?,'inbound','inbox','s@x.test','o','<p><img src=\"cid:pic\"></p>','','received','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','admin@arihakdang.com')").bind(origin).run();
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES('att1',?,'p.png','image/png','pic','inline',3,?,'x')").bind(origin, Buffer.from('png')).run();
const fwd = (await (await api(env, 'mail', cookie, 'POST', { to: 'f@example.test', subject: 'fwd', html: `<p>전달</p><img src="/api/mail/${origin}/attachments/att1?inline=1">` })).json()).id;
eq((await api(env, 'mail/' + fwd + '/send', cookie, 'POST', {})).status, 200, '전달 발송');
const stored = await env.DB.prepare('SELECT html FROM messages WHERE id=?').bind(fwd).first(); const copy = await env.DB.prepare("SELECT id FROM attachments WHERE message_id=? AND disposition='inline'").bind(fwd).first();
ok(copy && stored.html.includes('/api/mail/' + fwd + '/attachments/' + copy.id), '저장본 html 이 복사본을 가리킴'); ok(!stored.html.includes('/api/mail/' + origin + '/'), '원본 주소 없음');
});
// 0.9.9 요구사항 1: 회신 창에 여러 줄을 붙여넣으면 글이 편집기 상자를 넘어 아래 단추 위에 겹치던 문제.
// 원인은 CSS 라서(.html-editor 가 flex 항목으로 눌림) 단위 시험으로는 규칙이 들어 있는지와, 함께 고친 정제기의 height→min-height 변환을 확인합니다.
// 실제 겹침 여부는 headless Chromium 으로 확인했습니다(편집기 높이 320px 고정 → 내용 높이대로 4415px, footer 와 겹침 없음).
await t('0.9.9 회신 편집기 붙여넣기 넘침 수정: 편집기가 flex 로 눌리지 않고(flex-shrink:0, overflow:auto), 정제기는 div/p 의 height 를 min-height 로 바꿔 글이 상자 밖으로 번지지 않음', async () => {
const { readFileSync } = await import('node:fs');
const css = readFileSync(new URL('../web/style.css', import.meta.url), 'utf8');
const rule = (css.match(/\.html-editor\{([^}]*)\}/) || [])[1] || '';
ok(/(^|;)flex-shrink:0(;|$)/.test(rule) || /(^|;)flex:(none|0 0 auto)(;|$)/.test(rule), '편집기 flex-shrink:0 — ' + rule);
ok(/(^|;)overflow:auto(;|$)/.test(rule), '편집기 overflow:auto(가로로 긴 표는 편집기 안에서 스크롤)');
eq(sanitizeHtml('<div style="height:40px;color:red">글</div>').html, '<div style="min-height:40px;color:red">글</div>', 'div height → min-height');
eq(sanitizeHtml('<p style="min-height:10px">글</p><span style="height: 2em">s</span>').html, '<p style="min-height:10px">글</p><span style="min-height:2em">s</span>', 'min-height 그대로, span height 변환');
eq(sanitizeHtml('<table><tr><td style="height:20px">칸</td></tr></table>').html, '<table><tr><td style="height:20px">칸</td></tr></table>', 'td height 유지');
eq(sanitizeHtml('<img src="https://x.test/a.png" style="width:100px;height:50px">').html, '<img src="https://x.test/a.png" style="width:100px;height:50px">', 'img height 유지');
});
// 0.9.9 요구사항 0(오류 수정): 점검에서 확인된 서버 쪽 결함 3건. 화면 쪽(읽기 창 경쟁, 검색어 잔존, 전달 초안 잔존, 작은 그림 확대, 실패 상태 줄)은 headless Chromium 으로 확인했습니다.
await t('0.9.9 오류 수정: 음수 여백 제거, Content-ID 없는 inline 첨부는 일반 첨부(수신·첨부 수·첨부 필터·전달 복사), 발송 때 받는 사람/참조/숨은 참조의 중복 주소 제거', async () => {
// 1) sanitize: 음수 여백이 붙은 문단은 앞 문단 위로 겹치거나 화면 밖으로 사라졌습니다. 양수 여백은 그대로.
eq(sanitizeHtml('<p style="margin-top:-400px;color:red">a</p><p style="margin-left: -9999px">b</p><p style="margin:-1px 0 0 -2px">c</p><p style="margin:8px 0;text-indent:2em">d</p>').html, '<p style="color:red">a</p><p>b</p><p>c</p><p style="margin:8px 0">d</p>', '음수 여백 제거');
// 2) 수신: Content-ID 가 없는 "inline"(Apple Mail 의 inline; filename=…) 은 attachment 로, cid 가 있는 그림만 inline 으로 저장합니다.
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls, received: [sample('ap', { attachments: [{ id: 'pdf1' }, { id: 'pic1' }] })], attachments: { ap: [
{ id: 'pdf1', filename: 'doc.pdf', content_type: 'application/pdf', content_disposition: 'inline', size: 4, download_url: 'https://files.test/att/pdf1', bytes: 'PDF!' },
{ id: 'pic1', filename: 'p.png', content_type: 'image/png', content_disposition: 'inline', content_id: '<c1>', size: 3, download_url: 'https://files.test/att/pic1', bytes: 'PNG' }] } }) }), cookie = await login(env);
await ingestReceived(env, 'ap', { source: 'webhook' });
const msg = await env.DB.prepare("SELECT id FROM messages WHERE provider_id='ap'").first();
const rows = (await env.DB.prepare('SELECT filename,disposition FROM attachments WHERE message_id=? ORDER BY filename').bind(msg.id).all()).results;
eq(rows.map((r) => r.filename + ':' + r.disposition).join(','), 'doc.pdf:attachment,p.png:inline', 'cid 없는 inline → attachment, cid 있는 그림 → inline');
const listed = async (extra = '') => (await (await api(env, 'mail?folder=inbox' + extra, cookie)).json()).messages.filter((m) => m.id === msg.id);
eq((await listed())[0].attachment_count, 1, '첨부 수 1(본문 그림 제외)');
// 3) 예전 규칙으로 저장된 행(inline 인데 cid 없음)도 첨부 수·"첨부" 필터·전달 복사에 포함됩니다.
await env.DB.prepare("UPDATE attachments SET disposition='inline' WHERE message_id=? AND filename='doc.pdf'").bind(msg.id).run();
const pdfId = (await env.DB.prepare("SELECT id FROM attachments WHERE message_id=? AND filename='doc.pdf'").bind(msg.id).first()).id;
eq((await listed())[0].attachment_count, 1, '옛 inline 행도 첨부 수에 포함'); eq((await listed('&attach=1')).length, 1, '첨부 필터에 나옴');
const fwd = (await (await api(env, 'mail', cookie, 'POST', { to: 'f@example.test', subject: 'fwd', html: '<p>전달</p>' })).json()).id;
eq((await api(env, 'mail/' + fwd + '/attachments', cookie, 'POST', { from: msg.id, ids: [pdfId] })).status, 201, '전달 복사 요청');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM attachments WHERE message_id=?').bind(fwd).first()).n, 1, '옛 inline 행이 전달 초안에 복사됨');
// 4) 발송: 대소문자만 다른 같은 주소가 받는 사람·참조·숨은 참조에 겹치면 받는 사람 > 참조 > 숨은 참조 순으로 한 번만 보냅니다.
const id = (await (await api(env, 'mail', cookie, 'POST', { to: 'Bob@Example.com', cc: 'carol@x.test, bob@example.com', bcc: 'BOB@example.com, dan@x.test', subject: 's', html: '<p>x</p>' })).json()).id;
eq((await api(env, 'mail/' + id + '/send', cookie, 'POST', {})).status, 200, '발송');
const post = calls.find((c) => c.method === 'POST' && c.path === '/emails').body;
eq(JSON.stringify([post.to, post.cc, post.bcc]), JSON.stringify([['bob@example.com'], ['carol@x.test'], ['dan@x.test']]), 'Resend 요청에서 중복 주소 제거(주소는 저장 때 소문자로정리됨)');
});
await t('1.0.11 웹훅과 동기화가 같은 메일을 동시에 받아들일 때: 늦은 쪽은 provider_id UNIQUE 오류 대신 "이미 있음" 으로 끝나고 메일은 한 통만 저장된다(운영 heart 10/12·10/13 의 동기화 일부 실패)', async () => {
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('race')] }) });
// 둘 다 "이미 있나" 확인을 통과한 뒤(Resend 상세 조회를 기다리는 동안) INSERT 에 이르도록 동시에 시작합니다.
const [a, b] = await Promise.all([ingestReceived(env, 'race', { source: 'webhook' }), ingestReceived(env, 'race', { source: 'sync' })]);
const results = [a, b].map((r) => (r.imported ? 'imported' : r.skipped));
eq(results.filter((r) => r === 'imported').length, 1, '한 쪽만 저장'); eq(results.filter((r) => r === 'exists').length, 1, '다른 쪽은 exists');
eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE provider_id='race'").first()).n, 1, '한 통만');
const late = [a, b].find((r) => r.skipped === 'exists'); eq(late.id, [a, b].find((r) => r.imported).id, '늦은 쪽도 저장된 메일 id 를 돌려줌');
});
done();