@flun/mailer 2.1.4
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/CHANGELOG.md +4 -0
- package/LICENSE +15 -0
- package/Mail.js +47 -0
- package/README.md +475 -0
- package/copy-files.js +36 -0
- package/index.d.ts +816 -0
- package/index.js +109 -0
- package/lib/addressparser.js +230 -0
- package/lib/base64.js +103 -0
- package/lib/dkim/index.js +211 -0
- package/lib/dkim/message-parser.js +114 -0
- package/lib/dkim/relaxed-body.js +133 -0
- package/lib/dkim/sign.js +121 -0
- package/lib/fetch/cookies.js +224 -0
- package/lib/fetch/index.js +233 -0
- package/lib/http-proxy-client.js +98 -0
- package/lib/json-transport.js +38 -0
- package/lib/mail-composer.js +316 -0
- package/lib/mailer/index.js +301 -0
- package/lib/mailer/mail-message.js +226 -0
- package/lib/mime-funcs/index.js +493 -0
- package/lib/mime-funcs/mime-types.js +2061 -0
- package/lib/mime-node/index.js +811 -0
- package/lib/mime-node/last-newline.js +44 -0
- package/lib/mime-node/le-unix.js +37 -0
- package/lib/mime-node/le-windows.js +46 -0
- package/lib/punycode.js +283 -0
- package/lib/qp.js +171 -0
- package/lib/regexs.js +124 -0
- package/lib/sendmail-transport.js +91 -0
- package/lib/ses-transport.js +115 -0
- package/lib/shared.js +725 -0
- package/lib/smtp-connection/data-stream.js +68 -0
- package/lib/smtp-connection/index.js +1062 -0
- package/lib/smtp-pool/index.js +294 -0
- package/lib/smtp-pool/pool-resource.js +93 -0
- package/lib/smtp-transport.js +81 -0
- package/lib/stream-transport.js +59 -0
- package/lib/well-known/index.js +54 -0
- package/lib/well-known/services.json +802 -0
- package/lib/xoauth2.js +249 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
import { connect as tlsConnect } from 'tls';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { PassThrough } from 'stream';
|
|
4
|
+
import { randomBytes, createHmac } from 'crypto';
|
|
5
|
+
import { DataStream } from './data-stream.js';
|
|
6
|
+
import {
|
|
7
|
+
PK, osHostname, isIP, netConnect, regexs, resetRegex, getLogger, resolveHostname, callbackPromise, encodeXText, cleanup
|
|
8
|
+
} from '../shared.js';
|
|
9
|
+
|
|
10
|
+
// 默认超时(连接,套接字无活动,等待SMTP问候,DNS解析)时间(毫秒)
|
|
11
|
+
const CONNECTION_TIMEOUT = 2 * 60 * 1000, SOCKET_TIMEOUT = 10 * 60 * 1000,
|
|
12
|
+
GREETING_TIMEOUT = 30 * 1000, DNS_TIMEOUT = 30 * 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 生成SMTP连接对象;
|
|
16
|
+
* >查看定义:@see {@link SmtpConnection}
|
|
17
|
+
* 可选的选项对象可以包含以下属性:
|
|
18
|
+
* - **port** - 连接的端口(默认为587或465)
|
|
19
|
+
* - **host** - 连接的主机名或IP地址(默认为'localhost')
|
|
20
|
+
* - **secure** - 使用SSL
|
|
21
|
+
* - **ignoreTLS** - 忽略服务器对STARTTLS的支持
|
|
22
|
+
* - **requireTLS** - 强制客户端使用STARTTLS
|
|
23
|
+
* - **name** - 客户端服务器名称
|
|
24
|
+
* - **localAddress** - 绑定的出站地址
|
|
25
|
+
* - **greetingTimeout** - 等待服务器问候消息的超时时间(毫秒)
|
|
26
|
+
* - **connectionTimeout** - 等待连接建立的超时时间(毫秒)
|
|
27
|
+
* - **socketTimeout** - 连接关闭前的无活动超时时间(默认为1小时)
|
|
28
|
+
* - **dnsTimeout** - 等待DNS请求解析的超时时间(默认为30秒)
|
|
29
|
+
* - **lmtp** - 如果为true,使用LMTP而不是SMTP协议
|
|
30
|
+
* - **logger** - bunyan兼容的日志接口
|
|
31
|
+
* - **debug** - 如果为true,将SMTP流量传递给日志记录器
|
|
32
|
+
* - **tls** - createCredentials的选项
|
|
33
|
+
* - **socket** - 使用现有套接字而不是创建新套接字
|
|
34
|
+
* - **secured** - 布尔值,指示提供的套接字是否已升级到TLS
|
|
35
|
+
*
|
|
36
|
+
* @constructor
|
|
37
|
+
* @namespace SMTP Client module
|
|
38
|
+
* @param {Object} [options] 选项属性
|
|
39
|
+
*/
|
|
40
|
+
class SmtpConnection extends EventEmitter {
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
super(options);
|
|
43
|
+
|
|
44
|
+
// 生成随机连接ID
|
|
45
|
+
const rUSB = resetRegex(regexs.URL_SAFE_BASE64);
|
|
46
|
+
this.id = randomBytes(8).toString('base64').replace(rUSB, ''); this.stage = 'init'; this.options = options;
|
|
47
|
+
const { secure, secured, port, host = 'localhost', servername, allowInternalNetworkInterfaces = false, name,
|
|
48
|
+
component = 'smtpConnection', customAuth = {} } = this.options;
|
|
49
|
+
this.secureConnection = !!secure, this.alreadySecured = !!secured;
|
|
50
|
+
this.port = Number(port) || (this.secureConnection ? 465 : 587), this.host = host;
|
|
51
|
+
this.servername = servername ? servername : !isIP(this.host) ? this.host : false;
|
|
52
|
+
this.allowInternalNetworkInterfaces = allowInternalNetworkInterfaces;
|
|
53
|
+
|
|
54
|
+
// 如果secure选项未设置但端口是465,则默认为安全连接
|
|
55
|
+
if (typeof secure === 'undefined' && this.port === 465) this.secureConnection = true;
|
|
56
|
+
this.name = name || this.#getHostname();
|
|
57
|
+
this.logger = getLogger(this.options, { component, sid: this.id }); // 设置日志记录器
|
|
58
|
+
|
|
59
|
+
// 初始化自定义认证方法
|
|
60
|
+
this.customAuth = new Map();
|
|
61
|
+
Object.keys(customAuth).forEach((key = '') => {
|
|
62
|
+
const mapKey = key.toString().trim().toUpperCase();
|
|
63
|
+
if (!mapKey) return;
|
|
64
|
+
this.customAuth.set(mapKey, customAuth[key]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.secure = !!this.secureConnection; // 定义当前连接是否安全。如果不安全,如果服务器支持,可以使用STARTTLS
|
|
68
|
+
this.version = PK.version; // 暴露版本号
|
|
69
|
+
this._remainder = ''; // 存储来自服务器的不完整消息
|
|
70
|
+
this._maxAllowedSize = 0; // 定义单个消息允许的最大大小
|
|
71
|
+
this._supportedExtensions = []; // 列出支持的扩展列表
|
|
72
|
+
this._responseActions = []; // 当从服务器接收到数据块时要运行的函数队列
|
|
73
|
+
this._recipientQueue = []; // 这个队列包含要发送的收件人列表
|
|
74
|
+
this._responseQueue = []; // 来自服务器的未处理响应队列
|
|
75
|
+
this._supportedAuth = []; // 列出支持的认证机制列表
|
|
76
|
+
this._socket = false; // 连接到服务器的套接字,如果没有套接字,则为false
|
|
77
|
+
this._closing = false; // 如果套接字正在关闭,则设置为true
|
|
78
|
+
this._envelope = false; // 包含当前信封(发件人,收件人)的对象,如果没有信封,则为false
|
|
79
|
+
this._destroyed = false; // 如果套接字被认为已关闭,则设置为true
|
|
80
|
+
this._greetingTimeout = false; // 等待问候消息的超时变量,如果为true,则表示已超时
|
|
81
|
+
this._connectionTimeout = false; // 等待连接开始的超时变量,如果为true,则表示已超时
|
|
82
|
+
this.destroyed = false; // 如果设置为true,此实例不再活跃
|
|
83
|
+
this.allowsAuth = false; // 如果EHLO响应包含"AUTH",则设置为true。如果为false,则不尝试认证
|
|
84
|
+
this.authenticated = false; // 如果为true,则表示用户已认证
|
|
85
|
+
this.lastServerResponse = false; // 最后的服务器响应,如果没有响应,则为false
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 套接字监听器的回调函数
|
|
89
|
+
*/
|
|
90
|
+
this.onSocketData = chunk => this.#onData(chunk), this._onSocketClose = () => this.#onClose();
|
|
91
|
+
this.onSocketError = error => this.#onError(error, 'ESOCKET', false, 'CONN');
|
|
92
|
+
this.onSocketEnd = () => this.#onEnd(), this.onSocketTimeout = () => this.#onTimeout();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 设置连接处理器
|
|
97
|
+
*/
|
|
98
|
+
#setupConnectionHandlers() {
|
|
99
|
+
this._connectionTimeout = setTimeout(() => this.#onError('连接超时', 'ETIMEDOUT', false, 'CONN'),
|
|
100
|
+
this.options.connectionTimeout || CONNECTION_TIMEOUT);
|
|
101
|
+
|
|
102
|
+
this._socket.on('error', this.onSocketError);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 处理 DNS 解析和连接建立
|
|
107
|
+
*/
|
|
108
|
+
#resolveAndConnect(opts, connectCallback) {
|
|
109
|
+
resolveHostname(opts, (err, resolved) => {
|
|
110
|
+
if (err) return setImmediate(() => this.#onError(err, 'EDNS', false, 'CONN'));
|
|
111
|
+
|
|
112
|
+
this.#logDnsResolution(opts.host, resolved);
|
|
113
|
+
Object.keys(resolved).forEach(key => {
|
|
114
|
+
if (!key.startsWith('_') && resolved[key]) opts[key] = resolved[key];
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
connectCallback(opts), this.#setupConnectionHandlers();
|
|
119
|
+
} catch (E) {
|
|
120
|
+
return setImmediate(() => this.#onError(E, 'ECONNECTION', false, 'CONN'));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 记录 DNS 解析日志
|
|
127
|
+
*/
|
|
128
|
+
#logDnsResolution(source, resolved) {
|
|
129
|
+
this.logger.debug(
|
|
130
|
+
{ tnx: 'dns', source, resolved: resolved.host, cached: !!resolved.cached }, '将 %s 解析为 %s [缓存 %s]',
|
|
131
|
+
source, resolved.host, resolved.cached ? '命中' : '未命中'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 创建到SMTP服务器的连接并设置连接监听器
|
|
137
|
+
*/
|
|
138
|
+
connect(connectCallback) {
|
|
139
|
+
if (typeof connectCallback === 'function') {
|
|
140
|
+
this.once('connect', () => {
|
|
141
|
+
this.logger.debug({ tnx: 'smtp' }, 'SMTP握手完成'), connectCallback();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const destroyedMsg = this.#isDestroyedMessage('connect');
|
|
145
|
+
if (destroyedMsg) return connectCallback(this.#formatError(destroyedMsg, 'ECONNECTION', false, 'CONN'));
|
|
146
|
+
}
|
|
147
|
+
const { dnsTimeout, localAddress, connection, socket, tls } = this.options;
|
|
148
|
+
let opts = {
|
|
149
|
+
port: this.port, host: this.host, allowInternalNetworkInterfaces: this.allowInternalNetworkInterfaces,
|
|
150
|
+
timeout: dnsTimeout || DNS_TIMEOUT
|
|
151
|
+
};
|
|
152
|
+
if (localAddress) opts.localAddress = localAddress;
|
|
153
|
+
|
|
154
|
+
// 如果已提供连接对象,则使用它
|
|
155
|
+
if (connection) {
|
|
156
|
+
this._socket = connection, this.#setupConnectionHandlers();
|
|
157
|
+
if (this.secureConnection && !this.alreadySecured)
|
|
158
|
+
setImmediate(() =>
|
|
159
|
+
this.#upgradeConnection(err => {
|
|
160
|
+
if (err) return this.#onError(new Error(`启动TLS错误-${(err.message || err)}`), 'ETLS', false, 'CONN');
|
|
161
|
+
this.#onConnect();
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
else setImmediate(() => this.#onConnect());
|
|
165
|
+
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 提取创建连接逻辑
|
|
170
|
+
const createConnection = connectionCreator => {
|
|
171
|
+
return this.#resolveAndConnect(opts, resolvedOpts => {
|
|
172
|
+
this._socket = connectionCreator(resolvedOpts);
|
|
173
|
+
this._socket.setKeepAlive(true), this._socket.once('connect', () => this.#onConnect());
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
// 如果套接字已设置,则使用它
|
|
177
|
+
if (socket)
|
|
178
|
+
return createConnection((resolvedOpts) => {
|
|
179
|
+
socket.connect(this.port, this.host, () => { });
|
|
180
|
+
return socket;
|
|
181
|
+
});
|
|
182
|
+
// 如果secureConnection为true,则使用TLS连接
|
|
183
|
+
else if (this.secureConnection) {
|
|
184
|
+
if (tls) Object.keys(tls).forEach(key => { opts[key] = tls[key]; })
|
|
185
|
+
if (this.servername && !opts.servername) opts.servername = this.servername; // 确保SNI的服务器名称
|
|
186
|
+
|
|
187
|
+
return createConnection((resolvedOpts) => tlsConnect(resolvedOpts));
|
|
188
|
+
}
|
|
189
|
+
return createConnection((resolvedOpts) => netConnect(resolvedOpts)); // 否则,则使用明文连接
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 发送QUIT命令
|
|
194
|
+
*/
|
|
195
|
+
quit() {
|
|
196
|
+
this.#sendCommand('QUIT'), this._responseActions.push(this.close);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 关闭与服务器的连接
|
|
201
|
+
*/
|
|
202
|
+
close() {
|
|
203
|
+
clearTimeout(this._connectionTimeout), clearTimeout(this._greetingTimeout), this._responseActions = [];
|
|
204
|
+
// 确保此函数只运行一次
|
|
205
|
+
if (this._closing) return;
|
|
206
|
+
this._closing = true;
|
|
207
|
+
|
|
208
|
+
// 连接超时时立即关闭套接字
|
|
209
|
+
const closeMethod = this.stage === 'init' ? 'destroy' : 'end', socket = this._socket?.socket ?? this._socket;
|
|
210
|
+
this.logger.debug({ tnx: 'smtp' }, '使用"%s"关闭与服务器的连接', closeMethod);
|
|
211
|
+
if (!socket?.destroyed) {
|
|
212
|
+
try { socket[closeMethod]() } catch (E) { }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.#destroy();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 生成AUTH PLAIN认证字符串
|
|
220
|
+
*/
|
|
221
|
+
#generateAuthPlainString(includePassword = true) {
|
|
222
|
+
const { pass, user } = this._auth.credentials, credentials = includePassword ? pass : '/* secret */';
|
|
223
|
+
return Buffer.from(`\u0000${user}\u0000${credentials}`, 'utf-8').toString('base64');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 用户认证
|
|
228
|
+
*/
|
|
229
|
+
login(authData = {}, callback) {
|
|
230
|
+
const destroyedMsg = this.#isDestroyedMessage('login');
|
|
231
|
+
if (destroyedMsg) return callback(this.#formatError(destroyedMsg, 'ECONNECTION', false, 'API'));
|
|
232
|
+
|
|
233
|
+
const { method = '', oauth2, credentials, user, pass, options } = this._auth = authData;
|
|
234
|
+
this._authMethod = method.toString().trim().toUpperCase(); // 选择SASL认证方法
|
|
235
|
+
// 如果没有指定认证方法,则使用XOAUTH2认证,如果没有OAuth2凭据,则使用PLAIN认证
|
|
236
|
+
if (!this._authMethod && oauth2 && !credentials) this._authMethod = 'XOAUTH2';
|
|
237
|
+
else if (!this._authMethod || (this._authMethod === 'XOAUTH2' && !oauth2))
|
|
238
|
+
this._authMethod = (this._supportedAuth[0] || 'PLAIN').toUpperCase().trim();
|
|
239
|
+
|
|
240
|
+
// 如果没有指定凭据,则使用用户名和密码
|
|
241
|
+
if (this._authMethod !== 'XOAUTH2' && (!credentials || !credentials.user || !credentials.pass)) {
|
|
242
|
+
if ((user && pass) || this.customAuth.has(this._authMethod)) this._auth.credentials = { user, pass, options };
|
|
243
|
+
else return callback(this.#formatError(`缺少 "${this._authMethod}" 的凭据`, 'EAUTH', false, 'API'));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (this.customAuth.has(this._authMethod)) {
|
|
247
|
+
let lastResponse;
|
|
248
|
+
const handler = this.customAuth.get(this._authMethod), state = { returned: false },
|
|
249
|
+
resolve = () => {
|
|
250
|
+
cleanup(state);
|
|
251
|
+
this.logger.info({ tnx: 'smtp', username: user, action: 'authenticated', method: this._authMethod },
|
|
252
|
+
'用户 %s 已认证', JSON.stringify(user));
|
|
253
|
+
this.authenticated = true, callback(null, true);
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
reject = err => {
|
|
257
|
+
cleanup(state), callback(this.#formatError(err, 'EAUTH', lastResponse, `AUTH ${this._authMethod}`));
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
handlerResponse = handler({
|
|
261
|
+
auth: this._auth, method: this._authMethod,
|
|
262
|
+
extensions: [].concat(this._supportedExtensions), authMethods: [].concat(this._supportedAuth),
|
|
263
|
+
maxAllowedSize: this._maxAllowedSize || false,
|
|
264
|
+
sendCommand: (cmd, done) => {
|
|
265
|
+
const promise = !done ? new Promise((resolve, reject) => done = callbackPromise(resolve, reject)) : null;
|
|
266
|
+
this._responseActions.push(str => {
|
|
267
|
+
lastResponse = str;
|
|
268
|
+
const codes = str.match(regexs.RESPONSE_CODE), data = { command: cmd, response: str };
|
|
269
|
+
if (codes) {
|
|
270
|
+
data.status = Number(codes[1]) || 0;
|
|
271
|
+
if (codes[2]) data.code = codes[2];
|
|
272
|
+
data.text = str.substring(codes[0].length);
|
|
273
|
+
}
|
|
274
|
+
else data.text = str, data.status = 0; // 设置状态码以防需要进行数字比较
|
|
275
|
+
done(null, data);
|
|
276
|
+
});
|
|
277
|
+
setImmediate(() => this.#sendCommand(cmd));
|
|
278
|
+
return promise;
|
|
279
|
+
}, resolve, reject
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// 如果返回了Promise,则等待Promise完成
|
|
283
|
+
if (handlerResponse && typeof handlerResponse.catch === 'function') handlerResponse.then(resolve).catch(reject);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
switch (this._authMethod) {
|
|
288
|
+
case 'XOAUTH2':
|
|
289
|
+
return this.#actionAUTH_XOAUTH2_TOKEN(false, callback);
|
|
290
|
+
case 'LOGIN':
|
|
291
|
+
this._responseActions.push(str => this.#actionAUTH_LOGIN_USER(str, callback)), this.#sendCommand('AUTH LOGIN');
|
|
292
|
+
return;
|
|
293
|
+
case 'PLAIN':
|
|
294
|
+
this._responseActions.push(str => this.#actionAUTH_PLAIN_RESPONSE(str, callback));
|
|
295
|
+
this.#sendCommand(`AUTH PLAIN ${this.#generateAuthPlainString(true)}`,// 认证参数
|
|
296
|
+
`AUTH PLAIN ${this.#generateAuthPlainString(false)}`); // 日志参数(不包含密码)
|
|
297
|
+
return;
|
|
298
|
+
case 'CRAM-MD5':
|
|
299
|
+
this._responseActions.push(str => this.#actionAUTH_CRAM_MD5(str, callback));
|
|
300
|
+
this.#sendCommand('AUTH CRAM-MD5');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return callback(this.#formatError(`未知的认证方法 "${this._authMethod}"`, 'EAUTH', false, 'API'));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 发送消息
|
|
309
|
+
*
|
|
310
|
+
* @param {Object} envelope 信封对象,{from: addr, to: [addr]}
|
|
311
|
+
* @param {Object} message 字符串、缓冲区或流
|
|
312
|
+
* @param {Function} callback 发送完成后调用的回调函数
|
|
313
|
+
*/
|
|
314
|
+
send(envelope, message, done) {
|
|
315
|
+
if (!message) return done(this.#formatError('空消息', 'EMESSAGE', false, 'API'));
|
|
316
|
+
|
|
317
|
+
const destroyedMsg = this.#isDestroyedMessage('send message');
|
|
318
|
+
if (destroyedMsg) return done(this.#formatError(destroyedMsg, 'ECONNECTION', false, 'API'));
|
|
319
|
+
|
|
320
|
+
// 拒绝大于允许大小的消息
|
|
321
|
+
if (this._maxAllowedSize && envelope.size > this._maxAllowedSize)
|
|
322
|
+
return setImmediate(() => {
|
|
323
|
+
done(this.#formatError(`消息大小超过允许值 ${this._maxAllowedSize}`, 'EMESSAGE', false, 'MAIL FROM'));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const state = { returned: false }, callback = (...args) => { cleanup(state), done(...args) };
|
|
327
|
+
if (typeof message.on === 'function') message.on('error', err => callback(this.#formatError(err, 'ESTREAM', false, 'API')));
|
|
328
|
+
|
|
329
|
+
// 将消息写入流
|
|
330
|
+
const writeMessageToStream = stream => {
|
|
331
|
+
typeof message.pipe === 'function' ? message.pipe(stream) : (stream.write(message), stream.end());
|
|
332
|
+
}, startTime = Date.now();
|
|
333
|
+
this.#setEnvelope(envelope, (err, info) => {
|
|
334
|
+
if (err) {
|
|
335
|
+
const pStream = new PassThrough(); // 创建直通流来消耗数据以防止内存不足
|
|
336
|
+
writeMessageToStream(pStream);
|
|
337
|
+
return callback(err);
|
|
338
|
+
}
|
|
339
|
+
const envelopeTime = Date.now(),
|
|
340
|
+
stream = this.#createSendStream((err, str) => {
|
|
341
|
+
if (err) return callback(err);
|
|
342
|
+
info.envelopeTime = envelopeTime - startTime, info.messageTime = Date.now() - envelopeTime;
|
|
343
|
+
info.messageSize = stream.outByteCount, info.response = str;
|
|
344
|
+
return callback(null, info);
|
|
345
|
+
});
|
|
346
|
+
writeMessageToStream(stream);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 重置连接状态
|
|
352
|
+
*
|
|
353
|
+
* @param {Function} callback 连接重置后调用的回调函数
|
|
354
|
+
*/
|
|
355
|
+
reset(callback) {
|
|
356
|
+
this.#sendCommand('RSET');
|
|
357
|
+
this._responseActions.push(str => {
|
|
358
|
+
if (!str.startsWith('2')) return callback(this.#formatError(`无法重置会话状态;响应=${str}`, 'EPROTOCOL', str, 'RSET'));
|
|
359
|
+
this._envelope = false;
|
|
360
|
+
return callback(null, true);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 当与服务器的连接打开时运行的连接监听器
|
|
366
|
+
*
|
|
367
|
+
* @event
|
|
368
|
+
*/
|
|
369
|
+
#onConnect() {
|
|
370
|
+
clearTimeout(this._connectionTimeout);
|
|
371
|
+
const { localAddress, localPort, remoteAddress, remotePort } = this._socket;
|
|
372
|
+
this.logger.info({ tnx: 'network', localAddress, localPort, remoteAddress, remotePort },
|
|
373
|
+
'%s 已建立到 %s:%s', this.secure ? '安全连接' : '连接', remoteAddress, remotePort
|
|
374
|
+
);
|
|
375
|
+
if (this._destroyed) return this.close(); // 如果连接已销毁,则关闭连接
|
|
376
|
+
|
|
377
|
+
this.stage = 'connected';
|
|
378
|
+
// 清除套接字的现有监听器
|
|
379
|
+
this._socket.removeListener('data', this.onSocketData).removeListener('timeout', this.onSocketTimeout)
|
|
380
|
+
.removeListener('close', this._onSocketClose).removeListener('end', this.onSocketEnd);
|
|
381
|
+
// 设置新监听器
|
|
382
|
+
this._socket.on('data', this.onSocketData).once('close', this._onSocketClose).once('end', this.onSocketEnd)
|
|
383
|
+
.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT).on('timeout', this.onSocketTimeout);
|
|
384
|
+
|
|
385
|
+
this._greetingTimeout = setTimeout(() => {
|
|
386
|
+
// 如果仍在等待问候消息,则放弃
|
|
387
|
+
if (this._socket && !this._destroyed && this._responseActions[0] === this.#actionGreeting)
|
|
388
|
+
this.#onError('从未收到问候消息', 'ETIMEDOUT', false, 'CONN');
|
|
389
|
+
|
|
390
|
+
}, this.options.greetingTimeout || GREETING_TIMEOUT);
|
|
391
|
+
|
|
392
|
+
this._responseActions.push(this.#actionGreeting), this._socket.resume();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 从服务器接收数据的'data'监听器
|
|
397
|
+
*
|
|
398
|
+
* @event
|
|
399
|
+
* @param {Buffer} chunk 从服务器来的数据块
|
|
400
|
+
*/
|
|
401
|
+
#onData(chunk) {
|
|
402
|
+
if (this._destroyed || !chunk?.length) return;
|
|
403
|
+
const data = chunk.toString('latin1'), lines = (this._remainder + data).split(regexs.LINE_SEPARATOR),
|
|
404
|
+
rQueue = this._responseQueue;
|
|
405
|
+
this._remainder = lines.pop(); // 保存不完整的行
|
|
406
|
+
let lastline = rQueue.at(-1) ?? null;
|
|
407
|
+
for (let i = 0; i < lines.length; i++) {
|
|
408
|
+
const line = lines[i];
|
|
409
|
+
// 检查是否需要延续(确保 lastline 存在)
|
|
410
|
+
if (lastline && regexs.CONTINUATION_RESPONSE.test(lastline.split('\n').pop()))
|
|
411
|
+
lastline += `\n${line}`, rQueue[rQueue.length - 1] = lastline;
|
|
412
|
+
else rQueue.push(line), lastline = line; // 新响应:推入队列并更新 lastline
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (lastline && regexs.CONTINUATION_RESPONSE.test(lastline.split('\n').pop())) return; // 如果响应未完成,等待后续数据
|
|
416
|
+
this.#processResponse(); // 处理完整响应
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 套接字的'error'监听器
|
|
421
|
+
*
|
|
422
|
+
* @event
|
|
423
|
+
* @param {Error} err 错误对象
|
|
424
|
+
* @param {String} type 错误名称
|
|
425
|
+
*/
|
|
426
|
+
#onError(err, type, data, command) {
|
|
427
|
+
clearTimeout(this._connectionTimeout), clearTimeout(this._greetingTimeout);
|
|
428
|
+
if (this._destroyed) return; // 如果连接已被销毁,则忽略错误
|
|
429
|
+
|
|
430
|
+
err = this.#formatError(err, type, data, command);
|
|
431
|
+
this.logger.error(data, err.message), this.emit('error', err), this.close();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#formatError(message, type, response, command) {
|
|
435
|
+
const err = regexs.ERROR_SUFFIX.test(Object.prototype.toString.call(message)) ? message : new Error(message),
|
|
436
|
+
responseCode = (typeof response === 'string' && parseInt(response)) || false;
|
|
437
|
+
|
|
438
|
+
if (type && type !== 'Error') err.code = type;
|
|
439
|
+
if (response) err.response = response, err.message += `: ${response}`;
|
|
440
|
+
if (responseCode) err.responseCode = responseCode;
|
|
441
|
+
if (command) err.command = command;
|
|
442
|
+
|
|
443
|
+
return err;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 套接字的'close'监听器
|
|
448
|
+
*
|
|
449
|
+
* @event
|
|
450
|
+
*/
|
|
451
|
+
#onClose() {
|
|
452
|
+
let serverResponse = false;
|
|
453
|
+
const _remainder = this._remainder, { debug, transactionLog } = this.options;
|
|
454
|
+
if (_remainder?.trim()) {
|
|
455
|
+
if (debug || transactionLog) this.logger.debug({ tnx: 'server' }, _remainder.replace(regexs.OPTIONAL_CRLF, ''));
|
|
456
|
+
this.lastServerResponse = serverResponse = _remainder.trim();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
this.logger.info({ tnx: 'network' }, '连接已关闭');
|
|
460
|
+
if (this.upgrading && !this._destroyed) return this.#onError(new Error('连接意外关闭'), 'ETLS', serverResponse, 'CONN');
|
|
461
|
+
else if ((![this.#actionGreeting, this.close].includes(this._responseActions[0]) && !this._destroyed) ||
|
|
462
|
+
regexs.RESPONSE_4XX_5XX.test(serverResponse))
|
|
463
|
+
return this.#onError(new Error('连接意外关闭'), 'ECONNECTION', serverResponse, 'CONN');
|
|
464
|
+
|
|
465
|
+
this.#destroy();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 套接字的'end'监听器
|
|
470
|
+
*
|
|
471
|
+
* @event
|
|
472
|
+
*/
|
|
473
|
+
#onEnd() {
|
|
474
|
+
if (this._socket && !this._socket.destroyed) this._socket.destroy();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 套接字的'timeout'监听器
|
|
479
|
+
*
|
|
480
|
+
* @event
|
|
481
|
+
*/
|
|
482
|
+
#onTimeout() {
|
|
483
|
+
return this.#onError(new Error('超时'), 'ETIMEDOUT', false, 'CONN');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 销毁客户端,发出'end'事件
|
|
488
|
+
*/
|
|
489
|
+
#destroy() {
|
|
490
|
+
if (this._destroyed) return;
|
|
491
|
+
this._destroyed = true, this.emit('end');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 将连接升级到TLS
|
|
496
|
+
*
|
|
497
|
+
* @param {Function} callback 连接安全后运行的回调函数
|
|
498
|
+
*/
|
|
499
|
+
#upgradeConnection(callback) {
|
|
500
|
+
// 定义原始socket,移除相关监听,保留'error'、'end'、'close'等事件
|
|
501
|
+
const socketPlain = this._socket,
|
|
502
|
+
opts = { socket: socketPlain, host: this.host, ...(this.options.tls || {}) }; // 直接合并TLS选项
|
|
503
|
+
|
|
504
|
+
this._socket.removeListener('data', this.onSocketData).removeListener('timeout', this.onSocketTimeout);
|
|
505
|
+
// 确保SNI的服务器名称
|
|
506
|
+
if (this.servername && !opts.servername) opts.servername = this.servername;
|
|
507
|
+
this.upgrading = true;
|
|
508
|
+
try {
|
|
509
|
+
const tlsSocket = tlsConnect(opts, () => {
|
|
510
|
+
this.secure = true, this.upgrading = false, tlsSocket.on('data', this.onSocketData);
|
|
511
|
+
// 清理旧socket的事件剩余监听器
|
|
512
|
+
socketPlain.removeListener('error', this.onSocketError).removeListener('close', this._onSocketClose)
|
|
513
|
+
.removeListener('end', this.onSocketEnd);
|
|
514
|
+
return callback(null, true);
|
|
515
|
+
});
|
|
516
|
+
// 更新实例socket引用,并绑定新socket的事件处理器
|
|
517
|
+
this._socket = tlsSocket;
|
|
518
|
+
tlsSocket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT).on('timeout', this.onSocketTimeout)
|
|
519
|
+
.on('error', this.onSocketError).once('close', this._onSocketClose).once('end', this.onSocketEnd);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
this.upgrading = false;
|
|
522
|
+
return callback(err);
|
|
523
|
+
}
|
|
524
|
+
socketPlain.resume(); // 如果套接字暂停,则恢复
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 处理来自服务器的排队响应
|
|
529
|
+
*
|
|
530
|
+
* @param {Boolean} force 如果为true,忽略_processing标志
|
|
531
|
+
*/
|
|
532
|
+
#processResponse() {
|
|
533
|
+
if (!this._responseQueue.length) return false;
|
|
534
|
+
|
|
535
|
+
const str = (this.lastServerResponse = (this._responseQueue.shift() || '').toString());
|
|
536
|
+
if (regexs.CONTINUATION_RESPONSE.test(str.split('\n').pop())) return; // 继续等待多行响应的最后部分
|
|
537
|
+
|
|
538
|
+
if (this.options.debug || this.options.transactionLog)
|
|
539
|
+
this.logger.debug({ tnx: 'server' }, str.replace(regexs.OPTIONAL_CRLF, ''));
|
|
540
|
+
if (!str.trim()) setImmediate(() => this.#processResponse()); // 跳过意外的空行
|
|
541
|
+
|
|
542
|
+
const action = this._responseActions.shift();
|
|
543
|
+
if (typeof action === 'function') action.call(this, str), setImmediate(() => this.#processResponse());
|
|
544
|
+
else return this.#onError(new Error('意外响应'), 'EPROTOCOL', str, 'CONN');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 向服务器发送命令,附加\r\n
|
|
549
|
+
*
|
|
550
|
+
* @param {String} str 要发送到服务器的字符串
|
|
551
|
+
* @param {String} logStr 用于记录的可选字符串,而不是实际字符串
|
|
552
|
+
*/
|
|
553
|
+
#sendCommand(str = '', logStr) {
|
|
554
|
+
if (this._destroyed) return; // 连接已关闭,无法发送更多数据
|
|
555
|
+
if (this._socket.destroyed) return this.close();
|
|
556
|
+
if (this.options.debug || this.options.transactionLog)
|
|
557
|
+
this.logger.debug({ tnx: 'client' }, (logStr || str).toString().replace(regexs.OPTIONAL_CRLF, ''));
|
|
558
|
+
|
|
559
|
+
this._socket.write(Buffer.from(`${str}\r\n`, 'utf-8'));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 通过提交信封数据来初始化新消息,以MAIL FROM:命令开始
|
|
564
|
+
*
|
|
565
|
+
* @param {Object} envelope 信封对象,形式为 {from:'...', to:['...']} 或
|
|
566
|
+
* {from:{address:'...',name:'...'}, to:[address:'...',name:'...']}
|
|
567
|
+
*/
|
|
568
|
+
#setEnvelope(envelope = {}, callback) {
|
|
569
|
+
const args = [], errors = [];
|
|
570
|
+
this._envelope = envelope;
|
|
571
|
+
let { from = '', to, dsn, use8BitMime, size } = this._envelope, useSmtpUtf8 = false;
|
|
572
|
+
// 处理发件人地址
|
|
573
|
+
from = ((from?.address) || from).toString().trim();
|
|
574
|
+
if (!from) return callback(this.#formatError('发件人地址不能为空', 'EENVELOPE', false, 'API'));
|
|
575
|
+
// 处理收件人地址
|
|
576
|
+
to = [].concat(to || []).map(to => ((to?.address) || to || '').toString().trim()).filter(ad => ad);
|
|
577
|
+
if (!to.length) return callback(this.#formatError('未定义收件人', 'EENVELOPE', false, 'API'));
|
|
578
|
+
to = [...new Set(to)]; // 去重
|
|
579
|
+
// 验证单个地址并检测非ASCII字符
|
|
580
|
+
const _validateAddress = (address, type) => {
|
|
581
|
+
if (regexs.INVALID_ADDRESS_CHARS.test(address)) errors.push(`无效的${type}: ${JSON.stringify(address)}`);
|
|
582
|
+
else if (regexs.NON_ASCII.test(address)) useSmtpUtf8 = true; // 检查地址是否使用ASCII字符,否则需使用SMTPUTF8扩展
|
|
583
|
+
};
|
|
584
|
+
_validateAddress(from, '发件人');
|
|
585
|
+
to.forEach(addr => _validateAddress(addr, '收件人'));
|
|
586
|
+
if (errors.length > 0) return callback(this.#formatError(errors.join('; '), 'EENVELOPE', false, 'API'));
|
|
587
|
+
|
|
588
|
+
// 克隆收件人数组以供后续操作
|
|
589
|
+
this._envelope.rcptQueue = [...to];
|
|
590
|
+
this._envelope.rejected = [], this._envelope.rejectedErrors = [], this._envelope.accepted = [];
|
|
591
|
+
|
|
592
|
+
if (dsn) {
|
|
593
|
+
try {
|
|
594
|
+
dsn = this.#setDsnEnvelope(dsn);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
return callback(this.#formatError(`无效的DSN:${err.message}`, 'EENVELOPE', false, 'API'));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
this._responseActions.push(str => this.#actionMAIL(str, callback));
|
|
601
|
+
|
|
602
|
+
// 如果服务器支持SMTPUTF8并且信封包含国际化电子邮件地址,则将SMTPUTF8关键字附加到MAIL FROM命令
|
|
603
|
+
if (useSmtpUtf8 && this._supportedExtensions.includes('SMTPUTF8')) args.push('SMTPUTF8'), this._usingSmtpUtf8 = true;
|
|
604
|
+
|
|
605
|
+
// 如果服务器支持8BITMIME并且消息可能包含非ASCII字节,则将8BITMIME关键字附加到MAIL FROM命令
|
|
606
|
+
if (use8BitMime && this._supportedExtensions.includes('8BITMIME')) args.push('BODY=8BITMIME'), this._using8BitMime = true;
|
|
607
|
+
|
|
608
|
+
if (size && this._supportedExtensions.includes('SIZE')) args.push(`SIZE=${size}`);
|
|
609
|
+
|
|
610
|
+
// 如果服务器支持DSN并且信封包含DSN属性,则将DSN参数附加到MAIL FROM命令
|
|
611
|
+
if (dsn && this._supportedExtensions.includes('DSN')) {
|
|
612
|
+
if (dsn.ret) args.push(`RET=${encodeXText(dsn.ret)}`);
|
|
613
|
+
if (dsn.envid) args.push(`ENVID=${encodeXText(dsn.envid)}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
this.#sendCommand(`MAIL FROM:<${from}>${args.length ? ' ' + args.join(' ') : ''}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 设置DSN信封属性
|
|
620
|
+
#setDsnEnvelope(params) {
|
|
621
|
+
const { ret: ret_, return: return_, envid: envid_, id = '', notify: notify_, recipient, orcpt: orcpt_ } = params;
|
|
622
|
+
let ret = (ret_ || return_ || '').toString().toUpperCase() || null;
|
|
623
|
+
if (ret)
|
|
624
|
+
switch (ret) {
|
|
625
|
+
case 'HDRS':
|
|
626
|
+
case 'HEADERS':
|
|
627
|
+
ret = 'HDRS';
|
|
628
|
+
break;
|
|
629
|
+
case 'FULL':
|
|
630
|
+
case 'BODY':
|
|
631
|
+
ret = 'FULL';
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
if (ret && !['FULL', 'HDRS'].includes(ret)) throw new Error(`ret: ${JSON.stringify(ret)}`);
|
|
635
|
+
|
|
636
|
+
const envid = (envid_ || id).toString() || null;
|
|
637
|
+
let notify = notify_ || null;
|
|
638
|
+
if (notify) {
|
|
639
|
+
if (typeof notify === 'string') notify = notify.split(',');
|
|
640
|
+
|
|
641
|
+
notify = notify.map(n => n.trim().toUpperCase());
|
|
642
|
+
const vNotify = ['NEVER', 'SUCCESS', 'FAILURE', 'DELAY'], iNotify = notify.filter(n => !vNotify.includes(n));
|
|
643
|
+
if (iNotify.length || (notify.length > 1 && notify.includes('NEVER')))
|
|
644
|
+
throw new Error(`notify: ${JSON.stringify(notify.join(','))}`);
|
|
645
|
+
notify = notify.join(',');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
let orcpt = (recipient || orcpt_ || '').toString() || null;
|
|
649
|
+
if (orcpt && !orcpt.includes(';')) orcpt = `rfc822;${orcpt}`;
|
|
650
|
+
|
|
651
|
+
return { ret, envid, notify, orcpt };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// 获取DSN参数
|
|
655
|
+
#getDsnRcptToArgs() {
|
|
656
|
+
const args = [], { dsn } = this._envelope;
|
|
657
|
+
// 如果服务器支持DSN并且信封包含DSN属性,则将DSN参数附加到RCPT TO命令
|
|
658
|
+
if (dsn && this._supportedExtensions.includes('DSN')) {
|
|
659
|
+
const { notify, orcpt } = dsn;
|
|
660
|
+
if (notify) args.push(`NOTIFY=${encodeXText(notify)}`);
|
|
661
|
+
if (orcpt) args.push(`ORCPT=${encodeXText(orcpt)}`);
|
|
662
|
+
}
|
|
663
|
+
return args.length ? ` ${args.join(' ')}` : '';
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
//创建发送流
|
|
667
|
+
#createSendStream(callback) {
|
|
668
|
+
const dataStream = new DataStream(), accepted = this._envelope.accepted, lastIndex = accepted.length - 1,
|
|
669
|
+
rActions = this._responseActions;
|
|
670
|
+
if (this.options.lmtp)
|
|
671
|
+
accepted.forEach((recipient, i) => {
|
|
672
|
+
rActions.push(str => this.#actionLMTPStream(recipient, i === lastIndex, str, callback));
|
|
673
|
+
});
|
|
674
|
+
else rActions.push(str => this.#actionSMTPStream(str, callback));
|
|
675
|
+
|
|
676
|
+
dataStream.pipe(this._socket, { end: false });
|
|
677
|
+
if (this.options.debug) {
|
|
678
|
+
const logStream = new PassThrough();
|
|
679
|
+
logStream.on('readable', () => {
|
|
680
|
+
let chunk;
|
|
681
|
+
while ((chunk = logStream.read()))
|
|
682
|
+
this.logger.debug({ tnx: 'message' }, chunk.toString('binary').replace(regexs.OPTIONAL_CRLF, ''));
|
|
683
|
+
});
|
|
684
|
+
dataStream.pipe(logStream);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const { inByteCount, outByteCount } = dataStream;
|
|
688
|
+
dataStream.once('end', () => {
|
|
689
|
+
this.logger.info({ tnx: 'message', inByteCount, outByteCount },
|
|
690
|
+
'<%s bytes encoded mime message (source size %s bytes)>', outByteCount, inByteCount);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
return dataStream;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** 动作处理函数 **/
|
|
697
|
+
// 发送EHLO/LHLO命令并设置响应处理器
|
|
698
|
+
#sendHeloCommand() {
|
|
699
|
+
const [action, command] = this.options.lmtp ? [this.#actionLHLO, 'LHLO'] : [this.#actionEHLO, 'EHLO'];
|
|
700
|
+
this._responseActions.push(action), this.#sendCommand(`${command} ${this.name}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* 在连接创建后且服务器发送问候消息后运行。
|
|
705
|
+
* 如果传入消息以220开头,则通过发送EHLO命令启动SMTP会话
|
|
706
|
+
*
|
|
707
|
+
* @param {String} str 来自服务器的消息
|
|
708
|
+
*/
|
|
709
|
+
#actionGreeting(str) {
|
|
710
|
+
clearTimeout(this._greetingTimeout);
|
|
711
|
+
if (!str.startsWith('220')) return this.#onError(new Error(`无效的问候;响应=${str}`), 'EPROTOCOL', str, 'CONN');
|
|
712
|
+
this.#sendHeloCommand();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* 处理LHLO命令的服务器响应。如果产生错误,
|
|
717
|
+
* 发出'error',否则将其视为EHLO响应
|
|
718
|
+
*
|
|
719
|
+
* @param {String} str 来自服务器的消息
|
|
720
|
+
*/
|
|
721
|
+
#actionLHLO(str) {
|
|
722
|
+
if (!str.startsWith('2')) return this.#onError(new Error(`无效的LHLO;响应=${str}`), 'EPROTOCOL', str, 'LHLO');
|
|
723
|
+
this.#actionEHLO(str);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 处理EHLO命令的服务器响应。如果产生错误,
|
|
728
|
+
* 尝试改用HELO,否则如果服务器支持STARTTLS
|
|
729
|
+
* 则启动TLS协商,或进入认证阶段。
|
|
730
|
+
*
|
|
731
|
+
* @param {String} str 来自服务器的消息
|
|
732
|
+
*/
|
|
733
|
+
#actionEHLO(str) {
|
|
734
|
+
if (str.startsWith('421')) return this.#onError(new Error(`服务器终止连接;响应=${str}`), 'ECONNECTION', str, 'EHLO');
|
|
735
|
+
|
|
736
|
+
// 通用命令队列函数
|
|
737
|
+
const queueCommand = (action, command) => {
|
|
738
|
+
this._responseActions.push(action), this.#sendCommand(command);
|
|
739
|
+
}, { requireTLS, ignoreTLS } = this.options;
|
|
740
|
+
if (!str.startsWith('2')) {
|
|
741
|
+
if (requireTLS)
|
|
742
|
+
return this.#onError(new Error(`EHLO失败但HELO不支持必需的STARTTLS;响应=${str}`), 'ECONNECTION', str, 'EHLO');
|
|
743
|
+
return queueCommand(this.#actionHELO, `HELO ${this.name}`); // 使用通用函数发送 HELO
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
this._ehloLines = str.split(regexs.LINE_SEPARATOR).map(line => line.replace(regexs.DIGIT_HYPHEN_PREFIX, '').trim())
|
|
747
|
+
.filter(line => line).slice(1);
|
|
748
|
+
|
|
749
|
+
// 检测服务器是否支持STARTTLS,SMTPUTF8,DSN,8BITMIME,PIPELINING,AUTH
|
|
750
|
+
if (!this.secure && !ignoreTLS && (regexs.STARTTLS.test(str) || requireTLS))
|
|
751
|
+
return queueCommand(this.#actionSTARTTLS, 'STARTTLS'); // 使用通用函数发送 STARTTLS
|
|
752
|
+
if (regexs.SMTPUTF8.test(str)) this._supportedExtensions.push('SMTPUTF8');
|
|
753
|
+
if (regexs.DSN.test(str)) this._supportedExtensions.push('DSN');
|
|
754
|
+
if (regexs._8BITMIME.test(str)) this._supportedExtensions.push('8BITMIME');
|
|
755
|
+
if (regexs.PIPELINING.test(str)) this._supportedExtensions.push('PIPELINING');
|
|
756
|
+
if (regexs.AUTH.test(str)) this.allowsAuth = true;
|
|
757
|
+
|
|
758
|
+
const authMechanismSource = regexs.AUTH_MECHANISM.source, match = str.match(regexs.SIZE)
|
|
759
|
+
// 检测服务器是否支持(PLAIN,LOGIN,CRAM-MD5,XOAUTH2)认证
|
|
760
|
+
if (new RegExp(`${authMechanismSource}PLAIN`, 'i').test(str)) this._supportedAuth.push('PLAIN');
|
|
761
|
+
if (new RegExp(`${authMechanismSource}LOGIN`, 'i').test(str)) this._supportedAuth.push('LOGIN');
|
|
762
|
+
if (new RegExp(`${authMechanismSource}CRAM-MD5`, 'i').test(str)) this._supportedAuth.push('CRAM-MD5');
|
|
763
|
+
if (new RegExp(`${authMechanismSource}XOAUTH2`, 'i').test(str)) this._supportedAuth.push('XOAUTH2');
|
|
764
|
+
|
|
765
|
+
// 检测服务器是否支持SIZE扩展(以及最大允许大小)
|
|
766
|
+
if (match) this._supportedExtensions.push('SIZE'), this._maxAllowedSize = Number(match[1]) || 0;
|
|
767
|
+
this.emit('connect');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* 处理HELO命令的服务器响应。如果产生错误,
|
|
772
|
+
* 发出'error',否则进入认证阶段。
|
|
773
|
+
*
|
|
774
|
+
* @param {String} str 来自服务器的消息
|
|
775
|
+
*/
|
|
776
|
+
#actionHELO(str) {
|
|
777
|
+
if (!str.startsWith('2')) return this.#onError(new Error(`无效的HELO;响应=${str}`), 'EPROTOCOL', str, 'HELO');
|
|
778
|
+
this.allowsAuth = true, this.emit('connect'); // 假设认证已启用
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* 处理STARTTLS命令的服务器响应。如果有错误
|
|
783
|
+
* 尝试改用HELO,否则启动TLS升级。如果升级成功,重新启动EHLO
|
|
784
|
+
*
|
|
785
|
+
* @param {String} str 来自服务器的消息
|
|
786
|
+
*/
|
|
787
|
+
#actionSTARTTLS(str) {
|
|
788
|
+
if (!str.startsWith('2')) {
|
|
789
|
+
if (this.options.opportunisticTLS) {
|
|
790
|
+
this.logger.info({ tnx: 'smtp' }, 'STARTTLS升级失败,继续未加密');
|
|
791
|
+
return this.emit('connect');
|
|
792
|
+
}
|
|
793
|
+
return this.#onError(new Error('使用STARTTLS升级连接时出错'), 'ETLS', str, 'STARTTLS');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
this.#upgradeConnection((err, secured) => {
|
|
797
|
+
if (err) return this.#onError(new Error(`启动TLS错误-${err.message || err}`), 'ETLS', false, 'STARTTLS');
|
|
798
|
+
|
|
799
|
+
this.logger.info({ tnx: 'smtp' }, '连接已使用STARTTLS升级');
|
|
800
|
+
secured ? this.#sendHeloCommand() : this.emit('connect');// 如果升级成功,重新启动会话,否则继续未加密
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* 处理AUTH LOGIN用户名认证阶段
|
|
806
|
+
* 预期收到服务器334响应(Base64编码的"Username:"提示)
|
|
807
|
+
* 发送Base64编码的用户名,并准备处理密码认证
|
|
808
|
+
*
|
|
809
|
+
* @param {String} str 服务器响应消息
|
|
810
|
+
* @param {Function} callback 认证结果回调
|
|
811
|
+
*/
|
|
812
|
+
#actionAUTH_LOGIN_USER(str, callback) {
|
|
813
|
+
if (!regexs.AUTH_RESPONSE.test(str))
|
|
814
|
+
return callback(this.#formatError('在等待"334 VXNlcm5hbWU6"时登录序列无效', 'EAUTH', str, 'AUTH LOGIN'));
|
|
815
|
+
|
|
816
|
+
this._responseActions.push(str => this.#actionAUTH_LOGIN_PASS(str, callback));
|
|
817
|
+
this.#sendCommand(Buffer.from(this._auth.credentials.user, 'utf-8').toString('base64'));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 处理AUTH CRAM-MD5命令:
|
|
822
|
+
* 1. 服务器发送一个随机字符串
|
|
823
|
+
* 2. 客户端用密码作为密钥,对其进行HMAC-MD5加密
|
|
824
|
+
* 3. 将"用户名 加密结果"进行base64编码后返回给服务器
|
|
825
|
+
*
|
|
826
|
+
* @param {String} str 服务器发送的消息,格式:334 <base64编码的随机字符串>
|
|
827
|
+
* @param {Function} callback 认证结果回调函数
|
|
828
|
+
*/
|
|
829
|
+
#actionAUTH_CRAM_MD5(str, callback) {
|
|
830
|
+
const challengeMatch = str.match(regexs.CRAM_MD5_CHALLENGE);
|
|
831
|
+
let challengeString = '';
|
|
832
|
+
if (!challengeMatch) return callback(this.#formatError('在等待服务器响应时登录序列无效', 'EAUTH', str, 'AUTH CRAM-MD5'));
|
|
833
|
+
else challengeString = challengeMatch[1];
|
|
834
|
+
|
|
835
|
+
// 从base64解码
|
|
836
|
+
const { pass, user } = this._auth.credentials, hmacMD5 = createHmac('md5', pass),
|
|
837
|
+
base64decoded = Buffer.from(challengeString, 'base64').toString('ascii');
|
|
838
|
+
|
|
839
|
+
hmacMD5.update(base64decoded);
|
|
840
|
+
const prepended = `${user} ${hmacMD5.digest('hex')}`;
|
|
841
|
+
|
|
842
|
+
this._responseActions.push(str => this.#actionAUTH_CRAM_MD5_PASS(str, callback));
|
|
843
|
+
this.#sendCommand(Buffer.from(prepended).toString('base64'), Buffer.from(`${user} /* secret */`).toString('base64'));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* 处理CRAM-MD5认证的响应,如果没有错误,
|
|
848
|
+
* 用户可以视为已登录。开始等待要发送的消息
|
|
849
|
+
*
|
|
850
|
+
* @param {String} str 来自服务器的消息
|
|
851
|
+
*/
|
|
852
|
+
#actionAUTH_CRAM_MD5_PASS(str, callback) {
|
|
853
|
+
if (!str.match(regexs.AUTH_SUCCESS))
|
|
854
|
+
return callback(this.#formatError('在等待"235"时登录序列无效', 'EAUTH', str, 'AUTH CRAM-MD5'));
|
|
855
|
+
|
|
856
|
+
const { user } = this._auth;
|
|
857
|
+
this.logger.info({ tnx: 'smtp', username: user, action: 'authenticated', method: this._authMethod },
|
|
858
|
+
'用户 %s 已认证', JSON.stringify(user)
|
|
859
|
+
);
|
|
860
|
+
this.authenticated = true, callback(null, true);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* 处理AUTH LOGIN命令的响应
|
|
865
|
+
* 以'334 UGFzc3dvcmQ6'('Password:'的base64编码)作为响应发送的数据
|
|
866
|
+
*
|
|
867
|
+
* @param {String} str 来自服务器的消息
|
|
868
|
+
*/
|
|
869
|
+
#actionAUTH_LOGIN_PASS(str, callback) {
|
|
870
|
+
if (!regexs.AUTH_RESPONSE.test(str))
|
|
871
|
+
return callback(this.#formatError('在等待"334 UGFzc3dvcmQ6"时登录序列无效', 'EAUTH', str, 'AUTH LOGIN'));
|
|
872
|
+
|
|
873
|
+
this._responseActions.push(str => this.#actionAUTH_PLAIN_RESPONSE(str, callback));
|
|
874
|
+
this.#sendCommand(
|
|
875
|
+
Buffer.from((this._auth.credentials.pass || '').toString(), 'utf-8').toString('base64'),
|
|
876
|
+
Buffer.from('/* secret */', 'utf-8').toString('base64') // 在日志中隐藏密码
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* 处理认证的响应,如果没有错误,
|
|
882
|
+
* 用户可以视为已登录。开始等待要发送的消息
|
|
883
|
+
*
|
|
884
|
+
* @param {String} str 来自服务器的消息
|
|
885
|
+
*/
|
|
886
|
+
#actionAUTH_PLAIN_RESPONSE(str, isRetry, callback) {
|
|
887
|
+
if (!callback && typeof isRetry === 'function') callback = isRetry, isRetry = false;
|
|
888
|
+
|
|
889
|
+
const { user } = this._auth, method = this._authMethod;
|
|
890
|
+
if (str.startsWith('334')) {
|
|
891
|
+
this._responseActions.push(str => {
|
|
892
|
+
if (isRetry || method !== 'XOAUTH2') this.#actionAUTH_PLAIN_RESPONSE(str, true, callback);
|
|
893
|
+
else setImmediate(() => this.#actionAUTH_XOAUTH2_TOKEN(true, callback)); // 获取新的OAuth2访问令牌
|
|
894
|
+
});
|
|
895
|
+
this.#sendCommand('');
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (!str.startsWith('2')) {
|
|
900
|
+
this.logger.info({ tnx: 'smtp', username: user, action: 'authfail', method },
|
|
901
|
+
'用户 %s 认证失败', JSON.stringify(user));
|
|
902
|
+
return callback(this.#formatError('无效的登录', 'EAUTH', str, `AUTH ${method}`));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
this.logger.info({ tnx: 'smtp', username: user, action: 'authenticated', method }, '用户 %s 已认证', JSON.stringify(user));
|
|
906
|
+
this.authenticated = true, callback(null, true);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* 处理MAIL FROM:命令的响应
|
|
911
|
+
*
|
|
912
|
+
* @param {String} str 来自服务器的消息
|
|
913
|
+
*/
|
|
914
|
+
#actionMAIL(str, callback) {
|
|
915
|
+
if (!str.startsWith('2')) {
|
|
916
|
+
const message = this._usingSmtpUtf8 && str.startsWith('550 ') && regexs.NON_ASCII.test(this._envelope.from) ?
|
|
917
|
+
'不允许国际化的邮箱名称' : '邮件命令失败';
|
|
918
|
+
return callback(this.#formatError(message, 'EENVELOPE', str, 'MAIL FROM'));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const { rcptQueue } = this._envelope;
|
|
922
|
+
if (!rcptQueue.length) return callback(this.#formatError("无法发送邮件-未定义收件人", 'EENVELOPE', false, 'API'));
|
|
923
|
+
|
|
924
|
+
this._recipientQueue = [];
|
|
925
|
+
const processRecipient = (recipient) => {
|
|
926
|
+
this._recipientQueue.push(recipient), this._responseActions.push(str => this.#actionRCPT(str, callback));
|
|
927
|
+
this.#sendCommand(`RCPT TO:<${recipient}>${this.#getDsnRcptToArgs()}`);
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// 流水线模式:一次性发送所有收件人
|
|
931
|
+
if (this._supportedExtensions.includes('PIPELINING')) while (rcptQueue.length) processRecipient(rcptQueue.shift());
|
|
932
|
+
else processRecipient(rcptQueue.shift()); // 非流水线模式:逐个发送收件
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* 处理RCPT TO:命令的响应
|
|
937
|
+
*
|
|
938
|
+
* @param {String} str 来自服务器的消息
|
|
939
|
+
*/
|
|
940
|
+
#actionRCPT(str, callback) {
|
|
941
|
+
const { _envelope, _recipientQueue, _responseActions, _usingSmtpUtf8 } = this,
|
|
942
|
+
{ rejected, rejectedErrors, accepted, rcptQueue, to } = _envelope;
|
|
943
|
+
let err, curRecipient = _recipientQueue.shift();
|
|
944
|
+
if (!str.startsWith('2')) {
|
|
945
|
+
const message = _usingSmtpUtf8 && str.startsWith('553 ') && regexs.NON_ASCII.test(curRecipient) ?
|
|
946
|
+
'不允许国际化的邮箱名称' : '收件人命令失败';
|
|
947
|
+
rejected.push(curRecipient);
|
|
948
|
+
err = this.#formatError(message, 'EENVELOPE', str, 'RCPT TO'); // 为失败的收件人存储错误
|
|
949
|
+
err.recipient = curRecipient, rejectedErrors.push(err);
|
|
950
|
+
}
|
|
951
|
+
else accepted.push(curRecipient);
|
|
952
|
+
|
|
953
|
+
if (!rcptQueue.length && !_recipientQueue.length) {
|
|
954
|
+
if (rejected.length < to.length)
|
|
955
|
+
_responseActions.push(str => this.#actionDATA(str, callback)), this.#sendCommand('DATA');
|
|
956
|
+
else {
|
|
957
|
+
err = this.#formatError("无法发送邮件 - 所有收件人都被拒绝", 'EENVELOPE', str, 'RCPT TO');
|
|
958
|
+
err.rejected = rejected, err.rejectedErrors = rejectedErrors;
|
|
959
|
+
return callback(err);
|
|
960
|
+
}
|
|
961
|
+
} else if (rcptQueue.length) {
|
|
962
|
+
curRecipient = rcptQueue.shift(), _recipientQueue.push(curRecipient);
|
|
963
|
+
_responseActions.push(str => this.#actionRCPT(str, callback));
|
|
964
|
+
this.#sendCommand(`RCPT TO:<${curRecipient}>${this.#getDsnRcptToArgs()}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* 处理DATA命令的响应
|
|
970
|
+
*
|
|
971
|
+
* @param {String} str 来自服务器的消息
|
|
972
|
+
*/
|
|
973
|
+
#actionDATA(str, callback) {
|
|
974
|
+
// 如果不是23,则数据命令失败
|
|
975
|
+
if (!regexs.DATA_RESPONSE.test(str)) return callback(this.#formatError('数据命令失败', 'EENVELOPE', str, 'DATA'));
|
|
976
|
+
const { accepted, rejected, rejectedErrors } = this._envelope, response = { accepted, rejected };
|
|
977
|
+
|
|
978
|
+
if (this._ehloLines?.length) response.ehlo = this._ehloLines;
|
|
979
|
+
if (rejectedErrors.length) response.rejectedErrors = rejectedErrors;
|
|
980
|
+
callback(null, response);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* 使用SMTP时处理DATA流的响应,判断发送是否成功
|
|
985
|
+
*
|
|
986
|
+
* @param {String} str 来自服务器的消息
|
|
987
|
+
*/
|
|
988
|
+
#actionSMTPStream(str, callback) {
|
|
989
|
+
return str.startsWith('2') ? callback(null, str) : callback(this.#formatError('消息失败', 'EMESSAGE', str, 'DATA'));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* 处理DATA流的响应
|
|
994
|
+
*
|
|
995
|
+
* @param {String} recipient 此响应适用的收件人
|
|
996
|
+
* @param {Boolean} final 是否为最后一个收件人
|
|
997
|
+
* @param {String} str 来自服务器的消息
|
|
998
|
+
*/
|
|
999
|
+
#actionLMTPStream(recipient, final, str, callback) {
|
|
1000
|
+
// 检查响应状态码
|
|
1001
|
+
if (!str.startsWith('2')) {
|
|
1002
|
+
const { accepted } = this._envelope, index = accepted.indexOf(recipient),
|
|
1003
|
+
err = this.#formatError(`收件人 ${recipient} 的消息失败`, 'EMESSAGE', str, 'DATA');
|
|
1004
|
+
err.recipient = recipient;
|
|
1005
|
+
this._envelope.rejected.push(recipient), this._envelope.rejectedErrors.push(err);
|
|
1006
|
+
if (index > -1) accepted.splice(index, 1);
|
|
1007
|
+
return callback(err);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
return callback(null, final ? str : null); // 成功情况:总是调用回调
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// 处理_XOAUTH2认证
|
|
1014
|
+
#actionAUTH_XOAUTH2_TOKEN(isRetry, callback) {
|
|
1015
|
+
const { oauth2, user } = this._auth;
|
|
1016
|
+
oauth2.getToken(isRetry, (err, accessToken) => {
|
|
1017
|
+
if (err) {
|
|
1018
|
+
this.logger.info({ tnx: 'smtp', username: user, action: 'authfail', method: this._authMethod },
|
|
1019
|
+
'用户 %s 认证失败', JSON.stringify(user));
|
|
1020
|
+
return callback(this.#formatError(err, 'EAUTH', false, 'AUTH XOAUTH2'));
|
|
1021
|
+
}
|
|
1022
|
+
this._responseActions.push(str => this.#actionAUTH_PLAIN_RESPONSE(str, isRetry, callback));
|
|
1023
|
+
this.#sendCommand(
|
|
1024
|
+
`AUTH XOAUTH2 ${oauth2.buildXOAuth2Token(accessToken)}`,
|
|
1025
|
+
`AUTH XOAUTH2 ${oauth2.buildXOAuth2Token('/* secret */')}` // 日志隐藏密码
|
|
1026
|
+
);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* 检查SMTP连接状态并生成对应的错误消息
|
|
1032
|
+
* @param {string} command SMTP命令
|
|
1033
|
+
* @private
|
|
1034
|
+
*/
|
|
1035
|
+
#isDestroyedMessage(command) {
|
|
1036
|
+
if (this._destroyed) return `无法 ${command} -SMTP连接已销毁;`;
|
|
1037
|
+
if (this._socket) {
|
|
1038
|
+
if (this._socket.destroyed) return `无法 ${command} -SMTP连接套接字已销毁;`;
|
|
1039
|
+
if (!this._socket.writable) return `无法 ${command} -SMTP连接套接字已半关闭;`;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// 获取主机名(设备名或[IP])
|
|
1044
|
+
#getHostname() {
|
|
1045
|
+
let defaultHostname;
|
|
1046
|
+
try {
|
|
1047
|
+
defaultHostname = osHostname() || '';
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
defaultHostname = 'localhost'; // 在Windows 7上失败
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// 如果不是FQDN则忽略
|
|
1053
|
+
if (!defaultHostname || !defaultHostname.includes('.')) defaultHostname = '[127.0.0.1]';
|
|
1054
|
+
// IP地址应包含在[]中
|
|
1055
|
+
if (regexs.IPV4_ADDRESS.test(defaultHostname) || regexs.IPV6_ADDRESS.test(defaultHostname))
|
|
1056
|
+
defaultHostname = `[${defaultHostname}]`;
|
|
1057
|
+
|
|
1058
|
+
return defaultHostname;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
export { SmtpConnection };
|