2026-10-26 11:02:58 +00:00
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 <sender@example.test>' , to : [ 'admin@arihakdang.com' ], subject : '받은 메일 ' + id , html : '<p>내용 <b>' + id + '</b><script>x()</script></p>' , 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 ( '<script>alert(1)</script><p onclick="bad()">본문<img src="javascript:bad" onerror="bad()"><img src="cid:img1"></p><a href="javascript:bad">링크</a><strong>굵게</strong><style>p{}</style>' );
ok ( ! /script|onclick|onerror|javascript|<style/ . test ( c . html ), '실행 코드 제거' ); ok ( c . html . includes ( '<strong>굵게</strong>' ) && c . html . includes ( 'src="cid:img1"' ), '서식·cid 유지' ); ok ( c . text . includes ( '본문' ), '텍스트' );
});
await t ( '웹훅 서명은 Svix 규칙으로 검증하고 시각 허용 범위를 벗어나면 거부한다' , async () => {
const secret = 'whsec_' + Buffer . from ( 'supersecretkey123' ). toString ( 'base64' ), body = '{"type":"email.received"}' , ts = String ( Math . floor ( Date . now () / 1000 ));
const headers = { 'svix-id' : 'msg_1' , 'svix-timestamp' : ts , 'svix-signature' : 'v1,AAAA ' + signSvix ( secret , 'msg_1' , ts , body ) };
ok ( verifySvix ( secret , headers , Buffer . from ( body )). ok , '정상' ); ok ( ! verifySvix ( secret , { ... headers , 'svix-signature' : 'v1,AAAA' }, Buffer . from ( body )). ok , '위조' );
ok ( ! verifySvix ( secret , headers , Buffer . from ( body + ' ' )). ok , '본문 변조' ); ok ( ! verifySvix ( secret , { ... headers , 'svix-timestamp' : String ( Number ( ts ) - 1000 ) }, Buffer . from ( body )). ok , '오래된 서명' );
});
await t ( '웹훅 수신은 원본과 첨부를 저장하고 다른 메일함·중복·서명 없음은 처리하지 않는다' , async () => {
const secret = 'whsec_' + Buffer . from ( 'k' ). toString ( 'base64' ), raw = { a : Buffer . from ( 'From: sender@example.test\r\nSubject: raw\r\n\r\nbody\r\n' ) };
const env = await makeEnv ({ RESEND_WEBHOOK_SECRET : secret , RESEND_FETCH : fakeResend ({ received : [ sample ( 'a' , { attachments : [{ id : 'att1' , filename : 'f.txt' }] }), sample ( 'b' , { to : [ 'other@arihakdang.com' ] })], raw , attachments : { a : [{ id : 'att1' , filename : 'f.txt' , content_type : 'text/plain' , size : 5 , download_url : 'https://files.test/att/att1' , bytes : 'hello' }] } }) });
const send = async ( payload , id = 'evt_' + Math . random ()) => { const body = JSON . stringify ( payload ), ts = String ( Math . floor ( Date . now () / 1000 )); return api ( env , 'webhooks/resend' , '' , 'POST' , body , { 'svix-id' : id , 'svix-timestamp' : ts , 'svix-signature' : signSvix ( secret , id , ts , body ) }); };
eq (( await api ( env , 'webhooks/resend' , '' , 'POST' , '{}' )). status , 401 , '서명 없음 거부' );
const r = await send ({ type : 'email.received' , data : { email_id : 'a' , to : [ 'admin@arihakdang.com' ], subject : '받은 메일 a' } }, 'evt_a' );
eq ( r . status , 200 , '수신' ); const row = await env . DB . prepare ( "SELECT * FROM messages WHERE provider_id='a'" ). first ();
eq ( row . folder , 'inbox' , '받은 메일함' ); eq ( row . source , 'webhook' , '경로' ); eq ( row . raw_status , 'stored' , '원본 그대로' ); ok ( ! row . html . includes ( 'script' ), '정제' );
eq ( Buffer . from (( await env . DB . prepare ( 'SELECT eml FROM message_raw WHERE message_id=?' ). bind ( row . id ). first ()). eml ). toString (), raw . a . toString (), '원본 바이트' );
eq ( Buffer . from (( await env . DB . prepare ( 'SELECT bytes FROM attachments WHERE message_id=?' ). bind ( row . id ). first ()). bytes ). toString (), 'hello' , '첨부 바이트' );
eq ( row . expires_at , null , '보관 기한 없음(만료일을 두지 않음)' );
eq (( await ( await send ({ type : 'email.received' , data : { email_id : 'a' , to : [ 'admin@arihakdang.com' ] } }, 'evt_a' )). json ()). duplicate , true , '같은 이벤트 중복' );
eq (( await ( await send ({ type : 'email.received' , data : { email_id : 'b' , to : [ 'other@arihakdang.com' ] } })). json ()). ignored , 'other_mailbox' , '다른 메일함' );
eq (( await env . DB . prepare ( 'SELECT COUNT(*) n FROM messages' ). first ()). n , 1 , '한 통만' );
});
await t ( '정기 동기화는 웹훅이 놓친 메일을 보충하고 이미 있는 메일·삭제된 메일은 다시 가져오지 않는다' , async () => {
const sent = [{ id : 's1' , from : 'Ari Hak Dang <admin@arihakdang.com>' , to : [ 'x@example.test' ], subject : '보낸 메일' , html : '<p>hi</p>' , text : 'hi' , created_at : new Date (). toISOString () }, { id : 's2' , from : 'Ari Hakdang <hello@arihakdang.com>' , to : [ 'y@example.test' ], subject : '다른 발신자' , created_at : new Date (). toISOString () }];
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'a' ), sample ( 'b' ), sample ( 'old' , { created_at : new Date ( Date . now () - 40 * 86400000 ). toISOString () })], sent }) });
await ingestReceived ( env , 'a' , { source : 'webhook' });
const r = await syncMailbox ( env , { trigger : 'test' }); eq ( r . imported , 3 , '놓친 b + 40일 지난 old + 보낸 s1' );
eq (( await env . DB . prepare ( "SELECT COUNT(*) n FROM messages WHERE direction='inbound'" ). first ()). n , 3 , '보관 기한이 없으므로 오래된 메일도 가져온다' );
eq (( await env . DB . prepare ( "SELECT folder FROM messages WHERE provider_id='s1'" ). first ()). folder , 'sent' , '보낸 메일함' );
eq (( await env . DB . prepare ( "SELECT source FROM messages WHERE provider_id='a'" ). first ()). source , 'webhook' , '기존 유지' );
const cookie = await login ( env ); const b = await env . DB . prepare ( "SELECT id FROM messages WHERE provider_id='b'" ). first ();
eq (( await api ( env , 'mail/' + b . id , cookie , 'DELETE' )). status , 409 , '휴지통 먼저' );
await api ( env , 'mail/' + b . id , cookie , 'PATCH' , { folder : 'trash' }); eq (( await api ( env , 'mail/' + b . id , cookie , 'DELETE' )). status , 200 , '영구 삭제' );
eq (( await syncMailbox ( env , { trigger : 'test' })). imported , 0 , '재수신 없음' );
eq (( await env . DB . prepare ( 'SELECT COUNT(*) n FROM sync_runs' ). first ()). n , 2 , '실행 기록' );
});
await t ( '메일은 보관 기한 없이 남고 주기 정리는 만료된 세션만 지운다' , async () => {
// 예전 설정(RETENTION_DAYS=30)이 환경에 남아 있어도 무시되어야 합니다.
const env = await makeEnv ({ RETENTION_DAYS : '30' , RESEND_FETCH : fakeResend ({ received : [ sample ( 'a' , { created_at : new Date ( Date . now () - 400 * 86400000 ). toISOString () })] }) });
await ingestReceived ( env , 'a' ); const cookie = await login ( env );
await env . DB . prepare ( "INSERT INTO sessions(id,user_id,login,expires_at,created_at) VALUES('old','admin-1','arihakdang','2000-01-01T00:00:00.000Z','2000-01-01T00:00:00.000Z')" ). run ();
eq (( await cleanupSessions ( env )). sessions , 1 , '만료 세션만 삭제' );
eq (( await env . DB . prepare ( 'SELECT COUNT(*) n FROM messages' ). first ()). n + ( await env . DB . prepare ( 'SELECT COUNT(*) n FROM message_raw' ). first ()). n , 2 , '400일 지난 메일·원본 유지' );
eq (( await api ( env , 'mail?folder=inbox' , cookie )). status , 200 , '유효 세션은 그대로' );
const s = await ( await api ( env , 'settings' , cookie )). json (); eq ( s . retention , 'unlimited' , '설정 화면 보관 정책' ); eq ( s . retentionDays , undefined , '일수 표시 없음' );
});
await t ( '0004 마이그레이션은 기존 만료일을 비우고 Resend 형식 시각을 ISO 로 바꾼다' , async () => {
// 0.7 까지의 스키마(0001~0003)로 DB 를 만든 뒤 예전 형식 데이터를 넣고, 전체 마이그레이션을 다시 돌립니다.
const all = join ( dirname ( fileURLToPath ( import . meta . url )), '..' , 'migrations' ), old = mkdtempSync ( join ( tmpdir (), 'mg-mig-' ));
try {
for ( const f of readdirSync ( all ). filter (( x ) => x < '0004' )) copyFileSync ( join ( all , f ), join ( old , f ));
const db = openDb ( ':memory:' ); migrate ( db , old , { quiet : true });
db . prepare ( "INSERT INTO messages(id,direction,folder,received_at,sent_at,created_at,updated_at,expires_at) VALUES('m1','outbound','sent','2026-09-28 06:20:26.514000+00','2026-09-28 06:20:26.514000+00','x','x','2026-10-28T06:20:26.514Z'),('m2','inbound','inbox','2026-09-28T07:00:00.000Z',NULL,'x','x','2026-10-28T07:00:00.000Z')" ). run ();
2026-10-26 11:47:45 +00:00
eq ( migrate ( db , all , { quiet : true }), 6 , '0004·0005·0006·0007·0008·0009 적용' ); // [Ariweb Suite 변경] 0009 발송 대기열
2026-10-26 11:02:58 +00:00
const rows = db . prepare ( 'SELECT id,received_at,sent_at,expires_at FROM messages ORDER BY received_at DESC' ). all ();
eq ( rows . map (( r ) => r . id ). join ( ',' ), 'm2,m1' , '정렬이 실제 시각 순서' );
eq ( rows [ 1 ]. received_at , '2026-09-28T06:20:26.514Z' , 'received_at ISO' ); eq ( rows [ 1 ]. sent_at , '2026-09-28T06:20:26.514Z' , 'sent_at ISO' );
eq ( rows [ 0 ]. received_at , '2026-09-28T07:00:00.000Z' , 'ISO 값은 그대로' ); ok ( rows . every (( r ) => r . expires_at === null ), '만료일 비움' );
ok ( ! db . prepare ( "SELECT 1 FROM sqlite_master WHERE name='messages_expires'" ). get (), '만료 색인 삭제' );
db . close ();
} finally { rmSync ( old , { recursive : true , force : true }); }
});
await t ( '보낸 메일 동기화는 Resend 의 공백·짧은 시간대 시각을 ISO 로 저장한다' , async () => {
eq ( isoTime ( '2026-09-28 06:20:26.514000+00' ), '2026-09-28T06:20:26.514Z' , '공백 형식' ); eq ( isoTime ( '2026-09-28T06:20:26.514Z' ), '2026-09-28T06:20:26.514Z' , 'ISO 그대로' );
eq ( isoTime ( '2026-09-28' ), '2026-09-28T00:00:00.000Z' , '날짜만' ); eq ( isoTime ( 'nonsense' , 'F' ), 'F' , '해석 불가 → 대체값' );
const sent = [{ id : 's1' , from : 'Ari Hak Dang <admin@arihakdang.com>' , to : [ 'x@example.test' ], subject : '보낸 메일' , html : '<p>hi</p>' , text : 'hi' , created_at : '2026-09-28 06:20:26.514000+00' }];
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ sent }) }); await syncMailbox ( env , { trigger : 'test' });
const row = await env . DB . prepare ( "SELECT received_at,sent_at FROM messages WHERE provider_id='s1'" ). first ();
eq ( row . received_at , '2026-09-28T06:20:26.514Z' , 'received_at' ); eq ( row . sent_at , '2026-09-28T06:20:26.514Z' , 'sent_at' );
});
await t ( '빠른 확인 동기화는 이미 저장한 메일에서 멈추고 전체 동기화는 그 뒤의 빠진 메일까지 채운다' , async () => {
// Resend 목록은 최신순: new(새 메일) → known(웹훅으로 이미 저장) → missed(예전에 놓친 메일)
const at = ( h ) => new Date ( Date . now () - h * 3600000 ). toISOString (), calls = [];
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls , received : [ sample ( 'new' , { created_at : at ( 1 ) }), sample ( 'known' , { created_at : at ( 2 ) }), sample ( 'missed' , { created_at : at ( 3 ) })] }) });
await ingestReceived ( env , 'known' , { source : 'webhook' });
eq (( await syncMailbox ( env , { trigger : 'poll' , quick : true })). imported , 1 , '새 메일만' );
ok ( ! ( await env . DB . prepare ( "SELECT 1 FROM messages WHERE provider_id='missed'" ). first ()), '빠른 확인은 이미 아는 메일 뒤로 내려가지 않는다' );
eq (( await syncMailbox ( env , { trigger : 'timer' })). imported , 1 , '전체 동기화가 놓친 메일 보충' );
eq (( await env . DB . prepare ( "SELECT trigger FROM sync_runs ORDER BY id LIMIT 1" ). first ()). trigger , 'poll' , '실행 계기 기록' );
// API: 목록 자동 새로 고침은 { quick: true } 로 부릅니다.
const cookie = await login ( env ); const r = await ( await api ( env , 'mail/sync' , cookie , 'POST' , { quick : true })). json (); eq ( r . started , true , 'API 시작' );
await new Promise (( res ) => setTimeout ( res , 50 )); eq (( await env . DB . prepare ( "SELECT trigger FROM sync_runs ORDER BY id DESC LIMIT 1" ). first ()). trigger , 'poll' , 'API 빠른 확인 계기' );
});
await t ( '0.8.0 오류 수정: 실패한 웹훅 재시도, 관리자 인라인 이미지, 이동 규칙, 첨부 필터, 동기화 잠김' , async () => {
// 1) 웹훅 저장이 실패(500)한 이벤트는 Resend 가 같은 svix-id 로 다시 보내면 처리되어야 합니다.
const secret = 'whsec_' + Buffer . from ( 'k' ). toString ( 'base64' ); let down = true ; const base = fakeResend ({ received : [ sample ( 'w' )] });
const env = await makeEnv ({ RESEND_WEBHOOK_SECRET : secret , RESEND_FETCH : ( u , o ) => down ? Promise . resolve ( Response . json ({ message : 'boom' }, { status : 500 })) : base ( u , o ) });
const send = async ( id ) => { const body = JSON . stringify ({ type : 'email.received' , data : { email_id : 'w' , to : [ 'admin@arihakdang.com' ] } }), ts = String ( Math . floor ( Date . now () / 1000 )); return api ( env , 'webhooks/resend' , '' , 'POST' , body , { 'svix-id' : id , 'svix-timestamp' : ts , 'svix-signature' : signSvix ( secret , id , ts , body ) }); };
eq (( await send ( 'evt_retry' )). status , 500 , '첫 시도 실패 → 500' );
down = false ; const retry = await ( await send ( 'evt_retry' )). json (); ok ( ! retry . duplicate && retry . imported , '같은 id 재시도는 다시 처리' );
eq (( await ( await send ( 'evt_retry' )). json ()). duplicate , true , '성공한 뒤의 재전송은 중복' );
// 2) 관리자가 다른 메일함을 열었을 때 인라인 이미지 주소(?inline=1, mailbox 없음)도 열려야 합니다.
const env2 = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'i' , { to : [ 'info@arihakdang.com' ] })] }) }), cookie = await login ( env2 );
await createUser ( env2 , { login : 'info' , password : 'staff-secret-1' }, 'test' ); await syncMailbox ( env2 , { trigger : 'test' });
const info = await env2 . DB . prepare ( "SELECT id FROM messages WHERE provider_id='i'" ). first ();
await env2 . DB . prepare ( "INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES('img1',?,'p.png','image/png','p1','inline',3,?,'x')" ). bind ( info . id , Buffer . from ( 'png' )). run ();
const staff = ( await api ( env2 , 'login' , '' , 'POST' , { login : 'info' , password : 'staff-secret-1' })). headers . get ( 'set-cookie' ). split ( ';' )[ 0 ];
eq (( await api ( env2 , `mail/ ${ info . id } /attachments/img1?inline=1` , staff )). status , 200 , '자기 메일함 인라인 이미지' );
eq (( await api ( env2 , `mail/ ${ info . id } /attachments/img1?inline=1` , cookie )). status , 404 , '0.9.0: 관리자도 다른 메일함은 보지 못함' );
const adminMail = ( await saveDraft ( env2 , { to : 'x@example.test' , subject : '관리자 초안' , html : '<p>x</p>' }));
eq (( await api ( env2 , 'mail/' + adminMail , staff )). status , 404 , '일반 사용자는 여전히 남의 메일 차단' );
// 3) 이동 규칙: 받은 메일 → 임시 보관함·보낸 메일함 금지, 초안 → 보낸 메일함 금지.
eq (( await api ( env2 , 'mail/' + info . id , staff , 'PATCH' , { action : 'move' , target : 'drafts' })). status , 409 , '받은 메일 → 임시 보관함 거부' );
eq (( await ( await api ( env2 , 'mail/bulk' , cookie , 'POST' , { ids : [ adminMail ], action : 'move' , target : 'sent' })). json ()). done , 0 , '초안 → 보낸 메일함 거부' );
eq (( await ( await api ( env2 , 'mail/bulk' , cookie , 'POST' , { ids : [ adminMail ], action : 'move' , target : 'trash' })). json ()). done , 1 , '초안 → 휴지통 허용' );
eq (( await api ( env2 , 'mail/' + info . id , staff , 'PATCH' , { action : 'move' , target : 'trash' })). status , 200 , '받은 메일 → 휴지통 허용' );
// 4) 첨부 필터는 서버에서 걸러 합계가 맞아야 합니다.
const env3 = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'x1' ), sample ( 'x2' ), sample ( 'x3' )] }) }), c3 = await login ( env3 ); await syncMailbox ( env3 , { trigger : 'test' });
const x2 = await env3 . DB . prepare ( "SELECT id FROM messages WHERE provider_id='x2'" ). first ();
await env3 . DB . prepare ( "INSERT INTO attachments(id,message_id,filename,content_type,disposition,size,bytes,created_at) VALUES('f1',?,'a.txt','text/plain','attachment',1,?,'x')" ). bind ( x2 . id , Buffer . from ( 'a' )). run ();
const filtered = await ( await api ( env3 , 'mail?folder=inbox&attach=1' , c3 )). json (); eq ( filtered . total , 1 , '첨부 있는 메일만 합계' ); eq ( filtered . messages [ 0 ]. id , x2 . id , '첨부 메일' );
// 5) 동기화 시작 단계에서 예외가 나도 "진행 중" 표시가 풀려 다음 동기화가 돌아야 합니다.
const env4 = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 's1' )] }) }); const prepare = env4 . DB . prepare ; let broken = true ;
env4 . DB . prepare = ( sql ) => { if ( broken && sql . startsWith ( 'INSERT INTO sync_runs' )) { broken = false ; throw new Error ( 'SQLITE_BUSY' ); } return prepare ( sql ); };
let threw = false ; try { await syncMailbox ( env4 , { trigger : 'test' }); } catch { threw = true ; } ok ( threw , '첫 동기화 예외' );
eq (( await syncMailbox ( env4 , { trigger : 'test' })). imported , 1 , '잠기지 않고 다음 동기화 진행' );
});
await t ( '0.8.1 오류 수정: 큰 HTML 수신, 미완성 주소 초안, 인라인 이미지 발송, RFC 2231, 경계 접두사, STLS 주입, 메일함 이름 변경' , async () => {
// 1) 40만 자가 넘는 HTML 메일도 저장됩니다(예전에는 예외로 수신 실패 → 웹훅 무한 재시도).
const big = '<div style="color:red">' + 'x' . repeat ( 480000 ) + '</div>' ;
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'big' , { html : big , text : '큰 메일' })] }) }), cookie = await login ( env );
ok (( await ingestReceived ( env , 'big' )). imported , '큰 HTML 메일 저장' );
ok (( await env . DB . prepare ( "SELECT html FROM messages WHERE provider_id='big'" ). first ()). html . length > 400000 , 'HTML 그대로 정제' );
// 2) 초안은 미완성·따옴표 안 쉼표 주소도 저장하고, 발송 때 검사합니다.
eq ( addresses ( '"Kim, Minsu" <k@x.test>, b@y.test' ). join (), 'k@x.test,b@y.test' , '따옴표 안 쉼표' );
const draft = ( await ( await api ( env , 'mail' , cookie , 'POST' , { to : 'bob' , subject : '미완성' , html : '<p>본문</p>' })). json ()). id ; ok ( draft , '미완성 주소 초안 저장' );
eq (( await api ( env , 'mail/' + draft , cookie , 'PATCH' , { to : 'bob@example' , subject : '미완성' , html : '<p>본문</p>' })). status , 200 , '자동 저장 성공' );
eq (( await api ( env , 'mail/' + draft + '/send' , cookie , 'POST' , {})). status , 422 , '발송 때 주소 검사' );
eq (( await ( await api ( env , 'mail/' + draft + '/send' , cookie , 'POST' , {})). json ()). error , 'invalid_email' , '오류 코드' );
// 3) 본문에서 지운 인라인 이미지는 보내지 않고, 4) 인용한 원래 메일의 인라인 이미지는 cid 첨부로 복사해 보냅니다.
const calls = []; const env2 = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls , received : [ sample ( 'orig' , { html : '<p><img src="cid:pic1"></p>' })] }) }), c2 = await login ( env2 );
await ingestReceived ( env2 , 'orig' ); const orig = await env2 . DB . prepare ( "SELECT id FROM messages WHERE provider_id='orig'" ). first ();
await env2 . DB . prepare ( "INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES('pic',?,'p.png','image/png','pic1','inline',3,?,'x')" ). bind ( orig . id , Buffer . from ( 'PNG' )). run ();
const reply = await saveDraft ( env2 , { to : 'friend@example.test' , subject : 'Re' , html : `<p>답장</p><blockquote><img src="/api/mail/ ${ orig . id } /attachments/pic?inline=1"></blockquote>` });
await addAttachment ( env2 , reply , { filename : 'gone.png' , contentType : 'image/png' , bytes : Buffer . from ( 'GONE' ), inline : true });
eq (( await api ( env2 , 'mail/' + reply + '/send' , c2 , 'POST' , {})). status , 200 , '답장 발송' );
const post = calls . find (( x ) => x . method === 'POST' && x . path === '/emails' ). body ;
eq ( post . attachments . length , 1 , '지운 인라인 이미지는 빠짐' ); eq ( Buffer . from ( post . attachments [ 0 ]. content , 'base64' ). toString (), 'PNG' , '원래 메일 이미지 복사' );
ok ( post . html . includes ( 'cid:' + post . attachments [ 0 ]. content_id ) && ! post . html . includes ( '/api/mail/' ), '받는 사람에게는 cid 로' );
// 7) RFC 2231 파라미터
eq ( parseParams ( `attachment; filename*=UTF-8''%ED%95%9C%EA%B8%80.pdf` ). filename , '한글.pdf' , '퍼센트 인코딩' );
eq ( parseParams ( `x; filename*0*=UTF-8''%ED%95%9C; filename*1="-doc.txt"` ). filename , '한-doc.txt' , '이어 쓰기 조각' );
eq ( parseParams ( 'x; name="plain \\"q\\".txt"' ). name , 'plain "q".txt' , '일반 따옴표 값' );
// 10) 경계 접두사: 바깥 경계 b1 이 안쪽 경계 b1x 와 섞이지 않아야 합니다.
const nested = Buffer . from ( 'Content-Type: multipart/mixed; boundary="b1"\r\n\r\n--b1\r\nContent-Type: multipart/alternative; boundary="b1x"\r\n\r\n--b1x\r\nContent-Type: text/plain\r\n\r\nplain\r\n--b1x\r\nContent-Type: text/html\r\n\r\n<p>html</p>\r\n--b1x--\r\n--b1\r\nContent-Type: application/pdf; name="a.pdf"\r\n\r\nPDF\r\n--b1--\r\n' );
const tree = parseMime ( nested ); eq ( tree . root . parts . length , 2 , '바깥 파트 2개' ); eq ( tree . root . parts [ 0 ]. parts . length , 2 , '안쪽 파트 2개' );
const info = summarize ( tree ); eq ( info . text . trim (), 'plain' , '텍스트' ); eq ( info . attachments . length , 1 , '첨부 1개' );
// 첨부로 전달한 메일(message/rfc822)도 첨부로 남습니다.
const fwd = summarize ( parseMime ( Buffer . from ( 'Content-Type: multipart/mixed; boundary="z"\r\n\r\n--z\r\nContent-Type: text/plain\r\n\r\nsee\r\n--z\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\n\r\nhi\r\n--z--\r\n' )));
eq ( fwd . attachments . length , 1 , '전달 메일 첨부' ); eq ( fwd . attachments [ 0 ]. filename , 'message.eml' , '전달 메일 이름' );
// 9) STLS 뒤에 붙여 보낸 평문 명령은 실행되지 않습니다(TLS 없는 서버에서는 STLS 가 거절되고 붙은 명령은 버려짐).
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 out = await new Promise (( resolve ) => { const sock = net . connect ( port , '127.0.0.1' ); let buf = '' ; sock . on ( 'data' , ( d ) => { buf += d ; if ( buf . includes ( '-ERR TLS' )) { setTimeout (() => { sock . end (); resolve ( buf ); }, 150 ); } }); sock . once ( 'data' , () => sock . write ( 'STLS\r\nUSER injected\r\n' )); });
ok ( out . includes ( '-ERR TLS not available' ) && ! out . includes ( 'name accepted' ), 'STLS 뒤 주입 명령 버림: ' + JSON . stringify ( out ));
} finally { pop . close (); }
// 11) 계정의 메일함 주소를 바꾸면 메일도 따라갑니다.
const env3 = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'm1' , { to : [ 'info@arihakdang.com' ] })] }) });
const uidStaff = await createUser ( env3 , { login : 'info' , password : 'staff-secret-1' }, 'test' ); await syncMailbox ( env3 , { trigger : 'test' });
// 0.8.4 부터 메일함 주소는 로그인 ID 에 묶여 바뀌지 않습니다(옛 주소의 메일이 고아가 되던 문제의 근본 해결).
await updateUser ( env3 , uidStaff , { mailbox : 'office@arihakdang.com' });
eq (( await verifyAdmin ( env3 , 'info' , 'staff-secret-1' )). mailbox , 'info@arihakdang.com' , '메일함 주소 고정' ); eq (( await env3 . DB . prepare ( "SELECT mailbox FROM messages WHERE provider_id='m1'" ). first ()). mailbox , 'info@arihakdang.com' , '메일 그대로' );
});
await t ( '0.8.4: 메일함 자동 지정, 계정별 POP/IMAP 허용, 관리자 페이지 별도 세션' , async () => {
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'a' )] }) }), cookie = await login ( env ), admin = await adminLogin ( env );
// 1) 계정의 메일함은 항상 로그인 ID@도메인. 수정 때 mailbox 를 보내도 바뀌지 않습니다.
const r = await adminApi ( env , 'admin/users' , admin , 'POST' , { login : 'sales' , password : 'sales-secret-1' , mailbox : 'anything@arihakdang.com' , services : { pop : false , imap : true } }); eq ( r . status , 201 , '생성' );
const sid = ( await r . json ()). id ; eq (( await verifyAdmin ( env , 'sales' , 'sales-secret-1' )). mailbox , 'sales@arihakdang.com' , '자동 메일함' );
eq (( await adminApi ( env , 'admin/users/' + sid , admin , 'PATCH' , { mailbox : 'x@arihakdang.com' , name : '영업' })). status , 200 , '수정' );
eq (( await verifyAdmin ( env , 'sales' , 'sales-secret-1' )). mailbox , 'sales@arihakdang.com' , '메일함 주소는 바뀌지 않음' );
const list = await ( await adminApi ( env , 'admin/users' , admin )). json (); eq ( list . users [ 0 ]. services . pop , false , '목록에 서비스 표시' ); eq ( list . me . services . imap , true , '관리자 자신 서비스' );
// 2) 서비스 허용: 계정 설정(prefs)으로 저장되고, POP 서버는 꺼진 계정의 로그인을 거부합니다.
const sales = await verifyAdmin ( env , 'sales' , 'sales-secret-1' ); eq (( await readPrefs ( env , sales )). pop , false , 'prefs 저장' );
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 talk = ( lines ) => new Promise (( resolve ) => { const sock = net . connect ( port , '127.0.0.1' ); let buf = '' ; sock . on ( 'data' , ( d ) => { buf += d ; }); sock . on ( 'close' , () => resolve ( buf )); sock . once ( 'data' , () => { sock . write ( lines . join ( '\r\n' ) + '\r\n' ); setTimeout (() => sock . end (), 300 ); }); });
ok (( await talk ([ 'USER sales' , 'PASS sales-secret-1' ])). includes ( 'POP access is disabled' ), 'POP 꺼진 계정 거부' );
await adminApi ( env , 'admin/users/' + sid , admin , 'PATCH' , { services : { pop : true , imap : false } });
ok (( await talk ([ 'USER sales' , 'PASS sales-secret-1' , 'STAT' ])). includes ( '+OK 0 0' ), 'POP 켜면 로그인' );
} finally { pop . close (); }
// 사용자도 자기 설정 화면에서 바꿀 수 있습니다.
const sc = ( await api ( env , 'login' , '' , 'POST' , { login : 'sales' , password : 'sales-secret-1' })). headers . get ( 'set-cookie' ). split ( ';' )[ 0 ];
const p = await ( await api ( env , 'prefs' , sc , 'PATCH' , { imap : true , remoteImages : true })). json (); eq ( p . prefs . imap , true , '사용자 설정' ); eq ( p . prefs . pop , true , '다른 값 유지' );
// 3) 관리자 페이지 세션 분리: 메일 세션으로는 /api/admin 불가, 관리자 세션으로는 메일 API 불가, 일반 계정은 관리자 로그인 불가.
eq (( await api ( env , 'admin/users' , cookie )). status , 403 , '메일 세션으로 계정 관리 금지' );
eq (( await ( await api ( env , 'admin/users' , cookie )). json ()). error , 'admin_login_required' , '안내 코드' );
eq (( await adminApi ( env , 'mail?folder=inbox' , admin )). status , 403 , '관리자 세션으로 메일 API 금지' );
eq (( await adminApi ( env , 'session' , admin )). status , 200 , '관리자 세션 조회' ); ok (( await ( await adminApi ( env , 'session' , admin )). json ()). user , '관리자 세션 사용자' );
eq (( await api ( env , 'session' , admin )). status === 200 && ! ( await ( await api ( env , 'session' , admin )). json ()). user , true , '관리자 쿠키는 메일 범위에서 무효' );
const denied = await handle ( env , request ( 'login' , '' , 'POST' , { login : 'sales' , password : 'sales-secret-1' }, { 'X-Mail-Scope' : 'admin' }), params ( 'login' )); eq ( denied . status , 403 , '일반 계정 관리자 로그인 거부' ); eq (( await denied . json ()). error , 'admin_only' , '거부 코드' );
const granted = await handle ( env , request ( 'login' , '' , 'POST' , { login : 'admin' , password : 'admin-secret-12' }, { 'X-Mail-Scope' : 'admin' }), params ( 'login' )); eq ( granted . status , 200 , '관리자 로그인' ); ok ( granted . headers . get ( 'set-cookie' ). startsWith ( ADMIN_COOKIE + '=' ), '관리자 쿠키' );
const st = await ( await adminApi ( env , 'admin/status' , admin )). json (); ok ( st . webhook && st . sync && st . ports . submission === 587 , '서버 상태' );
const s = await ( await api ( env , 'settings' , cookie )). json (); ok ( ! s . webhook && s . prefs && s . smtp , '웹 메일 설정에는 운영 정보 없음' );
eq (( await adminApi ( env , 'admin/sync' , admin , 'POST' , {})). status , 200 , '관리자 동기화 시작' );
});
await t ( '0.8.4 오류 수정: 로그인 제한은 실패만 세고, 올린 초안의 헤더는 답장용만 발송하며, 이름 붙은 텍스트 파트는 첨부다' , async () => {
const { memoryRateStore } = await import ( '../server/util.mjs' ); const rate = memoryRateStore ();
for ( let i = 0 ; i < 25 ; i ++ ) ok ( ! rate . blocked ( 'k' , { limit : 20 }), '성공만으로는 막히지 않음 ' + i );
for ( let i = 0 ; i < 20 ; i ++ ) rate . hit ( 'k' , { limit : 20 , windowSec : 900 }); ok ( rate . blocked ( 'k' , { limit : 20 }), '실패 20회 뒤 차단' );
// IMAP 으로 올린 초안(모든 원본 헤더를 가짐)을 웹에서 보내면 In-Reply-To/References 만 넘깁니다.
const calls = [], env = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls }) });
const eml = buildEml ({ from : 'Ari <admin@arihakdang.com>' , to : [ 'x@example.test' ], subject : '올린 초안' , text : '본문' , messageId : '<d1@x>' , headers : { 'In-Reply-To' : '<p@x>' , 'X-Mozilla-Draft-Info' : 'internal/draft; vcard=0' } });
const { appendMessage } = await import ( '../server/store.mjs' ); const { id } = await appendMessage ( env , 'drafts' , eml , { mailbox : 'admin@arihakdang.com' });
await sendDraft ( env , id ); const post = calls . find (( c ) => c . method === 'POST' && c . path === '/emails' ). body ;
eq ( Object . keys ( post . headers ). sort (). join (), 'In-Reply-To' , '답장 헤더만' ); ok ( ! JSON . stringify ( post ). includes ( 'Mozilla' ) && ! ( 'Content-Type' in post . headers ), '원본 MIME 헤더 제외' );
// 이름이 붙은 텍스트 파트(Apple Mail 의 inline; filename=)는 첨부로 남습니다.
const parsed = parseMime ( Buffer . from ( 'Content-Type: multipart/mixed; boundary="q"\r\n\r\n--q\r\nContent-Type: text/plain\r\n\r\nbody\r\n--q\r\nContent-Type: text/plain; name="notes.txt"\r\nContent-Disposition: inline; filename="notes.txt"\r\n\r\nnotes\r\n--q\r\nContent-Type: text/csv\r\n\r\na,b\r\n--q--\r\n' ));
const info = summarize ( parsed ); eq ( info . text . trim (), 'body' , '본문' ); eq ( info . attachments . map (( a ) => a . filename ). join (), 'notes.txt,attachment' , '텍스트 첨부 2개' );
});
await t ( '0.8.12 오류 수정: Chrome 밑줄·취소선 보존, 웹 로그인 제한은 실패만, 게이트웨이 관리자에게는 admin 행 없음, 설정 응답' , async () => {
eq ( sanitizeHtml ( '<span style="text-decoration-line: underline;">u</span><span style="text-decoration-line: line-through">s</span>' ). html , '<span style="text-decoration-line:underline">u</span><span style="text-decoration-line:line-through">s</span>' , 'text-decoration-line 유지' );
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({}) });
for ( let i = 0 ; i < 12 ; i ++ ) eq (( await api ( env , 'login' , '' , 'POST' , { login : 'arihakdang' , password : 'admin-secret-12' })). status , 200 , '성공 로그인은 제한에 들지 않음 ' + i );
for ( let i = 0 ; i < 10 ; i ++ ) await api ( env , 'login' , '' , 'POST' , { login : 'arihakdang' , password : 'wrong' });
eq (( await api ( env , 'login' , '' , 'POST' , { login : 'arihakdang' , password : 'admin-secret-12' })). status , 429 , '실패 10회 뒤 차단' );
// 역할이 관리자인 게이트웨이 계정으로 /admin 에 들어오면 www 관리자 행(me)이 없고 me 수정도 거부됩니다.
await createUser ( env , { login : 'boss' , password : 'boss-secret-123' , role : 'admin' }, 'test' );
const boss = await verifyAdmin ( env , 'boss' , 'boss-secret-123' ); const bossCookie = ADMIN_COOKIE + '=' + await createSession ( env , boss , new Request ( 'https://x' ), { scope : 'admin' });
const d = await ( await adminApi ( env , 'admin/users' , bossCookie )). json (); eq ( d . me , null , 'me 없음' ); eq ( d . users . some (( u ) => u . login === 'boss' ), true , '자기 행은 목록에' );
eq (( await adminApi ( env , 'admin/users/me' , bossCookie , 'PATCH' , { services : { pop : false } })). status , 404 , 'me 수정 거부' );
const www = await ( await adminApi ( env , 'admin/users' , await adminLogin ( env ))). json (); eq ( www . me . source , 'www' , 'www 관리자에게는 me' );
// 웹 메일 설정 응답은 화면이 쓰는 필드를 모두 갖습니다(pop.deliveries 는 관리자 페이지로 옮겨져 화면도 더는 쓰지 않음).
const s = await ( await api ( env , 'settings' , await login ( env ))). json (); ok ( s . pop . host && s . pop . ports && s . imap . ports && s . smtp . ports && s . prefs , '설정 필드' );
});
await t ( '발송은 Resend API 로 한 번만 보내고 보낸 메일함에 원본을 만들며 오류는 원인 코드로 돌려준다' , async () => {
const calls = [], env = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls }) }), cookie = await login ( env );
const id = ( await ( await api ( env , 'mail' , cookie , 'POST' , { to : 'Member <member@example.test>' , subject : '테스트' , html : '<p>본문</p>' })). json ()). id ;
const form = new FormData (); form . append ( 'files' , new Blob ([ 'attached' ], { type : 'text/plain' }), '첨부.txt' );
eq (( await api ( env , 'mail/' + id + '/attachments' , cookie , 'POST' , form )). status , 201 , '첨부 업로드' );
const results = await Promise . allSettled ([ sendDraft ( env , id ), sendDraft ( env , id )]); ok ( results . some (( r ) => r . status === 'fulfilled' ), '발송' );
const posts = calls . filter (( c ) => c . method === 'POST' && c . path === '/emails' ); eq ( posts . length , 1 , '동시 발송 중복 방지' );
ok ( posts [ 0 ]. body . from . includes ( 'admin@arihakdang.com' ), '고정 발신자' ); eq ( posts [ 0 ]. body . attachments [ 0 ]. filename , '첨부.txt' , '첨부 전송' ); eq ( Buffer . from ( posts [ 0 ]. body . attachments [ 0 ]. content , 'base64' ). toString (), 'attached' , '첨부 내용' );
const row = await env . DB . prepare ( 'SELECT * FROM messages WHERE id=?' ). bind ( id ). first (); eq ( row . folder , 'sent' , '보낸 메일함' ); eq ( row . raw_status , 'built' , '원본 생성' );
ok ( Buffer . from (( await env . DB . prepare ( 'SELECT eml FROM message_raw WHERE message_id=?' ). bind ( id ). first ()). eml ). toString (). includes ( 'Content-Disposition: attachment' ), 'eml 첨부' );
const failing = await makeEnv ({ RESEND_FETCH : fakeResend ({ failStatus : 401 }) }), c2 = await login ( failing );
const d2 = ( await ( await api ( failing , 'mail' , c2 , 'POST' , { to : 'member@example.test' , subject : '실패' , html : '<p>본문</p>' })). json ()). id ;
const r = await api ( failing , 'mail/' + d2 + '/send' , c2 , 'POST' , {}); const body = await r . json ();
eq ( body . error , 'mail_auth_failed' , '키 오류 코드' ); ok ( body . detail . includes ( 'invalid' ), '상세' ); eq (( await api ( failing , 'mail/status' , c2 )). status , 200 , '상태 API' );
eq (( await ( await api ( failing , 'mail/status' , c2 )). json ()). provider , 'mail_auth_failed' , '상태 표시' );
});
await t ( '메일 API 는 목록·상세·원본 다운로드·첨부 다운로드·읽음 처리를 제공한다' , async () => {
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'a' , { attachments : [{ id : 'att1' }] })], attachments : { a : [{ id : 'att1' , filename : '사진.png' , content_type : 'image/png' , size : 3 , content_id : 'img1' , download_url : 'https://files.test/att/att1' , bytes : 'png' }] } }) }), cookie = await login ( env );
await ingestReceived ( env , 'a' ); const list = await ( await api ( env , 'mail?folder=inbox' , cookie )). json (); eq ( list . total , 1 , '목록' ); eq ( list . messages [ 0 ]. attachment_count , 1 , '첨부 수' ); ok ( ! list . messages [ 0 ]. read_at , '읽지 않음' );
const detail = await ( await api ( env , 'mail/' + list . messages [ 0 ]. id , cookie )). json (); eq ( detail . message . attachments [ 0 ]. filename , '사진.png' , '첨부 목록' ); ok ( detail . message . previewHtml !== undefined , '미리보기' );
const orig = await api ( env , 'mail/' + list . messages [ 0 ]. id + '/original' , cookie ); eq ( orig . headers . get ( 'content-type' ), 'message/rfc822' , '원본' ); ok (( await orig . text ()). includes ( 'Subject:' ), 'eml' );
const att = await api ( env , 'mail/' + list . messages [ 0 ]. id + '/attachments/' + detail . message . attachments [ 0 ]. id , cookie ); eq ( await att . text (), 'png' , '첨부 바이트' );
ok (( await ( await api ( env , 'mail?folder=inbox' , cookie )). json ()). messages [ 0 ]. read_at , '읽음 처리' );
eq (( await api ( env , 'settings' , cookie )). status , 200 , '설정' ); const smtpInfo = ( await ( await api ( env , 'settings' , cookie )). json ()). smtp ;
eq ( smtpInfo . host , 'mail.arihakdang.com' , 'SMTP 는 이 서버' ); eq ( smtpInfo . ports . smtps + '/' + smtpInfo . ports . starttls , '465/587' , 'SMTP 포트' );
ok ( ! ( 'username' in smtpInfo ) && ! ( 'apiKeyConfigured' in smtpInfo ) && ! JSON . stringify ( smtpInfo ). includes ( 'resend' ), 'SMTP 안내에 발송 서비스·키 정보 없음' );
});
// ------------------------------------------------------------------ POP3
function popClient ( 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 idx = w . multi ? buffer . indexOf ( '\r\n.\r\n' ) : buffer . indexOf ( '\r\n' ); if ( idx < 0 ) { if ( w . multi && buffer . startsWith ( '-ERR' )) { const e = buffer . indexOf ( '\r\n' ); if ( e < 0 ) return ; waiters . shift (); w . resolve ( buffer . slice ( 0 , e )); buffer = buffer . slice ( e + 2 ); continue ; } return ; } waiters . shift (); const end = w . multi ? idx + 5 : idx + 2 ; w . resolve ( buffer . slice ( 0 , w . multi ? idx + 2 : idx )); buffer = buffer . slice ( end ); } }
const read = ( multi = false ) => new Promise (( resolve ) => { waiters . push ({ resolve , multi }); flush (); });
return { greeting : () => read (), cmd : ( line , multi = false ) => { socket . write ( line + '\r\n' ); return read ( multi ); }, end : () => socket . end () };
}
await t ( 'POP3 는 www 관리자 계정으로 로그인해 UIDL·RETR·DELE 를 처리하고 한 번 내려준 메일은 다시 주지 않는다' , async () => {
const raw = { a : Buffer . from ( 'From: sender@example.test\r\nSubject: raw a\r\n\r\nline\r\n.dot line\r\n' ), b : Buffer . from ( 'From: sender@example.test\r\nSubject: raw b\r\n\r\nbody b\r\n' ) };
const at = ( s ) => new Date ( Date . now () - s * 1000 ). toISOString ();
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 }) });
for ( const id of [ 'a' , 'b' , 'c' ]) await ingestReceived ( env , id );
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 {
let c = popClient ( port ); ok (( await c . greeting ()). startsWith ( '+OK' ), '인사' );
ok (( await c . cmd ( 'CAPA' , true )). includes ( 'UIDL' ), 'CAPA' ); ok (( await c . cmd ( 'STAT' )). startsWith ( '-ERR' ), '로그인 전 거부' );
await c . cmd ( 'USER arihakdang' ); ok (( await c . cmd ( 'PASS wrong' )). startsWith ( '-ERR' ), '비밀번호 오류' );
await c . cmd ( 'USER student' ); ok (( await c . cmd ( 'PASS admin-secret-12' )). startsWith ( '-ERR' ), '학생 거부' );
await c . cmd ( 'USER arihakdang' ); ok (( await c . cmd ( 'PASS admin-secret-12' )). startsWith ( '+OK' ), '관리자 로그인' );
eq (( await c . cmd ( 'STAT' )). split ( ' ' )[ 1 ], '3' , '3통' );
const uidl = await c . cmd ( 'UIDL' , true ); eq ( uidl . split ( '\r\n' ). filter (( l ) => /^ \ d + /.test(l)).length, 3, 'UIDL 목록'); ok(/ ^ \ + OK / . test ( uidl ), 'UIDL 형식' );
const retr = await c . cmd ( 'RETR 1' , true ); ok ( retr . includes ( 'Subject: raw a' ) && retr . includes ( '\r\n..dot line' ), '원본 그대로 + 점 스터핑' );
const top = await c . cmd ( 'TOP 2 0' , true ); ok ( top . includes ( 'Subject: raw b' ) && ! top . includes ( 'body b' ), 'TOP' );
ok (( await c . cmd ( 'DELE 2' )). startsWith ( '+OK' ), 'DELE' ); eq (( await c . cmd ( 'STAT' )). split ( ' ' )[ 1 ], '2' , '삭제 반영' );
ok (( await c . cmd ( 'RSET' )). startsWith ( '+OK' ), 'RSET' ); eq (( await c . cmd ( 'STAT' )). split ( ' ' )[ 1 ], '3' , '복원' );
await c . cmd ( 'DELE 2' ); ok (( await c . cmd ( 'QUIT' )). startsWith ( '+OK' ), 'QUIT' ); c . end ();
await new Promise (( r ) => setTimeout ( r , 50 ));
eq (( await maildrop ( env , 'www:admin-1' )). length , 1 , 'RETR·DELE 한 메일 제외' );
c = popClient ( port ); await c . greeting (); await c . cmd ( 'USER admin@arihakdang.com' ); ok (( await c . cmd ( 'PASS admin-secret-12' )). startsWith ( '+OK' ), '이메일 로그인' );
eq (( await c . cmd ( 'STAT' )). split ( ' ' )[ 1 ], '1' , '두 번째 세션은 남은 1통만' );
const list = await c . cmd ( 'LIST' , true ); ok ( list . includes ( '1 ' ), 'LIST' );
const retr3 = await c . cmd ( 'RETR 1' , true ); ok ( retr3 . includes ( 'Subject: =?UTF-8?B?' ) || retr3 . includes ( 'Subject: 받은 메일 c' ), '재구성 원본' );
await c . cmd ( 'QUIT' ); c . end (); await new Promise (( r ) => setTimeout ( r , 50 ));
eq (( await maildrop ( env , 'www:admin-1' )). length , 0 , '모두 전달됨' );
eq (( await env . DB . prepare ( "SELECT COUNT(*) n FROM messages WHERE direction='inbound'" ). first ()). n , 3 , '서버 저장본 유지' );
const cookie = await login ( env ); const c3 = ( await env . DB . prepare ( "SELECT id FROM messages WHERE provider_id='c'" ). first ()). id ;
eq (( await api ( env , 'mail/' + c3 , cookie , 'PATCH' , { popReset : true })). status , 200 , '전달 기록 초기화' ); eq (( await maildrop ( env , 'www:admin-1' )). length , 1 , '다시 내려받기 가능' );
const p = createPop3 ( env , { hostname : 'mail.test' , allowPlaintext : false }); const port2 = port + 1 ; await p . listen ({ plainPort : port2 , host : '127.0.0.1' });
const c4 = popClient ( port2 ); await c4 . greeting (); await c4 . cmd ( 'USER arihakdang' ); ok (( await c4 . cmd ( 'PASS admin-secret-12' )). startsWith ( '-ERR' ), '평문 로그인 거부(STLS 필요)' ); c4 . end (); p . close ();
} finally { pop . close (); }
});
await t ( 'MIME 생성은 한글 헤더·첨부·인라인 이미지를 표준 형식으로 만든다' , async () => {
const eml = buildEml ({ from : '아리학당 <admin@arihakdang.com>' , to : [ 'x@example.test' ], subject : '한글 제목' , text : '본문' , html : '<p>본문<img src="cid:img1"></p>' , headers : { 'X-Custom' : 'v' , 'Bad\r\nHeader' : 'x' }, attachments : [{ filename : '파일.txt' , contentType : 'text/plain' , bytes : Buffer . from ( 'hi' ) }, { filename : 'a.png' , contentType : 'image/png' , contentId : 'img1' , bytes : Buffer . from ([ 1 ]) }] }). toString ();
ok ( eml . includes ( 'Subject: =?UTF-8?B?' ) && eml . includes ( 'multipart/mixed' ) && eml . includes ( 'multipart/related' ) && eml . includes ( 'Content-ID: <img1>' ) && eml . includes ( 'X-Custom: v' ) && ! eml . includes ( 'Bad' ), '구조' );
ok ( /\r\n\r\n/ . test ( eml ) && ! /[^\r]\n/ . test ( eml ), 'CRLF' );
});
await t ( '관리자는 게이트웨이 계정을 만들고 수정·삭제할 수 있으며 일반 사용자는 관리 API 에 접근할 수 없다' , async () => {
const env = await makeEnv (), cookie = await adminLogin ( env );
eq (( await adminApi ( env , 'admin/users' , cookie )). status , 200 , '목록' );
let r = await adminApi ( env , 'admin/users' , cookie , 'POST' , { login : 'info' , name : '직원' , mailbox : 'info@arihakdang.com' , password : 'staff-secret-1' , role : 'user' }); eq ( r . status , 201 , '생성' ); const sid = ( await r . json ()). id ;
eq (( await adminApi ( env , 'admin/users' , cookie , 'POST' , { login : 'info' , mailbox : 'x@arihakdang.com' , password : 'staff-secret-1' })). status , 409 , '중복 로그인' );
const ignored = await adminApi ( env , 'admin/users' , cookie , 'POST' , { login : 'other' , mailbox : 'x@other.test' , password : 'staff-secret-1' }); eq ( ignored . status , 201 , '메일함 주소 입력은 무시(자동 지정)' );
eq (( await verifyAdmin ( env , 'other' , 'staff-secret-1' )). mailbox , 'other@arihakdang.com' , '메일함 = 로그인 ID@도메인' );
eq (( await adminApi ( env , 'admin/users' , cookie , 'POST' , { login : 'admin' , mailbox : 'a@arihakdang.com' , password : 'staff-secret-1' })). status , 422 , '별칭 이름 거부' );
eq (( await adminApi ( env , 'admin/users' , cookie , 'POST' , { login : 'weak' , mailbox : 'w@arihakdang.com' , password : 'short' })). status , 422 , '짧은 비밀번호 거부' );
const staff = await verifyAdmin ( env , 'info' , 'staff-secret-1' ); eq ( staff . mailbox , 'info@arihakdang.com' , '계정 메일함' ); eq ( staff . role , 'user' , '역할' );
ok ( await verifyAdmin ( env , 'info@arihakdang.com' , 'staff-secret-1' ), '메일함 주소로 로그인' );
const sc = await api ( env , 'login' , '' , 'POST' , { login : 'info' , password : 'staff-secret-1' }); const staffCookie = sc . headers . get ( 'set-cookie' ). split ( ';' )[ 0 ];
eq (( await api ( env , 'admin/users' , staffCookie )). status , 403 , '일반 사용자(메일 세션) 관리 금지' );
const session = await ( await api ( env , 'session' , staffCookie )). json (); eq ( session . mailbox , 'info@arihakdang.com' , '세션 메일함' ); eq ( session . mailboxes . length , 1 , '자기 메일함만' );
eq (( await adminApi ( env , 'admin/users/' + sid , cookie , 'PATCH' , { password : 'new-secret-123' , name : '직원2' })). status , 200 , '수정' );
eq (( await api ( env , 'session' , staffCookie )). status === 200 && ! ( await ( await api ( env , 'session' , staffCookie )). json ()). user , true , '비밀번호 변경 시 세션 종료' );
ok ( await verifyAdmin ( env , 'info' , 'new-secret-123' ), '새 비밀번호' );
eq (( await adminApi ( env , 'admin/users/' + sid , cookie , 'PATCH' , { status : 'suspended' })). status , 200 , '정지' ); eq ( await verifyAdmin ( env , 'info' , 'new-secret-123' ), null , '정지 계정 거부' );
eq (( await adminApi ( env , 'admin/users/' + sid , cookie , 'DELETE' )). status , 200 , '삭제' ); eq (( await ( await adminApi ( env , 'admin/users' , cookie )). json ()). users . length , 1 , '삭제 반영(other 계정만 남음)' );
});
await t ( '메일은 메일함별로 분리되고 ?mailbox= 는 누구에게나 무시된다(0.9.0: 관리자의 다른 메일함 열람 삭제)' , async () => {
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'a' ), sample ( 'b' , { to : [ 'info@arihakdang.com' ] }), sample ( 'c' , { to : [ 'nobody@arihakdang.com' ] })] }) }), cookie = await login ( env );
await createUser ( env , { login : 'info' , password : 'staff-secret-1' }, 'test' );
const r = await syncMailbox ( env , { trigger : 'test' }); eq ( r . imported , 2 , '알려진 메일함 두 곳만 수신' );
eq (( await env . DB . prepare ( "SELECT mailbox FROM messages WHERE provider_id='b'" ). first ()). mailbox , 'info@arihakdang.com' , '라우팅' );
eq (( await ( await api ( env , 'mail?folder=inbox' , cookie )). json ()). total , 1 , '관리자 기본 메일함 1통' );
const adminList = await ( await api ( env , 'mail?folder=inbox&mailbox=info@arihakdang.com' , cookie )). json (); eq ( adminList . mailbox , 'admin@arihakdang.com' , '관리자도 ?mailbox= 무시(자기 메일함)' );
eq (( await ( await api ( env , 'session' , cookie )). json ()). mailboxes . length , 1 , '세션의 메일함 목록도 하나뿐' );
const staffCookie = ( await api ( env , 'login' , '' , 'POST' , { login : 'info' , password : 'staff-secret-1' })). headers . get ( 'set-cookie' ). split ( ';' )[ 0 ];
const list = await ( await api ( env , 'mail?folder=inbox&mailbox=admin@arihakdang.com' , staffCookie )). json (); eq ( list . mailbox , 'info@arihakdang.com' , '사용자는 자기 메일함만' ); eq ( list . total , 1 , '자기 메일함 1통' );
const a = await env . DB . prepare ( "SELECT id FROM messages WHERE provider_id='a'" ). first (); eq (( await api ( env , 'mail/' + a . id , staffCookie )). status , 404 , '남의 메일 접근 차단' );
const draft = ( await ( await api ( env , 'mail' , staffCookie , 'POST' , { to : 'x@example.test' , subject : '직원 초안' , html : '<p>본문</p>' })). json ()). id ;
const row = await env . DB . prepare ( 'SELECT sender,mailbox FROM messages WHERE id=?' ). bind ( draft ). first (); eq ( row . mailbox , 'info@arihakdang.com' , '초안 메일함' ); ok ( row . sender . includes ( 'info@arihakdang.com' ), '발신 주소' );
eq (( await ( await api ( env , 'mail/bulk' , staffCookie , 'POST' , { ids : [ a . id , draft ], action : 'trash' })). json ()). done , 1 , '일괄 작업은 자기 메일만' );
eq (( await maildrop ( env , 'x' , 'info@arihakdang.com' )). length , 1 , 'POP 메일함 범위' );
});
await t ( '인라인 이미지는 cid 첨부로 발송되고 별표·읽음 일괄 처리가 동작한다' , async () => {
const calls = [], env = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls }) }), cookie = await login ( env );
const id = ( await ( await api ( env , 'mail' , cookie , 'POST' , { to : 'member@example.test' , subject : '이미지' , html : '<p>본문</p>' })). json ()). id ;
const form = new FormData (); form . append ( 'files' , new Blob ([ Buffer . from ([ 137 , 80 , 78 , 71 ])], { type : 'image/png' }), 'pic.png' );
const up = await ( await api ( env , 'mail/' + id + '/attachments?inline=1' , cookie , 'POST' , form )). json (); ok ( up . urls [ 0 ]. includes ( '?inline=1' ), '인라인 주소' );
eq (( await api ( env , 'mail/' + id , cookie , 'PATCH' , { to : 'member@example.test' , subject : '이미지' , html : `<p>본문 <img src=" ${ up . urls [ 0 ] } "></p>` })). status , 200 , '본문에 이미지 저장' );
ok (( await env . DB . prepare ( 'SELECT html FROM messages WHERE id=?' ). bind ( id ). first ()). html . includes ( '/api/mail/' ), '정제기가 자체 첨부 주소 허용' );
eq (( await api ( env , 'mail/' + id + '/send' , cookie , 'POST' , {})). status , 200 , '발송' );
const post = calls . find (( c ) => c . method === 'POST' && c . path === '/emails' ); ok ( post . body . html . includes ( 'src="cid:' ), 'cid 치환' ); eq ( post . body . attachments [ 0 ]. content_id , post . body . html . match ( /cid:([^"]+)/ )[ 1 ], '첨부 content_id 일치' );
const eml = Buffer . from (( await env . DB . prepare ( 'SELECT eml FROM message_raw WHERE message_id=?' ). bind ( id ). first ()). eml ). toString (); ok ( eml . includes ( 'multipart/related' ) && eml . includes ( 'Content-ID:' ), '원본에 인라인 파트' );
eq (( await api ( env , 'mail/' + id , cookie , 'PATCH' , { flagged : true })). status , 200 , '별표' ); ok (( await ( await api ( env , 'mail?folder=sent' , cookie )). json ()). messages [ 0 ]. flagged , '목록 별표 표시' );
ok (( await ( await api ( env , 'mail?folder=sent&flagged=1' , cookie )). json ()). total === 1 , '별표 필터' );
eq (( await ( await api ( env , 'mail/bulk' , cookie , 'POST' , { ids : [ id ], action : 'unread' })). json ()). done , 1 , '일괄 읽지 않음' ); ok ( ! ( await ( await api ( env , 'mail?folder=sent&unread=1' , cookie )). json ()). messages [ 0 ]. read_at , '읽지 않음 필터' );
});
await t ( '화면 기본값(외부 이미지 표시)은 계정별로 저장되고 세션 응답에 실린다' , async () => {
const env = await makeEnv (), cookie = await login ( env );
eq (( await ( await api ( env , 'session' , cookie )). json ()). prefs . remoteImages , false , '기본 차단' );
eq (( await ( await api ( env , 'prefs' , cookie , 'PATCH' , { remoteImages : true })). json ()). prefs . remoteImages , true , '저장' );
eq (( await ( await api ( env , 'session' , cookie )). json ()). prefs . remoteImages , true , '세션 반영' );
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 ];
eq (( await ( await api ( env , 'session' , sc )). json ()). prefs . remoteImages , false , '다른 계정은 별도' );
});
await t ( '0.8.16 메일 전달: 받은 메일을 지정 주소로 첨부·원본 HTML 과 함께 전달하고 고리·옛 메일·전달 대상 발신은 건너뛴다' , async () => {
const calls = [];
const received = [
sample ( 'f1' , { html : '<p>안녕 <img src="cid:pic1"></p>' , attachments : [{ id : 'att-1' , filename : 'a.pdf' , content_type : 'application/pdf' , size : 3 , content_disposition : 'attachment' }, { id : 'att-2' , filename : 'pic.png' , content_type : 'image/png' , size : 3 , content_id : '<pic1>' , content_disposition : 'inline' }] }),
sample ( 'f2' , { headers : { [ FORWARD_MARKER ] : 'other@arihakdang.com' } }), // 우리 전달 표시가 있는 메일 → 다시 전달하지 않음
sample ( 'f3' , { from : 'Me Elsewhere <me@gmail.test>' }), // 전달 대상이 보낸 메일 → 돌려보내지 않음
sample ( 'f4' , { created_at : new Date ( Date . now () - 4 * 86400000 ). toISOString () }), // 72시간 지난 메일 → 건너뜀
];
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received , calls , attachments : { f1 : [{ id : 'att-1' , filename : 'a.pdf' , content_type : 'application/pdf' , size : 3 , download_url : 'https://files.test/a/att-1' , bytes : 'pdf' }, { id : 'att-2' , filename : 'pic.png' , content_type : 'image/png' , size : 3 , content_id : '<pic1>' , content_disposition : 'inline' , download_url : 'https://files.test/a/att-2' , bytes : 'png' }] } }) }), cookie = await login ( env );
// 설정 저장 검사: 켜면서 주소가 없거나, 자기 메일함이거나, 6개 이상이면 거부. 꺼진 상태의 잘못된 주소도 거부.
eq (( await api ( env , 'prefs' , cookie , 'PATCH' , { forward : { enabled : true , to : '' } })). status , 422 , '주소 없음' );
eq (( await ( await api ( env , 'prefs' , cookie , 'PATCH' , { forward : { enabled : true , to : 'admin@arihakdang.com' } })). json ()). error , 'forward_invalid' , '자기 메일함 금지' );
eq (( await api ( env , 'prefs' , cookie , 'PATCH' , { forward : { enabled : true , to : 'a@x.test,b@x.test,c@x.test,d@x.test,e@x.test,f@x.test' } })). status , 422 , '최대 5개' );
eq (( await api ( env , 'prefs' , cookie , 'PATCH' , { forward : { enabled : false , to : 'not-an-address' } })). status , 422 , '형식 검사' );
// 설정이 꺼져 있으면 아무것도 하지 않습니다.
await ingestReceived ( env , 'f4' ); eq ( calls . filter (( c ) => c . path === '/emails' && c . method === 'POST' ). length , 0 , '꺼짐: 발송 없음' );
const r = await ( await api ( env , 'prefs' , cookie , 'PATCH' , { forward : { enabled : true , to : 'Me <me@gmail.test>, boss@example.test' } })). json ();
eq ( r . prefs . forward . to , 'me@gmail.test, boss@example.test' , '주소 정리 저장' ); eq (( await ( await api ( env , 'session' , cookie )). json ()). prefs . forward . enabled , true , '세션에 실림' );
const r1 = await ingestReceived ( env , 'f1' , { source : 'webhook' }); eq ( r1 . auto . forward . status , 'sent' , '전달됨' );
const fwd = calls . find (( c ) => c . path === '/emails' && c . method === 'POST' ); ok ( fwd , '발송 호출' );
eq ( fwd . body . to . join ( ',' ), 'me@gmail.test,boss@example.test' , '전달 주소' ); eq ( fwd . body . from , '"Sender (via admin@arihakdang.com)" <admin@arihakdang.com>' , '보내는 사람은 메일함, 이름은 원래 발신자' );
eq ( fwd . body . reply_to [ 0 ], 'Sender <sender@example.test>' , '답장은 원래 발신자에게' ); eq ( fwd . body . subject , '받은 메일 f1' , '제목 유지' );
ok ( fwd . body . html . includes ( 'cid:pic1' ), '원본 HTML' ); eq ( fwd . body . headers [ FORWARD_MARKER ], 'admin@arihakdang.com' , '전달 표시 헤더' );
eq ( fwd . body . attachments . length , 2 , '첨부·인라인 포함' ); ok ( fwd . body . attachments . some (( a ) => a . content_id === 'pic1' && a . content === Buffer . from ( 'png' ). toString ( 'base64' )), '인라인 cid' ); eq ( fwd . key , 'auto-forward-' + r1 . id , '멱등 키' );
// 같은 메일을 다시 처리해도(원본 없이 저장했다가 다시 채우는 경우) 두 번 보내지 않습니다.
eq (( await autoProcess ( env , r1 . id )). forward . detail , 'claimed' , '중복 처리 방지' ); eq ( calls . filter (( c ) => c . path === '/emails' && c . method === 'POST' ). length , 1 , '발송 1회' );
eq (( await ingestReceived ( env , 'f2' )). auto . forward . detail , 'loop' , '전달 고리 차단' ); eq (( await ingestReceived ( env , 'f3' )). auto . forward . detail , 'self' , '전달 대상이 보낸 메일' );
await env . DB . prepare ( "DELETE FROM messages WHERE provider_id='f4'" ). run (); eq (( await ingestReceived ( env , 'f4' )). auto . forward . detail , 'stale' , '옛 메일' );
eq ( calls . filter (( c ) => c . path === '/emails' && c . method === 'POST' ). length , 1 , '건너뛴 경우 발송 없음' );
// 메일 상세에 처리 기록이 실리고, 원본은 받은 메일함에 그대로 남습니다.
const m = ( await ( await api ( env , 'mail/' + r1 . id , cookie )). json ()). message ; eq ( m . autoActions [ 0 ]. kind , 'forward' , '기록' ); eq ( m . autoActions [ 0 ]. status , 'sent' , '상태' ); eq ( m . folder , 'inbox' , '원본 유지' );
eq (( await ( await api ( env , 'mail?folder=sent' , cookie )). json ()). total , 0 , '전달 메일은 보낸 메일함에 쌓지 않음' );
// 발송 실패는 수신 저장을 막지 않고 failed 로 기록됩니다.
const inner = fakeResend ({ received : [ sample ( 'f5' )], calls }); env . RESEND_FETCH = ( url , options = {}) => options . method === 'POST' && new URL ( url ). pathname === '/emails' ? Response . json ({ message : 'boom' }, { status : 500 }) : inner ( url , options );
const bad = await ingestReceived ( env , 'f5' ); ok ( bad . imported , '수신은 저장' ); eq ( bad . auto . forward . status , 'failed' , '실패 기록' ); ok ( bad . auto . forward . detail . includes ( 'boom' ), '실패 사유 기록' );
// 다른 계정의 메일함은 그 계정의 설정을 따릅니다(mailboxUser).
await createUser ( env , { login : 'info' , password : 'staff-secret-1' }, 'test' ); eq (( await mailboxUser ( env , 'info@arihakdang.com' )). login , 'info' , '계정 메일함 주인' ); eq (( await mailboxUser ( env , 'admin@arihakdang.com' )). source , 'www' , '기본 메일함 주인' ); eq ( await mailboxUser ( env , 'nobody@arihakdang.com' ), null , '주인 없음' );
});
await t ( '0.8.16 자동 응답: 기간·기한 없음 판단, 자동 발송 메일 제외, 상대별 하루 한 번, 보낸 메일함 저장' , async () => {
const h = ( ms ) => new Date ( Date . now () + ms ). toISOString ();
eq ( autoReplyActive ({ enabled : true , noLimit : true , message : 'x' }), true , '기한 없음' ); eq ( autoReplyActive ({ enabled : false , noLimit : true , message : 'x' }), false , '꺼짐' ); eq ( autoReplyActive ({ enabled : true , noLimit : true , message : ' ' }), false , '메시지 없음' );
eq ( autoReplyActive ({ enabled : true , noLimit : false , start : h ( - 3600000 ), end : h ( 3600000 ), message : 'x' }), true , '기간 안' ); eq ( autoReplyActive ({ enabled : true , noLimit : false , start : h ( 3600000 ), end : h ( 7200000 ), message : 'x' }), false , '시작 전' );
eq ( autoReplyActive ({ enabled : true , noLimit : false , start : '' , end : h ( - 1000 ), message : 'x' }), false , '종료 뒤' ); eq ( autoReplyActive ({ enabled : true , noLimit : false , start : '' , end : h ( 1000 ), message : 'x' }), true , '시작 비우면 지금부터' ); eq ( autoReplyActive ({ enabled : true , noLimit : false , start : '' , end : '' , message : 'x' }), false , '종료 없으면 동작 안 함' );
eq ( automatedReason ({ sender : 'a@b.test' }, { 'Auto-Submitted' : 'auto-replied' }), 'auto-submitted' , 'RFC 3834' ); eq ( automatedReason ({ sender : 'a@b.test' }, { precedence : 'bulk' }), 'bulk' , 'Precedence' ); eq ( automatedReason ({ sender : 'a@b.test' }, { 'List-Id' : '<x.list>' }), 'list' , '메일링 리스트' );
eq ( automatedReason ({ sender : 'MAILER-DAEMON@mx.test' }, {}), 'noreply' , '반송' ); eq ( automatedReason ({ sender : 'No Reply <noreply@shop.test>' }, {}), 'noreply' , 'no-reply' ); eq ( automatedReason ({ sender : 'Kim <kim@b.test>' }, { 'Auto-Submitted' : 'no' }), null , '사람' );
const calls = [];
const received = [ sample ( 'r1' ), sample ( 'r2' ), sample ( 'r3' , { from : 'Other <other@example.test>' , reply_to : [ 'Desk <desk@example.test>' ] }), sample ( 'r4' , { headers : { 'Auto-Submitted' : 'auto-generated' } }), sample ( 'r5' , { from : 'noreply@shop.test' })];
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received , calls }) }), cookie = await login ( env );
// 설정 검사: 켜는데 메시지가 없거나, 기한 제한인데 종료가 없거나 종료가 시작보다 앞서면 거부. 시각은 ISO 로 정리해 저장.
eq (( await ( await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { enabled : true , noLimit : true , message : '' } })). json ()). error , 'autoreply_message' , '메시지 필요' );
eq (( await ( await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { enabled : true , noLimit : false , end : '' , message : '부재중' } })). json ()). error , 'autoreply_period' , '종료 필요' );
eq (( await ( await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { enabled : true , noLimit : false , start : h ( 7200000 ), end : h ( 3600000 ), message : '부재중' } })). json ()). error , 'autoreply_period' , '종료가 시작보다 앞' );
eq (( await ( await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { enabled : true , noLimit : false , start : 'garbage' , end : h ( 3600000 ), message : '부재중' } })). json ()). error , 'autoreply_period' , '시각 형식' );
let r = await ( await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { enabled : true , noLimit : false , start : '2026-10-01T09:00' , end : h ( 3600000 ), subject : '' , message : '휴가 중입니다.\r\n10일에 돌아옵니다.' } })). json ();
eq ( r . prefs . autoReply . start , new Date ( '2026-10-01T09:00' ). toISOString (), 'ISO 정리' ); // 오프셋 없는 시각은 서버의 시간대로 해석되므로 기대값도 같은 방식으로 만듭니다(UTC·KST 서버 모두 통과) eq(r.prefs.autoReply.message, '휴가 중입니다.\n10일에 돌아옵니다.', '줄바꿈 정리'); eq(r.autoReplyActive, true, '지금 동작 중'); eq((await (await api(env, 'session', cookie)).json()).autoReplyActive, true, '세션 응답');
const r1 = await ingestReceived ( env , 'r1' , { source : 'webhook' }); eq ( r1 . auto . reply . status , 'sent' , '응답 보냄' );
const sent = calls . filter (( c ) => c . path === '/emails' && c . method === 'POST' ); eq ( sent . length , 1 , '발송 1회' );
eq ( sent [ 0 ]. body . to [ 0 ], 'sender@example.test' , '보낸 사람에게' ); eq ( sent [ 0 ]. body . subject , 'Re: 받은 메일 r1' , '제목 비우면 Re: 원제목' ); eq ( sent [ 0 ]. body . text , '휴가 중입니다.\n10일에 돌아옵니다.' , '본문' );
ok ( sent [ 0 ]. body . html . includes ( '휴가 중입니다.<br>10일에' ), 'HTML 본문' ); eq ( sent [ 0 ]. body . headers [ 'Auto-Submitted' ], 'auto-replied' , 'RFC 3834 표시' ); eq ( sent [ 0 ]. body . headers [ 'In-Reply-To' ], '<r1@example.test>' , '스레드' ); eq ( sent [ 0 ]. body . from , '관리자 <admin@arihakdang.com>' , '보내는 사람' );
const box = await ( await api ( env , 'mail?folder=sent' , cookie )). json (); eq ( box . total , 1 , '보낸 메일함에 저장' ); eq ( box . messages [ 0 ]. subject , 'Re: 받은 메일 r1' , '보낸 메일 제목' );
const m = ( await ( await api ( env , 'mail/' + r1 . id , cookie )). json ()). message ; eq ( m . answered , true , '원래 메일에 답장함 표시' ); eq ( m . autoActions [ 0 ]. kind , 'reply' , '기록' ); eq ( m . autoActions [ 0 ]. sent_message_id , box . messages [ 0 ]. id , '보낸 메일 연결' );
eq (( await ingestReceived ( env , 'r2' )). auto . reply . detail , 'recent' , '같은 상대 하루 한 번' );
const r3 = await ingestReceived ( env , 'r3' ); eq ( r3 . auto . reply . status , 'sent' , '다른 상대' ); eq ( calls . filter (( c ) => c . path === '/emails' && c . method === 'POST' ). at ( - 1 ). body . to [ 0 ], 'desk@example.test' , 'Reply-To 우선' );
eq (( await ingestReceived ( env , 'r4' )). auto . reply . detail , 'auto-submitted' , '자동 발송 메일 제외' ); eq (( await ingestReceived ( env , 'r5' )). auto . reply . detail , 'noreply' , 'no-reply 제외' );
eq ( calls . filter (( c ) => c . path === '/emails' && c . method === 'POST' ). length , 2 , '발송은 2회뿐' );
// 사용자 지정 제목, 기간이 끝나면 멈춤, 기한 없음이면 계속.
await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { subject : '[자동 응답] 부재중' , start : '' , end : h ( 3600000 ) } });
env . RESEND_FETCH = fakeResend ({ received : [ sample ( 'r6' , { from : 'Lee <lee@example.test>' }), sample ( 'r7' , { from : 'Park <park@example.test>' }), sample ( 'r8' , { from : 'Choi <choi@example.test>' })], calls });
await ingestReceived ( env , 'r6' ); eq ( calls . at ( - 1 ). body . subject , '[자동 응답] 부재중' , '지정 제목' );
r = await ( await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { enabled : true , noLimit : false , start : h ( - 7200000 ), end : h ( - 3600000 ) } })). json (); eq ( r . autoReplyActive , false , '기간 지남' );
eq (( await ingestReceived ( env , 'r7' )). auto , undefined , '기간 밖: 처리 없음' );
await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { noLimit : true } }); eq (( await ingestReceived ( env , 'r8' )). auto . reply . status , 'sent' , '기한 없음' );
eq (( await ( await api ( env , 'prefs' , cookie , 'PATCH' , { autoReply : { enabled : false } })). json ()). autoReplyActive , false , '끄기' );
// 게이트웨이 계정 메일함도 자기 설정으로 동작합니다. 전달과 자동 응답을 함께 켜면 둘 다 합니다.
await createUser ( env , { login : 'info' , password : 'staff-secret-1' }, 'test' ); const info = await verifyAdmin ( env , 'info' , 'staff-secret-1' );
await writePrefs ( env , info , { forward : { enabled : true , to : 'archive@example.test' }, autoReply : { enabled : true , noLimit : true , message : '문의 감사합니다.' } });
env . RESEND_FETCH = fakeResend ({ received : [ sample ( 'i1' , { to : [ 'info@arihakdang.com' ] })], calls });
const i1 = await ingestReceived ( env , 'i1' ); eq ( i1 . auto . forward . status , 'sent' , '계정 전달' ); eq ( i1 . auto . reply . status , 'sent' , '계정 자동 응답' );
eq ( calls . at ( - 1 ). body . from , 'Ari Hak Dang <info@arihakdang.com>' , '이름 없는 계정은 기본 발신 이름' ); eq ( calls . at ( - 2 ). body . to [ 0 ], 'archive@example.test' , '전달 주소' );
});
await t ( '0.8.16 오류 수정: 웹 발송은 초안의 From 을 믿지 않고, cid 그림 초안을 보존·발송하며, 수정한 초안의 옛 원본을 버리고, 빈 PATCH 는 거부하고, 주소 이름을 바르게 나눈다' , async () => {
const calls = [];
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls }) }), cookie = await login ( env );
await createUser ( env , { login : 'info' , password : 'staff-secret-1' }, 'test' ); const ic = ( await api ( env , 'login' , '' , 'POST' , { login : 'info' , password : 'staff-secret-1' })). headers . get ( 'set-cookie' ). split ( ';' )[ 0 ];
// 1) 일반 계정이 IMAP 으로 From: 관리자 메일함인 초안을 올려 웹에서 보내도 보내는 주소는 자기 메일함입니다.
const forged = Buffer . from ( 'From: Boss <admin@arihakdang.com>\r\nTo: victim@example.test\r\nSubject: forged\r\nContent-Type: text/plain\r\n\r\nhello\r\n' );
const { id : fid } = await appendMessage ( env , 'drafts' , forged , { mailbox : 'info@arihakdang.com' });
eq (( await api ( env , 'mail/' + fid + '/send' , ic , 'POST' , {})). status , 200 , '발송' ); eq ( calls . at ( - 1 ). body . from , 'Ari Hak Dang <info@arihakdang.com>' , 'From 은 자기 메일함' );
// 2) IMAP 으로 올린 cid 그림 초안: 그림 첨부가 지워지지 않고 cid 그대로 발송에 실립니다. 상세 응답의 previewHtml 은 편집기용 주소로 바뀝니다.
const related = buildEml ({ from : 'info@arihakdang.com' , to : [ 'x@example.test' ], subject : 'pic' , html : '<p>pic: <img src="cid:part1@x"></p>' , text : 'pic' , attachments : [{ filename : 'p.png' , contentType : 'image/png' , contentId : 'part1@x' , disposition : 'inline' , bytes : Buffer . from ( 'png' ) }] });
const { id : pid } = await appendMessage ( env , 'drafts' , related , { mailbox : 'info@arihakdang.com' });
const detail = ( await ( await api ( env , 'mail/' + pid , ic )). json ()). message ; ok ( detail . previewHtml . includes ( '/api/mail/' + pid + '/attachments/' ), '편집기용 그림 주소' );
await sendDraft ( env , pid ); const sentPic = calls . at ( - 1 ). body ; eq ( sentPic . attachments . length , 1 , '인라인 그림 발송' ); eq ( sentPic . attachments [ 0 ]. content_id , 'part1@x' , 'cid' ); ok ( sentPic . html . includes ( 'src="cid:part1@x"' ), '본문 cid 유지' );
eq (( await env . DB . prepare ( 'SELECT COUNT(*) n FROM attachments WHERE message_id=?' ). bind ( pid ). first ()). n , 1 , '첨부 보존' );
// 3) IMAP 으로 올린 초안을 웹에서 고치면 옛 원본(.eml)은 버리고 raw_status 가 none 이 됩니다(IMAP/POP 이 새 내용을 재구성).
const plain = Buffer . from ( 'From: info@arihakdang.com\r\nTo: a@example.test\r\nSubject: old subject\r\nContent-Type: text/plain\r\n\r\nold body\r\n' );
const { id : did } = await appendMessage ( env , 'drafts' , plain , { mailbox : 'info@arihakdang.com' }); eq (( await env . DB . prepare ( 'SELECT raw_status FROM messages WHERE id=?' ). bind ( did ). first ()). raw_status , 'stored' , '올린 원본' );
eq (( await api ( env , 'mail/' + did , ic , 'PATCH' , { to : 'b@example.test' , subject : 'NEW subject' , html : '<p>new</p>' })). status , 200 , '수정' );
eq (( await env . DB . prepare ( 'SELECT raw_status,raw_size FROM messages WHERE id=?' ). bind ( did ). first ()). raw_status , 'none' , '옛 원본 폐기' ); eq ( await env . DB . prepare ( 'SELECT 1 FROM message_raw WHERE message_id=?' ). bind ( did ). first (), null , 'message_raw 삭제' );
await addAttachment ( env , did , { filename : 'f.txt' , contentType : 'text/plain' , bytes : Buffer . from ( 'f' ) }); eq (( await env . DB . prepare ( 'SELECT raw_status FROM messages WHERE id=?' ). bind ( did ). first ()). raw_status , 'none' , '첨부 뒤에도 none' );
// 4) 동작도 내용도 없는 PATCH {} 는 400 이고 초안은 그대로입니다.
eq (( await api ( env , 'mail/' + did , ic , 'PATCH' , {})). status , 400 , '빈 PATCH 거부' ); eq (( await env . DB . prepare ( 'SELECT subject FROM messages WHERE id=?' ). bind ( did ). first ()). subject , 'NEW subject' , '초안 유지' );
// 5) 주소 이름: 따옴표 안 콜론·"Re:"·부호화된 쉼표가 이름을 자르거나 쪼개지 않고, 그룹 구문은 여전히 구성원만 남깁니다.
const addrs = parseAddresses ( '"Meeting 10:30" <a@b.test>, Re: invoice <c@b.test>, =?UTF-8?B?6rmALCDrr7zsiJg=?= <k@x.test>, team: d@b.test, "E, F" <e@b.test>;' );
eq ( addrs . map (( a ) => ( a . name || '' ) + '|' + a . mailbox + '@' + a . host ). join ( ' / ' ), 'Meeting 10:30|a@b.test / Re: invoice|c@b.test / 김, 민수|k@x.test / |d@b.test / E, F|e@b.test' , '주소 이름 해석' );
void cookie ;
});
await t ( '개수 조회 API 는 읽음 처리 직후의 읽지 않음 수를 돌려준다' , async () => {
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'a' ), sample ( 'b' )] }) }), cookie = await login ( env );
await ingestReceived ( env , 'a' ); await ingestReceived ( env , 'b' );
let c = await ( await api ( env , 'mail/counts' , cookie )). json (); eq ( c . counts . find (( x ) => x . folder === 'inbox' ). unread , 2 , '읽지 않음 2' );
const a = await env . DB . prepare ( "SELECT id FROM messages WHERE provider_id='a'" ). first (); await api ( env , 'mail/' + a . id , cookie );
c = await ( await api ( env , 'mail/counts' , cookie )). json (); eq ( c . counts . find (( x ) => x . folder === 'inbox' ). unread , 1 , '읽은 뒤 1' );
await api ( env , 'mail/bulk' , cookie , 'POST' , { ids : [ a . id ], action : 'trash' });
c = await ( await api ( env , 'mail/counts' , cookie )). json (); eq ( c . counts . find (( x ) => x . folder === 'inbox' ). n , 1 , '이동 반영' ); eq ( c . counts . find (( x ) => x . folder === 'trash' ). n , 1 , '휴지통 반영' );
});
await t ( '0.8.18 독립 실행: www DB 없이 기본 메일함 별칭 계정을 만들고 그 계정이 로그인·관리자 페이지·메일함 설정의 주인이 된다' , async () => {
// WWW_DB 를 빼면 독립 실행입니다. 기본 메일함(admin@…)의 앞부분을 로그인 ID 로 하는 게이트웨이 계정을 만들 수 있어야 합니다.
const env = await makeEnv ({ WWW_DB : undefined , MAILBOX : 'admin@heart.test' });
delete env . WWW_DB ;
ok ( standalone ( env ), '독립 실행 판정' );
const id = await createUser ( env , { login : 'admin' , name : '관리자' , password : 'standalone-pw-1' , role : 'admin' }, 'cli' );
const me = await verifyAdmin ( env , 'admin' , 'standalone-pw-1' );
eq ( me ? . source , 'local' , '게이트웨이 계정으로 로그인' ); eq ( me . mailbox , 'admin@heart.test' , '메일함 = 기본 메일함' ); eq ( me . role , 'admin' , '관리자' );
ok ( await verifyAdmin ( env , 'admin@heart.test' , 'standalone-pw-1' ), '메일함 주소로도 로그인' );
eq ( await verifyAdmin ( env , 'admin' , 'wrong-password-1' ), null , '틀린 비밀번호' );
// 기본 메일함의 설정 주인(전달·자동 응답)이 이 계정입니다. www 연동에서는 www 관리자만 주인이 될 수 있었습니다.
eq (( await mailboxUser ( env , 'admin@heart.test' )) ? . id , id , '기본 메일함의 주인' );
// 관리자 페이지: 별도 세션으로 들어가 계정 목록을 보면 자신이 목록에 있고 www 행(me)은 없습니다.
const cookie = ADMIN_COOKIE + '=' + await createSession ( env , me , new Request ( 'https://x' ), { scope : 'admin' });
const list = await ( await adminApi ( env , 'admin/users' , cookie )). json ();
eq ( list . me , null , 'www 행 없음' ); eq ( list . users . map (( u ) => u . login ). join (), 'admin' , '계정 목록' ); eq ( list . defaultMailbox , 'admin@heart.test' , '기본 메일함' );
// www 연동 환경에서는 별칭 로그인 ID 를 여전히 막습니다(www 관리자 몫).
const linked = await makeEnv ({ MAILBOX : 'admin@heart.test' });
await createUser ( linked , { login : 'admin' , password : 'standalone-pw-1' }, 'cli' ). then (() => ok ( false , '연동 환경은 거부' ), ( e ) => eq ( e . message , 'invalid_login' , '연동 환경은 거부' ));
});
await t ( '0.8.24 비밀번호 정책: 기본 6자, 관리자가 길이·대소문자·숫자·특수문자 규칙을 정하고 계정 추가·수정·API 가 그 규칙으로 검사한다' , async () => {
const env = await makeEnv ();
eq (( await readPasswordPolicy ( env )). minLength , 6 , '기본 최소 6자' );
// 예전의 10자 규칙이 사라졋는지: 6자 비밀번호로 계정 생성이 됩니다. 5자는 거부.
await createUser ( env , { login : 'six' , password : 'abcdef' }, 'test' );
await createUser ( env , { login : 'five' , password : 'abcde' }, 'test' ). then (() => ok ( false , '5자 거부' ), ( e ) => { eq ( e . message , 'weak_password' , '5자 거부' ); eq ( e . problems . join (), 'length' , '어긋난 항목' ); });
// 규칙 검사 함수
eq ( passwordProblems ( 'abcdef' , { minLength : 8 , mixedCase : true , digit : true , special : true }). join (), 'length,mixedCase,digit,special' , '모든 항목' );
eq ( passwordProblems ( 'Abcdef1!' , { minLength : 8 , mixedCase : true , digit : true , special : true }). length , 0 , '모두 만족' );
eq ( passwordProblems ( '한글비밀번호' , { minLength : 6 , mixedCase : false , digit : false , special : false }). length , 0 , '글자 수는 코드 포인트 기준' );
// 관리자 API 로 정책 변경: 6 미만·65 이상·정수 아님은 거부(422 policy_invalid)
const cookie = await adminLogin ( env );
eq (( await adminApi ( env , 'admin/policy' , cookie )). status , 200 , '정책 조회' );
eq (( await ( await adminApi ( env , 'admin/policy' , cookie , 'PATCH' , { minLength : 5 })). json ()). error , 'policy_invalid' , '5 거부' );
eq (( await ( await adminApi ( env , 'admin/policy' , cookie , 'PATCH' , { minLength : 65 })). json ()). error , 'policy_invalid' , '65 거부' );
eq (( await ( await adminApi ( env , 'admin/policy' , cookie , 'PATCH' , { minLength : '8.5' })). json ()). error , 'policy_invalid' , '정수 아님 거부' );
let r = await ( await adminApi ( env , 'admin/policy' , cookie , 'PATCH' , { minLength : 8 , mixedCase : true , digit : 1 , special : false })). json ();
eq ( JSON . stringify ( r . policy ), JSON . stringify ({ minLength : 8 , mixedCase : true , digit : true , special : false }), '정책 저장' );
eq (( await ( await adminApi ( env , 'admin/users' , cookie )). json ()). policy . minLength , 8 , '계정 목록에 정책 포함' );
// 새 규칙으로 계정 추가: 어긋나면 422 + problems/policy, 맞으면 201
r = await adminApi ( env , 'admin/users' , cookie , 'POST' , { login : 'weak' , password : 'abcdefgh' });
eq ( r . status , 422 , '규칙 위반 422' ); const body = await r . json (); eq ( body . error , 'weak_password' , '코드' ); eq ( body . problems . join (), 'mixedCase,digit' , '어긋난 항목' ); eq ( body . policy . minLength , 8 , '정책 동봉' );
eq (( await adminApi ( env , 'admin/users' , cookie , 'POST' , { login : 'strong' , password : 'Abcdefg1' })). status , 201 , '규칙 만족' );
// 수정(비밀번호 변경)도 같은 규칙. 기존 계정(six, 6자)은 소급 적용되지 않아 그대로 로그인됩니다.
const six = ( await ( await adminApi ( env , 'admin/users' , cookie )). json ()). users . find (( u ) => u . login === 'six' );
eq (( await adminApi ( env , 'admin/users/' + six . id , cookie , 'PATCH' , { password : 'short1A' })). status , 422 , '수정도 검사' );
eq (( await adminApi ( env , 'admin/users/' + six . id , cookie , 'PATCH' , { password : 'Longer1x' })). status , 200 , '수정 통과' );
ok ( await verifyAdmin ( env , 'strong' , 'Abcdefg1' ), '새 계정 로그인' );
// 비관리자 세션으로는 정책을 바꿀 수 없습니다.
eq (( await api ( env , 'admin/policy' , await login ( env ), 'PATCH' , { minLength : 6 })). status , 403 , '메일 세션 거부' );
// 정책 완화(기본으로 되돌림)도 됩니다.
eq (( await ( await adminApi ( env , 'admin/policy' , cookie , 'PATCH' , { minLength : 6 , mixedCase : false , digit : false })). json ()). policy . mixedCase , false , '완화' );
});
await t ( '0.8.24 초기 관리자: 독립 실행에 계정이 없으면 admin / adminpasswd 가 자동으로 생기고, 비밀번호를 바꾸면 초기 비밀번호 경고가 사라진다' , async () => {
const env = await makeEnv ({ MAILBOX : 'admin@heart.test' }); delete env . WWW_DB ;
eq ( INITIAL_ADMIN_PASSWORD , 'adminpasswd' , '초기 비밀번호 값' );
const made = await ensureInitialAdmin ( env );
eq ( made ? . login , 'admin' , '초기 계정' ); eq ( made . password , 'adminpasswd' , '초기 비밀번호' );
eq ( await ensureInitialAdmin ( env ), null , '두 번째 호출은 만들지 않음' );
const me = await verifyAdmin ( env , 'admin' , 'adminpasswd' ); eq ( me ? . role , 'admin' , '초기 비밀번호로 로그인' ); eq ( me . mailbox , 'admin@heart.test' , '기본 메일함 주인' );
ok ( await usesInitialPassword ( env , me ), '초기 비밀번호 표시' );
// 세션 조회에 initialPassword 가 실려 화면이 경고를 띄웁니다.
const cookie = SESSION_COOKIE + '=' + await createSession ( env , me , new Request ( 'https://x' ));
eq (( await ( await api ( env , 'session' , cookie )). json ()). initialPassword , true , '세션에 경고 표시' );
// 정책을 올린 뒤에도(초기 비밀번호는 정책 검사를 받지 않음) 바꾸는 새 비밀번호는 정책을 따릅니다.
await writePasswordPolicy ( env , { minLength : 8 , digit : true });
await updateUser ( env , me . id , { password : 'nodigits' }). then (() => ok ( false , '정책 위반 거부' ), ( e ) => eq ( e . message , 'weak_password' , '정책 위반 거부' ));
await updateUser ( env , me . id , { password : 'changed12' });
eq ( await usesInitialPassword ( env , me ), false , '바꾸면 표시 해제' );
eq ( await verifyAdmin ( env , 'admin' , 'adminpasswd' ), null , '옛 비밀번호 무효' );
// www 연동 환경에서는 초기 관리자를 만들지 않습니다(www 관리자가 있음).
eq ( await ensureInitialAdmin ( await makeEnv ()), null , 'www 연동은 생성 안 함' );
// 독립 실행이라도 계정이 이미 있으면 만들지 않습니다.
const env2 = await makeEnv ({ MAILBOX : 'admin@heart.test' }); delete env2 . WWW_DB ;
await createUser ( env2 , { login : 'someone' , password : 'whatever1' }, 'test' );
eq ( await ensureInitialAdmin ( env2 ), null , '계정이 있으면 생성 안 함' );
});
await t ( '0.8.24 오류 수정: 정지 계정의 메일도 저장되고, 우리 메일함 둘에 온 메일은 둘 다 받으며, 자동 전달 사본은 보낸 메일함에 쌓이지 않고, 없는 계정의 로그인도 해시 시간을 쓴다' , async () => {
const stamp = new Date (). toISOString ();
const received = [
{ id : 'both' , from : 'S <s@ext.test>' , to : [ 'info@arihakdang.com' , 'admin@arihakdang.com' ], subject : '둘에게' , text : 'x' , created_at : stamp , headers : {} },
{ id : 'susp' , from : 'S <s@ext.test>' , to : [ 'info@arihakdang.com' ], subject : '정지 계정에게' , text : 'x' , created_at : stamp , headers : {} },
{ id : 'f1' , from : 'Sender <s@ext.test>' , to : [ 'admin@arihakdang.com' ], subject : '전달될 메일' , text : 'body' , html : '<p>body</p>' , created_at : stamp , headers : {} },
];
const sent = [], calls = [];
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received , sent , calls }) });
const infoId = await createUser ( env , { login : 'info' , password : 'password-1234' }, 'admin' );
// 1) 두 메일함 모두에 저장: 주 저장본(provider_id 그대로) + 사본(provider_id#메일함). 다시 가져와도 중복되지 않습니다.
const r = await ingestReceived ( env , 'both' ); eq ( r . copies ? . length , 1 , '사본 1개' ); eq ( r . copies [ 0 ]. mailbox , 'admin@arihakdang.com' , '사본 메일함' );
const rows = ( await env . DB . prepare ( "SELECT mailbox, provider_id, folder FROM messages WHERE provider_id = 'both' OR provider_id LIKE 'both#%' ORDER BY mailbox" ). all ()). results ;
eq ( rows . map (( x ) => x . mailbox ). join (), 'admin@arihakdang.com,info@arihakdang.com' , '두 메일함' ); eq ( rows . map (( x ) => x . provider_id ). join (), 'both#admin@arihakdang.com,both' , '사본 provider_id' ); ok ( rows . every (( x ) => x . folder === 'inbox' ), '받은 메일함' );
eq (( await ingestReceived ( env , 'both' )). skipped , 'exists' , '재수신은 건너뜀' );
eq (( await env . DB . prepare ( "SELECT COUNT(*) AS n FROM messages WHERE provider_id LIKE 'both%'" ). first ()). n , 2 , '중복 없음' );
ok (( await env . DB . prepare ( "SELECT COUNT(*) AS n FROM message_raw WHERE message_id IN (SELECT id FROM messages WHERE provider_id LIKE 'both%')" ). first ()). n === 2 , '원본도 사본에' );
// 2) 정지 계정의 메일함은 수신 대상에 남습니다(예전에는 other_mailbox 로 버려져 유실).
await updateUser ( env , infoId , { status : 'suspended' });
ok (( await knownMailboxes ( env )). includes ( 'info@arihakdang.com' ), '정지 계정 메일함 유지' );
ok (( await ingestReceived ( env , 'susp' )). imported , '정지 계정 메일 저장' );
eq ( await verifyAdmin ( env , 'info' , 'password-1234' ), null , '정지 계정은 로그인 불가' );
// 3) 자동 전달로 나간 메일은 동기화가 보낸 메일함으로 가져오지 않습니다.
const owner = await mailboxUser ( env , 'admin@arihakdang.com' );
await writePrefs ( env , owner , { forward : { enabled : true , to : 'archive@ext.test' } });
const f = await ingestReceived ( env , 'f1' , { source : 'webhook' }); eq ( f . auto . forward . status , 'sent' , '전달됨' );
eq ( sent . length , 1 , '발송 목록에 전달 사본' ); eq (( await ingestSent ( env , sent [ 0 ]. id )). skipped , 'auto_forward' , '전달 사본은 건너뜀' );
const s = await syncMailbox ( env , { trigger : 'timer' }); eq (( await env . DB . prepare ( "SELECT COUNT(*) AS n FROM messages WHERE folder='sent'" ). first ()). n , 0 , '보낸 메일함 비어 있음 ' + JSON . stringify ( s ));
// 4) 없는 계정의 비밀번호 검사도 실제 해시를 돌려 시간으로 계정 존재가 드러나지 않습니다(있는 계정의 절반 이상 걸려야 함).
const time = async ( login ) => { const t0 = process . hrtime . bigint (); for ( let i = 0 ; i < 3 ; i ++ ) await verifyAdmin ( env , login , 'wrong-password-xx' ); return Number ( process . hrtime . bigint () - t0 ) / 3e6 ; };
await updateUser ( env , infoId , { status : 'active' }); await time ( 'info' );
const existing = await time ( 'info' ), missing = await time ( 'nobody-here' );
ok ( missing > existing * 0.5 , `없는 계정도 해시 시간 사용( ${ missing . toFixed ( 0 ) } ms vs ${ existing . toFixed ( 0 ) } ms)` );
});
await t ( '0.8.25→0.9.0 비밀번호: 관리자 페이지는 다른 계정만 한 번 입력으로 재설정하고, 자기 비밀번호는 설정(PATCH /api/password)에서 현재 비밀번호·2회 입력으로 바꾼다. 서비스 이름은 도메인에서 정한다' , async () => {
eq ( siteName ( 'heart-m.com' ), 'HEART-M 메일' , '도메인 → 이름' ); eq ( siteName ( 'cafe.heart-m.com' ), 'CAFE 메일' , '하위 도메인은 첫 이름표' ); eq ( siteName ( 'arihakdang.com' ), 'ARIHAKDANG 메일' , '아리학당' ); eq ( siteName ( '' , '기본' ), '기본' , '도메인 없음' );
// 독립 실행에서 게이트웨이 관리자(me)와 직원(staff)을 만들고 me 로 관리자 페이지에 들어갑니다.
const env = await makeEnv ({ MAILBOX : 'admin@heart.test' }); delete env . WWW_DB ;
const meId = await createUser ( env , { login : 'admin' , password : 'adminpasswd' , role : 'admin' }, 'cli' );
const otherId = await createUser ( env , { login : 'staff' , password : 'staff-pw-1' , role : 'user' }, 'cli' );
const me = await verifyAdmin ( env , 'admin' , 'adminpasswd' );
const cookie = ADMIN_COOKIE + '=' + await createSession ( env , me , new Request ( 'https://x' ), { scope : 'admin' });
eq (( await ( await adminApi ( env , 'session' , cookie )). json ()). version , null , '세션 응답에 version(테스트 환경엔 APP_VERSION 없음 → null)' );
// 1) 관리자 페이지에서 자기 비밀번호는 바꿀 수 없습니다(설정 화면으로 안내). 다른 항목(이름)은 바뀝니다.
let r = await adminApi ( env , 'admin/users/' + meId , cookie , 'PATCH' , { password : 'newpass-99' });
eq ( r . status , 422 , '자기 비밀번호 거부' ); eq (( await r . json ()). error , 'self_password_settings' , '코드' ); ok ( await verifyAdmin ( env , 'admin' , 'adminpasswd' ), '비밀번호 유지' );
eq (( await adminApi ( env , 'admin/users/' + meId , cookie , 'PATCH' , { name : '관리자' })). status , 200 , '이름만 수정' );
// 2) 다른 계정은 한 번 입력으로 재설정(현재 비밀번호·확인 입력 불필요, 보조 필드는 무시).
eq (( await adminApi ( env , 'admin/users/' + otherId , cookie , 'PATCH' , { password : 'reset-pw-77' , passwordConfirm : 'ignored' , currentPassword : '' })). status , 200 , '다른 계정 재설정' );
ok ( await verifyAdmin ( env , 'staff' , 'reset-pw-77' ), '재설정된 비밀번호' );
// 3) 설정 화면의 비밀번호 변경: 웹 메일 세션(mail 범위)으로 PATCH /api/password.
const sc = ( await api ( env , 'login' , '' , 'POST' , { login : 'staff' , password : 'reset-pw-77' })). headers . get ( 'set-cookie' ). split ( ';' )[ 0 ];
r = await api ( env , 'password' , sc , 'PATCH' , { password : 'my-new-pw-1' , passwordConfirm : 'my-new-pw-2' , currentPassword : 'reset-pw-77' }); eq (( await r . json ()). error , 'password_mismatch' , '확인 불일치' );
r = await api ( env , 'password' , sc , 'PATCH' , { password : 'my-new-pw-1' , passwordConfirm : 'my-new-pw-1' , currentPassword : 'wrong' }); eq ( r . status , 422 , '현재 비밀번호 틀림' ); eq (( await r . json ()). error , 'current_password_invalid' , '코드' );
r = await api ( env , 'password' , sc , 'PATCH' , { password : 'short' , passwordConfirm : 'short' , currentPassword : 'reset-pw-77' }); eq (( await r . json ()). error , 'weak_password' , '정책 적용' );
r = await api ( env , 'password' , sc , 'PATCH' , { password : 'my-new-pw-1' , passwordConfirm : 'my-new-pw-1' , currentPassword : 'reset-pw-77' }); eq ( r . status , 200 , '변경 성공' );
ok ( await verifyAdmin ( env , 'staff' , 'my-new-pw-1' ), '새 비밀번호' ); eq ( await verifyAdmin ( env , 'staff' , 'reset-pw-77' ), null , '옛 비밀번호 무효' );
eq (( await api ( env , 'settings' , sc )). status , 401 , '옛 세션 종료(다시 로그인)' );
ok (( await env . DB . prepare ( "SELECT COUNT(*) AS n FROM audit_log WHERE action='password_change_denied'" ). first ()). n >= 1 , '거부 기록' );
ok (( await env . DB . prepare ( "SELECT COUNT(*) AS n FROM audit_log WHERE action='password_change'" ). first ()). n >= 1 , '변경 기록' );
// 4) www 관리자는 설정 화면에서 바꿀 수 없습니다(www 사이트에서 관리).
const www = await makeEnv (); const wc = await login ( www );
eq (( await api ( www , 'password' , wc , 'PATCH' , { password : 'x-pw-1234' , passwordConfirm : 'x-pw-1234' , currentPassword : 'admin-secret-12' })). status , 403 , 'www 관리자 거부' );
eq (( await ( await api ( www , 'settings' , wc )). json ()). canChangePassword , false , '설정 응답의 canChangePassword' );
eq (( await ( await api ( env , 'settings' , ( await api ( env , 'login' , '' , 'POST' , { login : 'staff' , password : 'my-new-pw-1' })). headers . get ( 'set-cookie' ). split ( ';' )[ 0 ])). json ()). canChangePassword , true , '게이트웨이 계정은 가능' );
});
await t ( '0.8.25 오류 수정: Content-ID 없는 inline 첨부는 일반 첨부로 발송되고, IMAP 초안의 Bcc 가 발송되며, 전달 첨부 복사 API, 헤더 없는 MIME 파트, <embed>·닫히지 않은 <title> 뒤 본문 보존' , async () => {
// 1) sanitize: embed(빈 요소)·닫히지 않은 title 뒤의 본문이 남습니다. 닫힌 script 는 내용만 사라집니다.
eq ( sanitizeHtml ( '<p>Intro</p><embed src="x.swf"><p>After</p>' ). text . replace ( /\s+/g , ' ' ). trim (), 'Intro After' , 'embed 뒤 본문' );
eq ( sanitizeHtml ( '<title>no close<p>Body</p>' ). text . trim (), 'Body' , '닫히지 않은 title' );
ok ( ! sanitizeHtml ( '<p>A</p><script>alert(1)</script><p>B</p>' ). html . includes ( 'alert' ) && sanitizeHtml ( '<p>A</p><script>alert(1)</script><p>B</p>' ). text . includes ( 'B' ), 'script 내용 제거·뒤 본문 보존' );
ok ( ! sanitizeHtml ( '<p>A</p><style>p{color:red}</style><p>B</p>' ). html . includes ( 'color' ) && sanitizeHtml ( '<p>A</p><style>p{color:red}</style><p>B</p>' ). 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 : '<p><img src="cid:logo"></p>' , 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 : '<p>fwd</p>' })). 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 : '<p>x</p>' })). 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 : '<p>x</p>' });
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 : '<p>x</p>' });
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 : '<p>x</p>' })). 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 : '<p>x</p>' });
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' } });
2026-10-26 11:47:45 +00:00
// [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' , '월간 한도에서 새 수신 임시 거부' );
2026-10-26 11:02:58 +00:00
// 현황
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) 정제기: <form> 으로 감싼 본문·닫히지 않은 <form>/<iframe>/<head> 뒤의 본문이 남습니다.
eq ( sanitizeHtml ( '<p>안녕하세요</p><form action=x><p>본문 1</p>' ). html , '<p>안녕하세요</p><p>본문 1</p>' , '닫히지 않은 form' );
eq ( sanitizeHtml ( '<form><table><tr><td><p>뉴스레터 본문</p></td></tr></table></form><p>꼬리말</p>' ). text , '뉴스레터 본문\n\n꼬리말' , 'form 안의 내용 유지' );
eq ( sanitizeHtml ( '<p>머리말</p><iframe src=x><p>본문 2</p>' ). text , '머리말\n본문 2' , '닫히지 않은 iframe' );
eq ( sanitizeHtml ( '<html><head><meta charset=utf-8><body><p>본문 5</p>' ). text , '본문 5' , '닫히지 않은 head' );
eq ( sanitizeHtml ( '<svg><script>x()</script><p>svg 안</p><body><p>본문 6</p>' ). text , 'svg 안\n본문 6' , 'svg 범위는 <p> 같은 HTML 태그에서 닫힘(브라우저와 같음, 0.9.3)' );
eq ( sanitizeHtml ( '<object data=x><p>본문 7</p></object><p>뒤</p>' ). text , '본문 7\n뒤' , 'object 껍데기 제거' );
// 2) 재구성 원본: headers_json 에 남은 옛 Cc/Reply-To/Bcc/Subject 가 되살아나지 않습니다.
const eml = buildEml ({ from : 'a@x.test' , to : [], subject : '' , headers : { To : 'leak@x.test' , Subject : 'leaked' , Bcc : 'hidden@x.test' , Cc : 'old-cc@x.test' , 'Reply-To' : 'old-reply@x.test' , 'X-Keep' : 'yes' } }). toString ();
ok ( ! /^(Cc|Reply-To|Bcc): /m . test ( eml ) && ! /leaked|leak@x.test/ . test ( eml ), '옛 값 제거: ' + eml . split ( '\r\n\r\n' )[ 0 ]); ok ( /^X-Keep: yes/m . test ( eml ), '다른 사용자 헤더는 유지' );
ok ( /^Bcc: b@x.test/m . test ( buildEml ({ from : 'a@x.test' , to : [ 't@x.test' ], subject : 's' , bcc : [ 'b@x.test' ] }). toString ()), '현재 bcc 는 기록' );
// 3) 동기화: 우리 메일함이 Cc 에만 있는 메일도 가져옵니다.
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'cc1' , { to : [ 'other@external.test' ], cc : [ 'admin@arihakdang.com' ] })] }) });
eq (( await syncMailbox ( env , { trigger : 'test' })). imported , 1 , 'Cc 전용 메일 동기화' );
// 4) 검색의 % _ 는 글자 그대로.
const cookie = await login ( env );
for ( const [ id , subject ] of [[ 's1' , 'alpha' ], [ 's2' , 'beta' ], [ 's3' , '100% sale' ], [ 's4' , 'a_b' ]]) await env . DB . prepare ( "INSERT INTO messages(id,direction,folder,sender,subject,text,state,received_at,created_at,updated_at,mailbox) VALUES(?,'inbound','inbox','s@x.test',?,'','received',?,?,?,'admin@arihakdang.com')" ). bind ( id , subject , '2026-01-01T00:00:00.000Z' , '2026-01-01T00:00:00.000Z' , '2026-01-01T00:00:00.000Z' ). run ();
eq (( await ( await api ( env , 'mail?folder=inbox&q=_____' , cookie )). json ()). total , 0 , '_ 는 와일드카드 아님' ); eq (( await ( await api ( env , 'mail?folder=inbox&q=%25' , cookie )). json ()). total , 1 , '% 는 글자 그대로(100% sale)' ); eq (( await ( await api ( env , 'mail?folder=inbox&q=a_b' , cookie )). json ()). total , 1 , '밑줄 검색' );
// 5) 인코딩되지 않은 UTF-8 제목·이름(RFC 6532)이 깨지지 않습니다.
eq ( fixUtf8 ( Buffer . from ( '한글 제목' , 'utf8' ). toString ( 'latin1' )), '한글 제목' , 'latin1 → utf8 복원' ); eq ( fixUtf8 ( 'plain' ), 'plain' , 'ASCII 그대로' ); eq ( fixUtf8 ( 'café' ), 'café' , '유효하지 않은 UTF-8 은 유지' );
const raw8 = Buffer . concat ([ Buffer . from ( 'From: 홍길동 <hong@example.test>\r\nTo: admin@arihakdang.com\r\nSubject: 한글 제목\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n한글 본문\r\n' , 'utf8' )]);
const info = summarize ( parseMime ( raw8 )); eq ( info . subject , '한글 제목' , '8비트 제목' ); eq ( info . from , '홍길동 <hong@example.test>' , '8비트 이름' ); eq ( info . text . trim (), '한글 본문' , '본문' );
// 6) 보내는 사람 이름의 특수문자는 따옴표로 감쌉니다.
eq ( formatSender ( 'Lee, Director' , 'a@x.test' ), '"Lee, Director" <a@x.test>' , '쉼표 이름' ); eq ( formatSender ( 'Kim "Boss" <x@y>' , 'a@x.test' ), '"Kim Boss x@y" <a@x.test>' , '따옴표·꺾쇠 제거, @ 는 따옴표로' ); eq ( formatSender ( '' , 'a@x.test' ), 'a@x.test' , '이름 없음' ); eq ( nameOf ( '"Lee, D" <a@x>' ), 'Lee, D' , '이름 추출' );
const env2 = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls : [] }) }); const user = { ...( await verifyAdmin ( env2 , 'arihakdang' , 'admin-secret-12' )), name : 'Lee, Director' };
const did = await saveDraft ( env2 , { to : 'f@example.test' , subject : 's' , html : '<p>x</p>' }, undefined , user );
eq (( await env2 . DB . prepare ( 'SELECT sender FROM messages WHERE id=?' ). bind ( did ). first ()). sender , '"Lee, Director" <admin@arihakdang.com>' , '초안 발신 표시' );
const calls = []; env2 . RESEND_FETCH = fakeResend ({ calls }); await sendDraft ( env2 , did ); eq ( calls . find (( c ) => c . method === 'POST' ). body . from , '"Lee, Director" <admin@arihakdang.com>' , '발송 요청의 from' );
});
await t ( '0.9.3 대용량 첨부파일: 시스템 상한(무제한 불가)·관리자 설정(기본 50%)·스트리밍 업로드·발송 조건·링크 블록·랜딩/다운로드 횟수·만료 삭제·되돌리기·정리' , async () => {
// 1) 시스템 상한: 없거나 0 이면 기본값(1GB/30일/20회). 관리자 기본값은 그 50%.
eq ( systemLargeFiles ({}). bytes , 1024 ** 3 , '기본 1GB' ); eq ( systemLargeFiles ({ LIMIT_LARGE_FILE_DAYS : '0' , LIMIT_LARGE_FILE_DOWNLOADS : 'x' }). days , 30 , '0 → 기본' ); eq ( systemLargeFiles ({ LIMIT_LARGE_FILE_BYTES : '200M' , LIMIT_LARGE_FILE_DAYS : '10' , LIMIT_LARGE_FILE_DOWNLOADS : '4' }). downloads , 4 , '지정값' );
const def = defaultAdminLarge ({ bytes : 1024 ** 3 , days : 30 , downloads : 20 }); eq ( def . maxBytes , 512 * 1024 ** 2 , '50% 크기' ); eq ( def . maxDays , 15 , '50% 일' ); eq ( def . maxDownloads , 10 , '50% 횟수' ); eq ( def . enabled , false , '기본은 사용 안 함' );
const dir = mkdtempSync ( join ( tmpdir (), 'mg-large-' ));
const env = await makeEnv ({ LIMIT_LARGE_FILE_BYTES : '100K' , LIMIT_LARGE_FILE_DAYS : '10' , LIMIT_LARGE_FILE_DOWNLOADS : '4' , DB_FILE : join ( dir , 'db.sqlite' ), PUBLIC_HOST : 'mail.test' , RESEND_FETCH : fakeResend ({ calls : [] }) });
eq ( filesDir ( env ), join ( dir , 'files' ), '파일 디렉터리 = DB 옆 files/' );
let cfg = await readLargeFileSettings ( env ); eq ( cfg . maxBytes , 50 * 1024 , '관리자 기본 50%' ); eq ( cfg . maxDays , 5 , '5일' ); eq ( cfg . maxDownloads , 2 , '2회' ); eq ( cfg . enabled , false , '꺼짐' );
// 2) 관리자 설정 검사: 0/무제한 불가, 시스템 초과 불가.
let threw = null ; try { await writeLargeFileSettings ( env , { maxDays : 0 }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'limit_invalid' , '0 거부' ); eq ( threw ? . field , 'maxDays' , '항목' );
threw = null ; try { await writeLargeFileSettings ( env , { maxBytes : 200 * 1024 }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'limit_exceeds_system' , '시스템 초과' ); eq ( threw ? . limit , 100 * 1024 , '상한' );
cfg = await writeLargeFileSettings ( env , { enabled : true , maxBytes : 60 * 1024 , maxDays : 7 , maxDownloads : 3 }); eq ( cfg . maxDownloads , 3 , '저장' );
const ac = await adminLogin ( env ), cookie = await login ( env );
const conf = await ( await adminApi ( env , 'admin/config' , ac )). json (); eq ( conf . largeFiles . maxDays , 7 , 'config 응답' ); eq ( conf . systemLargeFiles . days , 10 , '시스템 값' ); eq ( conf . largeFileUsage . count , 0 , '사용량' );
eq (( await ( await adminApi ( env , 'admin/config' , ac , 'PATCH' , { largeFiles : { maxDownloads : 9 } })). json ()). error , 'limit_exceeds_system' , 'API 도 거부' );
const sess = await ( await api ( env , 'session' , cookie )). json (); eq ( sess . largeFiles . enabled , true , '세션에 설정' ); ok ( sess . attachmentBytes > 0 , '일반 첨부 상한' );
// 3) 업로드: 초안 소유 확인, 상한 초과는 중단·삭제, 정상은 디스크에 저장.
const user = await verifyAdmin ( env , 'arihakdang' , 'admin-secret-12' );
const draft = await saveDraft ( env , { to : 'friend@example.test' , subject : '큰 파일' , html : '<p>받으세요</p>' }, undefined , user );
const chunks = ( n , size = 10 * 1024 ) => ( async function * () { for ( let i = 0 ; i < n ; i ++ ) yield Buffer . alloc ( size , 65 + i ); })();
threw = null ; try { await uploadLargeFile ( env , user , chunks ( 7 ), { draftId : draft , filename : 'big.zip' }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'file_too_large' , '70KB > 60KB' ); eq ( threw ? . limit , 60 * 1024 , '상한' );
eq (( await env . DB . prepare ( 'SELECT COUNT(*) AS n FROM large_files' ). first ()). n , 0 , '초과 파일은 기록 없음' ); eq ( existsSync ( join ( dir , 'files' )) ? readdirSync ( join ( dir , 'files' )). length : 0 , 0 , '디스크에도 없음' );
threw = null ; try { await uploadLargeFile ( env , user , chunks ( 1 ), { draftId : draft , filename : 'x' , declaredSize : 999999 }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'file_too_large' , 'Content-Length 로 미리 거부' );
threw = null ; try { await uploadLargeFile ( env , { ... user , mailbox : 'other@arihakdang.com' }, chunks ( 1 ), { draftId : draft , filename : 'x' }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'mail_locked' , '남의 초안 거부' );
const f1 = await uploadLargeFile ( env , user , chunks ( 3 ), { draftId : draft , filename : 'report/v1.zip' , contentType : 'application/zip; charset=x' });
eq ( f1 . size , 30 * 1024 , '크기' ); eq ( f1 . filename , 'report_v1.zip' , '이름 정리' ); eq ( f1 . content_type , 'application/zip' , '형식' ); eq ( f1 . state , 'draft' , '초안 상태' ); eq ( readFileSync ( join ( dir , 'files' , f1 . id )). length , 30 * 1024 , '디스크 저장' );
const f2 = await uploadLargeFile ( env , user , chunks ( 2 ), { draftId : draft , filename : 'photos.zip' });
eq (( await listLargeFiles ( env , draft )). length , 2 , '목록' ); eq (( await largeFileStats ( env )). bytes , 50 * 1024 , '통계' );
await removeLargeFile ( env , f2 . id , user . mailbox ); eq ( existsSync ( join ( dir , 'files' , f2 . id )), false , '초안 파일 떼기 = 삭제' ); eq (( await listLargeFiles ( env , draft )). length , 1 , '목록 갱신' );
const lst = await ( await api ( env , 'mail/' + draft + '/files' , cookie )). json (); eq ( lst . files . length , 1 , '목록 API' ); eq ( lst . files [ 0 ]. id , f1 . id , '목록 항목' );
// 4) 발송 조건: 없거나 상한을 넘으면 거부(초안은 그대로), 맞으면 링크 블록이 본문에 붙고 파일은 sent.
let r = await api ( env , 'mail/' + draft + '/send' , cookie , 'POST' , {}); eq ( r . status , 422 , '조건 없음' ); let e = await r . json (); eq ( e . error , 'large_file_terms' , '코드' ); eq ( e . field , 'days' , '항목' );
r = await api ( env , 'mail/' + draft + '/send' , cookie , 'POST' , { largeFiles : { days : 7 , downloads : 4 } }); e = await r . json (); eq ( e . error , 'large_file_terms' , '횟수 초과' ); eq ( e . field , 'downloads' , '항목' ); eq ( e . limit , 3 , '상한' );
eq (( await env . DB . prepare ( 'SELECT state FROM messages WHERE id=?' ). bind ( draft ). first ()). state , 'draft' , '검사는 발송 전' );
eq ( await checkLargeFileTerms ( env , draft , { days : 1 , downloads : 1 }), true , '조건 검사 통과' );
// 발송 실패(Resend 401)에도 링크는 그대로 남고(0.9.5), 재시도는 같은 토큰·같은 멱등 키·같은 본문으로 나갑니다. 조건을 다시 묻지 않습니다.
const failCalls = []; env . RESEND_FETCH = fakeResend ({ failStatus : 401 , calls : failCalls });
r = await api ( env , 'mail/' + draft + '/send' , cookie , 'POST' , { largeFiles : { days : 3 , downloads : 2 } }); eq ( r . status , 422 , '발송 실패' );
let row = await env . DB . prepare ( 'SELECT * FROM large_files WHERE id=?' ). bind ( f1 . id ). first (); eq ( row . state , 'sent' , '링크 유지' ); ok ( row . token , '토큰 유지' ); const firstToken = row . token ;
await env . DB . prepare ( "UPDATE messages SET state='draft' WHERE id=?" ). bind ( draft ). run ();
const calls = []; env . RESEND_FETCH = fakeResend ({ calls });
r = await api ( env , 'mail/' + draft + '/send' , cookie , 'POST' , {}); eq ( r . status , 200 , '재시도(조건 없이)' );
row = await env . DB . prepare ( 'SELECT * FROM large_files WHERE id=?' ). bind ( f1 . id ). first (); eq ( row . state , 'sent' , '보냄' ); eq ( row . max_downloads , 2 , '횟수' ); eq ( row . token , firstToken , '같은 토큰' ); ok ( Date . parse ( row . expires_at ) > Date . now () + 2.9 * 86400000 , '기한 3일' );
const post = calls . find (( c ) => c . method === 'POST' && c . path === '/emails' ); const failPost = failCalls . find (( c ) => c . method === 'POST' && c . path === '/emails' );
eq ( post . key , failPost . key , '재시도의 멱등 키 동일' ); eq ( JSON . stringify ( post . body ), JSON . stringify ( failPost . body ), '재시도 본문 동일' ); ok ( post . body . html . includes ( '/dl/' + row . token ) && post . body . html . includes ( 'report_v1.zip' ), 'HTML 링크 블록' ); ok ( post . body . text . includes ( 'https://mail.test/dl/' + row . token ), '텍스트 링크' ); ok ( post . body . html . includes ( '2회' ), '횟수 표시' ); ok ( ! post . body . attachments , '첨부로는 가지 않음' );
const sentMsg = await ( await api ( env , 'mail/' + draft , cookie )). json (); ok ( sentMsg . message . html . includes ( '/dl/' + row . token ), '보낸 메일 저장본에도 링크' ); eq ( sentMsg . message . largeFiles [ 0 ]. remaining , 2 , '상세의 남은 횟수' );
// 본문이 비어 있어도 대용량 파일이 있으면 보낼 수 있습니다.
const d2 = await saveDraft ( env , { to : 'friend@example.test' , subject : '파일만' , html : '' }, undefined , user );
await uploadLargeFile ( env , user , chunks ( 1 ), { draftId : d2 , filename : 'only.bin' });
eq (( await api ( env , 'mail/' + d2 + '/send' , cookie , 'POST' , { largeFiles : { days : 1 , downloads : 1 } })). status , 200 , '본문 없이 발송' );
// 5) 랜딩(횟수 안 셈) → 다운로드 차지(횟수 1 감소) → 마지막이면 파일 삭제·만료.
eq (( await largeFileInfo ( env , 'nope' )). status , 'missing' , '없는 토큰' ); let info = await largeFileInfo ( env , row . token ); eq ( info . status , 'ok' , '살아 있음' ); eq ( info . remaining , 2 , '남은 2' );
info = await largeFileInfo ( env , row . token ); eq ( info . remaining , 2 , '랜딩 조회는 세지 않음' );
let c1 = await claimDownload ( env , row . token ); eq ( c1 . row . downloads , 1 , '1회 차지' ); eq ( c1 . last , false , '마지막 아님' ); eq ( readFileSync ( c1 . path ). length , 30 * 1024 , '파일 경로' );
await finishDownload ( env , c1 ); ok ( existsSync ( c1 . path ), '아직 파일 유지' );
const c2 = await claimDownload ( env , row . token ); eq ( c2 . last , true , '마지막 허용 횟수' ); eq ( await claimDownload ( env , row . token ), null , '초과 거부' );
await finishDownload ( env , c2 ); eq ( existsSync ( c2 . path ), false , '마지막 뒤 파일 삭제' ); eq (( await largeFileInfo ( env , row . token )). status , 'expired' , '만료' );
eq (( await listLargeFiles ( env , draft ))[ 0 ]. expired , true , '상세에 만료' );
// 기한 만료는 정리 작업이 지웁니다. 보낸 사람의 "링크 끊기" 도 즉시 삭제.
const d2file = ( await listLargeFiles ( env , d2 ))[ 0 ]; await env . DB . prepare ( "UPDATE large_files SET expires_at=? WHERE id=?" ). bind ( new Date ( Date . now () - 1000 ). toISOString (), d2file . id ). run ();
eq ( await claimDownload ( env , ( await env . DB . prepare ( 'SELECT token FROM large_files WHERE id=?' ). bind ( d2file . id ). first ()). token ), null , '기한 지난 파일 거부' );
const cleaned = await cleanupLargeFiles ( env ); eq ( cleaned . expired , 1 , '정리: 만료 1' ); eq ( existsSync ( join ( dir , 'files' , d2file . id )), false , '파일 삭제' );
const d3 = await saveDraft ( env , { to : 'friend@example.test' , subject : '끊기' , html : '<p>x</p>' }, undefined , user ); const f3 = await uploadLargeFile ( env , user , chunks ( 1 ), { draftId : d3 , filename : 'rev.bin' });
await api ( env , 'mail/' + d3 + '/send' , cookie , 'POST' , { largeFiles : { days : 2 , downloads : 3 } });
eq (( await api ( env , 'mail/' + d3 + '/files/' + f3 . id , cookie , 'DELETE' )). status , 200 , '링크 끊기' ); eq ( existsSync ( join ( dir , 'files' , f3 . id )), false , '파일 삭제' ); eq (( await largeFileInfo ( env , ( await env . DB . prepare ( 'SELECT token FROM large_files WHERE id=?' ). bind ( f3 . id ). first ()). token )). status , 'expired' , '끊긴 링크 만료' );
// 버려진 초안(휴지통·삭제)의 파일은 하루 뒤 정리됩니다.
const d4 = await saveDraft ( env , { to : 'a@example.test' , subject : 'd4' , html : '<p>x</p>' }, undefined , user ); const f4 = await uploadLargeFile ( env , user , chunks ( 1 ), { draftId : d4 , filename : 'orphan.bin' });
await env . DB . prepare ( "UPDATE large_files SET created_at=? WHERE id=?" ). bind ( '2026-01-01T00:00:00.000Z' , f4 . id ). run (); await env . DB . prepare ( 'DELETE FROM messages WHERE id=?' ). bind ( d4 ). run ();
eq (( await env . DB . prepare ( 'SELECT message_id FROM large_files WHERE id=?' ). bind ( f4 . id ). first ()). message_id , null , '메일 삭제 시 NULL' ); eq (( await cleanupLargeFiles ( env )). removed , 1 , '고아 정리' ); eq ( existsSync ( join ( dir , 'files' , f4 . id )), false , '고아 파일 삭제' );
// 휴지통 유예는 버린 시점 기준(0.9.5): 하루 넘게 쓰던 초안을 방금 버리면 파일이 남고, 버린 지 하루가 지나면 지워집니다.
const d5 = await saveDraft ( env , { to : 'a@example.test' , subject : 'd5' , html : '<p>x</p>' }, undefined , user ); const f5 = await uploadLargeFile ( env , user , chunks ( 1 ), { draftId : d5 , filename : 'old.bin' });
await env . DB . prepare ( "UPDATE large_files SET created_at=? WHERE id=?" ). bind ( '2026-01-01T00:00:00.000Z' , f5 . id ). run ();
eq (( await api ( env , 'mail/' + d5 , cookie , 'PATCH' , { action : 'trash' })). status , 200 , '휴지통' ); eq (( await cleanupLargeFiles ( env )). removed , 0 , '방금 버린 초안의 파일은 유지' ); ok ( existsSync ( join ( dir , 'files' , f5 . id )), '파일 있음' );
await env . DB . prepare ( "UPDATE messages SET updated_at=? WHERE id=?" ). bind ( '2026-01-01T00:00:00.000Z' , d5 ). run (); eq (( await cleanupLargeFiles ( env )). removed , 1 , '버린 지 하루 지나면 삭제' );
// 다른 메일의 파일 id 를 끼워 넣은 경로로는 지울 수 없습니다(0.9.5).
const d6 = await saveDraft ( env , { to : 'a@example.test' , subject : 'd6' , html : '<p>x</p>' }, undefined , user ); const f6 = await uploadLargeFile ( env , user , chunks ( 1 ), { draftId : d6 , filename : 'mine.bin' });
eq (( await api ( env , 'mail/' + d3 + '/files/' + f6 . id , cookie , 'DELETE' )). status , 404 , '다른 메일 경로로 삭제 거부' ); eq (( await listLargeFiles ( env , d6 )). length , 1 , '파일 유지' );
// 저장 실패(디렉터리를 만들 수 없음)는 프로세스를 죽이지 않고 upload_write_failed 로 끝납니다(0.9.5).
writeFileSync ( join ( dir , 'blocker' ), 'x' ); const badEnv = { ... env , FILES_DIR : join ( dir , 'blocker' , 'files' ) }; threw = null ; try { await uploadLargeFile ( badEnv , user , chunks ( 1 ), { draftId : d6 , filename : 'x' }); } catch ( e2 ) { threw = e2 ; } eq ( threw ? . message , 'upload_write_failed' , '쓰기 실패 코드' ); ok ( threw ? . detail , '원인 코드' );
// 기능을 끄면 업로드가 거부됩니다.
await writeLargeFileSettings ( env , { enabled : false }); threw = null ; try { await uploadLargeFile ( env , user , chunks ( 1 ), { draftId : d3 , filename : 'x' }); } catch ( e2 ) { threw = e2 ; } eq ( threw ? . message , 'large_files_disabled' , '꺼짐' );
rmSync ( dir , { recursive : true , force : true });
});
await t ( '0.9.3 오류 수정: 자기 역할·상태 변경 거부, 비밀번호 변경 제한, email 필드 로그인 제한 키, APPEND 전체 용량·중복 순서, 크기 저장 순서, 상태 값 검사, 내장 상한, prefs 정리, 닫히지 않은 svg' , async () => {
// 1) 유일한 관리자가 자기 역할을 내리거나 정지할 수 없습니다.
const env = await makeEnv ({ MAILBOX : 'admin@heart.test' , LIMIT_TOTAL_BYTES : '4K' }); delete env . WWW_DB ;
const meId = 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' });
let r = await adminApi ( env , 'admin/users/' + meId , ac , 'PATCH' , { role : 'user' }); eq ( r . status , 409 , '자기 역할 강등 거부' ); eq (( await r . json ()). error , 'cannot_modify_self' , '코드' );
eq (( await adminApi ( env , 'admin/users/' + meId , ac , 'PATCH' , { status : 'suspended' })). status , 409 , '자기 정지 거부' );
eq (( await adminApi ( env , 'admin/users/' + meId , ac , 'PATCH' , { role : 'admin' , status : 'active' , name : '나' })). status , 200 , '같은 값은 허용' );
// 2) 설정 화면의 비밀번호 변경은 10회 실패 뒤 429.
const sc = ( await api ( env , 'login' , '' , 'POST' , { login : 'admin' , password : 'adminpasswd' })). headers . get ( 'set-cookie' ). split ( ';' )[ 0 ];
for ( let i = 0 ; i < 10 ; i ++ ) eq (( await api ( env , 'password' , sc , 'PATCH' , { currentPassword : 'wrong' + i , password : 'new-pass-123' , passwordConfirm : 'new-pass-123' })). status , 422 , '틀린 현재 비밀번호 ' + i );
eq (( await api ( env , 'password' , sc , 'PATCH' , { currentPassword : 'adminpasswd' , password : 'new-pass-123' , passwordConfirm : 'new-pass-123' })). status , 429 , '11번째는 제한' );
// 3) email 필드 로그인도 계정별 키를 씁니다(빈 공용 키 없음).
const env2 = await makeEnv ();
for ( let i = 0 ; i < 10 ; i ++ ) await api ( env2 , 'login' , '' , 'POST' , { email : 'nobody@arihakdang.com' , password : 'x' }, undefined , undefined );
eq (( await handle ( env2 , request ( 'login' , '' , 'POST' , { email : 'admin@arihakdang.com' , password : 'admin-secret-12' }), params ( 'login' , '10.0.0.9' ))). status , 200 , '다른 계정의 email 로그인은 막히지 않음' );
ok ( env2 . RATE . blocked ( 'login:nobody@arihakdang.com' , { limit : 10 }), '계정별 키에 쌓임' ); ok ( ! env2 . RATE . blocked ( 'login:' , { limit : 10 }), '빈 키 없음' );
// 4·5) APPEND: 전체 용량 초과는 storage_full, 보낸 메일함의 같은 Message-ID 사본은 용량이 가득 차도 중복으로 처리.
const user = await verifyAdmin ( env , 'admin' , 'adminpasswd' );
const eml = buildEml ({ from : 'admin@heart.test' , to : [ 'a@example.test' ], subject : 's' , messageId : '<dup@heart.test>' , text : 'x' . repeat ( 3000 ) });
const first = await appendMessage ( env , 'sent' , eml , { mailbox : user . mailbox , enforceQuota : false }); ok ( first . id , '발송 사본 저장(용량 무시)' );
const dup = await appendMessage ( env , 'sent' , eml , { mailbox : user . mailbox }); eq ( dup . duplicate , true , '가득 찬 뒤 같은 사본은 중복 처리' );
let threw = null ; try { await appendMessage ( env , 'drafts' , buildEml ({ from : 'admin@heart.test' , to : [ 'a@example.test' ], subject : 'd' , text : 'y' . repeat ( 2000 ) }), { mailbox : user . mailbox }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'storage_full' , '전체 용량 초과 APPEND 거부' );
// 6) 계정 수정: 실패하면 메일함 크기도 저장되지 않습니다.
const env3 = await makeEnv (), ac3 = await adminLogin ( env3 );
eq (( await adminApi ( env3 , 'admin/users/does-not-exist' , ac3 , 'PATCH' , { quotaBytes : 1048576 })). status , 404 , '없는 계정' );
eq (( await env3 . DB . prepare ( "SELECT COUNT(*) AS n FROM settings WHERE key LIKE 'quota:%'" ). first ()). n , 0 , '크기 행 없음' );
const bob = await ( await adminApi ( env3 , 'admin/users' , ac3 , 'POST' , { login : 'bob' , password : 'bob-pass-1' , quotaBytes : 2048 })). json ();
eq (( await adminApi ( env3 , 'admin/users/' + bob . id , ac3 , 'PATCH' , { password : 'short' , quotaBytes : 4096 })). status , 422 , '정책 위반' ); eq (( await env3 . DB . prepare ( 'SELECT value FROM settings WHERE key=?' ). bind ( 'quota:' + bob . id ). first ()). value , '2048' , '크기 유지' );
// 9) 상태 값은 active/suspended 만, 대기 계정을 정지로 바꾸면 대기가 풀립니다.
await adminApi ( env3 , 'admin/config' , ac3 , 'PATCH' , { signup : { enabled : true , approval : true } });
const kim = await ( await api ( env3 , 'signup' , '' , 'POST' , { login : 'kim' , password : 'kim-pass-1' })). json ();
eq (( await adminApi ( env3 , 'admin/users/' + kim . id , ac3 , 'PATCH' , { status : 'pending' })). status , 422 , "'pending' 거부" ); eq ( await isPending ( env3 , kim . id ), true , '대기 유지' );
eq (( await adminApi ( env3 , 'admin/users/' + kim . id , ac3 , 'PATCH' , { status : 'suspended' })). status , 200 , '정지로' ); eq ( await isPending ( env3 , kim . id ), false , '대기 표시 제거' ); eq (( await listUsers ( env3 )). find (( u ) => u . login === 'kim' ). status , 'suspended' , '목록 상태 정지' );
eq (( await adminApi ( env3 , 'admin/users/' + kim . id , ac3 , 'PATCH' , { role : 'root' })). status , 422 , '모르는 역할 거부' );
// 10) 관리자 제한은 내장 상한을 넘지 못하고 불리언은 거부합니다.
threw = null ; try { await writeLimits ( env3 , { recipients : 500 }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'limit_exceeds_system' , '내장 상한 50 초과' ); eq ( threw ? . limit , 50 , '상한값' );
threw = null ; try { await writeLimits ( env3 , { attachmentBytes : 100 * 1048576 }); } catch ( e ) { threw = e ; } eq ( threw ? . limit , 25 * 1048576 , '첨부 내장 상한' );
threw = null ; try { await writeLimits ( env3 , { recipients : true }); } catch ( e ) { threw = e ; } eq ( threw ? . message , 'limit_invalid' , '불리언 거부' );
eq (( await writeLimits ( env3 , { recipients : 50 })). recipients , 50 , '상한값은 허용' ); eq ( await recipientLimit ( env3 ), 50 , '적용' ); eq ( await attachmentLimit ( env3 ), 25 * 1048576 , '첨부 적용' );
// 11) 계정 삭제는 prefs 도 지웁니다.
await writePrefs ( env3 , { id : bob . id }, { pop : false }); await deleteUser ( env3 , bob . id );
eq (( await env3 . DB . prepare ( 'SELECT COUNT(*) AS n FROM settings WHERE key=?' ). bind ( 'prefs:' + bob . id ). first ()). n , 0 , 'prefs 삭제' );
// 8) 닫히지 않은 svg 뒤의 본문은 <p>/<div> 에서 범위를 닫아 남습니다.
eq ( sanitizeHtml ( '<p>Hi</p><svg viewBox="0 0 1 1"><circle r="4"/><p>Rest</p><div>Footer</div>' ). text , 'Hi\nRest\nFooter' , '닫히지 않은 svg' );
eq ( sanitizeHtml ( '<div><svg class="icon"><use href="#x"/></div><p>Body text</p>' ). text , 'Body text' , 'div 안 svg' );
});
await t ( '0.9.5 참조·숨은 참조: 웹 작성 창의 숨은 참조가 초안에 저장되고 발송·재구성 원본에 반영되며, 보내지 않은 키는 유지된다' , async () => {
const calls = []; const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls }) }), cookie = await login ( env );
const id = ( await ( await api ( env , 'mail' , cookie , 'POST' , { to : 'a@example.test' , cc : 'c@example.test' , bcc : 'Hidden <b@example.test>, b2@example.test' , subject : '숨은 참조' , html : '<p>x</p>' })). json ()). id ;
let row = await env . DB . prepare ( 'SELECT bcc_json,cc_json FROM messages WHERE id=?' ). bind ( id ). first (); eq ( row . bcc_json , '["b@example.test","b2@example.test"]' , '초안 저장' ); eq ( row . cc_json , '["c@example.test"]' , '참조' );
eq (( await api ( env , 'mail/' + id , cookie , 'PATCH' , { subject : '제목만' })). status , 200 , 'bcc 없이 수정' ); row = await env . DB . prepare ( 'SELECT bcc_json,recipients_json,subject,text FROM messages WHERE id=?' ). bind ( id ). first (); eq ( row . bcc_json , '["b@example.test","b2@example.test"]' , '보내지 않은 키는 유지' );
eq ( row . recipients_json , '["a@example.test"]' , '받는 사람 유지(부분 수정)' ); eq ( row . subject , '제목만' , '제목 갱신' ); eq ( row . text , 'x' , '본문 유지' );
eq (( await api ( env , 'mail/' + id , cookie , 'PATCH' , { bcc : 'b3@example.test' })). status , 200 , 'bcc 만 수정(PATCH 허용 키)' ); eq (( await env . DB . prepare ( 'SELECT bcc_json FROM messages WHERE id=?' ). bind ( id ). first ()). bcc_json , '["b3@example.test"]' , '갱신' );
eq (( await api ( env , 'mail/' + id , cookie , 'PATCH' , { bcc : '' })). status , 200 , '비우기' ); eq (( await env . DB . prepare ( 'SELECT bcc_json FROM messages WHERE id=?' ). bind ( id ). first ()). bcc_json , '[]' , '빈 목록' );
await api ( env , 'mail/' + id , cookie , 'PATCH' , { bcc : 'b@example.test' });
eq (( await api ( env , 'mail/' + id + '/send' , cookie , 'POST' , {})). status , 200 , '발송' );
const post = calls . find (( c ) => c . method === 'POST' && c . path === '/emails' ); eq ( post . body . bcc . join (), 'b@example.test' , '발송 요청의 bcc' ); eq ( post . body . cc . join (), 'c@example.test' , 'cc' );
const raw = Buffer . from (( await env . DB . prepare ( 'SELECT eml FROM message_raw WHERE message_id=?' ). bind ( id ). first ()). eml ). toString (); ok ( /^Bcc: b@example.test/m . test ( raw ), '보낸 메일 원본에 Bcc' );
// 0.9.5: 전달·답장 인용문의 인라인 그림 복사가 보낸 메일 저장본(html)에도 반영됩니다(0.9.3 은 복사 전 html 을 다시 써서 원본을 지우면 그림이 깨졌음).
const origin = uid (); await env . DB . prepare ( "INSERT INTO messages(id,direction,folder,sender,subject,html,text,state,received_at,created_at,updated_at,mailbox) VALUES(?,'inbound','inbox','s@x.test','o','<p><img src=\"cid:pic\"></p>','','received','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','admin@arihakdang.com')" ). bind ( origin ). run ();
await env . DB . prepare ( "INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES('att1',?,'p.png','image/png','pic','inline',3,?,'x')" ). bind ( origin , Buffer . from ( 'png' )). run ();
const fwd = ( await ( await api ( env , 'mail' , cookie , 'POST' , { to : 'f@example.test' , subject : 'fwd' , html : `<p>전달</p><img src="/api/mail/ ${ origin } /attachments/att1?inline=1">` })). json ()). id ;
eq (( await api ( env , 'mail/' + fwd + '/send' , cookie , 'POST' , {})). status , 200 , '전달 발송' );
const stored = await env . DB . prepare ( 'SELECT html FROM messages WHERE id=?' ). bind ( fwd ). first (); const copy = await env . DB . prepare ( "SELECT id FROM attachments WHERE message_id=? AND disposition='inline'" ). bind ( fwd ). first ();
ok ( copy && stored . html . includes ( '/api/mail/' + fwd + '/attachments/' + copy . id ), '저장본 html 이 복사본을 가리킴' ); ok ( ! stored . html . includes ( '/api/mail/' + origin + '/' ), '원본 주소 없음' );
});
// 0.9.9 요구사항 1: 회신 창에 여러 줄을 붙여넣으면 글이 편집기 상자를 넘어 아래 단추 위에 겹치던 문제.
// 원인은 CSS 라서(.html-editor 가 flex 항목으로 눌림) 단위 시험으로는 규칙이 들어 있는지와, 함께 고친 정제기의 height→min-height 변환을 확인합니다.
// 실제 겹침 여부는 headless Chromium 으로 확인했습니다(편집기 높이 320px 고정 → 내용 높이대로 4415px, footer 와 겹침 없음).
await t ( '0.9.9 회신 편집기 붙여넣기 넘침 수정: 편집기가 flex 로 눌리지 않고(flex-shrink:0, overflow:auto), 정제기는 div/p 의 height 를 min-height 로 바꿔 글이 상자 밖으로 번지지 않음' , async () => {
const { readFileSync } = await import ( 'node:fs' );
const css = readFileSync ( new URL ( '../web/style.css' , import . meta . url ), 'utf8' );
const rule = ( css . match ( /\.html-editor\{([^}]*)\}/ ) || [])[ 1 ] || '' ;
ok ( /(^|;)flex-shrink:0(;|$)/ . test ( rule ) || /(^|;)flex:(none|0 0 auto)(;|$)/ . test ( rule ), '편집기 flex-shrink:0 — ' + rule );
ok ( /(^|;)overflow:auto(;|$)/ . test ( rule ), '편집기 overflow:auto(가로로 긴 표는 편집기 안에서 스크롤)' );
eq ( sanitizeHtml ( '<div style="height:40px;color:red">글</div>' ). html , '<div style="min-height:40px;color:red">글</div>' , 'div height → min-height' );
eq ( sanitizeHtml ( '<p style="min-height:10px">글</p><span style="height: 2em">s</span>' ). html , '<p style="min-height:10px">글</p><span style="min-height:2em">s</span>' , 'min-height 그대로, span height 변환' );
eq ( sanitizeHtml ( '<table><tr><td style="height:20px">칸</td></tr></table>' ). html , '<table><tr><td style="height:20px">칸</td></tr></table>' , 'td height 유지' );
eq ( sanitizeHtml ( '<img src="https://x.test/a.png" style="width:100px;height:50px">' ). html , '<img src="https://x.test/a.png" style="width:100px;height:50px">' , 'img height 유지' );
});
// 0.9.9 요구사항 0(오류 수정): 점검에서 확인된 서버 쪽 결함 3건. 화면 쪽(읽기 창 경쟁, 검색어 잔존, 전달 초안 잔존, 작은 그림 확대, 실패 상태 줄)은 headless Chromium 으로 확인했습니다.
await t ( '0.9.9 오류 수정: 음수 여백 제거, Content-ID 없는 inline 첨부는 일반 첨부(수신·첨부 수·첨부 필터·전달 복사), 발송 때 받는 사람/참조/숨은 참조의 중복 주소 제거' , async () => {
// 1) sanitize: 음수 여백이 붙은 문단은 앞 문단 위로 겹치거나 화면 밖으로 사라졌습니다. 양수 여백은 그대로.
eq ( sanitizeHtml ( '<p style="margin-top:-400px;color:red">a</p><p style="margin-left: -9999px">b</p><p style="margin:-1px 0 0 -2px">c</p><p style="margin:8px 0;text-indent:2em">d</p>' ). html , '<p style="color:red">a</p><p>b</p><p>c</p><p style="margin:8px 0">d</p>' , '음수 여백 제거' );
// 2) 수신: Content-ID 가 없는 "inline"(Apple Mail 의 inline; filename=…) 은 attachment 로, cid 가 있는 그림만 inline 으로 저장합니다.
const calls = [], env = await makeEnv ({ RESEND_FETCH : fakeResend ({ calls , received : [ sample ( 'ap' , { attachments : [{ id : 'pdf1' }, { id : 'pic1' }] })], attachments : { ap : [
{ id : 'pdf1' , filename : 'doc.pdf' , content_type : 'application/pdf' , content_disposition : 'inline' , size : 4 , download_url : 'https://files.test/att/pdf1' , bytes : 'PDF!' },
{ id : 'pic1' , filename : 'p.png' , content_type : 'image/png' , content_disposition : 'inline' , content_id : '<c1>' , size : 3 , download_url : 'https://files.test/att/pic1' , bytes : 'PNG' }] } }) }), cookie = await login ( env );
await ingestReceived ( env , 'ap' , { source : 'webhook' });
const msg = await env . DB . prepare ( "SELECT id FROM messages WHERE provider_id='ap'" ). first ();
const rows = ( await env . DB . prepare ( 'SELECT filename,disposition FROM attachments WHERE message_id=? ORDER BY filename' ). bind ( msg . id ). all ()). results ;
eq ( rows . map (( r ) => r . filename + ':' + r . disposition ). join ( ',' ), 'doc.pdf:attachment,p.png:inline' , 'cid 없는 inline → attachment, cid 있는 그림 → inline' );
const listed = async ( extra = '' ) => ( await ( await api ( env , 'mail?folder=inbox' + extra , cookie )). json ()). messages . filter (( m ) => m . id === msg . id );
eq (( await listed ())[ 0 ]. attachment_count , 1 , '첨부 수 1(본문 그림 제외)' );
// 3) 예전 규칙으로 저장된 행(inline 인데 cid 없음)도 첨부 수·"첨부" 필터·전달 복사에 포함됩니다.
await env . DB . prepare ( "UPDATE attachments SET disposition='inline' WHERE message_id=? AND filename='doc.pdf'" ). bind ( msg . id ). run ();
const pdfId = ( await env . DB . prepare ( "SELECT id FROM attachments WHERE message_id=? AND filename='doc.pdf'" ). bind ( msg . id ). first ()). id ;
eq (( await listed ())[ 0 ]. attachment_count , 1 , '옛 inline 행도 첨부 수에 포함' ); eq (( await listed ( '&attach=1' )). length , 1 , '첨부 필터에 나옴' );
const fwd = ( await ( await api ( env , 'mail' , cookie , 'POST' , { to : 'f@example.test' , subject : 'fwd' , html : '<p>전달</p>' })). json ()). id ;
eq (( await api ( env , 'mail/' + fwd + '/attachments' , cookie , 'POST' , { from : msg . id , ids : [ pdfId ] })). status , 201 , '전달 복사 요청' );
eq (( await env . DB . prepare ( 'SELECT COUNT(*) n FROM attachments WHERE message_id=?' ). bind ( fwd ). first ()). n , 1 , '옛 inline 행이 전달 초안에 복사됨' );
// 4) 발송: 대소문자만 다른 같은 주소가 받는 사람·참조·숨은 참조에 겹치면 받는 사람 > 참조 > 숨은 참조 순으로 한 번만 보냅니다.
const id = ( await ( await api ( env , 'mail' , cookie , 'POST' , { to : 'Bob@Example.com' , cc : 'carol@x.test, bob@example.com' , bcc : 'BOB@example.com, dan@x.test' , subject : 's' , html : '<p>x</p>' })). json ()). id ;
eq (( await api ( env , 'mail/' + id + '/send' , cookie , 'POST' , {})). status , 200 , '발송' );
const post = calls . find (( c ) => c . method === 'POST' && c . path === '/emails' ). body ;
eq ( JSON . stringify ([ post . to , post . cc , post . bcc ]), JSON . stringify ([[ 'bob@example.com' ], [ 'carol@x.test' ], [ 'dan@x.test' ]]), 'Resend 요청에서 중복 주소 제거(주소는 저장 때 소문자로정리됨)' );
});
await t ( '1.0.11 웹훅과 동기화가 같은 메일을 동시에 받아들일 때: 늦은 쪽은 provider_id UNIQUE 오류 대신 "이미 있음" 으로 끝나고 메일은 한 통만 저장된다(운영 heart 10/12·10/13 의 동기화 일부 실패)' , async () => {
const env = await makeEnv ({ RESEND_FETCH : fakeResend ({ received : [ sample ( 'race' )] }) });
// 둘 다 "이미 있나" 확인을 통과한 뒤(Resend 상세 조회를 기다리는 동안) INSERT 에 이르도록 동시에 시작합니다.
const [ a , b ] = await Promise . all ([ ingestReceived ( env , 'race' , { source : 'webhook' }), ingestReceived ( env , 'race' , { source : 'sync' })]);
const results = [ a , b ]. map (( r ) => ( r . imported ? 'imported' : r . skipped ));
eq ( results . filter (( r ) => r === 'imported' ). length , 1 , '한 쪽만 저장' ); eq ( results . filter (( r ) => r === 'exists' ). length , 1 , '다른 쪽은 exists' );
eq (( await env . DB . prepare ( "SELECT COUNT(*) n FROM messages WHERE provider_id='race'" ). first ()). n , 1 , '한 통만' );
const late = [ a , b ]. find (( r ) => r . skipped === 'exists' ); eq ( late . id , [ a , b ]. find (( r ) => r . imported ). id , '늦은 쪽도 저장된 메일 id 를 돌려줌' );
});
done ();