@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/LICENSE +15 -0
  3. package/Mail.js +47 -0
  4. package/README.md +475 -0
  5. package/copy-files.js +36 -0
  6. package/index.d.ts +816 -0
  7. package/index.js +109 -0
  8. package/lib/addressparser.js +230 -0
  9. package/lib/base64.js +103 -0
  10. package/lib/dkim/index.js +211 -0
  11. package/lib/dkim/message-parser.js +114 -0
  12. package/lib/dkim/relaxed-body.js +133 -0
  13. package/lib/dkim/sign.js +121 -0
  14. package/lib/fetch/cookies.js +224 -0
  15. package/lib/fetch/index.js +233 -0
  16. package/lib/http-proxy-client.js +98 -0
  17. package/lib/json-transport.js +38 -0
  18. package/lib/mail-composer.js +316 -0
  19. package/lib/mailer/index.js +301 -0
  20. package/lib/mailer/mail-message.js +226 -0
  21. package/lib/mime-funcs/index.js +493 -0
  22. package/lib/mime-funcs/mime-types.js +2061 -0
  23. package/lib/mime-node/index.js +811 -0
  24. package/lib/mime-node/last-newline.js +44 -0
  25. package/lib/mime-node/le-unix.js +37 -0
  26. package/lib/mime-node/le-windows.js +46 -0
  27. package/lib/punycode.js +283 -0
  28. package/lib/qp.js +171 -0
  29. package/lib/regexs.js +124 -0
  30. package/lib/sendmail-transport.js +91 -0
  31. package/lib/ses-transport.js +115 -0
  32. package/lib/shared.js +725 -0
  33. package/lib/smtp-connection/data-stream.js +68 -0
  34. package/lib/smtp-connection/index.js +1062 -0
  35. package/lib/smtp-pool/index.js +294 -0
  36. package/lib/smtp-pool/pool-resource.js +93 -0
  37. package/lib/smtp-transport.js +81 -0
  38. package/lib/stream-transport.js +59 -0
  39. package/lib/well-known/index.js +54 -0
  40. package/lib/well-known/services.json +802 -0
  41. package/lib/xoauth2.js +249 -0
  42. package/package.json +78 -0
@@ -0,0 +1,316 @@
1
+ import { MimeNode } from './mime-node/index.js';
2
+ import { regexs, resetRegex, parseDataURI } from './shared.js';
3
+ import { detectMimeType, detectExtension } from './mime-funcs/index.js';
4
+ /**
5
+ * 邮件编译器类,用于从邮件选项组合MimeNode实例;
6
+ * 负责处理邮件的多部分结构(纯文本、HTML、附件等)
7
+ * >查看定义:@see {@link MailComposer}
8
+ * @class MailComposer
9
+ */
10
+ class MailComposer {
11
+ constructor(mail = {}) { this.mail = mail, this.message = false; }
12
+
13
+ /**
14
+ * 编译邮件内容,生成MimeNode实例
15
+ * 根据邮件内容类型(纯文本、HTML、附件等)构建相应的MIME结构
16
+ * @returns {MimeNode} 编译后的MimeNode实例
17
+ */
18
+ compile() {
19
+ const aTives = this._alternatives = this.getAlternatives(); // 获取所有替代内容(纯文本、HTML、日历事件等)
20
+ // 从替代内容中提取HTML节点
21
+ this._htmlNode = aTives.filter(alternative => regexs.HTML_TYPE.test(alternative.contentType)).pop();
22
+ // 获取附件,并根据是否存在HTML内容区分相关附件
23
+ const { attached, related } = this._attachments = this.getAttachments(!!this._htmlNode);
24
+
25
+ // 确定需要使用的MIME类型结构
26
+ this._useRelated = !!(this._htmlNode && related.length); // 是否需要相关部分(HTML + 内联图片)
27
+ this._useAlternative = aTives.length > 1; // 是否需要替代部分(纯文本 + HTML)
28
+ this._useMixed = attached.length > 1 || (aTives.length && attached.length === 1); // 是否需要混合部分(内容 + 附件)
29
+
30
+ // 根据内容类型构建相应的MIME结构
31
+ const { raw, newline, headers, envelope } = this.mail;
32
+ if (raw) this.message = new MimeNode('message/rfc822', { newline }).setRaw(raw); // 如果有raw字段,则直接使用raw字段
33
+ else if (this._useMixed) this.message = this.#createMixed(); // 混合类型:内容 + 多个附件
34
+ else if (this._useAlternative) this.message = this.#createAlternative(); // 替代类型:纯文本 + HTML
35
+ else if (this._useRelated) this.message = this.#createRelated(); // 相关类型:HTML + 内联资源
36
+ else this.message = this.#createContentNode(false, [aTives, attached].flatMap(arr => arr || [])
37
+ .find(Boolean) || { contentType: 'text/plain', content: '' }); // 简单类型:单一内容
38
+
39
+ if (headers) this.message.addHeader(headers); // 添加自定义邮件头
40
+ // 设置标准邮件头
41
+ const rHR = resetRegex(regexs.HYPHEN_REPLACE);
42
+ ['from', 'sender', 'to', 'cc', 'bcc', 'reply-to', 'in-reply-to', 'references', 'subject', 'message-id', 'date']
43
+ .forEach(header => {
44
+ const key = header.replace(rHR, (_, c) => c.toUpperCase());
45
+ if (this.mail[key]) this.message.setHeader(header, this.mail[key]);
46
+ });
47
+
48
+ // 设置邮件信封和消息ID
49
+ if (envelope) this.message.setEnvelope(envelope);
50
+ this.message.messageId();
51
+ return this.message;
52
+ }
53
+
54
+ /**
55
+ * 获取邮件附件列表
56
+ * 处理普通附件和相关附件(如内联图片)
57
+ * @param {boolean} findRelated - 是否查找相关附件(用于HTML内联)
58
+ * @returns {Object} 包含attached和related附件的对象
59
+ */
60
+ getAttachments(findRelated = false) {
61
+ const { attachments = [], icalEvent } = this.mail;
62
+ let eventObject;
63
+
64
+ // 处理日历事件附件
65
+ if (icalEvent) {
66
+ const iEvent = typeof icalEvent === 'object' && (icalEvent.content || icalEvent.path || icalEvent.href || icalEvent.raw)
67
+ ? { ...icalEvent } : { content: icalEvent };
68
+
69
+ eventObject = {
70
+ ...iEvent, contentType: 'application/ics', filename: iEvent.filename || 'invite.ics',
71
+ headers: { ...iEvent.headers, 'Content-Disposition': 'attachment', 'Content-Transfer-Encoding': 'base64' }
72
+ };
73
+ }
74
+
75
+ // 处理所有附件
76
+ const pAttachments = attachments.map((attachment, i) => {
77
+ const pAtt = this.#prepareAttachmentObject(attachment), // 预处理附件
78
+ { filename, path, href, cid, raw, encoding, headers, contentType: cType, content = '', httpHeaders } = pAtt,
79
+ source = path || href || 'bin', contentType = cType || detectMimeType(filename || source),
80
+ isImage = regexs.IMAGE_TYPE.test(contentType), isMessageNode = regexs.MESSAGE_TYPE.test(contentType),
81
+ contentDisposition = pAtt.contentDisposition || (isMessageNode || (isImage && cid) ? 'inline' : 'attachment'),
82
+ contentTransferEncoding = 'contentTransferEncoding' in pAtt ? pAtt.contentTransferEncoding
83
+ : isMessageNode ? '7bit' : 'base64',
84
+ // 构建附件数据
85
+ data = {
86
+ contentType, contentDisposition, contentTransferEncoding, ...(filename && { filename }),
87
+ ...(cid && { cid }), ...(raw && { raw }), ...(encoding && { encoding }), ...(headers && { headers })
88
+ };
89
+
90
+ // 处理文件名生成
91
+ if (!filename && !isMessageNode && filename !== false) {
92
+ const baseName = source.split('/').pop().split('?').shift();
93
+ data.filename = baseName && !baseName.includes('.')
94
+ ? `${baseName}.${detectExtension(contentType)}` : baseName || `attachment-${i + 1}`;
95
+ }
96
+
97
+ // 设置内容源(优先级:raw > path > href > content)
98
+ if (!raw) data.content = path ? { path } : href ? { href, httpHeaders } : content;
99
+ return data;
100
+ }), eventArray = eventObject ? [eventObject] : [];
101
+ return findRelated ?
102
+ { attached: pAttachments.filter(a => !a.cid).concat(eventArray), related: pAttachments.filter(a => a.cid) }
103
+ : { attached: pAttachments.concat(eventArray), related: [] };
104
+ }
105
+
106
+ /**
107
+ * 获取邮件的替代内容(纯文本、HTML、日历事件等)
108
+ * 用于构建多部分替代结构
109
+ * @returns {Array} 替代内容数组
110
+ */
111
+ getAlternatives() {
112
+ let text_, html_, watchHtml_, amp_, eventObject;
113
+ const { text, html, watchHtml, amp, icalEvent, alternatives } = this.mail,
114
+ // 处理替代内容对象
115
+ processAlt = (letValue, v, defaultContentType) => {
116
+ letValue = (typeof v === 'object' && (v.content || v.path || v.href || v.raw)) ? v : { content: v };
117
+ letValue.contentType = defaultContentType;
118
+ return letValue;
119
+ };
120
+
121
+ // 处理纯文本内容,Apple Watch HTML内容,AMP HTML内容
122
+ if (text) text_ = processAlt(text_, text, 'text/plain; charset=utf-8');
123
+ if (watchHtml) watchHtml_ = processAlt(watchHtml_, watchHtml, 'text/watch-html; charset=utf-8');
124
+ if (amp) amp_ = processAlt(amp_, amp, 'text/x-amp-html; charset=utf-8');
125
+
126
+ // 处理日历事件
127
+ if (icalEvent) {
128
+ const iEvent = processAlt(iEvent, icalEvent, null);
129
+ eventObject = {
130
+ ...iEvent, filename: false,
131
+ contentType: `text/calendar; charset=utf-8; method=${(iEvent.method || 'PUBLISH').toString().trim().toUpperCase()}`,
132
+ headers: iEvent.headers || {},
133
+ ...(iEvent.content && typeof iEvent.content === 'object' && { content: { ...iEvent.content, _resolve: true } })
134
+ };
135
+ }
136
+
137
+ // 处理HTML内容
138
+ if (html) html_ = processAlt(html_, html, 'text/html; charset=utf-8');
139
+
140
+ // 将所有替代内容合并到数组中
141
+ const gAlternatives = Array.prototype.concat.call([], text_, html_, watchHtml_, amp_, eventObject, alternatives)
142
+ .filter(Boolean).map(originalAlternative => {
143
+ const oAtive = this.#prepareAttachmentObject(originalAlternative), // 预处理附件
144
+ { contentType, filename, path, href, raw, content = '', contentTransferEncoding, encoding, headers } = oAtive,
145
+ data = ({
146
+ contentType: contentType || detectMimeType(filename || path || href || 'txt'), contentTransferEncoding,
147
+ ...(raw && { raw }), ...(filename && { filename }), ...(encoding && { encoding }), ...(headers && { headers })
148
+ });
149
+
150
+ if (!raw) data.content = path ? { path } : href ? { href } : content;// 设置内容源(优先级:raw>path>href>content)
151
+ return data;
152
+ });
153
+
154
+ return gAlternatives;
155
+ }
156
+
157
+ /**
158
+ * 预处理附件对象(处理dataURL和标准化路径,将HTTP / HTTPS路径转换为href属性)
159
+ * @param {Object} obj - 要处理的附件对象
160
+ * @returns {Object} 处理后的对象
161
+ */
162
+ #prepareAttachmentObject(obj) {
163
+ // 处理dataURL格式的内容和标准化路径
164
+ const prepared = regexs.DATA_URL.test(obj.path || obj.href) ? { ...obj, ...this.#processDataUrl(obj) } : { ...obj },
165
+ { path } = prepared;
166
+ if (regexs.HTTP_URL.test(path)) prepared.href = path, prepared.path = undefined;
167
+ return prepared;
168
+ }
169
+
170
+ /**
171
+ * 创建MIME节点(根节点或子节点)
172
+ * @param {MimeNode|null} parentNode - 父节点,如果为null则创建根节点
173
+ * @param {string} mimeType - MIME类型
174
+ * @returns {MimeNode} 创建的节点
175
+ */
176
+ #createMimeNode(parentNode, mimeType) {
177
+ return !parentNode ? this.#createRootMimeNode(mimeType) : parentNode.createChild(mimeType, this.#getChildNodeOptions());
178
+ }
179
+
180
+ /**
181
+ * 创建混合类型的MIME节点(内容 + 附件)
182
+ * @param {MimeNode} parentNode - 父节点
183
+ * @returns {MimeNode} 创建的混合节点
184
+ */
185
+ #createMixed(parentNode) {
186
+ const node = this.#createMimeNode(parentNode, 'multipart/mixed');
187
+ // 根据内容类型添加子节点
188
+ if (this._useAlternative) this.#createAlternative(node);
189
+ else if (this._useRelated) this.#createRelated(node);
190
+
191
+ // 添加所有替代内容和附件
192
+ [...(this._useAlternative ? [] : this._alternatives || []), ...(this._attachments.attached || [])]
193
+ .filter(element => !this._useRelated || element !== this._htmlNode)
194
+ .forEach(element => this.#createContentNode(node, element));
195
+
196
+ return node;
197
+ }
198
+
199
+ /**
200
+ * 创建替代类型的MIME节点(纯文本 + HTML)
201
+ * @param {MimeNode} parentNode - 父节点
202
+ * @returns {MimeNode} 创建的替代节点
203
+ */
204
+ #createAlternative(parentNode) {
205
+ const node = this.#createMimeNode(parentNode, 'multipart/alternative');
206
+ // 添加所有替代内容
207
+ this._alternatives.forEach(ative => {
208
+ this._useRelated && this._htmlNode === ative ? this.#createRelated(node) : this.#createContentNode(node, ative);
209
+ });
210
+
211
+ return node;
212
+ }
213
+
214
+ /**
215
+ * 创建相关类型的MIME节点(HTML + 内联资源)
216
+ * @param {MimeNode} parentNode - 父节点
217
+ * @returns {MimeNode} 创建的相关节点
218
+ */
219
+ #createRelated(parentNode) {
220
+ const node = this.#createMimeNode(parentNode, 'multipart/related; type="text/html"');
221
+ // 添加HTML内容和相关附件
222
+ this.#createContentNode(node, this._htmlNode);
223
+ this._attachments.related.forEach(alternative => this.#createContentNode(node, alternative));
224
+ return node;
225
+ }
226
+
227
+ /**
228
+ * 创建内容节点
229
+ * @param {MimeNode} parentNode - 父节点
230
+ * @param {Object} element - 内容元素
231
+ * @returns {MimeNode} 创建的内容节点
232
+ */
233
+ #createContentNode(parentNode, element = {}) {
234
+ const { encoding = 'utf8', filename, headers, cid, contentTransferEncoding, contentType, contentDisposition,
235
+ content = '', raw } = element, rEF = resetRegex(regexs.ENCODING_FORMAT),
236
+ eEncoding = encoding.toString().toLowerCase().replace(rEF, ''),
237
+ { baseBoundary, textEncoding, boundaryPrefix, disableUrlAccess, disableFileAccess, normalizeHeaderKey, newline,
238
+ encoding: mEncoding } = this.mail
239
+
240
+ // 创建节点(根节点或子节点)
241
+ const node = !parentNode ? new MimeNode(contentType, {
242
+ filename, baseBoundary, textEncoding, boundaryPrefix, disableUrlAccess, disableFileAccess, normalizeHeaderKey, newline
243
+ }) : parentNode.createChild(contentType, {
244
+ filename, textEncoding, disableUrlAccess, disableFileAccess, normalizeHeaderKey, newline
245
+ });
246
+
247
+ // 设置节点头信息
248
+ if (headers) node.addHeader(headers);
249
+ const rCC = resetRegex(regexs.CID_CLEAN), CTE = 'Content-Transfer-Encoding';
250
+ if (cid) node.setHeader('Content-Id', `<${cid.replace(rCC, '')}>`);
251
+ if (contentTransferEncoding) node.setHeader(CTE, contentTransferEncoding);
252
+ else if (mEncoding && regexs.TEXT_TYPE.test(contentType)) node.setHeader(CTE, mEncoding);
253
+
254
+ // 设置内容处置方式
255
+ if (!regexs.TEXT_TYPE.test(contentType) || contentDisposition || filename) node.setHeader('Content-Disposition',
256
+ contentDisposition || (cid && regexs.IMAGE_TYPE.test(contentType) ? 'inline' : 'attachment'));
257
+
258
+ // 处理内容编码
259
+ let newContent = content;
260
+ if (typeof newContent === 'string' && !['utf8', 'usascii', 'ascii'].includes(eEncoding))
261
+ newContent = Buffer.from(newContent, eEncoding);
262
+
263
+ raw ? node.setRaw(raw) : node.setContent(newContent); // 设置节点内容
264
+ return node;
265
+ }
266
+
267
+ /**
268
+ * 处理DataURL格式的内容
269
+ * 将DataURL转换为二进制内容
270
+ * @param {Object} element - 包含DataURL的元素
271
+ * @returns {Object} 处理后的元素
272
+ */
273
+ #processDataUrl(element) {
274
+ const { path, href, contentType = 'application/octet-stream' } = element, dataUrl = path || href;
275
+ if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) return element;
276
+ // 对过大的DataURL进行限制
277
+ if (dataUrl.length > 100000) return { ...element, path: false, href: false, content: Buffer.alloc(0), contentType };
278
+
279
+ let parsedDataUri;
280
+ try {
281
+ parsedDataUri = parseDataURI(dataUrl);
282
+ } catch (err) {
283
+ return element;
284
+ }
285
+
286
+ // 替换DataURL为解析后的内容
287
+ element.content = parsedDataUri.data, element.contentType = parsedDataUri.contentType || contentType;
288
+ if ('path' in element) element.path = false;
289
+ if ('href' in element) element.href = false;
290
+
291
+ return element;
292
+ }
293
+
294
+ /**
295
+ * 创建根MIME节点
296
+ * @param {string} contentType - 内容类型
297
+ * @returns {MimeNode} 创建的根节点
298
+ */
299
+ #createRootMimeNode(contentType) {
300
+ const { baseBoundary, textEncoding, boundaryPrefix, disableUrlAccess, disableFileAccess, normalizeHeaderKey, newline } = this.mail;
301
+ return new MimeNode(contentType, {
302
+ baseBoundary, textEncoding, boundaryPrefix, disableUrlAccess, disableFileAccess, normalizeHeaderKey, newline
303
+ });
304
+ }
305
+
306
+ /**
307
+ * 获取子节点配置选项
308
+ * @returns {Object} 子节点配置选项
309
+ */
310
+ #getChildNodeOptions() {
311
+ const { disableUrlAccess, disableFileAccess, normalizeHeaderKey, newline } = this.mail;
312
+ return { disableUrlAccess, disableFileAccess, normalizeHeaderKey, newline };
313
+ }
314
+ }
315
+
316
+ export { MailComposer };
@@ -0,0 +1,301 @@
1
+ import { EventEmitter } from 'events';
2
+ import { randomBytes } from 'crypto';
3
+ import { MailMessage } from './mail-message.js';
4
+ import { MailComposer } from '../mail-composer.js';
5
+ import { DKIM } from '../dkim/index.js';
6
+ import { httpProxyClient } from '../http-proxy-client.js';
7
+ import { detectExtension } from '../mime-funcs/index.js';
8
+ import { PK, format, isIP, dnsResolve, regexs, resetRegex, getLogger, callbackPromise, newURL } from '../shared.js';
9
+
10
+ /**
11
+ * 创建 Mail API 对象
12
+ * >查看定义:@see {@link Mailer}
13
+ * @constructor
14
+ * @param {Object} transporter 用于传递邮件传输器对象的实例
15
+ */
16
+ class Mailer extends EventEmitter {
17
+ constructor(transporter, options = {}, defaults = {}) {
18
+ super();
19
+ this.options = options, this._defaults = defaults; // 初始化选项和默认值
20
+
21
+ // 默认插件配置(编译和流处理阶段)
22
+ this._defaultPlugins = { compile: [(...args) => this.#convertDataImages(...args)], stream: [] };
23
+
24
+ this._userPlugins = { compile: [], stream: [] }; // 用户自定义插件
25
+ this.meta = new Map(); // 元数据存储
26
+ const { dkim, proxy, component = 'mailer' } = this.options;
27
+ this.dkim = dkim ? new DKIM(dkim) : false; // DKIM配置(域名密钥识别邮件)
28
+ this.transporter = transporter, this.transporter.mailer = this; // 设置传输器并建立反向引用
29
+ this.logger = getLogger(this.options, { component }); // 初始化日志记录器
30
+ this.logger.debug({ tnx: 'create' }, '创建传输器: %s', this.getVersionString()); // 记录创建日志
31
+
32
+ // 设置传输器的事件处理器
33
+ if (typeof this.transporter.on === 'function') {
34
+ this.transporter
35
+ // 日志,错误,进入空闲状态,所有连接已终止各事件监听
36
+ .on('log', log => this.logger.debug({ tnx: 'transport' }, '%s: %s', log.type, log.message))
37
+ .on('error', err => {
38
+ this.logger.error({ err, tnx: 'transport' }, '传输器错误: %s', err.message), this.emit('error', err);
39
+ })
40
+ .on('idle', (...args) => this.emit('idle', ...args)).on('clear', (...args) => this.emit('clear', ...args));
41
+ }
42
+
43
+ // 为底层传输器对象添加可选方法
44
+ ['close', 'isIdle', 'verify'].forEach(method => {
45
+ this[method] = (...args) => {
46
+ if (typeof this.transporter[method] === 'function') {
47
+ // 特殊处理verify方法,设置getSocket
48
+ if (method === 'verify' && typeof this.getSocket === 'function') this.#setupTransporterSocket();
49
+ return this.transporter[method](...args);
50
+ } else {
51
+ this.logger.warn({ tnx: 'transport', methodName: method }, '传输器不支持的方法 %s', method);
52
+ return false;
53
+ }
54
+ };
55
+ });
56
+
57
+ if (proxy && typeof proxy === 'string') this.setupProxy(proxy); // 如果有代理,则设置代理
58
+ }
59
+
60
+ /**
61
+ * 设置传输器的socket获取方法
62
+ * 将当前实例的getSocket方法赋值给传输器,并重置当前实例的getSocket
63
+ */
64
+ #setupTransporterSocket() {
65
+ this.transporter.getSocket = this.getSocket, this.getSocket = false;
66
+ }
67
+
68
+ /**
69
+ * 使用插件
70
+ * @param {string} step 插件阶段('compile'或'stream')
71
+ * @param {Function} plugin 插件函数
72
+ */
73
+ use(step = '', plugin) {
74
+ const stepKey = step.toString(), sPlugins = this._userPlugins;
75
+ // 如果没有这个步骤,则创建一个数组,否则添加到数组中
76
+ sPlugins[stepKey] = sPlugins[stepKey] ?? [], sPlugins[stepKey].push(plugin);
77
+ return this;
78
+ }
79
+
80
+ /**
81
+ * 使用预选的传输器对象发送邮件
82
+ *
83
+ * @param {Object} data 邮件数据描述
84
+ * @param {Function?} callback 发送成功或失败后运行的回调函数
85
+ */
86
+ sendMail(data, callback = null) {
87
+ // 如果没有提供回调,则创建Promise
88
+ const promise = !callback ? new Promise((resolve, reject) => callback = callbackPromise(resolve, reject)) : null,
89
+ mail = new MailMessage(this, data); // 创建邮件消息对象
90
+ if (typeof this.getSocket === 'function') this.#setupTransporterSocket(); // 设置传输器的getSocket方法
91
+
92
+ // 记录发送日志
93
+ const { name, version } = this.transporter;
94
+ this.logger.debug({ tnx: 'transport', name, version, action: 'send' }, '使用 %s/%s 发送邮件', name, version);
95
+
96
+ const isCallbackErr = (err, action, context, tnx = 'plugin') => {
97
+ if (err) {
98
+ this.logger.error({ err, tnx, action }, context, err.message);
99
+ return callback(err);
100
+ }
101
+ };
102
+
103
+ // 处理编译阶段插件
104
+ this.#processPlugins('compile', mail, err => {
105
+ isCallbackErr(err, 'compile', '插件编译错误: %s'), mail.message = new MailComposer(mail.data).compile();
106
+ mail.setMailerHeader(), mail.setPriorityHeaders(), mail.setListHeaders(); // 设置各种邮件头
107
+
108
+ // 处理流阶段插件
109
+ this.#processPlugins('stream', mail, err => {
110
+ isCallbackErr(err, 'stream', '插件流错误: %s');
111
+ const mailDKIM = mail.data.dkim;
112
+ // DKIM签名处理
113
+ if (mailDKIM || this.dkim) {
114
+ mail.message.processFunc(input => {
115
+ // 使用邮件数据中的DKIM配置或全局DKIM配置
116
+ const dkim = mailDKIM ? new DKIM(mailDKIM) : this.dkim;
117
+ this.logger.debug(
118
+ {
119
+ tnx: 'DKIM', messageId: mail.message.messageId(),
120
+ dkimDomains: dkim.keys.map(key => `${key.keySelector}.${key.domainName}`).join(', ')
121
+ }, '使用 %s 个密钥签署外发邮件', dkim.keys.length
122
+ );
123
+ return dkim.sign(input);
124
+ });
125
+ }
126
+
127
+ // 使用传输器发送邮件
128
+ this.transporter.send(mail, (err, ...args) => {
129
+ isCallbackErr(err, 'send', '发送错误: %s', 'transport'), callback(null, ...args);
130
+ });
131
+ });
132
+ });
133
+
134
+ return promise;
135
+ }
136
+
137
+ /**
138
+ * 获取版本字符串
139
+ */
140
+ getVersionString() {
141
+ const { name, version } = this.transporter;
142
+ return format('%s (%s; +%s; %s/%s)', PK.name, PK.version, PK.homepage, name, version);
143
+ }
144
+
145
+ /**
146
+ * 处理插件
147
+ * @param {string} step 处理阶段
148
+ * @param {Object} mail 邮件对象
149
+ * @param {Function} callback 回调函数
150
+ */
151
+ #processPlugins(step = '', mail, callback) {
152
+ step = step.toString();
153
+ if (!this._userPlugins.hasOwnProperty(step)) return callback(); // 如果没有这个步骤,则直接返回
154
+
155
+ const userPlugins = this._userPlugins[step] || [], defaultPlugins = this._defaultPlugins[step] || [],
156
+ count = userPlugins.length;
157
+ // 记录插件使用情况(如果没有插件,则直接返回)
158
+ if (count) this.logger.debug({ tnx: 'transaction', pluginCount: count, step }, '在 %s 阶段使用 %s 个插件', step, count);
159
+ if (count + defaultPlugins.length === 0) return callback();
160
+
161
+ // 递归处理插件
162
+ let pos = 0, block = 'default';
163
+ const processPlugins = () => {
164
+ let curPlugins = block === 'default' ? defaultPlugins : userPlugins;
165
+ // 如果当前位置超过了当前插件列表的长度,则切换到下一个插件列表
166
+ if (pos >= curPlugins.length) {
167
+ if (block === 'default' && count) block = 'user', pos = 0, curPlugins = userPlugins;
168
+ else return callback();
169
+ }
170
+ let plugin = curPlugins[pos++];
171
+ plugin(mail, err => {
172
+ if (err) return callback(err); // 如果插件返回错误,则直接返回
173
+ processPlugins();
174
+ });
175
+ };
176
+
177
+ processPlugins();
178
+ }
179
+
180
+ /**
181
+ * 为Nodemailer对象设置代理处理器
182
+ *
183
+ * @param {String} proxyUrl 代理配置URL
184
+ */
185
+ setupProxy(proxyUrl) {
186
+ const proxy = newURL(proxyUrl);
187
+ if (!proxy) throw new Error(`无效的代理URL: ${proxyUrl}`);
188
+
189
+ // 为mailer对象设置socket处理器
190
+ this.getSocket = (options, callback) => {
191
+ const { protocol: protocol_, href, port: pPort, auth, hostname } = proxy,
192
+ protocol = (protocol_.endsWith(':') ? protocol_.slice(0, -1) : protocol_).toLowerCase(),
193
+ proxyHandlerKey = `proxy_handler_${protocol}`;
194
+ // 检查是否有自定义代理处理器
195
+ if (this.meta.has(proxyHandlerKey)) return this.meta.get(proxyHandlerKey)(proxy, options, callback);
196
+ const { port, host } = options;
197
+ // 根据协议类型处理不同代理
198
+ switch (protocol) {
199
+ // 使用HTTP CONNECT方法连接
200
+ case 'http':
201
+ case 'https':
202
+ httpProxyClient(href, port, host, (err, socket) => {
203
+ return err ? callback(err) : callback(null, { connection: socket });
204
+ });
205
+ return;
206
+ case 'socks':
207
+ case 'socks5':
208
+ case 'socks4':
209
+ case 'socks4a': {
210
+ const moduleName = 'proxy_socks_module'
211
+ // 检查是否已加载Socks模块
212
+ if (!this.meta.has(moduleName)) return callback(new Error('未加载Socks模块'));
213
+
214
+ const connect = ipaddress => {
215
+ const proxyV2 = !!this.meta.get(moduleName).SocksClient,
216
+ socksClient = proxyV2 ? this.meta.get(moduleName).SocksClient : this.meta.get(moduleName),
217
+ rND = resetRegex(regexs.NON_DIGIT), type = Number(protocol_.replace(rND, '')) || 5,
218
+ connectionOpts = {
219
+ proxy: { ipaddress, port: Number(pPort), type },
220
+ [proxyV2 ? 'destination' : 'target']: { host, port }, command: 'connect'
221
+ };
222
+
223
+ // 处理代理认证
224
+ if (auth) {
225
+ const username = decodeURIComponent(auth.split(':').shift()),
226
+ password = decodeURIComponent(auth.split(':').pop());
227
+ // 如果使用Socks5代理v2,则使用userId和password字段,否则使用authentication字段
228
+ if (proxyV2) connectionOpts.proxy.userId = username, connectionOpts.proxy.password = password;
229
+ else if (type === 4) connectionOpts.userid = username; // 如果使用Socks4代理,则使用userid字段
230
+ else connectionOpts.authentication = { username, password };
231
+ }
232
+
233
+ // 创建SOCKS连接
234
+ socksClient.createConnection(connectionOpts, (err, info) => {
235
+ return err ? callback(err) : callback(null, { connection: info.socket || info });
236
+ });
237
+ };
238
+
239
+ // 如果代理主机是IP地址,则直接连接
240
+ if (isIP(hostname)) return connect(hostname);
241
+
242
+ return dnsResolve(hostname, (err, address) => {
243
+ if (err) return callback(err); // 如果出错,则直接返回
244
+ connect(Array.isArray(address) ? address[0] : address);
245
+ });
246
+ }
247
+ }
248
+ callback(new Error('未知代理配置'));
249
+ };
250
+ }
251
+
252
+ /**
253
+ * 转换数据URL图片为附件
254
+ * @param {Object} mail 邮件对象
255
+ * @param {Function} callback 回调函数
256
+ */
257
+ #convertDataImages(mail, callback) {
258
+ // 如果没有附件数据URL或没有HTML内容,则直接返回
259
+ if ((!this.options.attachDataUrls && !mail.data.attachDataUrls) || !mail.data.html) return callback();
260
+
261
+ // 解析HTML内容
262
+ mail.resolveContent(mail.data, 'html', (err, html = '') => {
263
+ if (err) return callback(err);
264
+ let imgCid = 0, ats;
265
+ const { attachments } = mail.data;
266
+
267
+ // 确保attachments是数组
268
+ if (!Array.isArray(attachments)) ats = mail.data.attachments = [].concat(attachments || []);
269
+ const rDUI = resetRegex(regexs.DATA_URL_IMAGE);
270
+ // 替换数据URL为CID引用
271
+ html = html.toString()
272
+ .replace(rDUI, (_match, prefix, dataUri, mimeType) => {
273
+ // 添加附件
274
+ const cid = `${randomBytes(10).toString('hex')}@localhost`;
275
+ ats.push({ path: dataUri, cid, filename: `image-${++imgCid}.${detectExtension(mimeType)}` });
276
+ return `${prefix}cid:${cid}`;
277
+ });
278
+ mail.data.html = html;
279
+ callback();
280
+ });
281
+ }
282
+
283
+ /**
284
+ * 设置元数据
285
+ * @param {*} key 键
286
+ * @param {*} value 值
287
+ */
288
+ set(key, value) {
289
+ return this.meta.set(key, value);
290
+ }
291
+
292
+ /**
293
+ * 获取元数据
294
+ * @param {*} key 键
295
+ */
296
+ get(key) {
297
+ return this.meta.get(key);
298
+ }
299
+ }
300
+
301
+ export { Mailer };