Files
AriWeb-Suite/modules/arimail/server/smtp.mjs

453 lines
33 KiB
JavaScript

/**
* 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';
import { sendMail, outboundMode } from './providers/index.mjs';
import { submit } from './sendqueue.mjs';
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');
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 변경] 이전 중·동결
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 = '' }) {
// [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}`);
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(/&amp;/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(); },
};
}