2026-10-26 11:02:58 +00:00
/**
* SMTP 제출(submission) 서버 — RFC 5321 + RFC 6409(메일 제출) + RFC 3207(STARTTLS) + RFC 4954(AUTH).
*
* 왜 필요한가
* 0.8.0 까지는 메일 프로그램(Outlook·Thunderbird·Apple Mail 등)이 Resend SMTP(smtp.resend.com)에 직접 접속했고,
* SMTP 비밀번호로 Resend API 키를 입력해야 했습니다. 키가 사용자 PC·메일 프로그램 설정에 퍼지면 노출 위험이 큽니다.
* 이제 메일 프로그램은 이 게이트웨이(mail.arihakdang.com:465/587)에 **웹 메일 로그인과 같은 아이디·비밀번호**로 인증하고,
* 게이트웨이가 받은 메일을 서버에만 보관된 RESEND_API_KEY 로 Resend API 를 호출해 실제로 발송합니다.
* API 키는 서버 환경 파일 밖으로 나가지 않습니다.
*
* 동작 요약
* - 465: 접속 즉시 TLS(SMTPS). 587: 평문으로 접속한 뒤 STARTTLS 로 암호화(권장 제출 포트).
* - 인증(AUTH PLAIN / AUTH LOGIN)은 TLS 위에서만 받습니다(POP·IMAP 과 같은 정책, 진단용 SMTP_ALLOW_PLAINTEXT 예외).
* - 인증한 사용자는 자기 메일함 주소로만 보낼 수 있습니다(0.9.0 부터 관리자도 마찬가지 — 메일 기능에서는 자기 메일함만 씀).
* 다른 주소를 보내는 사람으로 쓰면 거부합니다(우리 키로 남의 주소를 사칭하지 못하게).
* - 받는 사람 수·첨부 크기·메일함 용량·월간 통수 제한(0.9.0, limits.mjs)을 발송 전에 검사해 SMTP 오류로 돌려줍니다.
* - DATA 로 받은 원본(.eml)을 해석해 Resend API 요청(받는 사람·참조·숨은 참조·제목·본문·첨부·인라인 이미지)으로 바꿔 보냅니다.
* - 발송에 성공하면 원본을 그대로 보낸 메일함에 저장합니다. 메일 프로그램이 IMAP 으로 같은 메일을 보낸 메일함에
* 올려도(APPEND) Message-ID 로 한 통으로 합칩니다.
* - 이 서버는 외부에서 오는 메일을 받는 MX 가 아닙니다. 인증하지 않은 접속은 MAIL 단계에서 거부합니다(오픈 릴레이 방지).
*/
import net from 'node:net' ;
import tls from 'node:tls' ;
import { createHash } from 'node:crypto' ;
import { verifyUser , audit } from './auth.mjs' ;
2026-10-26 11:47:45 +00:00
import { sendMail , outboundMode } from './providers/index.mjs' ;
import { submit } from './sendqueue.mjs' ;
2026-10-26 11:02:58 +00:00
import { normalizeCrlf } from './mime.mjs' ;
import { parseMime , summarize , header , parseAddresses , decodeWords } from './imap-mime.mjs' ;
import { appendMessage , setFlag } from './store.mjs' ;
import { now , isEmail , memoryRateStore } from './util.mjs' ;
import { checkOutbound , recipientLimit } from './limits.mjs' ;
const CRLF = '\r\n' ;
// 한 통의 최대 크기(원본 바이트). Resend 는 base64 로 늘어난 뒤 40MB 까지 받으므로 원본 기준 30MB 로 둡니다.
export const SMTP_MAX_SIZE = 30 * 1024 * 1024 ;
// 한 통의 최대 받는 사람 수는 0.9.0 부터 관리자·시스템 설정(limits.recipientLimit, 내장 상한 50 = Resend 한도)을 따릅니다.
// 명령 한 줄의 최대 길이. RFC 5321 은 512 바이트지만 AUTH 초기 응답이 길 수 있어 넉넉히 둡니다.
const MAX_LINE = 4096 ;
// 인증 실패 요청 제한(접속 IP 기준). 비밀번호 대입을 늦춥니다.
const rate = memoryRateStore ();
const TERMINATOR = Buffer . from ( '\r\n.\r\n' );
/**
* SMTP 응답 코드를 실어 나르는 오류. submitMessage 가 던지면 세션이 그대로 클라이언트에 전달합니다.
* @param {number} code 3자리 응답 코드(예: 550)
* @param {string} enhanced 확장 상태 코드(RFC 3463, 예: '5.7.1')
* @param {string} message 사람이 읽는 설명(영문, 메일 프로그램이 그대로 보여 줌)
*/
export class SmtpError extends Error {
constructor ( code , enhanced , message ) { super ( message ); this . code = code ; this . enhanced = enhanced ; }
get reply () { return ` ${ this . code } ${ this . enhanced } ${ this . message } ` ; }
}
/**
* 발송 모듈의 오류 코드(providers/resend.mjs 의 providerError, providers/smtp-client.mjs 의 smtpErrorCode)를 SMTP 응답으로 바꿉니다.
* 키·도메인 설정 문제나 일시 장애는 4xx(임시 실패)로 돌려 메일 프로그램이 나중에 다시 보내게 하고,
* 내용이 거부된 경우만 5xx(영구 실패)로 돌려 사용자가 고치게 합니다. 응답에 키 관련 정보는 넣지 않습니다.
*/
function providerReply ( e ) {
if ( e instanceof SmtpError ) return e ;
switch ( e ? . message ) {
case 'mail_rejected' : return new SmtpError ( 550 , '5.6.0' , 'Message rejected by the mail service' + ( e . providerMessage ? ': ' + String ( e . providerMessage ). replace ( /[\r\n]+/g , ' ' ). slice ( 0 , 200 ) : '' ));
case 'mail_rate_limited' : return new SmtpError ( 451 , '4.7.0' , 'Sending rate limit reached, try again shortly' );
default : return new SmtpError ( 451 , '4.3.0' , 'Mail service temporarily unavailable, try again later' );
}
}
/** "이름 <주소>" 형식 문자열. 이름이 없으면 주소만. 이름에 따옴표·꺾쇠가 있으면 따옴표로 감쌉니다. */
function displayAddress ( name , addr ) {
if ( ! name ) return addr ;
const clean = String ( name ). replace ( /[\r\n]+/g , ' ' ). trim ();
return /[",<>()@;:]/ . test ( clean ) ? `" ${ clean . replace ( /["\\]/g , '\\$&' ) } " < ${ addr } >` : ` ${ clean } < ${ addr } >` ;
}
/** parseAddresses 결과({name, mailbox, host})를 소문자 주소와 표시 문자열로 바꿉니다. */
const toEntries = ( value ) => parseAddresses ( value ). filter (( a ) => a . host ). map (( a ) => { const addr = ` ${ a . mailbox } @ ${ a . host } ` . toLowerCase (); return { addr , display : displayAddress ( a . name , addr ) }; });
/**
* 이 사용자가 보내는 사람으로 쓸 수 있는 주소 목록: 자기 메일함 하나뿐입니다.
* 0.8.x 까지는 관리자가 게이트웨이의 모든 메일함 주소로 보낼 수 있었지만(웹에서도 모든 메일함을 열 수 있었음), 0.9.0 에서 관리자의
* 메일함 전환 기능을 없애면서 발신 주소도 자기 메일함으로 좁혔습니다. 목록 형태는 호출부 호환을 위해 유지합니다.
*/
export async function allowedSenders ( env , user ) { return [ user . mailbox ]; }
/** limits.checkOutbound 의 오류 코드를 SMTP 응답으로 바꿉니다. 용량·통수 제한은 임시(4xx)/영구(5xx) 성격에 맞춰 돌려줍니다. */
function limitReply ( e ) {
switch ( e ? . message ) {
case 'too_many_recipients' : return new SmtpError ( 452 , '4.5.3' , `Too many recipients (limit ${ e . limit } )` );
case 'attachment_too_large' : return new SmtpError ( 552 , '5.3.4' , `Attachment exceeds the size limit ( ${ e . limit } bytes after MIME encoding)` );
case 'quota_exceeded' : return new SmtpError ( 452 , '4.2.2' , 'Mailbox full (quota exceeded), free space and try again' );
case 'storage_full' : return new SmtpError ( 452 , '4.3.1' , 'Insufficient system storage' );
2026-10-26 11:47:45 +00:00
case 'monthly_limit_exceeded' : return new SmtpError ( 550 , '5.7.1' , `Monthly message limit reached ( ${ e . limit } ) ${ e . resetAt ? ', resets at ' + e . resetAt : '' } ` );
case 'mail_domain_frozen' : return new SmtpError ( 451 , '4.3.2' , 'Mail domain is being migrated, try again later' ); // [Ariweb Suite 변경] 이전 중·동결
2026-10-26 11:02:58 +00:00
default : return null ;
}
}
/**
* 인증된 사용자가 DATA 로 보낸 원본을 Resend API 로 발송하고 보낸 메일함에 저장합니다.
*
* @param {object} env
* @param {object} user verifyUser 가 돌려준 사용자(id, login, name, role, mailbox)
* @param {object} envelope
* @param {string} envelope.mailFrom MAIL FROM 주소(빈 문자열 = 반송 주소 없음 "<>")
* @param {string[]} envelope.rcpt RCPT TO 주소들(실제로 배달할 대상, 소문자)
* @param {Buffer} envelope.eml DATA 원본(점 스터핑을 푼 RFC 5322 메시지)
* @param {string} [envelope.ip] 접속 IP(감사 기록용)
* @returns {Promise<{id:string, providerId:string}>} 저장한 메일 id 와 Resend 발송 id
* @throws {SmtpError} 클라이언트에 돌려줄 SMTP 오류
*/
export async function submitMessage ( env , user , { mailFrom = '' , rcpt = [], eml , ip = '' }) {
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 오류 수정: Resend 키는 발신 방식이 resend 일 때만 필요합니다. 예전에는 Haraka·외부 SMTP 중계를 쓰는 도메인도 키가 없으면 451 로 모든 제출이 거부됐습니다.
if ( outboundMode ( env ) === 'resend' && ! env . RESEND_API_KEY ) throw new SmtpError ( 451 , '4.3.0' , 'Mail service temporarily unavailable, try again later' );
// [Ariweb Suite 변경] 제출 속도 제한(§17.2): 계정·IP 가 초당 10건 이상이면 임시 거부하고 악용 확인 표에 남깁니다.
const th = await submit ( env , [ 'account:' + user . mailbox , ...( ip ? [ 'ip:' + ip ] : [])]);
if ( th . throttled ) throw new SmtpError ( 451 , '4.7.0' , `Submission rate limit reached, try again after ${ th . until } ` );
2026-10-26 11:02:58 +00:00
const raw = normalizeCrlf ( eml );
let parsed , info ;
try { parsed = parseMime ( raw ); info = summarize ( parsed ); } catch { throw new SmtpError ( 550 , '5.6.0' , 'Malformed message' ); }
const headers = parsed . root . headers ;
// 1) 보내는 사람 확인: 헤더 From 과 봉투 MAIL FROM 이 모두 이 사용자에게 허용된 주소여야 합니다.
const allowed = await allowedSenders ( env , user );
const fromEntry = toEntries ( header ( headers , 'from' ))[ 0 ];
const fromAddr = fromEntry ? . addr || mailFrom || user . mailbox ;
if ( ! allowed . includes ( fromAddr ) || ( mailFrom && ! allowed . includes ( mailFrom ))) throw new SmtpError ( 550 , '5.7.1' , `Sender address not allowed for this account (use ${ user . mailbox } )` );
// From 헤더가 없으면 계정 이름으로 채웁니다. 이름만 비어 있으면 계정 이름을 씁니다.
const from = fromEntry && fromEntry . display !== fromEntry . addr ? fromEntry . display : displayAddress ( user . name || '' , fromAddr );
// 2) 받는 사람 나누기. 실제 배달 대상은 봉투(RCPT)입니다. 헤더의 To/Cc 중 봉투에 있는 주소만 to/cc 로 두고,
// 봉투에만 있는 주소는 숨은 참조(bcc)로 보냅니다(메일 프로그램은 Bcc 헤더를 지우고 RCPT 로만 알려 주기 때문).
const rcptSet = new Set ( rcpt );
const to = toEntries ( header ( headers , 'to' )). filter (( a ) => rcptSet . has ( a . addr ));
const cc = toEntries ( header ( headers , 'cc' )). filter (( a ) => rcptSet . has ( a . addr ) && ! to . some (( t ) => t . addr === a . addr ));
const listed = new Set ([... to , ... cc ]. map (( a ) => a . addr ));
const bcc = rcpt . filter (( a ) => ! listed . has ( a ));
// Resend 는 to 가 꼭 있어야 합니다. 모두 숨은 참조로 보낸 경우(예: "받는 사람 없음" 단체 메일)에는
// 보내는 사람 자신을 to 로 둡니다. 받는 사람들에게는 서로의 주소가 보이지 않고, 보낸 사람에게 사본이 한 통 갑니다.
const toList = to . length ? to . map (( a ) => a . display ) : [ from ];
// 3) Resend 요청 만들기
const replyTo = toEntries ( header ( headers , 'reply-to' )). map (( a ) => a . display );
const custom = {};
for ( const name of [ 'In-Reply-To' , 'References' ]) { const v = header ( headers , name ); if ( v ) custom [ name ] = String ( v ). replace ( /[\r\n]+/g , ' ' ). slice ( 0 , 2000 ); }
const payload = {
from , to : toList , ...( cc . length ? { cc : cc . map (( a ) => a . display ) } : {}), ...( bcc . length ? { bcc } : {}), ...( replyTo . length ? { reply_to : replyTo } : {}),
subject : info . subject || '' , ...( info . html ? { html : info . html } : {}), text : info . text || ( info . html ? undefined : ' ' ),
...( Object . keys ( custom ). length ? { headers : custom } : {}),
};
if ( payload . text === undefined ) delete payload . text ;
const cidUsed = new Set ([... String ( info . html || '' ). matchAll ( /\bsrc\s*=\s*["']?cid:([^"'\s>]+)/gi )]. map (( x ) => x [ 1 ]. replace ( /&/g , '&' )));
if ( info . attachments . length ) payload . attachments = info . attachments . map (( a ) => ({
filename : decodeWords ( a . filename || '' ) || 'attachment' , content : Buffer . from ( a . bytes ). toString ( 'base64' ), content_type : a . contentType ,
// 본문에 cid: 로 끼워 넣은 이미지는 content_id 를 그대로 넘겨 받는 쪽에서도 본문 안에 보이게 합니다.
// Content-Disposition 없이 Content-ID 만 있는 파트(Outlook·Exchange 류)도 본문이 cid: 로 가리키면 인라인입니다(0.9.0 수정, 예전에는 깨진 그림 + 별도 첨부로 갔음).
...( a . contentId && ( a . disposition === 'inline' || cidUsed . has ( a . contentId )) ? { content_id : a . contentId } : {}),
}));
// 같은 메일을 메일 프로그램이 다시 보내는 경우(응답을 못 받고 재전송) 두 번 발송되지 않게, 사용자 + 원본 바이트로 멱등 키를 만듭니다.
// 같은 내용을 일부러 다시 보내도 메일 프로그램이 Date·Message-ID 를 새로 만들므로 키가 달라 정상 발송됩니다.
// 키에는 봉투(MAIL FROM, RCPT TO 집합)도 넣습니다. 예전에는 사용자+원본만 써서, 같은 DATA 를 받는 사람만 바꿔 따로 보내는 경우
// (메일 머지·"개별 발송")에 키가 겹쳐 두 번째 받는 사람에게 가지 않았습니다(0.8.25 수정).
const key = 'smtp-' + createHash ( 'sha256' ). update ( user . id + '\n' + ( mailFrom || '' ) + '\n' + [... rcptSet ]. sort (). join ( ',' ) + '\n' ). update ( raw ). digest ( 'hex' ). slice ( 0 , 48 );
// 제한 검사(0.9.0): 받는 사람 수·첨부 크기(MIME 기준)·메일함 용량(원본 크기만큼 늘어남)·월간 통수. 발송 전에 하므로 어긋나면 아무것도 보내지 않습니다.
try { await checkOutbound ( env , { mailbox : fromAddr , recipients : rcpt . length , attachmentSizes : info . attachments . map (( a ) => a . bytes . length ), bytes : raw . length }); }
catch ( e ) { const reply = limitReply ( e ); if ( reply ) throw reply ; throw e ; }
let result ;
// 1.0.0: 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)은 설정이 정합니다. 어느 쪽이든 같은 오류 코드로 실패하므로 SMTP 응답 변환(providerReply)은 그대로입니다.
try { result = await sendMail ( env , payload , key ); } catch ( e ) { throw providerReply ( e ); }
// 4) 보낸 메일함 저장. 메일 프로그램이 IMAP 으로 먼저 올린 사본(같은 Message-ID, 발송 id 없음)이 있으면 그 메일에 발송 id 만 붙입니다.
const stamp = now ();
let id = null ;
if ( info . messageId ) id = ( await env . DB . prepare ( "SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=? AND provider_id IS NULL" ). bind ( fromAddr , info . messageId ). first ()) ? . id || null ;
if ( ! id ) id = ( await appendMessage ( env , 'sent' , raw , { flags : [ '\\Seen' ], internalDate : stamp , mailbox : fromAddr , enforceQuota : false })). id ;
// provider_id 를 채워 두면 정기 동기화(ingestSent)가 같은 메일을 다시 가져오지 않습니다.
await env . DB . prepare ( "UPDATE messages SET provider_id=?,sent_at=?,state='sent',bcc_json=?,updated_at=? WHERE id=?" ). bind ( result . id , stamp , JSON . stringify ( bcc ), stamp , id ). run ();
// 답장이면 원래 메일에 \Answered(답장함) 표시를 남깁니다(웹 발송과 같은 규칙).
if ( custom [ 'In-Reply-To' ]) { const parent = await env . DB . prepare ( 'SELECT id FROM messages WHERE message_id=? AND mailbox=?' ). bind ( custom [ 'In-Reply-To' ], fromAddr ). first (); if ( parent ) await setFlag ( env , parent . id , '\\Answered' , true ); }
await audit ( env , user . login , 'smtp_send' , id , { ip , recipients : rcpt . length });
return { id , providerId : result . id };
}
/**
* DATA 본문 수집기. 받은 바이트를 조각으로 모으며 끝 표시(<CRLF>.<CRLF>)를 찾습니다.
* 큰 첨부(수십 MB)를 받을 때 매번 버퍼를 이어 붙이면 복사량이 제곱으로 늘어나므로, 조각 목록과 마지막 4바이트(tail)만 유지합니다.
* 끝 표시가 조각 경계에 걸쳐도 tail 과 새 조각을 붙여 찾으므로 놓치지 않습니다.
*/
export class DataCollector {
// tail 을 CRLF 로 시작하는 이유: DATA 명령 줄 끝의 CRLF 가 본문 바로 앞에 있으므로, 본문이 비어 있어
// 첫 줄이 곧바로 "." 인 경우(".\r\n")도 같은 끝 표시(<CRLF>.<CRLF>) 규칙으로 찾을 수 있습니다. 이 CRLF 는 본문에 넣지 않습니다.
constructor ( max ) { this . parts = []; this . size = 0 ; this . tail = Buffer . from ( CRLF ); this . max = max ; this . tooBig = false ; }
/**
* @param {Buffer} chunk 새로 받은 바이트
* @returns {null | {message: Buffer|null, rest: Buffer}} 끝 표시를 찾으면 원본(크기 초과면 null)과 그 뒤에 남은 바이트
*/
feed ( chunk ) {
const combined = Buffer . concat ([ this . tail , chunk ]);
const idx = combined . indexOf ( TERMINATOR );
if ( idx < 0 ) {
this . push ( chunk );
this . tail = Buffer . from ( combined . subarray ( Math . max ( 0 , combined . length - 4 )));
return null ;
}
// 본문은 끝 표시의 앞 CRLF 까지 포함합니다. bodyEnd 가 tail 안에 있으면(끝 표시가 이전 조각에서 시작) 이미 넣은 바이트를 덜어 냅니다.
const bodyEnd = idx + 2 , fromChunk = bodyEnd - this . tail . length ;
let drop = 0 ;
if ( fromChunk >= 0 ) this . push ( chunk . subarray ( 0 , fromChunk )); else drop = - fromChunk ;
const rest = combined . subarray ( idx + TERMINATOR . length );
if ( this . tooBig ) return { message : null , rest };
let message = Buffer . concat ( this . parts ); if ( drop ) message = message . subarray ( 0 , Math . max ( 0 , message . length - drop ));
return { message , rest };
}
push ( buf ) {
this . size += buf . length ;
if ( this . size > this . max ) { this . tooBig = true ; this . parts = []; return ; } // 크기를 넘으면 더 모으지 않고 끝 표시만 찾습니다.
if ( ! this . tooBig && buf . length ) this . parts . push ( Buffer . from ( buf ));
}
}
/** 점 스터핑 해제(RFC 5321 §4.5.2): 줄 맨 앞의 ".." 를 "." 로 되돌립니다. 바이트를 바꾸지 않도록 latin1 로 다룹니다. */
export function dotUnstuff ( buffer ) {
return Buffer . from ( Buffer . from ( buffer ). toString ( 'latin1' ). replace ( /(^|\r\n)\.\./g , '$1.' ), 'latin1' );
}
/** "<user@host>" 경로에서 주소만 꺼냅니다(소문자). 파라미터(SIZE=…)는 따로 돌려줍니다. */
function parsePath ( text ) {
const m = String ( text ). match ( /^\s*<([^<>]*)>\s*(.*)$/ ) || String ( text ). match ( /^\s*([^\s<>]+)\s*(.*)$/ );
if ( ! m ) return null ;
const params = Object . fromEntries (( m [ 2 ] || '' ). split ( /\s+/ ). filter ( Boolean ). map (( p ) => { const [ k , v = '' ] = p . split ( '=' ); return [ k . toUpperCase (), v ]; }));
return { address : m [ 1 ]. trim (). replace ( /^@[^:]+:/ , '' ). toLowerCase (), params }; // 소스 라우트(@a,@b:user@host)는 무시
}
/**
* 접속 한 개의 상태 기계.
* 상태: greeted(EHLO 여부) → 인증(user) → 트랜잭션(mailFrom, rcpt) → DATA 수집(collector) → 처리 후 트랜잭션 초기화.
* 입력은 pump() 가 한 번에 한 명령씩 순서대로 처리합니다. PIPELINING 으로 여러 명령이 한꺼번에 와도
* 앞 명령(예: RCPT 확인)이 끝난 뒤 다음 명령(DATA)을 판단해야 하므로 비동기 처리를 직렬화합니다.
*/
class Session {
constructor ( server , socket , { secure }) {
this . server = server ; this . env = server . env ; this . secure = secure ;
this . buf = Buffer . alloc ( 0 ); // 아직 처리하지 않은 명령 바이트
this . collector = null ; // DATA 수집 중이면 DataCollector
this . pendingData = null ; // 수집이 끝나 처리를 기다리는 DATA 결과
this . pumping = false ; this . closed = false ;
this . greeted = false ; this . user = null ; this . failures = 0 ;
this . sasl = null ; // 진행 중인 AUTH 대화 { mech, step, login }
this . reset ();
this . ip = socket . remoteAddress || '' ; this . attach ( socket );
console . log ( `SMTP 접속 ${ this . ip } ${ secure ? 'tls' : 'plain' } ` );
this . send ( `220 ${ server . hostname } ESMTP ready` );
}
/** 메일 한 통의 트랜잭션 상태(MAIL FROM·RCPT TO)를 비웁니다. 인증 상태는 유지합니다. */
reset () { this . mailFrom = null ; this . rcpt = []; }
attach ( socket ) {
this . socket = socket ; socket . setTimeout ( 5 * 60 * 1000 );
socket . on ( 'data' , ( chunk ) => this . onData ( chunk ));
socket . on ( 'timeout' , () => this . close ( '421 4.4.2 Idle timeout' ));
socket . on ( 'error' , () => { this . closed = true ; });
socket . on ( 'close' , () => { this . closed = true ; });
}
send ( line ) { if ( ! this . closed ) this . socket . write ( line + CRLF ); }
/** 여러 줄 응답: 마지막 줄만 "250 " 이고 나머지는 "250-" 입니다. */
sendMulti ( code , lines ) { lines . forEach (( l , i ) => this . send ( ` ${ code }${ i === lines . length - 1 ? ' ' : '-' }${ l } ` )); }
close ( line ) { if ( line ) this . send ( line ); this . closed = true ; try { this . socket . end (); } catch {} }
onData ( chunk ) {
if ( this . closed ) return ;
if ( this . collector ) {
// DATA 수집 중: 명령으로 해석하지 않고 수집기에 넘깁니다.
const done = this . collector . feed ( chunk );
// pendingData 는 { message } 로 감쌉니다. 크기 초과면 message 가 null 인데, 예전에는 null 자체를 넣어 "처리할 것 없음" 과 구분되지 않아
// 552 응답이 나가지 않고 세션이 멈췄습니다.
if ( done ) { this . collector = null ; this . pendingData = { message : done . message }; this . buf = Buffer . from ( done . rest ); this . pump (); }
return ;
}
this . buf = this . buf . length ? Buffer . concat ([ this . buf , chunk ]) : Buffer . from ( chunk );
this . pump ();
}
/** 쌓인 입력을 순서대로 처리합니다. 이미 처리 중이면 그 루프가 새 입력까지 이어서 처리합니다. */
async pump () {
if ( this . pumping ) return ; this . pumping = true ;
try {
while ( ! this . closed ) {
if ( this . pendingData ) { const { message } = this . pendingData ; this . pendingData = null ; await this . finishData ( message ); continue ; }
if ( this . collector ) {
// DATA 를 막 시작했고 같은 묶음에 본문이 이미 와 있던 경우
if ( ! this . buf . length ) break ;
const pending = this . buf ; this . buf = Buffer . alloc ( 0 );
const done = this . collector . feed ( pending );
if ( ! done ) break ;
this . collector = null ; this . pendingData = { message : done . message }; this . buf = Buffer . from ( done . rest ); continue ;
}
const i = this . buf . indexOf ( '\r\n' );
if ( i < 0 ) { if ( this . buf . length > MAX_LINE ) this . close ( '500 5.5.6 Line too long' ); break ; }
const line = this . buf . subarray ( 0 , i ). toString ( 'utf8' ); this . buf = this . buf . subarray ( i + 2 );
try { await this . handle ( line ); } catch ( e ) { console . error ( 'SMTP 오류' , e ? . stack || e ? . message ); this . send ( '451 4.3.0 Internal error' ); }
}
} finally { this . pumping = false ; }
}
async handle ( line ) {
if ( this . sasl ) return this . saslStep ( line );
const sp = line . indexOf ( ' ' ); const verb = ( sp < 0 ? line : line . slice ( 0 , sp )). toUpperCase (); const arg = sp < 0 ? '' : line . slice ( sp + 1 ). trim ();
const loginAllowed = this . secure || this . server . allowPlaintext ;
switch ( verb ) {
case 'EHLO' : case 'HELO' : {
if ( ! arg ) return this . send ( '501 5.5.4 Domain required' );
this . greeted = true ; this . reset ();
if ( verb === 'HELO' ) return this . send ( `250 ${ this . server . hostname } ` );
const ext = [ this . server . hostname , `SIZE ${ SMTP_MAX_SIZE } ` , '8BITMIME' , 'PIPELINING' , 'ENHANCEDSTATUSCODES' ];
if ( ! this . secure && this . server . secureContext ) ext . push ( 'STARTTLS' );
if ( loginAllowed && ! this . user ) ext . push ( 'AUTH PLAIN LOGIN' );
ext . push ( 'HELP' );
return this . sendMulti ( 250 , ext );
}
case 'STARTTLS' : {
if ( this . secure ) return this . send ( '503 5.5.1 Already using TLS' );
if ( ! this . server . secureContext ) return this . send ( '454 4.7.0 TLS not available' );
this . send ( '220 2.0.0 Ready to start TLS' );
// RFC 3207: STARTTLS 이전 상태(EHLO·인증)는 모두 잊습니다. 평문 구간에 끼워 넣은 명령이 암호화 뒤에 실행되지 않도록
// 남은 입력도 버립니다(명령 주입 방지).
const plain = this . socket ; plain . removeAllListeners ( 'data' ); plain . removeAllListeners ( 'timeout' ); plain . removeAllListeners ( 'error' ); plain . removeAllListeners ( 'close' );
const secureSocket = new tls . TLSSocket ( plain , { isServer : true , secureContext : this . server . secureContext });
this . secure = true ; this . greeted = false ; this . reset (); this . buf = Buffer . alloc ( 0 ); this . attach ( secureSocket ); return ;
}
case 'AUTH' : {
if ( ! this . greeted ) return this . send ( '503 5.5.1 Send EHLO first' );
if ( this . user ) return this . send ( '503 5.5.1 Already authenticated' );
if ( ! loginAllowed ) { console . log ( `SMTP 평문 로그인 거부 ${ this . ip } (STARTTLS 필요)` ); return this . send ( '530 5.7.0 Must issue a STARTTLS command first' ); }
const [ mech , initial ] = arg . split ( /\s+/ ); const m = String ( mech || '' ). toUpperCase ();
if ( m === 'PLAIN' ) { if ( initial && initial !== '=' ) return this . authPlain ( initial ); this . sasl = { mech : 'PLAIN' }; return this . send ( '334 ' ); }
if ( m === 'LOGIN' ) {
if ( initial ) { this . sasl = { mech : 'LOGIN' , login : b64 ( initial ) }; return this . send ( '334 UGFzc3dvcmQ6' ); } // "Password:"
this . sasl = { mech : 'LOGIN' }; return this . send ( '334 VXNlcm5hbWU6' ); // "Username:"
}
return this . send ( '504 5.5.4 Unrecognized authentication mechanism' );
}
case 'MAIL' : {
if ( ! this . greeted ) return this . send ( '503 5.5.1 Send EHLO first' );
if ( ! this . user ) return this . send ( '530 5.7.0 Authentication required' );
if ( this . mailFrom !== null ) return this . send ( '503 5.5.1 Nested MAIL command' );
const path = /^FROM:/i . test ( arg ) ? parsePath ( arg . slice ( 5 )) : null ; if ( ! path ) return this . send ( '501 5.5.4 Syntax: MAIL FROM:<address>' );
if ( path . params . SIZE && Number ( path . params . SIZE ) > SMTP_MAX_SIZE ) return this . send ( '552 5.3.4 Message size exceeds fixed limit' );
if ( path . address && ! ( await allowedSenders ( this . env , this . user )). includes ( path . address )) return this . send ( `550 5.7.1 Sender address not allowed for this account (use ${ this . user . mailbox } )` );
this . mailFrom = path . address ; return this . send ( '250 2.1.0 Sender OK' );
}
case 'RCPT' : {
if ( this . mailFrom === null ) return this . send ( '503 5.5.1 Need MAIL command first' );
const path = /^TO:/i . test ( arg ) ? parsePath ( arg . slice ( 3 )) : null ; if ( ! path || ! isEmail ( path . address )) return this . send ( '501 5.1.3 Bad recipient address syntax' );
// 이미 봉투에 있는 주소(받는 사람과 참조에 같은 주소)는 세지 않고 그대로 받습니다. 예전에는 한도에서 중복 주소가 452 를 받아 메일 프로그램이 발송 전체를 포기했습니다(0.9.3).
if ( this . rcpt . includes ( path . address )) return this . send ( '250 2.1.5 Recipient OK' );
if ( this . rcpt . length >= ( await recipientLimit ( this . env ))) return this . send ( '452 4.5.3 Too many recipients' );
this . rcpt . push ( path . address );
return this . send ( '250 2.1.5 Recipient OK' );
}
case 'DATA' : {
if ( this . mailFrom === null ) return this . send ( '503 5.5.1 Need MAIL command first' );
if ( ! this . rcpt . length ) return this . send ( '554 5.5.1 No valid recipients' );
this . collector = new DataCollector ( SMTP_MAX_SIZE );
return this . send ( '354 Start mail input; end with <CRLF>.<CRLF>' );
}
case 'BDAT' : return this . send ( '502 5.5.1 CHUNKING not supported' );
case 'RSET' : this . reset (); return this . send ( '250 2.0.0 OK' );
case 'NOOP' : return this . send ( '250 2.0.0 OK' );
case 'VRFY' : return this . send ( '252 2.5.0 Cannot VRFY user' );
case 'HELP' : return this . send ( '214 2.0.0 See https://' + this . server . hostname + '/' );
case 'QUIT' : return this . close ( `221 2.0.0 ${ this . server . hostname } closing connection` );
default : return this . send ( '500 5.5.2 Command not recognized' );
}
}
/** AUTH 대화의 다음 줄(334 질문에 대한 base64 응답)을 처리합니다. "*" 는 취소입니다. */
async saslStep ( line ) {
const s = this . sasl ; this . sasl = null ;
if ( line . trim () === '*' ) return this . send ( '501 5.7.0 Authentication cancelled' );
if ( s . mech === 'PLAIN' ) return this . authPlain ( line . trim ());
if ( s . login === undefined ) { this . sasl = { mech : 'LOGIN' , login : b64 ( line . trim ()) }; return this . send ( '334 UGFzc3dvcmQ6' ); }
return this . login ( s . login , b64 ( line . trim ()));
}
/** AUTH PLAIN: base64("authzid\0authcid\0password"). authcid(로그인 이름)와 비밀번호만 씁니다. */
authPlain ( payload ) {
const parts = b64 ( payload ). split ( '\0' );
if ( parts . length < 3 ) return this . send ( '501 5.5.2 Invalid AUTH PLAIN response' );
return this . login ( parts [ 1 ], parts . slice ( 2 ). join ( '\0' ));
}
/**
* 웹 메일·IMAP·POP 과 같은 계정으로 인증합니다(verifyUser: www 관리자·별칭 admin·게이트웨이 계정).
* 실패하면 잠시 기다렸다 응답하고, 같은 접속에서 3번 틀리면 끊습니다. IP 당 15분에 20번까지만 시도할 수 있습니다.
*/
async login ( login , password ) {
if ( rate . blocked ( 'smtp:' + this . ip , { limit : 20 })) return this . close ( '421 4.7.0 Too many authentication attempts' );
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 이후 발송·저장(submitMessage)도 그 도메인의 env 로 합니다.
const env = this . server . resolveEnv ? this . server . resolveEnv ( login , sniName ( this . socket )) : this . env ;
const user = env ? await verifyUser ( env , login , password ) : null ;
if ( user ) this . env = env ;
if ( ! user ) rate . hit ( 'smtp:' + this . ip , { limit : 20 , windowSec : 900 }); // 실패만 셉니다. 메일마다 새로 접속하는 메일 프로그램이 성공 횟수로 막히지 않게.
console . log ( `SMTP 인증 ${ user ? '성공' : '실패' } ${ this . ip } user= ${ String ( login ). slice ( 0 , 80 ) }${ this . secure ? '' : ' (plain)' } ` );
if ( ! user ) {
this . failures ++ ; await new Promise (( r ) => setTimeout ( r , this . env . SMTP_AUTH_DELAY_MS ?? this . env . POP_AUTH_DELAY_MS ?? 1500 ));
if ( this . failures >= 3 ) return this . close ( '535 5.7.8 Authentication credentials invalid' );
return this . send ( '535 5.7.8 Authentication credentials invalid' );
}
this . user = user ; return this . send ( '235 2.7.0 Authentication successful' );
}
/** DATA 수집이 끝났을 때: 크기 확인 → 점 스터핑 해제 → 발송·저장 → 결과 응답. 성공·실패와 무관하게 트랜잭션은 비웁니다. */
async finishData ( message ) {
const envelope = { mailFrom : this . mailFrom || '' , rcpt : this . rcpt . slice () }; this . reset ();
if ( message === null ) return this . send ( '552 5.3.4 Message size exceeds fixed limit' );
try {
const r = await submitMessage ( this . env , this . user , { ... envelope , eml : dotUnstuff ( message ), ip : this . ip });
console . log ( `SMTP 발송 ${ this . ip } user= ${ this . user . login } rcpt= ${ envelope . rcpt . length } id= ${ r . providerId } ` );
this . send ( `250 2.0.0 OK queued as ${ r . providerId } ` );
} catch ( e ) {
const reply = providerReply ( e );
console . log ( `SMTP 발송 실패 ${ this . ip } user= ${ this . user . login } ${ reply . code } ${ e . message } ` );
this . send ( reply . reply );
}
}
}
/** base64 → UTF-8 문자열(잘못된 입력은 빈 문자열). */
function b64 ( s ) { try { return Buffer . from ( String ( s || '' ), 'base64' ). toString ( 'utf8' ); } catch { return '' ; } }
/**
* SMTP 제출 서버를 만듭니다. listen() 으로 포트를 열고, 인증서가 갱신되면 setTls() 로 바꿉니다.
* @param {object} env
* @param {object} [options]
* @param {string} [options.hostname] 인사말·EHLO 에 쓸 서버 이름
* @param {{key, cert}|null} [options.tlsOptions] 인증서 PEM(POP·IMAP 과 같은 파일). 없으면 465 를 열지 않고 587 의 STARTTLS 도 없습니다.
* @param {boolean} [options.allowPlaintext] true 면 TLS 없이도 AUTH 를 받습니다(진단용, 운영에서는 끔).
*/
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */
const sniName = ( socket ) => String ( socket ? . servername || '' );
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */
export function createSmtp ( env , { hostname = 'mail.arihakdang.com' , tlsOptions = null , allowPlaintext = false , resolveEnv = null } = {}) {
const shared = { env , hostname , secureContext : tlsOptions ? tls . createSecureContext ( tlsOptions ) : null , allowPlaintext , resolveEnv };
const plain = net . createServer (( socket ) => { socket . on ( 'error' , () => {}); new Session ( shared , socket , { secure : false }); });
// secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7).
const secure = tls . createServer ({ ...( tlsOptions || {}), minVersion : 'TLSv1.2' }, ( socket ) => { socket . on ( 'error' , () => {}); new Session ( shared , socket , { secure : true }); });
secure . on ( 'tlsClientError' , () => {});
let pendingSecure = null ;
const listenOn = ( server , port , host , label , out ) => new Promise (( res , rej ) => server . once ( 'error' , rej ). listen ( port , host , () => { out . push ( ` ${ label } ${ host } : ${ port } ` ); res (); }));
return {
plain , secure ,
setTls ( options ) {
shared . secureContext = tls . createSecureContext ( options ); secure . setSecureContext ( options );
if ( pendingSecure && ! secure . listening ) { const { port , host } = pendingSecure ; pendingSecure = null ; listenOn ( secure , port , host , 'smtps' , []). then (() => console . log ( ` SMTP TLS 포트를 열었습니다: ${ host } : ${ port } ` )). catch (( e ) => console . error ( ' SMTP TLS 포트 열기 실패:' , e . message )); }
},
async listen ({ plainPort , securePort , host = '0.0.0.0' }) {
const out = [];
if ( plainPort ) await listenOn ( plain , plainPort , host , 'submission' , out );
if ( securePort ) { if ( shared . secureContext ) await listenOn ( secure , securePort , host , 'smtps' , out ); else pendingSecure = { port : securePort , host }; }
return out ;
},
close () { plain . close (); if ( secure . listening ) secure . close (); },
};
}