/** * SMTP 제출 서버(server/smtp.mjs) 회귀 테스트. * 메일 프로그램이 로그인 비밀번호로 인증하고, 게이트웨이가 내부 Resend 키로 발송한 뒤 보낸 메일함에 저장하는 흐름을 확인합니다. * 가짜 Resend(fakeResend)로 실제 발송 요청 내용(받는 사람 분리·첨부·인라인 이미지·멱등 키)을 검사합니다. */ import net from 'node:net'; import tls from 'node:tls'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs'; import { createSmtp, DataCollector, dotUnstuff, SMTP_MAX_SIZE } from '../server/smtp.mjs'; import { appendMessage } from '../server/store.mjs'; import { createUser } from '../server/auth.mjs'; import { writeAdminLimits } from '../server/limits.mjs'; import { buildEml } from '../server/mime.mjs'; const { t, done } = runner(); /** SMTP 응답 읽기 도우미: 여러 줄 응답("250-…")은 마지막 줄("250 …")까지 모아 한 번에 돌려줍니다. */ function client(socket) { let buffer = ''; const waiters = []; const onData = (d) => { buffer += d.toString('latin1'); flush(); }; socket.on('data', onData); function flush() { while (waiters.length) { const m = buffer.match(/(?:^|\r\n)(\d{3}) [^\r]*\r\n/); if (!m) return; const end = m.index + m[0].length; const w = waiters.shift(); w(buffer.slice(0, end).replace(/^\r\n/, '')); buffer = buffer.slice(end); } } const reply = () => new Promise((resolve) => { waiters.push(resolve); flush(); }); return { reply, socket, cmd: (line) => { socket.write(line + '\r\n'); return reply(); }, raw: (data) => socket.write(data), detach: () => socket.off('data', onData), end: () => socket.end(), }; } const connect = (port) => new Promise((resolve) => { const s = net.connect(port, '127.0.0.1', () => resolve(client(s))); }); const plain64 = (user, pass) => Buffer.from(`\0${user}\0${pass}`).toString('base64'); const at = (s) => new Date(Date.now() - s * 1000).toISOString(); /** 시험용 메일: 한글 제목, 받는 사람·참조, 첨부, 인라인 이미지, 점으로 시작하는 줄을 담습니다. */ function message({ from = '아리 관리자 ', to = ['Friend '], cc = ['copy@example.test'], subject = '안녕하세요 SMTP', messageId = '', text = '본문입니다\n.점으로 시작하는 줄', inReplyTo = null } = {}) { return buildEml({ from, to, cc, subject, messageId, text, html: '

본문

', headers: inReplyTo ? { 'In-Reply-To': inReplyTo } : {}, attachments: [{ filename: '보고서.txt', contentType: 'text/plain', bytes: Buffer.from('report') }, { filename: 'logo.png', contentType: 'image/png', bytes: Buffer.from('png'), contentId: 'logo1', disposition: 'inline' }] }); } /** RFC 5321 점 스터핑 후 DATA 로 보낼 바이트(끝 표시 포함). */ const dataOf = (eml) => Buffer.concat([Buffer.from(eml.toString('latin1').replace(/(^|\r\n)\./g, '$1..'), 'latin1'), Buffer.from('.\r\n')]); async function server(over = {}, options = {}) { const calls = []; const env = await makeEnv({ RESEND_FETCH: fakeResend({ calls, received: [] }), ...over }); const smtp = createSmtp(env, { hostname: 'mail.test', allowPlaintext: true, ...options }); const port = 30000 + Math.floor(Math.random() * 20000); await smtp.listen({ plainPort: port, host: '127.0.0.1' }); return { env, smtp, port, calls }; } await t('DATA 수집기는 조각 경계·빈 본문·크기 초과를 처리하고 점 스터핑을 푼다', async () => { let c = new DataCollector(1000); eq(c.feed(Buffer.from('a\r\n.')), null, '아직 끝 아님'); let r = c.feed(Buffer.from('\r\nQUIT\r\n')); eq(r.message.toString(), 'a\r\n', '경계에 걸친 끝 표시'); eq(r.rest.toString(), 'QUIT\r\n', '뒤 명령 보존'); c = new DataCollector(1000); r = c.feed(Buffer.from('.\r\n')); eq(r.message.length, 0, '빈 본문'); c = new DataCollector(1000); eq(c.feed(Buffer.from('.')), null, '점 한 바이트'); r = c.feed(Buffer.from('\r\n')); eq(r.message.length, 0, '빈 본문(조각)'); c = new DataCollector(5); r = c.feed(Buffer.from('0123456789\r\n.\r\n')); eq(r.message, null, '크기 초과'); eq(dotUnstuff(Buffer.from('..a\r\n..b\r\nc..\r\n')).toString(), '.a\r\n.b\r\nc..\r\n', '점 스터핑 해제'); }); await t('SMTP: 크기를 넘는 DATA 는 552 로 응답하고 세션은 계속 쓸 수 있다', async () => { const { env, smtp, port } = await server(); try { const c = await connect(port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12')); await c.cmd('MAIL FROM:'); await c.cmd('RCPT TO:'); ok((await c.cmd('DATA')).startsWith('354'), 'DATA'); c.raw(Buffer.concat([Buffer.from('Subject: big\r\n\r\n'), Buffer.alloc(SMTP_MAX_SIZE + 1024, 0x61), Buffer.from('\r\n.\r\n')])); const r = await c.reply(); ok(r.startsWith('552'), '크기 초과 응답: ' + r.slice(0, 40)); ok((await c.cmd('MAIL FROM:')).startsWith('250'), '다음 트랜잭션 진행'); c.end(); eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n, 0, '저장 없음'); } finally { smtp.close(); } }); await t('SMTP: 로그인 비밀번호로 인증하고 내부 키로 발송하며 보낸 메일함에 저장한다', async () => { const { env, smtp, port, calls } = await server(); try { const c = await connect(port); ok((await c.reply()).startsWith('220 '), '인사'); ok((await c.cmd('MAIL FROM:')).startsWith('503'), 'EHLO 먼저'); const ehlo = await c.cmd('EHLO client.test'); ok(ehlo.includes('AUTH PLAIN LOGIN') && ehlo.includes('SIZE') && ehlo.includes('PIPELINING'), 'EHLO 확장'); ok((await c.cmd('MAIL FROM:')).startsWith('530'), '인증 전 발송 거부(오픈 릴레이 방지)'); ok((await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'wrong'))).startsWith('535'), '틀린 비밀번호'); // AUTH LOGIN 대화형: 사용자 이름 → 비밀번호 ok((await c.cmd('AUTH LOGIN')).startsWith('334 VXNlcm5hbWU6'), 'Username 질문'); ok((await c.cmd(Buffer.from('admin').toString('base64'))).startsWith('334 UGFzc3dvcmQ6'), 'Password 질문'); ok((await c.cmd(Buffer.from('admin-secret-12').toString('base64'))).startsWith('235'), '로그인 비밀번호로 인증(별칭 admin)'); ok((await c.cmd('MAIL FROM:')).startsWith('550'), '남의 주소로 보내기 거부'); ok((await c.cmd('MAIL FROM: SIZE=999999999')).startsWith('552'), 'SIZE 초과'); // PIPELINING: MAIL·RCPT·RCPT·RCPT·DATA 를 한 번에 보냅니다. 숨은 참조(hidden@)는 헤더에 없고 RCPT 에만 있습니다. c.raw('MAIL FROM:\r\nRCPT TO:\r\nRCPT TO:\r\nRCPT TO:\r\nDATA\r\n'); for (const expect of ['250', '250', '250', '250', '354']) ok((await c.reply()).startsWith(expect), '파이프라인 ' + expect); const eml = message(); c.raw(dataOf(eml)); const done250 = await c.reply(); ok(done250.startsWith('250 2.0.0 OK queued as sent-'), '발송 완료: ' + done250); const post = calls.find((x) => x.method === 'POST' && x.path === '/emails'); ok(post, 'Resend 발송 호출'); eq(post.body.from, '아리 관리자 ', '보내는 사람 표시 이름'); eq(post.body.to.join(), 'Friend ', '받는 사람'); eq(post.body.cc.join(), 'copy@example.test', '참조'); eq(post.body.bcc.join(), 'hidden@example.test', '숨은 참조는 봉투에서'); eq(post.body.subject, '안녕하세요 SMTP', '한글 제목 해독'); ok(post.body.text.includes('\n.점으로 시작하는 줄'), '점 스터핑 해제된 본문'); ok(post.body.html.includes('cid:logo1'), 'HTML 본문'); const files = post.body.attachments, report = files.find((f) => f.filename === '보고서.txt'), logo = files.find((f) => f.filename === 'logo.png'); eq(files.length, 2, '첨부 2개'); ok(report && !report.content_id, '일반 첨부(한글 이름)'); eq(Buffer.from(report.content, 'base64').toString(), 'report', '첨부 내용'); eq(logo.content_id, 'logo1', '인라인 이미지 cid'); ok(post.key && post.key.startsWith('smtp-'), '멱등 키'); const row = await env.DB.prepare("SELECT * FROM messages WHERE folder='sent'").first(); ok(row && row.provider_id.startsWith('sent-'), '보낸 메일함 + 발송 id'); eq(row.mailbox, 'admin@arihakdang.com', '메일함'); eq(row.state, 'sent', '상태'); eq(JSON.parse(row.bcc_json).join(), 'hidden@example.test', '숨은 참조 기록'); ok(row.read_at, '읽음'); const raw = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(row.id).first(); ok(Buffer.from(raw.eml).equals(eml), '원본 그대로 저장'); // 메일 프로그램이 IMAP 으로 보낸 메일함에 같은 메일을 올리면 한 통으로 합쳐집니다. const dup = await appendMessage(env, 'sent', eml, { flags: ['\\Seen'], mailbox: 'admin@arihakdang.com' }); eq(dup.id, row.id, 'APPEND 중복 합침'); eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE folder='sent'").first()).n, 1, '보낸 메일함 1통'); ok((await c.cmd('QUIT')).startsWith('221'), 'QUIT'); c.end(); } finally { smtp.close(); } }); await t('SMTP: 반대 순서(APPEND 먼저)·답장 표시·일반 사용자 발신 주소 제한·발송 실패 응답', async () => { const { env, smtp, port, calls } = await server(); try { // APPEND 가 먼저 와도 SMTP 발송은 그 사본에 발송 id 만 붙입니다. const parentId = (await appendMessage(env, 'inbox', buildEml({ from: 'friend@example.test', to: ['admin@arihakdang.com'], subject: '질문', messageId: '', text: '?' }))).id; const eml = message({ messageId: '', inReplyTo: '', cc: [] }); await appendMessage(env, 'sent', eml, { flags: ['\\Seen'], mailbox: 'admin@arihakdang.com' }); let c = await connect(port); await c.reply(); await c.cmd('EHLO x'); ok((await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'admin-secret-12'))).startsWith('235'), 'AUTH PLAIN'); await c.cmd('MAIL FROM:'); await c.cmd('RCPT TO:'); ok((await c.cmd('DATA')).startsWith('354'), 'DATA'); // 끝 표시를 여러 조각으로 나눠 보냅니다. const data = dataOf(eml); c.raw(data.subarray(0, data.length - 4)); await new Promise((r) => setTimeout(r, 30)); c.raw(data.subarray(data.length - 4)); ok((await c.reply()).startsWith('250'), '조각난 끝 표시'); eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE folder='sent'").first()).n, 1, 'APPEND 사본과 합침'); ok((await env.DB.prepare("SELECT provider_id FROM messages WHERE folder='sent'").first()).provider_id, '사본에 발송 id'); eq(calls.find((x) => x.method === 'POST').body.headers['In-Reply-To'], '', '답장 헤더 전달'); ok((await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(parentId).first()).flags.includes('\\Answered'), '원래 메일에 답장함 표시'); // 헤더 From 이 다른 주소면 거부합니다(봉투는 맞아도). await c.cmd('MAIL FROM:'); await c.cmd('RCPT TO:'); await c.cmd('DATA'); c.raw(dataOf(message({ from: 'CEO ', messageId: '' }))); ok((await c.reply()).startsWith('550 5.7.1'), '헤더 From 사칭 거부'); c.end(); // 일반 사용자는 자기 메일함 주소로만 보낼 수 있습니다. await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test'); c = await connect(port); await c.reply(); await c.cmd('EHLO x'); ok((await c.cmd('AUTH PLAIN ' + plain64('info', 'staff-secret-1'))).startsWith('235'), '게이트웨이 계정 인증'); ok((await c.cmd('MAIL FROM:')).startsWith('550'), '다른 메일함 주소 거부'); ok((await c.cmd('MAIL FROM:')).startsWith('250'), '자기 주소 허용'); await c.cmd('RCPT TO:'); await c.cmd('DATA'); c.raw(dataOf(message({ from: 'Staff ', messageId: '', cc: [] }))); ok((await c.reply()).startsWith('250'), '직원 발송'); eq((await env.DB.prepare("SELECT mailbox FROM messages WHERE message_id=''").first()).mailbox, 'info@arihakdang.com', '직원 보낸 메일함'); c.end(); } finally { smtp.close(); } // 발송 서비스 장애는 임시 실패(4xx), 키·설정 정보는 응답에 넣지 않습니다. const failing = await server({ RESEND_FETCH: fakeResend({ failStatus: 401 }) }); try { const c = await connect(failing.port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12')); await c.cmd('MAIL FROM:'); await c.cmd('RCPT TO:'); await c.cmd('DATA'); c.raw(dataOf(message({ cc: [] }))); const r = await c.reply(); ok(r.startsWith('451 4.3.0') && !/key|resend/i.test(r), '임시 실패, 키 정보 숨김: ' + r); eq((await failing.env.DB.prepare("SELECT COUNT(*) n FROM messages").first()).n, 0, '실패하면 저장하지 않음'); c.end(); } finally { failing.smtp.close(); } }); await t('SMTP: TLS 없이는 로그인을 받지 않고 STARTTLS 뒤에 인증·발송한다', async () => { const dir = mkdtempSync(join(tmpdir(), 'mg-smtp-')); try { try { execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', join(dir, 'k.pem'), '-out', join(dir, 'c.pem'), '-days', '1', '-subj', '/CN=mail.test'], { stdio: 'ignore' }); } catch { console.log(' (openssl 없음 — STARTTLS 시험 생략)'); return; } const tlsOptions = { key: readFileSync(join(dir, 'k.pem')), cert: readFileSync(join(dir, 'c.pem')) }; const { smtp, port, calls } = await server({}, { allowPlaintext: false, tlsOptions }); try { const c = await connect(port); await c.reply(); const ehlo = await c.cmd('EHLO x'); ok(ehlo.includes('STARTTLS') && !ehlo.includes('AUTH'), '평문에서는 AUTH 광고 안 함'); ok((await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'))).startsWith('530'), '평문 로그인 거부'); ok((await c.cmd('STARTTLS')).startsWith('220'), 'STARTTLS'); c.detach(); const secure = tls.connect({ socket: c.socket, rejectUnauthorized: false }); await new Promise((r) => secure.once('secureConnect', r)); const s = client(secure); const ehlo2 = await s.cmd('EHLO x'); ok(ehlo2.includes('AUTH PLAIN LOGIN') && !ehlo2.includes('STARTTLS'), 'TLS 뒤 AUTH 광고'); ok((await s.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'))).startsWith('235'), 'TLS 위 인증'); await s.cmd('MAIL FROM:'); await s.cmd('RCPT TO:'); await s.cmd('DATA'); s.raw(dataOf(message({ cc: [] }))); ok((await s.reply()).startsWith('250'), 'TLS 위 발송'); ok(calls.some((x) => x.method === 'POST'), 'Resend 호출'); s.end(); } finally { smtp.close(); } } finally { rmSync(dir, { recursive: true, force: true }); } }); await t('0.8.25 오류 수정: 같은 DATA 를 받는 사람만 바꿔 따로 보내면 멱등 키가 달라 둘 다 발송된다', async () => { const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }); const smtp = createSmtp(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 31000 + Math.floor(Math.random() * 9000); await smtp.listen({ plainPort: port, host: '127.0.0.1' }); try { const c = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); })); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64')); const data = 'From: admin@arihakdang.com\r\nSubject: newsletter\r\nMessage-ID: \r\n\r\nhello\r\n.\r\n'; for (const rcpt of ['alice@example.test', 'bob@example.test']) { await c.cmd('MAIL FROM:'); await c.cmd('RCPT TO:<' + rcpt + '>'); await c.cmd('DATA'); c.raw(data); ok(/^250 /.test(await c.reply()), rcpt + ' 발송'); } await c.cmd('QUIT'); const posts = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(posts.length, 2, '두 번 발송'); ok(posts[0].key !== posts[1].key, '멱등 키가 다름'); eq(posts[0].body.bcc[0], 'alice@example.test', '첫 받는 사람'); eq(posts[1].body.bcc[0], 'bob@example.test', '둘째 받는 사람'); // 같은 봉투·같은 DATA 를 되풀이하면(응답을 못 받은 재전송) 키가 같습니다. const c2 = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); })); await c2.reply(); await c2.cmd('EHLO x'); await c2.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64')); await c2.cmd('MAIL FROM:'); await c2.cmd('RCPT TO:'); await c2.cmd('DATA'); c2.raw(data); await c2.reply(); await c2.cmd('QUIT'); const again = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(again[2].key, posts[0].key, '재전송은 같은 키'); } finally { smtp.close(); } }); await t('0.9.0: 관리자도 자기 주소로만 보내고, 받는 사람 수·첨부 크기·메일함 용량 제한이 SMTP 응답으로 돌아오며, Content-ID 만 있는 그림도 인라인으로 나간다', async () => { const { env, smtp, port, calls } = await server({ LIMIT_RECIPIENTS: '2', LIMIT_ATTACHMENT_BYTES: '100K', LIMIT_MAILBOX_BYTES: '1M' }); try { await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test'); let c = await connect(port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'admin-secret-12')); ok((await c.cmd('MAIL FROM:')).startsWith('550'), '관리자도 다른 계정의 메일함 주소로는 보내지 못함(0.9.0)'); ok((await c.cmd('MAIL FROM:')).startsWith('250'), '자기 주소'); await c.cmd('RCPT TO:'); await c.cmd('RCPT TO:'); ok((await c.cmd('RCPT TO:')).startsWith('452 4.5.3'), '받는 사람 수(시스템 2) 초과'); ok((await c.cmd('RCPT TO:')).startsWith('250'), '이미 있는 주소는 한도에서도 허용(0.9.3)'); // 첨부 한 개가 100KB(MIME) 를 넘으면 552. await c.cmd('DATA'); c.raw(dataOf(buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 'big', text: 'x', attachments: [{ filename: 'big.bin', contentType: 'application/octet-stream', bytes: Buffer.alloc(90 * 1024, 7) }] }))); const big = await c.reply(); ok(big.startsWith('552 5.3.4') && /MIME/.test(big), '첨부 초과: ' + big); // 관리자가 더 줄인 수신자 수(1)도 적용됩니다. await writeAdminLimits(env, { recipients: 1 }); await c.cmd('MAIL FROM:'); await c.cmd('RCPT TO:'); ok((await c.cmd('RCPT TO:')).startsWith('452'), '관리자 제한 1'); // Content-Disposition 없이 Content-ID 만 있는 그림(Outlook 류)도 본문이 cid: 로 가리키면 content_id 를 넘깁니다. const b = 'BB'; const raw = Buffer.from(['From: admin@arihakdang.com', 'To: a@example.test', 'Subject: cid', 'Message-ID: ', 'MIME-Version: 1.0', `Content-Type: multipart/related; boundary="${b}"`, '', `--${b}`, 'Content-Type: text/html; charset=utf-8', '', '

', `--${b}`, 'Content-Type: image/png', 'Content-ID: ', 'Content-Transfer-Encoding: base64', '', Buffer.from('png').toString('base64'), `--${b}--`, ''].join('\r\n')); await c.cmd('DATA'); c.raw(dataOf(raw)); ok((await c.reply()).startsWith('250'), 'cid 메일 발송'); const post = calls.filter((x) => x.method === 'POST' && x.path === '/emails').at(-1); eq(post.body.attachments[0].content_id, 'logo1', 'content_id 전달'); // 메일함이 가득 차면 452 4.2.2. await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,subject,state,received_at,created_at,updated_at,mailbox,raw_size) VALUES('fill','inbound','inbox','x','s','received','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','admin@arihakdang.com',1100000)").run(); await c.cmd('MAIL FROM:'); await c.cmd('RCPT TO:'); await c.cmd('DATA'); c.raw(dataOf(message({ cc: [], messageId: '' }))); ok((await c.reply()).startsWith('452 4.2.2'), '메일함 가득 참'); c.end(); } finally { smtp.close(); } }); done();