Files

157 lines
12 KiB
JavaScript

/**
* SMTP 클라이언트(1.0.0) — 외부 의존성 없이 node:net / node:tls 로 메일 한 통을 중계 서버에 넘깁니다.
*
* 쓰는 곳: providers/smtp.mjs(발신 방식 haraka / smtp). 내장 Haraka 의 로컬 제출 포트(127.0.0.1:2525, 암호화·인증 없음)나
* 외부 SMTP 중계 서버(587 STARTTLS + 로그인, 465 TLS 등)에 모두 쓸 수 있습니다.
*
* 흐름: 접속 → 220 인사 → EHLO → (STARTTLS → EHLO) → (AUTH PLAIN 또는 LOGIN) → MAIL FROM → RCPT TO(여러 명) → DATA → 본문(점 스터핑) → QUIT.
* 응답은 여러 줄(250-… / 250 …)을 끝 줄까지 모아 코드와 본문으로 돌려줍니다. 시간 제한 안에 응답이 없으면 끊고 실패로 봅니다.
*
* 오류 코드(providers/resend.mjs 의 providerError 와 같은 어휘로 맞춰 화면·SMTP 응답 변환이 공통으로 동작):
* mail_not_configured(중계 서버 주소 없음) · mail_auth_failed(535) · mail_rejected(5xx) · mail_rate_limited(421/450/451/452)
* · mail_provider_unavailable(접속 실패·시간 초과·그 밖의 4xx). providerMessage 에 서버 응답 문구를 남깁니다.
*/
import net from 'node:net';
import tls from 'node:tls';
import { dotStuff } from '../mime.mjs';
const CRLF = '\r\n';
/** 서버 응답 코드를 공통 오류 코드로 바꿉니다. */
export function smtpErrorCode(code, phase) {
if (phase === 'auth' && (code === 535 || code === 534 || code === 530)) return 'mail_auth_failed';
if ([421, 450, 451, 452].includes(code)) return 'mail_rate_limited';
if (code >= 500) return 'mail_rejected';
return 'mail_provider_unavailable';
}
/**
* 메일 한 통을 보냅니다.
* @param {object} o
* @param {string} o.host 중계 서버 주소
* @param {number} o.port 포트(587/465/25/2525)
* @param {'none'|'starttls'|'tls'} [o.security] none = 암호화 없음(로컬 Haraka), starttls = 접속 뒤 STARTTLS(없으면 실패), tls = 접속 즉시 TLS
* @param {string} [o.user] @param {string} [o.pass] 로그인(있을 때만 AUTH)
* @param {string} [o.hostname] EHLO 에 보낼 우리 이름
* @param {string} o.from 봉투 보낸 사람(MAIL FROM)
* @param {string[]} o.to 봉투 받는 사람 전체(To·Cc·Bcc 합침)
* @param {Buffer} o.eml 보낼 원본(CRLF)
* @param {number} [o.timeoutMs] 한 응답을 기다리는 최대 시간
* @param {boolean} [o.rejectUnauthorized] TLS 인증서 검증(기본 true, 로컬 시험용 false)
* @returns {Promise<{response:string, accepted:string[], rejected:{address:string,reply:string}[]}>}
*/
export async function smtpSend({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', from, to, eml, timeoutMs = 60000, rejectUnauthorized = true }) {
if (!host || !port) { const e = new Error('mail_not_configured'); e.providerMessage = 'SMTP relay host/port missing'; throw e; }
let socket = null;
const fail = (code, message, phase) => { const e = new Error(code); e.providerMessage = String(message || '').replace(/[\r\n]+/g, ' ').slice(0, 300); e.phase = phase; return e; };
// 줄 단위 응답 읽기: 데이터가 올 때마다 버퍼에 모으고, 마지막 줄(코드 뒤가 공백)이 오면 전체 응답을 돌려줍니다.
let buffer = '', waiter = null;
const onData = (chunk) => {
buffer += chunk.toString('utf8');
while (waiter) {
const lines = buffer.split(CRLF); if (lines.length < 2) return;
// 완성된 줄들 중 "NNN " 으로 끝나는 줄이 있는지 봅니다(여러 줄 응답은 "NNN-")
let endIndex = -1;
for (let i = 0; i < lines.length - 1; i++) if (/^\d{3}(?: |$)/.test(lines[i])) { endIndex = i; break; }
if (endIndex < 0) return;
const reply = lines.slice(0, endIndex + 1); buffer = lines.slice(endIndex + 1).join(CRLF);
const w = waiter; waiter = null; w.resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)), text: reply.join(' ') });
}
};
const read = () => new Promise((resolve, reject) => {
waiter = { resolve, reject };
const timer = setTimeout(() => { if (waiter) { waiter = null; reject(fail('mail_provider_unavailable', 'timeout waiting for reply', 'read')); } }, timeoutMs);
const done = waiter.resolve; waiter.resolve = (v) => { clearTimeout(timer); done(v); };
onData(Buffer.alloc(0));
});
const attach = (s) => { socket = s; s.setTimeout(timeoutMs + 5000); s.on('data', onData); s.on('error', (e) => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', e.message, 'socket')); } }); s.on('close', () => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', 'connection closed', 'socket')); } }); };
const send = async (line, expect, phase) => {
socket.write(line + CRLF);
const r = await read();
if (!expect.includes(r.code)) throw fail(smtpErrorCode(r.code, phase), `${r.code} ${r.lines.join(' ')}`, phase);
return r;
};
const connect = () => new Promise((resolve, reject) => {
const onError = (e) => reject(fail('mail_provider_unavailable', e.message, 'connect'));
const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s));
s.once('error', onError); s.setTimeout(timeoutMs, () => onError(new Error('connect timeout')));
});
try {
attach(await connect());
const greeting = await read(); if (greeting.code !== 220) throw fail('mail_provider_unavailable', greeting.text, 'greeting');
let ehlo = await send('EHLO ' + hostname, [250], 'ehlo');
if (security === 'starttls') {
if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) throw fail('mail_provider_unavailable', 'server does not offer STARTTLS', 'starttls');
await send('STARTTLS', [220], 'starttls');
const plain = socket; plain.removeAllListeners('data'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
const secure = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', (e) => reject(fail('mail_provider_unavailable', e.message, 'starttls'))); });
attach(secure);
ehlo = await send('EHLO ' + hostname, [250], 'ehlo');
}
if (user) {
const mechanisms = (ehlo.lines.find((l) => /^AUTH\b/i.test(l)) || '').toUpperCase();
if (mechanisms.includes('PLAIN') || !mechanisms.includes('LOGIN')) {
await send('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64'), [235], 'auth');
} else {
await send('AUTH LOGIN', [334], 'auth');
await send(Buffer.from(user, 'utf8').toString('base64'), [334], 'auth');
await send(Buffer.from(pass, 'utf8').toString('base64'), [235], 'auth');
}
}
await send(`MAIL FROM:<${from}>`, [250], 'mail');
const accepted = [], rejected = [];
for (const rcpt of to) {
socket.write(`RCPT TO:<${rcpt}>` + CRLF);
const r = await read();
if (r.code === 250 || r.code === 251) accepted.push(rcpt); else rejected.push({ address: rcpt, reply: `${r.code} ${r.lines.join(' ')}` });
}
if (!accepted.length) { const first = rejected[0]; throw fail(smtpErrorCode(Number(first.reply.slice(0, 3)), 'rcpt'), first.reply, 'rcpt'); }
await send('DATA', [354], 'data');
// 본문은 점 스터핑해 보내고 "<CRLF>.<CRLF>" 로 끝냅니다. 원본이 이미 CRLF 로 끝나면 빈 줄이 하나 더 생기지 않게 CRLF 를 덧붙이지 않습니다.
const body = dotStuff(eml); const endsCrlf = body.length >= 2 && body[body.length - 2] === 13 && body[body.length - 1] === 10;
socket.write(body); socket.write((endsCrlf ? '' : CRLF) + '.' + CRLF);
const final = await read();
if (final.code !== 250) throw fail(smtpErrorCode(final.code, 'data'), `${final.code} ${final.lines.join(' ')}`, 'data');
try { socket.write('QUIT' + CRLF); } catch { /* 응답은 기다리지 않습니다 */ }
return { response: final.lines.join(' '), accepted, rejected };
} finally {
if (socket) { waiter = null; socket.removeAllListeners('close'); socket.end(); setTimeout(() => socket.destroy(), 2000).unref(); }
}
}
/**
* 연결 확인(화면의 "연결 확인" 단추·상태 표시): 접속해 EHLO 까지만 주고받고 끊습니다. 로그인 정보가 있으면 AUTH 도 시험합니다.
* @returns {Promise<{ok:boolean, error?:string, detail?:string, features?:string[]}>}
*/
export async function smtpProbe({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', timeoutMs = 10000, rejectUnauthorized = true }) {
if (!host || !port) return { ok: false, error: 'mail_not_configured' };
// smtpSend 와 같은 절차를 쓰기 위해 받는 사람 없이 호출하면 RCPT 단계에서 끝나므로, 여기서는 간단히 접속·EHLO(·AUTH)만 따로 수행합니다.
let socket = null;
const fail = (code, detail) => ({ ok: false, error: code, detail: String(detail || '').replace(/[\r\n]+/g, ' ').slice(0, 300) });
try {
let buffer = '';
const read = (s) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
const onData = (c) => { buffer += c.toString('utf8'); const lines = buffer.split(CRLF); const end = lines.findIndex((l, i) => i < lines.length - 1 && /^\d{3}(?: |$)/.test(l)); if (end < 0) return; const reply = lines.slice(0, end + 1); buffer = lines.slice(end + 1).join(CRLF); clearTimeout(timer); s.off('data', onData); resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)) }); };
s.on('data', onData); s.once('error', (e) => { clearTimeout(timer); reject(e); });
});
socket = await new Promise((resolve, reject) => { const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s)); s.once('error', reject); s.setTimeout(timeoutMs, () => reject(new Error('connect timeout'))); });
const greeting = await read(socket); if (greeting.code !== 220) return fail('mail_provider_unavailable', greeting.lines.join(' '));
socket.write('EHLO ' + hostname + CRLF); let ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' '));
if (security === 'starttls') {
if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) return fail('mail_provider_unavailable', 'server does not offer STARTTLS');
socket.write('STARTTLS' + CRLF); const r = await read(socket); if (r.code !== 220) return fail('mail_provider_unavailable', r.lines.join(' '));
const plain = socket; plain.removeAllListeners('data');
socket = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', reject); });
socket.write('EHLO ' + hostname + CRLF); ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' '));
}
if (user) {
socket.write('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64') + CRLF);
const r = await read(socket); if (r.code !== 235) return fail(smtpErrorCode(r.code, 'auth'), r.lines.join(' '));
}
socket.write('QUIT' + CRLF);
return { ok: true, features: ehlo.lines.slice(1) };
} catch (e) { return fail('mail_provider_unavailable', e.message); }
finally { if (socket) { socket.end(); setTimeout(() => socket.destroy(), 1000).unref(); } }
}