'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('>', ``); 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 };