qq-bot-roost 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/package.json +3 -3
- package/src/config.js +10 -0
- package/src/identity.js +3 -2
- package/src/mail/bind-commands.js +117 -0
- package/src/mail/defaults.js +25 -0
- package/src/mail/service.js +275 -0
- package/src/mail/smtp.js +606 -0
- package/src/mail/verify.js +156 -0
- package/src/main.js +50 -4
- package/src/plugin/context.js +8 -0
- package/src/web/qq-call.js +6 -1
- package/src/web/server.js +84 -0
- package/web/app.js +73 -0
- package/web/index.html +59 -1
package/src/mail/smtp.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 零依赖 SMTP 客户端 —— 只用 node:net / node:tls / node:crypto。
|
|
3
|
+
*
|
|
4
|
+
* ## 来源与为什么这样写
|
|
5
|
+
*
|
|
6
|
+
* 1. 协议状态机(应答解析、STARTTLS 升级、MIME、dot-stuffing、超时)参考
|
|
7
|
+
* 本工作区 `douluo/snowluma/index.mjs` 的 `src/notifications/smtp.ts` 实现,
|
|
8
|
+
* 以及 `ersinkoc/mailer`(MIT,github.com/ersinkoc/mailer)的能力清单。
|
|
9
|
+
* 2. **「怎么适配任意邮箱服务商」的配置与加密决策,参考 BetterGI**
|
|
10
|
+
* (babalae/better-genshin-impact,GPL-3.0):
|
|
11
|
+
* - `Service/Notifier/EmailNotifier.cs` → `GetSecureSocketOptions()` /
|
|
12
|
+
* `IsKnownServerWithSpecialRequirements()`:465 = 隐式 TLS、587 = STARTTLS、
|
|
13
|
+
* 25 = 明文、其它端口自动协商;**网易系 163/126/yeah 的 587 实际是隐式 TLS**,
|
|
14
|
+
* 必须特判(用户截图里 smtp.163.com:587 能发信就是靠这条)。
|
|
15
|
+
* - `Service/Notification/NotificationConfig.cs` → 字段集合。
|
|
16
|
+
* - `Service/Notifier/EmailNotifier.cs` → 用户名为空就不做认证(内网中继)。
|
|
17
|
+
* 这里只借鉴「规则与字段设计」,未复制其 C# / MailKit 代码;框架仍保持零依赖。
|
|
18
|
+
* 3. 协议规范:RFC 5321(SMTP)、3207(STARTTLS)、4954(AUTH)、2047(头部编码)。
|
|
19
|
+
*
|
|
20
|
+
* ## 边界
|
|
21
|
+
* 本模块只做协议收发:不读配置、不落盘、不做限频、不认识 QQ 号。
|
|
22
|
+
* 配置、目标白名单、限频在 `service.js`;面板与命令在各自模块。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import net from 'node:net';
|
|
26
|
+
import tls from 'node:tls';
|
|
27
|
+
import crypto from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
const CRLF = '\r\n';
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
31
|
+
/** 服务端单次应答的缓冲区上限,防恶意/异常服务器无限灌数据 */
|
|
32
|
+
const MAX_REPLY_BYTES = 512 * 1024;
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
/* 错误 */
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
|
|
38
|
+
export class SmtpError extends Error {
|
|
39
|
+
constructor(message, { code = 'ESMTP', responseCode = 0, stage = '', cause = null } = {}) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'SmtpError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.responseCode = Number(responseCode) || 0;
|
|
44
|
+
this.stage = stage;
|
|
45
|
+
if (cause) this.cause = cause;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 把底层异常翻成「用户能看懂、能照着改」的中文 */
|
|
50
|
+
export function formatSmtpError(err, host = '', port = 0) {
|
|
51
|
+
const where = `${host || 'SMTP'}${port ? `:${port}` : ''}`;
|
|
52
|
+
const errno = String(err?.cause?.code || err?.code || '');
|
|
53
|
+
const rc = Number(err?.responseCode) || 0;
|
|
54
|
+
const msg = String(err?.message || err || '');
|
|
55
|
+
|
|
56
|
+
let code = 'ESMTP';
|
|
57
|
+
let message = `邮件发送失败:${msg}`;
|
|
58
|
+
|
|
59
|
+
if (rc === 535 || rc === 534 || rc === 530 || err?.stage === 'AUTH') {
|
|
60
|
+
code = 'EAUTH';
|
|
61
|
+
message = `SMTP 认证失败(${rc || '认证被拒'}):检查用户名是否是完整邮箱地址、密码是否用授权码而不是登录密码,以及邮箱是否开启了 SMTP 服务。`;
|
|
62
|
+
} else if (/self-signed|unable to verify|certificate|CERT_/i.test(msg) || /^ECERT/.test(errno)) {
|
|
63
|
+
code = 'ECERT';
|
|
64
|
+
message = `SMTP 证书校验失败:${where}。自签名证书的内网服务器可把 smtp.rejectUnauthorized 设为 false。`;
|
|
65
|
+
} else if (['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH'].includes(errno)) {
|
|
66
|
+
code = errno;
|
|
67
|
+
message = `连不上 SMTP 服务器 ${where}(${errno}):检查服务器地址、端口和防火墙;端口 25 常被运营商封禁,改用 465 或 587。`;
|
|
68
|
+
} else if (rc === 550 || rc === 551 || rc === 552 || rc === 553 || err?.stage === 'RCPT') {
|
|
69
|
+
code = 'ERCPT';
|
|
70
|
+
message = `收件人被服务器拒绝(${rc}):检查收件邮箱是否存在、是否被判为垃圾邮件。`;
|
|
71
|
+
} else if (err?.stage === 'STARTTLS') {
|
|
72
|
+
code = 'ESTARTTLS';
|
|
73
|
+
message = `STARTTLS 协商失败:${msg}`;
|
|
74
|
+
} else if (rc === 421 || rc === 451 || rc === 452) {
|
|
75
|
+
code = 'ETEMPFAIL';
|
|
76
|
+
message = `服务器临时拒收(${rc}):${msg || '稍后再试'}`;
|
|
77
|
+
} else if (err?.stage === 'GREETING' || err?.stage === 'EHLO') {
|
|
78
|
+
code = 'EGREETING';
|
|
79
|
+
message = `SMTP 握手失败:${msg}。若这个端口实际要求 SSL,可把 smtp.security 设为 'tls' 再试。`;
|
|
80
|
+
}
|
|
81
|
+
return { code, message };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------------------ */
|
|
85
|
+
/* 地址与 MIME */
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
|
|
88
|
+
/** 宽松校验:user@localhost 这类内网地址也算合法 */
|
|
89
|
+
export function extractAddress(value) {
|
|
90
|
+
const s = String(value ?? '').trim();
|
|
91
|
+
if (!s) return null;
|
|
92
|
+
const m = /<([^<>]+)>/.exec(s);
|
|
93
|
+
const addr = (m ? m[1] : s).trim();
|
|
94
|
+
return /^[^\s<>@]+@[^\s<>@]+$/.test(addr) ? addr : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** string / array 都收,支持逗号、分号、换行分隔,去重 */
|
|
98
|
+
export function parseRecipients(value) {
|
|
99
|
+
const list = Array.isArray(value) ? value : String(value ?? '').split(/[,;\n]+/);
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const part of list) {
|
|
103
|
+
const addr = extractAddress(part);
|
|
104
|
+
if (!addr) continue;
|
|
105
|
+
const key = addr.toLowerCase();
|
|
106
|
+
if (seen.has(key)) continue;
|
|
107
|
+
seen.add(key);
|
|
108
|
+
out.push(addr);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 头部一律去掉换行,防 CRLF 注入 */
|
|
114
|
+
function cleanHeader(v) {
|
|
115
|
+
return String(v ?? '').replace(/[\r\n]+/g, ' ').trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* RFC 2047 encoded-word。中文主题必须编码,且按 base64 的 4 字节边界切块,
|
|
120
|
+
* 保证每一行都不超过 76 列、又不会把多字节字符劈成两半。
|
|
121
|
+
*/
|
|
122
|
+
function encodeWord(value) {
|
|
123
|
+
const prefix = '=?UTF-8?B?';
|
|
124
|
+
const suffix = '?=';
|
|
125
|
+
const b64 = Buffer.from(value, 'utf8').toString('base64');
|
|
126
|
+
const chunk = Math.max(4, Math.floor((75 - prefix.length - suffix.length) / 4) * 4);
|
|
127
|
+
const words = [];
|
|
128
|
+
for (let i = 0; i < b64.length; i += chunk) {
|
|
129
|
+
words.push(`${prefix}${b64.slice(i, i + chunk)}${suffix}`);
|
|
130
|
+
}
|
|
131
|
+
return words.join(`${CRLF} `) || prefix + suffix;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function encodeHeader(value) {
|
|
135
|
+
const v = cleanHeader(value);
|
|
136
|
+
return /^[\x20-\x7E]*$/.test(v) ? v : encodeWord(v);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatAddress(addr, name = '') {
|
|
140
|
+
const a = extractAddress(addr);
|
|
141
|
+
if (!a) throw new SmtpError(`邮箱地址不合法:${addr}`, { code: 'EADDRESS' });
|
|
142
|
+
const n = cleanHeader(name);
|
|
143
|
+
return n ? `${encodeHeader(n)} <${a}>` : `<${a}>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function rfc5322Date(d) {
|
|
147
|
+
return d.toUTCString().replace(/GMT$/, '+0000');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function wrap76(b64) {
|
|
151
|
+
return b64.replace(/.{1,76}/g, (line) => `${line}${CRLF}`).trimEnd();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** DATA 阶段以点开头的行必须多点一个点 */
|
|
155
|
+
function dotStuff(payload) {
|
|
156
|
+
return payload.replace(/^\./gm, '..');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 生成单部分 MIME 文本。正文统一 base64(最兼容,不依赖 8BITMIME)。
|
|
161
|
+
* @param {{from:string, fromName?:string, to:string[], cc?:string[],
|
|
162
|
+
* subject?:string, text?:string, html?:boolean, replyTo?:string}} mail
|
|
163
|
+
*/
|
|
164
|
+
export function buildMimeMessage(mail, now = new Date()) {
|
|
165
|
+
const to = parseRecipients(mail.to);
|
|
166
|
+
const cc = parseRecipients(mail.cc);
|
|
167
|
+
const fromAddr = extractAddress(mail.from);
|
|
168
|
+
if (!fromAddr) throw new SmtpError('发件人邮箱不合法', { code: 'EADDRESS' });
|
|
169
|
+
if (!to.length && !cc.length) throw new SmtpError('没有收件人', { code: 'EADDRESS' });
|
|
170
|
+
|
|
171
|
+
const domain = fromAddr.split('@')[1] || 'localhost';
|
|
172
|
+
const isHtml = mail.html === true;
|
|
173
|
+
const headers = [
|
|
174
|
+
`From: ${formatAddress(fromAddr, mail.fromName)}`,
|
|
175
|
+
`To: ${to.map((a) => `<${a}>`).join(', ')}`,
|
|
176
|
+
...(cc.length ? [`Cc: ${cc.map((a) => `<${a}>`).join(', ')}`] : []),
|
|
177
|
+
`Subject: ${encodeHeader(mail.subject ?? '')}`,
|
|
178
|
+
`Date: ${rfc5322Date(now)}`,
|
|
179
|
+
`Message-ID: <${now.getTime()}.${crypto.randomBytes(8).toString('hex')}@${domain}>`,
|
|
180
|
+
'MIME-Version: 1.0',
|
|
181
|
+
`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=UTF-8`,
|
|
182
|
+
'Content-Transfer-Encoding: base64',
|
|
183
|
+
];
|
|
184
|
+
const replyTo = extractAddress(mail.replyTo);
|
|
185
|
+
if (replyTo) headers.push(`Reply-To: <${replyTo}>`);
|
|
186
|
+
|
|
187
|
+
const body = wrap76(Buffer.from(String(mail.text ?? ''), 'utf8').toString('base64'));
|
|
188
|
+
return `${headers.join(CRLF)}${CRLF}${CRLF}${body}${CRLF}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
/* 加密方式决策(BetterGI 规则) */
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 把用户配置 + host/port 解析成实际连接方式。
|
|
197
|
+
*
|
|
198
|
+
* 返回:
|
|
199
|
+
* 'tls' 隐式 TLS(SslOnConnect),建连即 TLS
|
|
200
|
+
* 'starttls' 明文建连 → EHLO → 服务器支持就升级(不支持则明文继续)
|
|
201
|
+
* 'auto' 同 starttls,但完全按 EHLO 能力协商(其它端口)
|
|
202
|
+
* 'none' 明文,不升级
|
|
203
|
+
*
|
|
204
|
+
* 对齐 BetterGI `EmailNotifier.GetSecureSocketOptions()`:
|
|
205
|
+
* 465 → SslOnConnect / 587 → StartTls / 25 → None / 其它 → Auto,
|
|
206
|
+
* 以及网易系 163/126/yeah 的 587 是隐式 TLS 这条特例。
|
|
207
|
+
* 差别:这里 587 用「可用则升级」而不是「必须升级」,避免某些服务商
|
|
208
|
+
* 587 只提供明文时直接失败;并额外做一次隐式 TLS 回退(见 sendSmtpMail)。
|
|
209
|
+
*/
|
|
210
|
+
export function resolveSecurity(host, port, security = 'auto') {
|
|
211
|
+
const explicit = String(security || 'auto').trim().toLowerCase();
|
|
212
|
+
if (['tls', 'ssl', 'ssl/tls', 'sslonconnect'].includes(explicit)) return 'tls';
|
|
213
|
+
if (['starttls', 'starttlswhenavailable'].includes(explicit)) return 'starttls';
|
|
214
|
+
if (['none', 'plain', 'off'].includes(explicit)) return 'none';
|
|
215
|
+
|
|
216
|
+
const h = String(host || '').toLowerCase();
|
|
217
|
+
const p = Number(port) || 587;
|
|
218
|
+
// 网易邮箱 587 端口实际是 SSL-on-connect(BetterGI 的已知特例表)
|
|
219
|
+
if (p === 587 && /(?:^|\.)(?:163|126)\.com$|yeah\.net$|(?:^|\.)yeah\.com$/.test(h)) return 'tls';
|
|
220
|
+
if (p === 465) return 'tls';
|
|
221
|
+
if (p === 587) return 'starttls';
|
|
222
|
+
if (p === 25) return 'none';
|
|
223
|
+
return 'auto';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ------------------------------------------------------------------ */
|
|
227
|
+
/* 应答解析与会话 */
|
|
228
|
+
/* ------------------------------------------------------------------ */
|
|
229
|
+
|
|
230
|
+
/** 从缓冲区取出一条完整的多行应答(末行第 4 字符是空格) */
|
|
231
|
+
function consumeReply(buf) {
|
|
232
|
+
let offset = 0;
|
|
233
|
+
const lines = [];
|
|
234
|
+
let code = 0;
|
|
235
|
+
while (offset < buf.length) {
|
|
236
|
+
const nl = buf.indexOf('\n', offset);
|
|
237
|
+
if (nl === -1) return null;
|
|
238
|
+
const line = buf.slice(offset, nl).replace(/\r$/, '');
|
|
239
|
+
offset = nl + 1;
|
|
240
|
+
const m = /^(\d{3})([ -])(.*)$/.exec(line);
|
|
241
|
+
if (!m) continue;
|
|
242
|
+
code = Number(m[1]);
|
|
243
|
+
lines.push(m[3]);
|
|
244
|
+
if (m[2] === ' ') {
|
|
245
|
+
return { reply: { code, lines, text: lines.join('\n') }, rest: buf.slice(offset) };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseCapabilities(reply) {
|
|
252
|
+
const auth = new Set();
|
|
253
|
+
let starttls = false;
|
|
254
|
+
let size = 0;
|
|
255
|
+
for (const line of reply?.lines ?? []) {
|
|
256
|
+
const u = String(line).toUpperCase().trim();
|
|
257
|
+
if (u === 'STARTTLS' || u.startsWith('STARTTLS ')) starttls = true;
|
|
258
|
+
if (u.startsWith('AUTH ')) {
|
|
259
|
+
for (const mech of u.slice(5).split(/\s+/)) if (mech) auth.add(mech);
|
|
260
|
+
} else if (u === 'AUTH') {
|
|
261
|
+
// 有些服务器把机制分行列在 AUTH 之后
|
|
262
|
+
auth.add('__BARE__');
|
|
263
|
+
}
|
|
264
|
+
const sz = /^SIZE\s+(\d+)/.exec(u);
|
|
265
|
+
if (sz) size = Number(sz[1]);
|
|
266
|
+
}
|
|
267
|
+
return { starttls, auth, size };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function connectSocket(host, port, secure, timeoutMs, tlsOptions = {}) {
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
const common = {
|
|
273
|
+
host,
|
|
274
|
+
port,
|
|
275
|
+
servername: net.isIP(host) === 0 ? host : undefined,
|
|
276
|
+
};
|
|
277
|
+
const opt = secure
|
|
278
|
+
? {
|
|
279
|
+
...common,
|
|
280
|
+
rejectUnauthorized: tlsOptions.rejectUnauthorized !== false,
|
|
281
|
+
...(tlsOptions.minVersion ? { minVersion: tlsOptions.minVersion } : {}),
|
|
282
|
+
}
|
|
283
|
+
: common;
|
|
284
|
+
const sock = secure ? tls.connect(opt) : net.connect(opt);
|
|
285
|
+
const fail = (err) => {
|
|
286
|
+
sock.destroy();
|
|
287
|
+
reject(new SmtpError(`连接 ${host}:${port} 失败:${err.message}`, {
|
|
288
|
+
code: 'ECONNECT', cause: err, stage: 'CONNECT',
|
|
289
|
+
}));
|
|
290
|
+
};
|
|
291
|
+
sock.setTimeout(timeoutMs);
|
|
292
|
+
sock.once('timeout', () => fail(Object.assign(new Error('连接超时'), { code: 'ETIMEDOUT' })));
|
|
293
|
+
sock.once('error', fail);
|
|
294
|
+
sock.once(secure ? 'secureConnect' : 'connect', () => {
|
|
295
|
+
sock.removeAllListeners('timeout');
|
|
296
|
+
sock.removeAllListeners('error');
|
|
297
|
+
resolve(sock);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function upgradeTls(socket, host, timeoutMs, tlsOptions = {}) {
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
const up = tls.connect({
|
|
305
|
+
socket,
|
|
306
|
+
servername: net.isIP(host) === 0 ? host : undefined,
|
|
307
|
+
rejectUnauthorized: tlsOptions.rejectUnauthorized !== false,
|
|
308
|
+
...(tlsOptions.minVersion ? { minVersion: tlsOptions.minVersion } : {}),
|
|
309
|
+
}, () => resolve(up));
|
|
310
|
+
up.setTimeout(timeoutMs);
|
|
311
|
+
up.once('timeout', () => {
|
|
312
|
+
up.destroy();
|
|
313
|
+
reject(new SmtpError('STARTTLS 握手超时', { code: 'ETIMEDOUT', stage: 'STARTTLS' }));
|
|
314
|
+
});
|
|
315
|
+
up.once('error', (err) => reject(new SmtpError(`STARTTLS 失败:${err.message}`, {
|
|
316
|
+
code: 'ESTARTTLS', stage: 'STARTTLS', cause: err,
|
|
317
|
+
})));
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
class SmtpSession {
|
|
322
|
+
constructor(socket) {
|
|
323
|
+
this.socket = socket;
|
|
324
|
+
this.buf = '';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
destroy() {
|
|
328
|
+
try { this.socket.destroy(); } catch { /* 已断开 */ }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
setTimeout(ms, onTimeout) {
|
|
332
|
+
this.socket.setTimeout(ms);
|
|
333
|
+
this.socket.once('timeout', onTimeout);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async read() {
|
|
337
|
+
const ready = consumeReply(this.buf);
|
|
338
|
+
if (ready) {
|
|
339
|
+
this.buf = ready.rest;
|
|
340
|
+
return ready.reply;
|
|
341
|
+
}
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
const cleanup = () => {
|
|
344
|
+
this.socket.removeListener('data', onData);
|
|
345
|
+
this.socket.removeListener('error', onError);
|
|
346
|
+
this.socket.removeListener('close', onClose);
|
|
347
|
+
};
|
|
348
|
+
const onData = (chunk) => {
|
|
349
|
+
this.buf += chunk.toString('utf8');
|
|
350
|
+
if (this.buf.length > MAX_REPLY_BYTES) {
|
|
351
|
+
cleanup();
|
|
352
|
+
reject(new SmtpError('SMTP 应答过长', { code: 'EREPLY' }));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const got = consumeReply(this.buf);
|
|
356
|
+
if (!got) return;
|
|
357
|
+
this.buf = got.rest;
|
|
358
|
+
cleanup();
|
|
359
|
+
resolve(got.reply);
|
|
360
|
+
};
|
|
361
|
+
const onError = (err) => {
|
|
362
|
+
cleanup();
|
|
363
|
+
reject(new SmtpError(`SMTP 连接错误:${err.message}`, { code: 'ESOCKET', cause: err }));
|
|
364
|
+
};
|
|
365
|
+
const onClose = () => {
|
|
366
|
+
cleanup();
|
|
367
|
+
reject(new SmtpError('SMTP 连接被关闭', { code: 'ECLOSED' }));
|
|
368
|
+
};
|
|
369
|
+
this.socket.on('data', onData);
|
|
370
|
+
this.socket.once('error', onError);
|
|
371
|
+
this.socket.once('close', onClose);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
write(data) {
|
|
376
|
+
this.socket.write(data, 'utf8');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async command(line, ok, stage = '') {
|
|
380
|
+
this.write(`${line}${CRLF}`);
|
|
381
|
+
const reply = await this.read();
|
|
382
|
+
if (!ok(reply.code)) {
|
|
383
|
+
throw new SmtpError(`${stage || line.split(' ')[0] || 'SMTP'} 失败:${reply.code} ${reply.text}`.trim(), {
|
|
384
|
+
code: 'EREPLY', responseCode: reply.code, stage,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return reply;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
replaceSocket(next) {
|
|
391
|
+
this.socket = next;
|
|
392
|
+
this.buf = '';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ------------------------------------------------------------------ */
|
|
397
|
+
/* 认证 */
|
|
398
|
+
/* ------------------------------------------------------------------ */
|
|
399
|
+
|
|
400
|
+
async function plainAuth(session, user, pass) {
|
|
401
|
+
const token = Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64');
|
|
402
|
+
await session.command(`AUTH PLAIN ${token}`, (c) => c === 235, 'AUTH');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function loginAuth(session, user, pass) {
|
|
406
|
+
await session.command('AUTH LOGIN', (c) => c === 334, 'AUTH');
|
|
407
|
+
await session.command(Buffer.from(user, 'utf8').toString('base64'), (c) => c === 334, 'AUTH');
|
|
408
|
+
await session.command(Buffer.from(pass, 'utf8').toString('base64'), (c) => c === 235, 'AUTH');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function cramAuth(session, user, pass) {
|
|
412
|
+
const r = await session.command('AUTH CRAM-MD5', (c) => c === 334, 'AUTH');
|
|
413
|
+
const challenge = Buffer.from(r.lines.join(''), 'base64').toString('utf8');
|
|
414
|
+
const digest = crypto.createHmac('md5', pass).update(challenge).digest('hex');
|
|
415
|
+
const answer = Buffer.from(`${user} ${digest}`, 'utf8').toString('base64');
|
|
416
|
+
await session.command(answer, (c) => c === 235, 'AUTH');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function xoauth2Auth(session, user, token) {
|
|
420
|
+
const payload = `user=${user}\x01auth=Bearer ${token}\x01\x01`;
|
|
421
|
+
try {
|
|
422
|
+
await session.command(`AUTH XOAUTH2 ${Buffer.from(payload, 'utf8').toString('base64')}`,
|
|
423
|
+
(c) => c === 235, 'AUTH');
|
|
424
|
+
} catch (e) {
|
|
425
|
+
// OAuth2 失败时服务器会先回 334 带错误说明,必须吃掉再发空行结束挑战
|
|
426
|
+
await session.command('', () => true, 'AUTH').catch(() => {});
|
|
427
|
+
throw e;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 按服务器 EHLO 宣告的机制挑一个能用的。
|
|
433
|
+
* 用户名为空 => 完全跳过认证(内网中继 / 免认证 SMTP),对齐 BetterGI。
|
|
434
|
+
*/
|
|
435
|
+
async function authenticate(session, caps, { user, pass, accessToken = '' }) {
|
|
436
|
+
const u = String(user ?? '').trim();
|
|
437
|
+
const p = String(pass ?? '');
|
|
438
|
+
if (!u && !accessToken) return 'none';
|
|
439
|
+
|
|
440
|
+
if (accessToken) {
|
|
441
|
+
await xoauth2Auth(session, u, accessToken);
|
|
442
|
+
return 'XOAUTH2';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const mechs = caps.auth ?? new Set();
|
|
446
|
+
const attempts = [];
|
|
447
|
+
if (mechs.has('PLAIN')) attempts.push(['PLAIN', plainAuth]);
|
|
448
|
+
if (mechs.has('LOGIN')) attempts.push(['LOGIN', loginAuth]);
|
|
449
|
+
if (mechs.has('CRAM-MD5')) attempts.push(['CRAM-MD5', cramAuth]);
|
|
450
|
+
// 服务器没宣告任何机制(或 EHLO 降级成 HELO)时盲试,老企业邮箱常见
|
|
451
|
+
if (!attempts.length) attempts.push(['PLAIN', plainAuth], ['LOGIN', loginAuth]);
|
|
452
|
+
|
|
453
|
+
let last = null;
|
|
454
|
+
for (const [name, fn] of attempts) {
|
|
455
|
+
try {
|
|
456
|
+
await fn(session, u, p);
|
|
457
|
+
return name;
|
|
458
|
+
} catch (e) {
|
|
459
|
+
last = e;
|
|
460
|
+
if (e.responseCode === 535 || e.responseCode === 534) throw e; // 凭据错,别再试别的机制
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
throw new SmtpError(`没有可用的 SMTP 认证方式:${last?.message || '服务器拒绝了认证'}`, {
|
|
464
|
+
code: 'EAUTH', stage: 'AUTH', cause: last,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ------------------------------------------------------------------ */
|
|
469
|
+
/* 发送 */
|
|
470
|
+
/* ------------------------------------------------------------------ */
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @param {object} mail
|
|
474
|
+
* host, port, security, user, pass, accessToken, from, fromName,
|
|
475
|
+
* to (string|string[]), cc, bcc, subject, text, html, replyTo, ehloName
|
|
476
|
+
* @param {object} opts timeoutMs, rejectUnauthorized, minVersion
|
|
477
|
+
* @returns {Promise<{ok:true, messageId:string, accepted:number, security:string, auth:string}>}
|
|
478
|
+
*/
|
|
479
|
+
export async function sendSmtpMail(mail = {}, opts = {}) {
|
|
480
|
+
const host = String(mail.host ?? '').trim();
|
|
481
|
+
const port = Number(mail.port) || 587;
|
|
482
|
+
const timeoutMs = Number(opts.timeoutMs ?? mail.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
483
|
+
const tlsOptions = {
|
|
484
|
+
rejectUnauthorized: opts.rejectUnauthorized ?? mail.rejectUnauthorized,
|
|
485
|
+
minVersion: opts.minVersion ?? mail.minVersion,
|
|
486
|
+
};
|
|
487
|
+
const resolved = resolveSecurity(host, port, mail.security ?? opts.security);
|
|
488
|
+
|
|
489
|
+
const to = parseRecipients(mail.to);
|
|
490
|
+
const cc = parseRecipients(mail.cc);
|
|
491
|
+
const bcc = parseRecipients(mail.bcc);
|
|
492
|
+
const envelopeTo = [...to, ...cc, ...bcc];
|
|
493
|
+
const from = String(mail.from ?? mail.user ?? '').trim();
|
|
494
|
+
const mimeMail = { ...mail, from, fromName: mail.fromName ?? '', to, cc };
|
|
495
|
+
const mime = buildMimeMessage(mimeMail);
|
|
496
|
+
const fromAddr = extractAddress(from);
|
|
497
|
+
const ehloName = String(mail.ehloName || '[127.0.0.1]');
|
|
498
|
+
|
|
499
|
+
if (!host) throw new SmtpError('还没配置 SMTP 服务器', { code: 'ECONFIG', stage: 'CONFIG' });
|
|
500
|
+
if (!envelopeTo.length) throw new SmtpError('没有收件人', { code: 'EADDRESS', stage: 'CONFIG' });
|
|
501
|
+
if (opts.maxSize && mime.length > opts.maxSize) {
|
|
502
|
+
throw new SmtpError(`邮件内容超过服务器限制(${opts.maxSize} 字节)`, { code: 'ESIZE' });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 真正跑一遍会话。implicitTls=true 用隐式 TLS;否则明文 + 能力协商 */
|
|
506
|
+
const attempt = async (implicitTls) => {
|
|
507
|
+
let sock = await connectSocket(host, port, implicitTls, timeoutMs, tlsOptions);
|
|
508
|
+
const session = new SmtpSession(sock);
|
|
509
|
+
let done = false;
|
|
510
|
+
const onTimeout = () => {
|
|
511
|
+
if (!done) session.destroy();
|
|
512
|
+
};
|
|
513
|
+
session.setTimeout(timeoutMs, onTimeout);
|
|
514
|
+
try {
|
|
515
|
+
const greeting = await session.read();
|
|
516
|
+
if (greeting.code !== 220) {
|
|
517
|
+
throw new SmtpError(`SMTP 欢迎语异常:${greeting.code} ${greeting.text}`.trim(), {
|
|
518
|
+
code: 'EGREETING', responseCode: greeting.code, stage: 'GREETING',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let caps = { starttls: false, auth: new Set(), size: 0 };
|
|
523
|
+
try {
|
|
524
|
+
caps = parseCapabilities(await session.command(`EHLO ${ehloName}`, (c) => c === 250, 'EHLO'));
|
|
525
|
+
} catch (e) {
|
|
526
|
+
// 只把「服务器不认识 EHLO」当降级信号;连接断了别硬套 HELO
|
|
527
|
+
if (!(e.responseCode >= 500 && e.responseCode < 600)) throw e;
|
|
528
|
+
// 老服务器不认 EHLO,降级 HELO(之后没有能力表,认证靠盲试)
|
|
529
|
+
await session.command(`HELO ${ehloName}`, (c) => c === 250, 'EHLO');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let usedStarttls = false;
|
|
533
|
+
// 'none' 是用户显式要求明文,即使服务器宣告 STARTTLS 也不升级
|
|
534
|
+
if (!implicitTls && caps.starttls && resolved !== 'none') {
|
|
535
|
+
await session.command('STARTTLS', (c) => c === 220, 'STARTTLS');
|
|
536
|
+
const upgraded = await upgradeTls(sock, host, timeoutMs, tlsOptions);
|
|
537
|
+
session.replaceSocket(upgraded);
|
|
538
|
+
session.setTimeout(timeoutMs, onTimeout);
|
|
539
|
+
sock = upgraded;
|
|
540
|
+
caps = parseCapabilities(await session.command(`EHLO ${ehloName}`, (c) => c === 250, 'EHLO'));
|
|
541
|
+
usedStarttls = true;
|
|
542
|
+
}
|
|
543
|
+
// 587 没宣告 STARTTLS 时按明文继续(部分服务商如此),不硬失败
|
|
544
|
+
|
|
545
|
+
if (opts.maxSize == null && caps.size > 0 && mime.length > caps.size) {
|
|
546
|
+
throw new SmtpError(`邮件内容超过服务器 SIZE 限制(${caps.size} 字节)`, {
|
|
547
|
+
code: 'ESIZE', stage: 'MAIL',
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const auth = await authenticate(session, caps, {
|
|
552
|
+
user: mail.user, pass: mail.pass, accessToken: mail.accessToken,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
await session.command(`MAIL FROM:<${fromAddr}>`, (c) => c === 250, 'MAIL');
|
|
556
|
+
const accepted = [];
|
|
557
|
+
for (const rcpt of envelopeTo) {
|
|
558
|
+
try {
|
|
559
|
+
await session.command(`RCPT TO:<${rcpt}>`, (c) => c === 250 || c === 251, 'RCPT');
|
|
560
|
+
accepted.push(rcpt);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
if (envelopeTo.length === 1) throw e;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (!accepted.length) {
|
|
566
|
+
throw new SmtpError('所有收件人都被服务器拒绝', { code: 'ERCPT', stage: 'RCPT' });
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
await session.command('DATA', (c) => c === 354, 'DATA');
|
|
570
|
+
session.write(dotStuff(mime));
|
|
571
|
+
await session.command('.', (c) => c === 250, 'DATA');
|
|
572
|
+
await session.command('QUIT', (c) => c === 221, 'QUIT').catch(() => {});
|
|
573
|
+
|
|
574
|
+
done = true;
|
|
575
|
+
const idMatch = /Message-ID:\s*(<[^>]+>)/.exec(mime);
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
messageId: idMatch?.[1] ?? '',
|
|
579
|
+
accepted: accepted.length,
|
|
580
|
+
security: implicitTls ? 'tls' : (usedStarttls ? 'starttls' : 'none'),
|
|
581
|
+
auth,
|
|
582
|
+
};
|
|
583
|
+
} finally {
|
|
584
|
+
done = true;
|
|
585
|
+
session.destroy();
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const firstSecure = resolved === 'tls';
|
|
590
|
+
try {
|
|
591
|
+
return await attempt(firstSecure);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
/**
|
|
594
|
+
* 自动回退:不少服务商把 587 / 自定义端口做成隐式 TLS,却没写进文档。
|
|
595
|
+
* 明文握手阶段就失败(拿不到 220 / 直接断连 / 超时)时,改用隐式 TLS 再试一次。
|
|
596
|
+
* 认证失败、收件人被拒这类「已经聊上了」的错误绝不重试,避免锁号或重复发信。
|
|
597
|
+
*/
|
|
598
|
+
const errno = String(err?.cause?.code || '');
|
|
599
|
+
const canRetry = !firstSecure
|
|
600
|
+
&& resolved !== 'none'
|
|
601
|
+
&& (['EGREETING', 'ECONNECT', 'ECLOSED', 'ESOCKET'].includes(err?.code)
|
|
602
|
+
|| errno === 'ECONNRESET' || errno === 'ETIMEDOUT');
|
|
603
|
+
if (!canRetry) throw err;
|
|
604
|
+
return await attempt(true);
|
|
605
|
+
}
|
|
606
|
+
}
|