Files
wpywmail-client/server/smtp.js
T

259 lines
8.6 KiB
JavaScript

'use strict';
/**
* 零依赖 SMTP 提交客户端(587 + STARTTLS + AUTH PLAIN)。
*
* 与服务器端实现相同的关键纪律:
* **DATA 段必须按字节写出,且除 dot-stuffing 外不得改动任何字节。**
* 中文乱码的历史根因就是这里用了 ASCII 编码写 DATA —— 本实现只做:
* ① 行尾统一成 CRLF;② 行首的点做 dot-stuffing;③ 结尾补 CRLF + "." + CRLF。
* 绝不碰正文的其它字节,也绝不重新编码。
*/
const tls = require('node:tls');
const net = require('node:net');
const { EventEmitter } = require('node:events');
class SmtpError extends Error {
constructor(message, info = {}) {
super(message);
this.name = 'SmtpError';
this.code = info.code || null;
this.stage = info.stage || null;
this.detail = info.detail || '';
}
}
/** 行尾统一为 CRLF(不动其它字节,UTF-8 原样保留) */
function normalizeCrlf(buffer) {
const out = Buffer.alloc(buffer.length + 16);
let n = 0;
let i = 0;
while (i < buffer.length) {
const c = buffer[i];
if (c === 0x0d) {
out[n++] = 0x0d; out[n++] = 0x0a;
i += (i + 1 < buffer.length && buffer[i + 1] === 0x0a) ? 2 : 1;
continue;
}
if (c === 0x0a) { out[n++] = 0x0d; out[n++] = 0x0a; i++; continue; }
out[n++] = c; i++;
}
let bytes = out.subarray(0, n);
if (!(bytes.length >= 2 && bytes[bytes.length - 2] === 0x0d && bytes[bytes.length - 1] === 0x0a)) {
bytes = Buffer.concat([bytes, Buffer.from('\r\n')]);
}
return bytes;
}
/** dot-stuffing:仅当行首是 "." 时补一个点 */
function dotStuff(normalized) {
const parts = [Buffer.from('')];
let start = 0;
for (let i = 0; i < normalized.length; i++) {
const atLineStart = i === 0 || normalized[i - 1] === 0x0a;
if (atLineStart && normalized[i] === 0x2e) {
parts.push(normalized.subarray(start, i));
parts.push(Buffer.from('.'));
start = i;
}
}
parts.push(normalized.subarray(start));
return Buffer.concat(parts);
}
/** 生成 DATA 段要写出的完整字节 */
function encodeData(raw) {
const normalized = normalizeCrlf(raw);
const stuffed = dotStuff(normalized);
return Buffer.concat([stuffed, Buffer.from('.\r\n')]);
}
class SmtpClient extends EventEmitter {
/**
* @param {object} opts
* @param {string} opts.host
* @param {number} [opts.port=587]
* @param {string} opts.user
* @param {string} opts.password
* @param {boolean} [opts.startTls=true]
* @param {string} [opts.heloName] 默认取本机主机名
* @param {number} [opts.timeoutMs=30000]
* @param {boolean} [opts.debug=false]
*/
constructor(opts) {
super();
this.opts = Object.assign({ port: 587, startTls: true, timeoutMs: 30000, debug: false }, opts);
this.socket = null;
this.buffer = '';
this.waiters = [];
this.capabilities = [];
this.on('error', () => {});
}
_log(dir, text) {
if (!this.opts.debug) return;
process.stderr.write(`[smtp ${dir}] ${String(text).replace(/\r?\n/g, '\\n').slice(0, 300)}\n`);
}
async connect() {
const { host, port, timeoutMs } = this.opts;
this.socket = await new Promise((resolve, reject) => {
const sock = net.connect({ host, port }, () => resolve(sock));
sock.setTimeout(timeoutMs, () => reject(new SmtpError(`连接 ${host}:${port} 超时`)));
sock.once('error', (err) => reject(new SmtpError(`连接 ${host}:${port} 失败:${err.message}`)));
});
this.socket.setTimeout(0);
this._attach(this.socket);
const greeting = await this.readResponse();
if (greeting.code !== 220) throw new SmtpError(`问候语异常:${greeting.text}`, { code: greeting.code });
return greeting;
}
_attach(sock) {
sock.setNoDelay(true);
sock.on('data', (chunk) => this._onData(chunk));
sock.on('error', (err) => this._failAll(err));
sock.on('close', () => { this.closed = true; this._failAll(new SmtpError('连接已关闭', { stage: 'close' })); });
}
_onData(chunk) {
this.buffer += chunk.toString('latin1');
this._drain();
}
_drain() {
for (;;) {
// 找到「最后一行」:形如 "250 xxx\r\n"
const idx = this.buffer.indexOf('\r\n');
if (idx < 0) return;
const line = this.buffer.slice(0, idx);
this.buffer = this.buffer.slice(idx + 2);
if (!this.curLines) this.curLines = [];
this.curLines.push(line);
this._log('<', line);
if (/^\d{3} /.test(line) || !/^\d{3}-/.test(line)) {
const lines = this.curLines;
this.curLines = null;
const code = Number(lines[0].slice(0, 3));
const w = this.waiters.shift();
const resp = { code, lines, text: lines.map((l) => l.slice(4)).join('\n') };
if (w) w.resolve(resp);
else this.emit('unsolicited', resp);
}
}
}
_failAll(err) {
const ws = this.waiters;
this.waiters = [];
for (const w of ws) { clearTimeout(w.timer); w.reject(err); }
}
readResponse(timeoutMs = this.opts.timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const i = this.waiters.findIndex((w) => w.resolve === resolve);
if (i >= 0) this.waiters.splice(i, 1);
reject(new SmtpError('等待服务器应答超时', { stage: 'read' }));
}, timeoutMs);
this.waiters.push({ resolve, reject, timer });
});
}
async command(text, expectCodes, stage) {
this._log('>', text);
this.socket.write(text + '\r\n');
const resp = await this.readResponse();
if (expectCodes && !expectCodes.includes(resp.code)) {
throw new SmtpError(`${stage || text.split(' ')[0]} 失败:${resp.code} ${resp.text}`,
{ code: resp.code, stage: stage || text.split(' ')[0], detail: resp.text });
}
return resp;
}
_parseEhlo(resp) {
const caps = [];
for (const line of resp.lines.slice(1)) {
const t = line.slice(4).trim();
if (t) caps.push(t);
}
this.capabilities = caps;
return caps;
}
/** EHLO → STARTTLS → EHLO → AUTH PLAIN */
async hello() {
const hostname = this.opts.heloName || require('node:os').hostname();
let resp = await this.command(`EHLO ${hostname}`, [250], 'EHLO');
this._parseEhlo(resp);
if (this.opts.startTls) {
const hasStartTls = this.capabilities.some((c) => /^STARTTLS/i.test(c));
if (!hasStartTls) throw new SmtpError('服务器未广告 STARTTLS', { stage: 'STARTTLS' });
await this.command('STARTTLS', [220], 'STARTTLS');
await this._upgrade();
resp = await this.command(`EHLO ${hostname}`, [250], 'EHLO');
this._parseEhlo(resp);
}
return this.capabilities;
}
_upgrade() {
const { host } = this.opts;
return new Promise((resolve, reject) => {
const secured = tls.connect({ socket: this.socket, servername: host }, () => {
this.socket = secured;
this.buffer = '';
this.curLines = null;
secured.removeAllListeners('data');
secured.removeAllListeners('error');
secured.removeAllListeners('close');
this._attach(secured);
resolve();
});
secured.once('error', reject);
});
}
async auth() {
const { user, password } = this.opts;
if (!user) return false;
const token = Buffer.from(`\u0000${user}\u0000${password}`, 'utf8').toString('base64');
const resp = await this.command(`AUTH PLAIN ${token}`, [235], 'AUTH');
return resp.code === 235;
}
/**
* 发一封信。
* @param {string} from 信封发件人(通常与 From 头一致)
* @param {string[]} to 收件人(含抄送/密送,密送不进头部)
* @param {Buffer} rawMessage 完整 MIME 报文(UTF-8 字节)
*/
async sendMail(from, to, rawMessage) {
await this.command(`MAIL FROM:<${from}>`, [250, 251], 'MAIL FROM');
for (const rcpt of to) {
await this.command(`RCPT TO:<${rcpt}>`, [250, 251], `RCPT TO ${rcpt}`);
}
await this.command('DATA', [354], 'DATA');
const payload = encodeData(rawMessage);
this._log('>', `<DATA ${payload.length} 字节>`);
this.socket.write(payload);
const resp = await this.readResponse(120000);
this._log('<', `${resp.code} ${resp.text}`);
if (resp.code !== 250) {
throw new SmtpError(`邮件正文被拒:${resp.code} ${resp.text}`, { code: resp.code, stage: 'DATA', detail: resp.text });
}
return resp;
}
async quit() {
try {
if (this.socket && !this.socket.destroyed) await this.command('QUIT', [221], 'QUIT');
} catch { /* 忽略 */ }
try { this.socket?.destroy(); } catch { /* 忽略 */ }
}
}
module.exports = { SmtpClient, SmtpError, encodeData, normalizeCrlf, dotStuff };