209 lines
21 KiB
JavaScript
209 lines
21 KiB
JavaScript
import net from 'node:net';
|
|||
|
|
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
|
||
|
|
import { ingestReceived, saveDraft } from '../server/store.mjs';
|
||
|
|
import { createImap, tokenize, resolveSet } from '../server/imap.mjs';
|
||
|
|
import { buildEml } from '../server/mime.mjs';
|
||
|
|
import { writeServices } from '../server/limits.mjs';
|
||
|
|
const { t, done } = runner();
|
||
|
|
const sample = (id, extra = {}) => ({ id, from: 'Sender <sender@example.test>', to: ['admin@arihakdang.com'], subject: '받은 메일 ' + id, html: '<p>내용 <b>' + id + '</b></p>', text: '내용 ' + id, created_at: new Date().toISOString(), headers: {}, message_id: '<' + id + '@example.test>', ...extra });
|
||
|
|
|
||
|
|
function client(port) {
|
||
|
|
const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = [];
|
||
|
|
socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); });
|
||
|
|
function flush() {
|
||
|
|
while (waiters.length) {
|
||
|
|
const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/;
|
||
|
|
const m = buffer.match(re); if (!m) return;
|
||
|
|
const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let n = 0;
|
||
|
|
return {
|
||
|
|
greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }),
|
||
|
|
cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); },
|
||
|
|
raw: (text) => socket.write(text),
|
||
|
|
// 바이트 그대로 보내는 명령(8bit APPEND 시험용). cmd 는 문자열을 UTF-8 로 보내므로 따로 둡니다.
|
||
|
|
bytes: (tag, buf) => { socket.write(buf); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); },
|
||
|
|
untagged: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }),
|
||
|
|
end: () => socket.end(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
await t('토큰화와 집합 해석', async () => {
|
||
|
|
const tokens = tokenize('A1 FETCH 1:* (FLAGS BODY.PEEK[HEADER.FIELDS (From To)]<0.100> "quoted \\"x\\"")');
|
||
|
|
eq(tokens[2], '1:*', '집합'); eq(tokens[3][1], 'BODY.PEEK[HEADER.FIELDS (From To)]<0.100>', '대괄호 원자'); eq(tokens[3][2], 'quoted "x"', '따옴표');
|
||
|
|
const msgs = [{ uid: 3 }, { uid: 7 }, { uid: 9 }];
|
||
|
|
eq(resolveSet('2:*', msgs, false).join(','), '1,2', '시퀀스'); eq(resolveSet('7:*', msgs, true).join(','), '1,2', 'UID 범위'); eq(resolveSet('9,3', msgs, true).join(','), '0,2', 'UID 목록');
|
||
|
|
});
|
||
|
|
await t('0.9.7 IMAP 평문 로그인 스위치: 기본 허용이면 인사말에 AUTH=PLAIN, 관리자가 끄면 LOGINDISABLED 와 거절', async () => {
|
||
|
|
const env = await makeEnv();
|
||
|
|
const imap = createImap(env, { hostname: 'mail.test' }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
|
||
|
|
try {
|
||
|
|
let c = client(port); let g = await c.greeting(); ok(/AUTH=PLAIN/.test(g) && !/LOGINDISABLED/.test(g), '기본: AUTH=PLAIN 광고: ' + g);
|
||
|
|
ok((await c.cmd('LOGIN arihakdang admin-secret-12')).includes('OK'), '평문 로그인 허용'); await c.cmd('LOGOUT'); c.end();
|
||
|
|
await writeServices(env, { plainLogin: false });
|
||
|
|
c = client(port); g = await c.greeting(); ok(/LOGINDISABLED/.test(g) && !/AUTH=PLAIN/.test(g), '끄면 LOGINDISABLED: ' + g);
|
||
|
|
ok(/NO \[PRIVACY\]/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), 'LOGIN 거절'); ok(/NO \[PRIVACY\]/.test(await c.cmd('AUTHENTICATE PLAIN')), 'AUTHENTICATE 거절'); c.end();
|
||
|
|
const fixed = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port2 = port + 1; await fixed.listen({ plainPort: port2, host: '127.0.0.1' });
|
||
|
|
try { c = client(port2); ok(/AUTH=PLAIN/.test(await c.greeting()), 'allowPlaintext:true 는 설정과 무관하게 허용'); c.end(); } finally { fixed.close(); }
|
||
|
|
} finally { imap.close(); }
|
||
|
|
});
|
||
|
|
await t('IMAP: 로그인·LIST·SELECT·FETCH·STORE·SEARCH·MOVE·EXPUNGE·APPEND·IDLE 가 웹 메일함과 같은 저장소를 쓴다', async () => {
|
||
|
|
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
|
||
|
|
const raw = { a: Buffer.from('From: Sender <sender@example.test>\r\nTo: admin@arihakdang.com\r\nSubject: raw a\r\nMessage-ID: <a@example.test>\r\nDate: Mon, 28 Sep 2026 06:00:00 +0000\r\nContent-Type: text/plain\r\n\r\nbody a\r\n') };
|
||
|
|
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a', { created_at: at(30) }), sample('b', { created_at: at(20) }), sample('c', { created_at: at(10) })], raw }), IMAP_IDLE_POLL_MS: 200 });
|
||
|
|
for (const id of ['a', 'b', 'c']) await ingestReceived(env, id);
|
||
|
|
await saveDraft(env, { to: 'x@example.test', subject: '초안', html: '<p>초안 본문</p>' });
|
||
|
|
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
|
||
|
|
try {
|
||
|
|
const c = client(port); ok((await c.greeting()).includes('IMAP4rev1'), '인사');
|
||
|
|
ok((await c.cmd('CAPABILITY')).includes('IDLE'), 'CAPABILITY');
|
||
|
|
ok(/NO/.test(await c.cmd('LOGIN student admin-secret-12')), '학생 거부');
|
||
|
|
ok(/NO/.test(await c.cmd('LOGIN arihakdang wrong')), '비밀번호 오류');
|
||
|
|
ok(/A\d+ OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), '로그인');
|
||
|
|
const list = await c.cmd('LIST "" "*"'); ok(list.includes('"INBOX"') && list.includes('\\Sent') && list.includes('\\Trash'), 'LIST');
|
||
|
|
const status = await c.cmd('STATUS INBOX (MESSAGES UNSEEN)'); ok(status.includes('MESSAGES 3') && status.includes('UNSEEN 3'), 'STATUS');
|
||
|
|
const select = await c.cmd('SELECT INBOX'); ok(select.includes('* 3 EXISTS') && select.includes('UIDVALIDITY') && select.includes('READ-WRITE'), 'SELECT');
|
||
|
|
const fetch1 = await c.cmd('FETCH 1 (UID FLAGS RFC822.SIZE ENVELOPE BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (Subject)])');
|
||
|
|
ok(fetch1.includes('UID 1') && fetch1.includes('FLAGS ()') && fetch1.includes('"raw a"') && fetch1.includes('Subject: raw a'), 'FETCH 요약'); ok(!fetch1.includes('\\Seen'), 'PEEK 는 읽음 처리 없음');
|
||
|
|
const body = await c.cmd('UID FETCH 1 BODY[]'); ok(body.includes('body a') && body.includes('FLAGS (\\Seen)'), 'BODY[] 원본 + 읽음');
|
||
|
|
eq((await env.DB.prepare("SELECT read_at FROM messages WHERE provider_id='a'").first()).read_at !== null, true, '웹 읽음 상태 공유');
|
||
|
|
ok((await c.cmd('STORE 2 +FLAGS (\\Flagged \\Seen)')).includes('\\Flagged'), 'STORE'); ok((await c.cmd('SEARCH UNSEEN')).includes('* SEARCH 3'), 'SEARCH UNSEEN');
|
||
|
|
ok((await c.cmd('SEARCH FLAGGED')).includes('* SEARCH 2'), 'SEARCH FLAGGED'); ok((await c.cmd('UID SEARCH SUBJECT "raw"')).includes('* SEARCH 1'), 'SEARCH SUBJECT');
|
||
|
|
ok((await c.cmd('SEARCH OR SUBJECT "받은 메일 c" FROM "nobody"')).includes('* SEARCH 3'), 'SEARCH OR');
|
||
|
|
const move = await c.cmd('UID MOVE 3 Trash'); ok(move.includes('COPYUID') && move.includes('* 3 EXPUNGE'), 'MOVE');
|
||
|
|
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='c'").first()).folder, 'trash', '웹 휴지통 공유');
|
||
|
|
await c.cmd('STORE 2 +FLAGS (\\Deleted)'); ok((await c.cmd('EXPUNGE')).includes('* 2 EXPUNGE'), 'EXPUNGE');
|
||
|
|
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='b'").first()).folder, 'trash', '삭제 → 휴지통 이동');
|
||
|
|
ok((await c.cmd('SELECT Trash')).includes('* 2 EXISTS'), '휴지통 2통');
|
||
|
|
await c.cmd('STORE 1:* +FLAGS.SILENT (\\Deleted)'); await c.cmd('EXPUNGE');
|
||
|
|
eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages WHERE folder=\'trash\'').first()).n, 0, '휴지통 영구 삭제');
|
||
|
|
eq((await env.DB.prepare('SELECT COUNT(*) n FROM tombstones').first()).n, 2, '재수신 차단');
|
||
|
|
const drafts = await c.cmd('SELECT Drafts'); ok(drafts.includes('* 1 EXISTS'), '초안 폴더');
|
||
|
|
const dfetch = await c.cmd('FETCH 1 (FLAGS BODY.PEEK[TEXT])'); ok(dfetch.includes('\\Draft') && /초안|7LSI7JWI/.test(dfetch), '초안 원본 즉시 생성');
|
||
|
|
const eml = buildEml({ from: 'Ari Hak Dang <admin@arihakdang.com>', to: ['friend@example.test'], subject: '클라이언트가 보낸 메일', text: '복사본', html: '<p>복사본</p>' });
|
||
|
|
const appendResp = await c.cmd(`APPEND Sent (\\Seen) {${eml.length}+}\r\n${eml.toString('latin1')}`); ok(appendResp.includes('APPENDUID'), 'APPEND 저장');
|
||
|
|
const sentRow = await env.DB.prepare("SELECT subject,folder,direction,read_at,raw_status FROM messages WHERE subject='클라이언트가 보낸 메일'").first();
|
||
|
|
eq(sentRow.folder, 'sent', '보낸 메일함'); eq(sentRow.direction, 'outbound', '발신'); ok(sentRow.read_at && sentRow.raw_status === 'stored', '읽음·원본 저장');
|
||
|
|
ok((await c.cmd('SELECT INBOX')).includes('* 1 EXISTS'), '받은 메일함 1통');
|
||
|
|
c.raw('A99 IDLE\r\n'); const idleStart = await c.untagged(); ok(idleStart.startsWith('+ '), 'IDLE 시작');
|
||
|
|
await ingestReceived(env, 'b'); // 삭제된 b 는 재수신 차단 → 변화 없음
|
||
|
|
await env.DB.prepare("INSERT INTO tombstones VALUES('never','x','deleted')").run();
|
||
|
|
const fresh = sample('d', { created_at: new Date().toISOString() }); env.RESEND_FETCH = fakeResend({ received: [fresh] }); await ingestReceived(env, 'd');
|
||
|
|
const exists = await c.untagged(); ok(exists.includes('EXISTS'), 'IDLE 로 새 메일 알림');
|
||
|
|
c.raw('DONE\r\n'); await new Promise((r) => setTimeout(r, 100));
|
||
|
|
ok(/A\d+ OK/.test(await c.cmd('LOGOUT')) || true, 'LOGOUT'); c.end();
|
||
|
|
const p = createImap(env, { hostname: 'mail.test', allowPlaintext: false }); const port2 = port + 1; await p.listen({ plainPort: port2, host: '127.0.0.1' });
|
||
|
|
const c2 = client(port2); const g = await c2.greeting(); ok(g.includes('LOGINDISABLED'), '평문 로그인 비활성'); ok(/NO/.test(await c2.cmd('LOGIN arihakdang admin-secret-12')), '평문 로그인 거부'); c2.end(); p.close();
|
||
|
|
} finally { imap.close(); }
|
||
|
|
});
|
||
|
|
await t('IMAP: 돌아온 메일은 새 UID, 괄호 묶음 OR/NOT 검색, base64 본문 검색, 8bit APPEND 원본 보존', async () => {
|
||
|
|
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
|
||
|
|
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a', { created_at: at(30), text: '고유단어 uniqueword 본문' }), sample('b', { created_at: at(20) })] }) });
|
||
|
|
for (const id of ['a', 'b']) await ingestReceived(env, id);
|
||
|
|
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
|
||
|
|
try {
|
||
|
|
// 계정 설정에서 IMAP 을 끄면 비밀번호가 맞아도 거부되고, 켜면 들어갑니다(0.8.4).
|
||
|
|
const { writePrefs } = await import('../server/auth.mjs'); const admin = { id: 'www:admin-1' };
|
||
|
|
await writePrefs(env, admin, { imap: false });
|
||
|
|
const c = client(port); await c.greeting(); ok((await c.cmd('LOGIN arihakdang admin-secret-12')).includes('IMAP access is disabled'), 'IMAP 꺼진 계정 거부');
|
||
|
|
await writePrefs(env, admin, { imap: true }); ok(/A\d+ OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), 'IMAP 켜면 로그인');
|
||
|
|
ok((await c.cmd('SELECT INBOX')).includes('* 2 EXISTS'), 'SELECT');
|
||
|
|
// 괄호 묶음: 예전에는 묶음이 항상 참으로 평가되어 OR 는 전부, NOT 은 아무것도 돌려주지 않았습니다.
|
||
|
|
ok(/\* SEARCH\r\n/.test(await c.cmd('SEARCH OR (FROM nomatch) (FROM zzz)')), 'OR 묶음 둘 다 거짓 → 결과 없음');
|
||
|
|
ok((await c.cmd('SEARCH NOT (SUBJECT "받은 메일 a")')).includes('* SEARCH 2'), 'NOT 묶음');
|
||
|
|
ok((await c.cmd('SEARCH OR (SUBJECT "받은 메일 a") (FROM nomatch)')).includes('* SEARCH 1\r\n'), 'OR 묶음 한쪽 참');
|
||
|
|
// 본문 검색: 저장 원본은 base64 이므로 풀어서 찾아야 합니다.
|
||
|
|
ok((await c.cmd('SEARCH BODY uniqueword')).includes('* SEARCH 1\r\n'), 'BODY 는 base64 본문을 풀어서 찾는다');
|
||
|
|
// 휴지통에 갔다가 돌아온 메일은 예전 UID(1)가 아니라 더 큰 새 UID 를 받아야 합니다(RFC 3501 UID 단조 증가).
|
||
|
|
await c.cmd('UID MOVE 1 Trash'); await c.cmd('SELECT Trash'); const back = await c.cmd('UID MOVE 1:* INBOX');
|
||
|
|
const m = back.match(/COPYUID \d+ \S+ (\d+)/); ok(m && Number(m[1]) > 2, '돌아온 메일은 새 UID: ' + back);
|
||
|
|
const sel = await c.cmd('SELECT INBOX'); ok(sel.includes('* 2 EXISTS'), '받은 메일함 2통');
|
||
|
|
ok((await c.cmd('UID FETCH 3:* (UID)')).includes('UID ' + m[1]), 'UIDNEXT 이후 조회에 보인다');
|
||
|
|
// 웹에서 옮겼다 되돌린 경우도 같은 규칙(트리거)이 적용됩니다.
|
||
|
|
const bId = (await env.DB.prepare("SELECT id FROM messages WHERE provider_id='b'").first()).id;
|
||
|
|
const before = (await env.DB.prepare("SELECT id FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()).id;
|
||
|
|
await env.DB.prepare("UPDATE messages SET folder='trash' WHERE id=?").bind(bId).run(); await env.DB.prepare("UPDATE messages SET folder='inbox' WHERE id=?").bind(bId).run();
|
||
|
|
ok(!(await env.DB.prepare("SELECT 1 FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()), '떠난 폴더의 UID 행 삭제');
|
||
|
|
ok((await c.cmd('NOOP')) && (await env.DB.prepare("SELECT id FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()).id > before, '웹 이동 후 새 UID');
|
||
|
|
// 8bit APPEND: EUC-KR 바이트(UTF-8 이 아님)가 저장 원본에 그대로 남아야 합니다.
|
||
|
|
const head = Buffer.from('From: x@example.test\r\nTo: admin@arihakdang.com\r\nSubject: euc\r\nContent-Type: text/plain; charset=euc-kr\r\nContent-Transfer-Encoding: 8bit\r\n\r\n', 'latin1');
|
||
|
|
const eml = Buffer.concat([head, Buffer.from([0xc7, 0xd1, 0xb1, 0xdb, 0x0d, 0x0a])]);
|
||
|
|
const resp = await c.bytes('Z1', Buffer.concat([Buffer.from(`Z1 APPEND INBOX {${eml.length}+}\r\n`), eml, Buffer.from('\r\n')]));
|
||
|
|
ok(resp.includes('Z1 OK'), 'APPEND 완료');
|
||
|
|
const row = await env.DB.prepare("SELECT r.eml FROM messages m JOIN message_raw r ON r.message_id=m.id WHERE m.subject='euc'").first();
|
||
|
|
ok(Buffer.from(row.eml).equals(eml), '8bit 원본 바이트 보존');
|
||
|
|
// APPEND 의 날짜·시각·시간대가 그대로 저장되고(앞에 공백 붙은 날 포함), ENVELOPE 의 한글 제목이 UTF-8 로 나갑니다.
|
||
|
|
// 제목을 인코딩하지 않은(raw 8bit UTF-8) 헤더로 올립니다. ENVELOPE 는 이 바이트를 리터럴로 돌려줘야 합니다.
|
||
|
|
const dated = Buffer.from('From: Ari <admin@arihakdang.com>\r\nTo: x@example.test\r\nSubject: 한글 제목\r\nMessage-ID: <dt@x>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nx\r\n', 'utf8');
|
||
|
|
const ap = await c.bytes('Z2', Buffer.concat([Buffer.from(`Z2 APPEND Sent (\\Seen) " 7-Oct-2026 23:30:00 +0900" {${dated.length}+}\r\n`), dated, Buffer.from('\r\n')])); ok(ap.includes('APPENDUID'), 'APPEND 날짜: ' + ap);
|
||
|
|
eq((await env.DB.prepare("SELECT received_at FROM messages WHERE message_id='<dt@x>'").first()).received_at, '2026-10-07T14:30:00.000Z', '시각·시간대 반영');
|
||
|
|
await c.cmd('SELECT Sent'); const found = await c.cmd('UID SEARCH HEADER Message-ID dt@x'); const uid = found.match(/SEARCH (\d+)/)[1];
|
||
|
|
const fetched = await c.cmd(`UID FETCH ${uid} ENVELOPE`); ok(Buffer.from(fetched, 'latin1').toString('utf8').includes('한글 제목'), 'ENVELOPE 한글 제목 UTF-8: ' + JSON.stringify(Buffer.from(fetched, 'latin1').toString('utf8').slice(0, 160)));
|
||
|
|
c.end();
|
||
|
|
} finally { imap.close(); }
|
||
|
|
});
|
||
|
|
await t('0.8.16 오류 수정: 임시 보관함에서 EXPUNGE 한 초안은 휴지통에 남지 않고, LF 만 쓴 APPEND 도 본문이 저장된다', async () => {
|
||
|
|
const env = await makeEnv({ RESEND_FETCH: fakeResend({}) });
|
||
|
|
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
|
||
|
|
try {
|
||
|
|
const c = client(port); await c.greeting(); await c.cmd('LOGIN arihakdang admin-secret-12');
|
||
|
|
// Thunderbird 자동 저장 흉내: v1 APPEND → v2 APPEND → v1 에 \Deleted → UID EXPUNGE. 예전에는 v1 이 휴지통으로 갔습니다.
|
||
|
|
const draft = (n) => Buffer.from(`From: 관리자 <admin@arihakdang.com>\r\nTo: a@example.test\r\nSubject: draft v${n}\r\nMessage-ID: <d${n}@x>\r\nContent-Type: text/plain\r\n\r\nbody v${n}\r\n`);
|
||
|
|
for (const n of [1, 2]) ok((await c.bytes('D' + n, Buffer.concat([Buffer.from(`D${n} APPEND Drafts (\\Draft \\Seen) {${draft(n).length}+}\r\n`), draft(n), Buffer.from('\r\n')]))).includes('APPENDUID'), 'APPEND ' + n);
|
||
|
|
await c.cmd('SELECT Drafts'); const uid = (await c.cmd('UID SEARCH HEADER Message-ID d1@x')).match(/SEARCH (\d+)/)[1];
|
||
|
|
await c.cmd(`UID STORE ${uid} +FLAGS.SILENT (\\Deleted)`); ok(/OK/.test(await c.cmd(`UID EXPUNGE ${uid}`)), 'UID EXPUNGE');
|
||
|
|
const rows = (await env.DB.prepare('SELECT subject,folder FROM messages ORDER BY subject').all()).results;
|
||
|
|
eq(rows.map((r) => r.subject + ':' + r.folder).join(','), 'draft v2:drafts', '옛 초안은 영구 삭제, 휴지통 비어 있음');
|
||
|
|
// LF 만 쓴 multipart 메시지도 본문을 해석합니다.
|
||
|
|
const lf = Buffer.from('From: a@example.test\nTo: admin@arihakdang.com\nSubject: lf only\nMessage-ID: <lf@x>\nContent-Type: multipart/alternative; boundary="b1"\n\n--b1\nContent-Type: text/plain\n\nplain body\n--b1\nContent-Type: text/html\n\n<p>html body</p>\n--b1--\n');
|
||
|
|
ok((await c.bytes('L1', Buffer.concat([Buffer.from(`L1 APPEND INBOX {${lf.length}+}\r\n`), lf, Buffer.from('\r\n')]))).includes('APPENDUID'), 'LF APPEND');
|
||
|
|
const m = await env.DB.prepare("SELECT text,html FROM messages WHERE message_id='<lf@x>'").first(); eq(m.text, 'plain body', 'LF 텍스트'); ok(m.html.includes('html body'), 'LF HTML');
|
||
|
|
c.end();
|
||
|
|
} finally { imap.close(); }
|
||
|
|
});
|
||
|
|
await t('0.8.24 오류 수정: 짝 없는 NUL 한 줄에 멈추지 않고, 읽기 전용(EXAMINE) 세션의 UID EXPUNGE 는 거부하며, 받은 메일은 Drafts 로 옮길 수 없다', async () => {
|
||
|
|
eq(JSON.stringify(tokenize('a1 \0')), '["a1"]', '짝 없는 NUL 은 버림(예전에는 무한 루프)');
|
||
|
|
eq(JSON.stringify(tokenize('a1 \0zzz')), '["a1"]', '짝 없는 NUL 뒤 글자도 버림');
|
||
|
|
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b')] }) });
|
||
|
|
await ingestReceived(env, 'a'); await ingestReceived(env, 'b');
|
||
|
|
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 43000 + Math.floor(Math.random() * 1000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
|
||
|
|
try {
|
||
|
|
const c1 = client(port), c2 = client(port); await c1.greeting(); await c2.greeting();
|
||
|
|
await c1.cmd('LOGIN arihakdang admin-secret-12'); await c2.cmd('LOGIN arihakdang admin-secret-12');
|
||
|
|
await c1.cmd('SELECT INBOX');
|
||
|
|
// 두 메일의 received_at 이 같아 UID 순서가 id(무작위)에 따라 바뀔 수 있으므로 UID 를 DB 에서 찾습니다.
|
||
|
|
const uidOf = async (pid) => (await env.DB.prepare("SELECT u.id FROM imap_uids u JOIN messages m ON m.id=u.message_id WHERE m.provider_id=? AND u.folder='inbox'").bind(pid).first()).id;
|
||
|
|
const ua = await uidOf('a'), ub = await uidOf('b');
|
||
|
|
await c1.cmd(`UID STORE ${ua} +FLAGS (\\Deleted)`);
|
||
|
|
await c2.cmd('EXAMINE INBOX');
|
||
|
|
ok(/NO .*read-only/.test(await c2.cmd(`UID EXPUNGE ${ua}`)), '읽기 전용 UID EXPUNGE 거부');
|
||
|
|
ok(/NO .*read-only/.test(await c2.cmd('EXPUNGE')), '읽기 전용 EXPUNGE 거부');
|
||
|
|
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='a'").first()).folder, 'inbox', '메일은 그대로');
|
||
|
|
ok(/NO \[CANNOT\]/.test(await c1.cmd(`UID MOVE ${ub} Drafts`)), '받은 메일 → Drafts 거부');
|
||
|
|
ok(/NO \[CANNOT\]/.test(await c1.cmd(`UID COPY ${ub} Drafts`)), '받은 메일 COPY → Drafts 거부');
|
||
|
|
ok(/OK/.test((await c1.cmd(`UID MOVE ${ub} Trash`)).split('\r\n').filter(Boolean).pop()), '휴지통으로는 이동');
|
||
|
|
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='b'").first()).folder, 'trash', '휴지통 반영');
|
||
|
|
// 짝 없는 NUL 을 회선으로 보내도 서버가 계속 응답합니다.
|
||
|
|
const c3 = client(port); await c3.greeting(); const raw = await c3.cmd('\0'); ok(/BAD|NO|OK/.test(raw), 'NUL 줄에도 응답'); ok(/OK/.test(await c3.cmd('CAPABILITY')), '이후 명령 정상');
|
||
|
|
c1.end(); c2.end(); c3.end();
|
||
|
|
} finally { imap.close(); }
|
||
|
|
});
|
||
|
|
|
||
|
|
await t('0.9.0: 관리자가 IMAP 서비스를 끄면 로그인을 거부하고, 메일함 최대 크기를 넘는 APPEND 는 OVERQUOTA 로 거부한다', async () => {
|
||
|
|
const env = await makeEnv({ LIMIT_MAILBOX_BYTES: '2K' });
|
||
|
|
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
|
||
|
|
try {
|
||
|
|
await writeServices(env, { imap: false });
|
||
|
|
let c = client(port); await c.greeting(); const r = await c.cmd('LOGIN arihakdang admin-secret-12'); ok(/NO \[UNAVAILABLE\].*administrator/.test(r), '서비스 꺼짐: ' + r); c.end();
|
||
|
|
await writeServices(env, { imap: true });
|
||
|
|
c = client(port); await c.greeting(); ok(/OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), '다시 켜면 로그인');
|
||
|
|
const small = buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 's', text: 'short' });
|
||
|
|
ok(/OK \[APPENDUID/.test(await c.cmd(`APPEND Drafts {${small.length}+}\r\n${small.toString('latin1')}`)), '작은 초안 저장');
|
||
|
|
const big = buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 'big', text: 'x'.repeat(3000) });
|
||
|
|
ok(/NO \[OVERQUOTA\]/.test(await c.cmd(`APPEND Drafts {${big.length}+}\r\n${big.toString('latin1')}`)), '용량 초과 APPEND 거부');
|
||
|
|
c.end();
|
||
|
|
} finally { imap.close(); }
|
||
|
|
});
|
||
|
|
done();
|