2026-10-26 11:02:58 +00:00
/**
* 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' );
2026-10-26 11:47:45 +00:00
// [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' );
2026-10-26 11:02:58 +00:00
} 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 ();