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