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 ', to: ['admin@arihakdang.com'], subject: '받은 메일 ' + id, html: '

내용 ' + id + '

', 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('

본문

링크굵게'); ok(!/script|onclick|onerror|javascript|

B

').html.includes('color') && sanitizeHtml('

A

B

').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: '

', 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: '

fwd

' })).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: '

x

' })).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: '

x

' }); 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: '

x

' }); 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: '

x

' })).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: '

x

' }); 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) 정제기:
으로 감싼 본문·닫히지 않은 /