@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
package/index.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * mailer 主模块
3
+ *
4
+ * 模块依赖说明:
5
+ * - Mailer: 核心邮件发送器,负责管理传输器和邮件发送流程
6
+ * - SmtpPool: SMTP连接池传输器,支持连接复用和并发发送
7
+ * - SmtpTransport: 标准SMTP传输器,用于通过SMTP服务器发送邮件
8
+ * - SendmailTransport: 本地sendmail传输器,使用系统sendmail命令发送
9
+ * - StreamTransport: 流传输器,将邮件输出为流格式,主要用于测试
10
+ * - JsonTransport: JSON传输器,将邮件转换为JSON格式输出,用于调试
11
+ * - SesTransport: Amazon SES传输器,通过AWS Simple Email Service发送邮件
12
+ * - shared: 共享工具函数模块,包含URL解析,正则常量等通用功能
13
+ */
14
+ import { Mailer } from './lib/mailer/index.js';
15
+ import { SmtpPool } from './lib/smtp-pool/index.js';
16
+ import { SmtpTransport } from './lib/smtp-transport.js';
17
+ import { SendmailTransport } from './lib/sendmail-transport.js';
18
+ import { StreamTransport } from './lib/stream-transport.js';
19
+ import { JsonTransport } from './lib/json-transport.js';
20
+ import { SesTransport } from './lib/ses-transport.js';
21
+ import { parseConnectionUrl, regexs } from './lib/shared.js';
22
+
23
+ // 创建传输器
24
+ const createTransport = (transporter, defaults) => {
25
+ const urlConfig = typeof transporter === 'string' ? transporter : transporter.url;
26
+ let options;
27
+
28
+ // 如果传入的transporter是配置对象而非传输器插件,或者看起来像是连接URL
29
+ if ((typeof transporter === 'object' && typeof transporter.send !== 'function') ||
30
+ (typeof transporter === 'string' && regexs.TRANSPORTER_PROTOCOL.test(transporter))) {
31
+ options = urlConfig && parseConnectionUrl(urlConfig) || transporter; // 将配置URL解析为配置选项
32
+
33
+ if (options.pool) transporter = new SmtpPool(options);
34
+ else if (options.sendmail) transporter = new SendmailTransport(options);
35
+ else if (options.streamTransport) transporter = new StreamTransport(options);
36
+ else if (options.jsonTransport) transporter = new JsonTransport(options);
37
+ else if (options.SES) {
38
+ if (options.SES.ses && options.SES.aws) {
39
+ let error = new Error('检测到旧版SES配置,请使用@aws-sdk/client-sesv2');
40
+ error.code = 'LegacyConfig';
41
+ throw error;
42
+ }
43
+ transporter = new SesTransport(options);
44
+ }
45
+ else transporter = new SmtpTransport(options);
46
+ }
47
+
48
+ const mailer = new Mailer(transporter, options, defaults);
49
+ return mailer;
50
+ },
51
+ // 验证配置
52
+ validateConfig = config => {
53
+ const errors = [], warnings = [];
54
+
55
+ // 检查配置类型
56
+ if (!config) {
57
+ errors.push('配置不能为空');
58
+ return { valid: false, errors, warnings };
59
+ }
60
+ const { host, service, auth, port, secure, tls, pool, maxConnections, maxMessages, sendmail, SES } = config;
61
+
62
+ // 如果是字符串(URL格式)
63
+ if (typeof config === 'string' && !regexs.TRANSPORTER_PROTOCOL.test(config))
64
+ errors.push('URL格式不正确,应以 smtp://, smtps:// 或 direct:// 开头');
65
+ // 否则,如果是配置对象
66
+ else if (typeof config === 'object') {
67
+ // 检查传输器类型
68
+ const transportTypes = ['SMTP', 'Sendmail', 'Stream', 'JSON', 'SES'],
69
+ hasTransportType = transportTypes.some(type =>
70
+ config[type.toLowerCase()] || config[type] || config[`${type}Transport`]
71
+ );
72
+
73
+ if (!hasTransportType && typeof config.send !== 'function') {
74
+ // 默认为SMTP传输器,验证SMTP相关配置
75
+ if (!host && !service) errors.push('必须指定 host 或 service');
76
+
77
+ if (auth) {
78
+ if (!auth.user) errors.push('认证配置中缺少 user 字段');
79
+ if (!auth.pass && !auth.oauth2 && !auth.xoauth2) warnings.push('(pass,oauth2,xoauth2)都无配置,将尝试无密码连接');
80
+ }
81
+ // 非25端口通常需要认证
82
+ else if (port !== 25) warnings.push('未提供认证信息,某些邮件服务器可能拒绝连接');
83
+
84
+ if (port && (port < 1 || port > 65535)) errors.push('端口号应在 1-65535 范围内');
85
+ // 检查TLS/SSL配置
86
+ if (secure !== undefined && typeof secure !== 'boolean') errors.push('secure 字段应为布尔值');
87
+ if (tls !== undefined && typeof tls !== 'object' && typeof tls !== 'boolean') errors.push('tls 字段应为对象或布尔值');
88
+ }
89
+
90
+ // 验证池配置
91
+ if (pool && typeof pool !== 'boolean') errors.push('pool 字段应为布尔值');
92
+ if (maxConnections && (typeof maxConnections !== 'number' || maxConnections < 1)) errors.push('maxConnections 应为大于0的数字');
93
+ if (maxMessages && (typeof maxMessages !== 'number' || maxMessages < 1)) errors.push('maxMessages 应为大于0的数字');
94
+
95
+ // 验证Sendmail配置
96
+ if (sendmail && sendmail !== true && typeof sendmail !== 'string') errors.push('sendmail 字段应为布尔值或字符串路径');
97
+
98
+ // 验证SES配置
99
+ if (SES) {
100
+ if (typeof SES !== 'object') errors.push('SES 配置应为对象');
101
+ else if (SES.ses && SES.aws) warnings.push('检测到旧版SES配置,建议使用新版AWS SDK');
102
+ }
103
+ }
104
+ else errors.push('配置应为字符串或对象');
105
+
106
+ return { valid: errors.length === 0, errors, warnings: warnings.length > 0 ? warnings : undefined };
107
+ };
108
+
109
+ export { createTransport, validateConfig };
@@ -0,0 +1,230 @@
1
+ import { regexs } from './regexs.js';
2
+
3
+ /**
4
+ * 用于标记地址字段字符串的标记器
5
+ * @constructor
6
+ * @param {String} str 需要解析的地址字段字符串
7
+ */
8
+ class Tokenizer {
9
+ constructor(str = '') {
10
+ this.str = str.toString(), this.operatorExpecting = '', this.node = null, this.escaped = false, this.list = [];
11
+
12
+ // 操作符映射:开始操作符到期望的结束操作符(引号,注释,邮件地址,地址分隔符,组定义开始,组定义结束)
13
+ this.operators = { '"': '"', '(': ')', '<': '>', ',': '', ':': ';', ';': '' };
14
+ }
15
+
16
+ /**
17
+ * 将输入字符串标记化为操作符和文本令牌
18
+ * @return {Array} 令牌对象数组
19
+ */
20
+ tokenize() {
21
+ const list = [];
22
+
23
+ for (let i = 0, len = this.str.length; i < len; i++) {
24
+ const chr = this.str.charAt(i), nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
25
+ this.#processChar(chr, nextChr);
26
+ }
27
+
28
+ // 过滤并清理令牌
29
+ this.list.forEach(node => {
30
+ node.value = (node.value || '').toString().trim();
31
+ if (node.value) list.push(node); // 忽略空文本节点
32
+ });
33
+
34
+ return list;
35
+ }
36
+
37
+ /**
38
+ * 处理单个字符,识别操作符或文本内容
39
+ * @param {String} chr 当前字符
40
+ * @param {String} nextChr 下一个字符(用于前瞻)
41
+ */
42
+ #processChar(chr, nextChr) {
43
+ const { escaped, operatorExpecting: oExpecting, operators } = this;
44
+ // 检查是否是期望的结束操作符
45
+ if (!escaped && chr === oExpecting) return this.#addOperatorToken(chr, nextChr);
46
+
47
+ // 检查是否是新的开始操作符
48
+ else if (!escaped && !oExpecting && chr in operators) return this.#addOperatorToken(chr, nextChr, operators[chr]);
49
+
50
+ // 检查转义字符(在引号或注释中)
51
+ else if (!escaped && ['"', "'"].includes(oExpecting) && chr === '\\') return this.escaped = true;
52
+ this.#addTextChar(chr), this.escaped = false; // 处理文本字符并重置转义标志
53
+ }
54
+
55
+ /**
56
+ * 添加操作符令牌到列表
57
+ * @param {String} chr 操作符字符
58
+ * @param {String} nextChr 下一个字符(用于设置noBreak标志)
59
+ * @param {String} newExpecting 新的期望操作符
60
+ */
61
+ #addOperatorToken(chr, nextChr, newExpecting = '') {
62
+ const node = { type: 'operator', value: chr };
63
+ // 如果下一个字符不是空白或分隔符,设置noBreak标志
64
+ if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) node.noBreak = true;
65
+ this.list.push(node), this.node = null, this.escaped = false, this.operatorExpecting = newExpecting;
66
+ }
67
+
68
+ /**
69
+ * 添加文本字符到当前文本节点
70
+ * @param {String} chr 文本字符
71
+ */
72
+ #addTextChar(chr) {
73
+ if (!this.node) this.node = { type: 'text', value: '' }, this.list.push(this.node); // 如果没有当前文本节点,则创建一个新的
74
+
75
+ const processedChr = chr === '\n' ? ' ' : chr; // 规范化空白字符
76
+ if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) this.node.value += processedChr;// 如果是可见字符或空格,则添加
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 如果文本数据为空但存在注释数据,则用注释数据替换文本数据
82
+ * @param {Object} data 包含文本和注释数据的对象
83
+ * @return {Object} 处理后的数据对象
84
+ */
85
+ const _replaceTextWithComment = data => {
86
+ if (!data.text.length && data.comment.length) data.text = data.comment, data.comment = [];
87
+ return data;
88
+ },
89
+
90
+ /**
91
+ * 从文本数据中检测并提取电子邮件地址
92
+ * @param {Object} data 包含地址和文本数据的对象
93
+ */
94
+ _extractEmailFromText = data => {
95
+ let foundAddress = false;
96
+ const { text } = data,
97
+ regexHandler = address => {
98
+ if (!foundAddress) {
99
+ data.address = [address.trim()], foundAddress = true;
100
+ return '';
101
+ }
102
+ return address;
103
+ };
104
+
105
+ for (let i = text.length - 1; i >= 0; i--) {
106
+ const currentText = text[i];
107
+ if (currentText.match(regexs.EMAIL_EXACT)) {
108
+ data.address = text.splice(i, 1), foundAddress = true;
109
+ break;
110
+ }
111
+ if (!foundAddress) {
112
+ // ✅ 移除 .trim(),保留原始空格,后续 join 会自然合并
113
+ text[i] = currentText.replace(regexs.EMAIL_LOOSE, regexHandler);
114
+ if (foundAddress) break;
115
+ }
116
+ }
117
+ // 清理文本数组中可能产生的多余空白项
118
+ data.text = data.text.filter(t => t.trim() !== '');
119
+ },
120
+
121
+ /**
122
+ * 将单个地址的令牌转换为地址对象
123
+ * @param {Array} tokens 令牌对象数组
124
+ * @return {Array} 地址对象数组
125
+ */
126
+ _handleAddress = tokens => {
127
+ let isGroup = false, state = 'text';
128
+ const data = { address: [], comment: [], group: [], text: [] };
129
+
130
+ // 解析令牌,分类到地址、注释、组或文本
131
+ for (let i = 0; i < tokens.length; i++) {
132
+ const token = tokens[i], prevToken = i ? tokens[i - 1] : null;
133
+ if (token.type === 'operator')
134
+ switch (token.value) {
135
+ case '<':
136
+ state = 'address';
137
+ break;
138
+ case '(':
139
+ state = 'comment';
140
+ break;
141
+ case ':':
142
+ state = 'group';
143
+ isGroup = true;
144
+ break;
145
+ default:
146
+ state = 'text';
147
+ break;
148
+ }
149
+ else if (token.value) {
150
+ // 清理地址令牌中的多余字符(如未引用的名称中的"<")
151
+ if (state === 'address') token.value = token.value.replace(regexs.ADDRESS_CLEAN, '');
152
+
153
+ // 如果前一个令牌设置了noBreak,则附加到最后一个元素,否则创建新的元素
154
+ const dLen = data[state].length;
155
+ prevToken?.noBreak && dLen ? data[state][dLen - 1] += token.value : data[state].push(token.value);
156
+ }
157
+ }
158
+ const { text, group, address } = _replaceTextWithComment(data); // 如果没有文本但有注释,用注释替换文本
159
+ // 如果组存在,处理组地址:组名和组成员列表
160
+ if (isGroup) return [{ name: text.join(' ') || '', group: group.length ? addressparser(group.join(',')) : [] }];
161
+ else {
162
+ if (!address.length && text.length) _extractEmailFromText(data); // 如果没有地址,从文本中提取电子邮件地址
163
+
164
+ const { text: newTxt, address: newAdd } = _replaceTextWithComment(data); // 再次检查是否需要将注释转换为文本
165
+ if (newAdd.length > 1) newTxt.push(...newAdd.splice(1)); // 如果找到多个地址,只保留第一个,其余的转到文本数组
166
+ const addressText = newTxt.join(' ').trim(), addressValue = newAdd.join(' ').trim(),// 合并文本和地址数据
167
+ // 创建地址对象
168
+ addressObj = { address: addressValue || addressText || '', name: addressText || addressValue || '' };
169
+
170
+ // 如果地址和名称相同,根据内容决定清除哪个(如果是邮件地址,清除名称,否则,清除地址)
171
+ if (addressObj.address === addressObj.name)
172
+ addressObj.address.includes('@') ? addressObj.name = '' : addressObj.address = '';
173
+
174
+ return [addressObj];
175
+ }
176
+ };
177
+
178
+ /**
179
+ * 解析地址字段字符串为结构化的电子邮件地址对象数组
180
+ * >查看定义:@see {@link addressparser}
181
+ * - 示例:
182
+ * '姓名 <user@example.com>'
183
+ * 转换为 [{name: '姓名', address: 'user@example.com'}]
184
+ *
185
+ * 支持:
186
+ * - 单个地址: 'user@example.com'
187
+ * - 带姓名地址: '姓名 <user@example.com>'
188
+ * - 带注释地址: '姓名 (注释) <user@example.com>'
189
+ * - 地址组: '组名: member1@example.com, member2@example.com;'
190
+ *
191
+ * @param {String} str 需要解析的地址字段字符串
192
+ * @param {Object} options 解析选项
193
+ * @param {Boolean} options.flatten 是否将组地址展平为单个地址列表
194
+ * @return {Array} 地址对象数组
195
+ */
196
+ const addressparser = (str, options = {}) => {
197
+ const tokenizer = new Tokenizer(str), tokens = tokenizer.tokenize(), addresses = [];
198
+ let currentAddress = [];
199
+
200
+ // 根据分隔符将令牌分组为各个地址
201
+ tokens.forEach(token => {
202
+ if (token.type === 'operator' && [',', ';'].includes(token.value)) {
203
+ if (currentAddress.length) addresses.push(currentAddress);
204
+ currentAddress = [];
205
+ }
206
+ else currentAddress.push(token);
207
+ });
208
+
209
+ if (currentAddress.length) addresses.push(currentAddress); // 处理最后一个地址
210
+ let parsedAddresses = []; // 解析每个地址组
211
+ addresses.forEach(addressTokens => {
212
+ const address = _handleAddress(addressTokens);
213
+ if (address.length) parsedAddresses = parsedAddresses.concat(address);
214
+ });
215
+
216
+ // 如果要求展平,将组地址转换为平面列表
217
+ if (options.flatten) {
218
+ const flattenedAddresses = [],
219
+ walkAddressList = list => {
220
+ list.forEach(({ group, ...address }) => group ? walkAddressList(group) : flattenedAddresses.push(address));
221
+ };
222
+
223
+ walkAddressList(parsedAddresses);
224
+ return flattenedAddresses;
225
+ }
226
+
227
+ return parsedAddresses;
228
+ }
229
+
230
+ export { addressparser };
package/lib/base64.js ADDED
@@ -0,0 +1,103 @@
1
+ import { Transform } from 'stream';
2
+
3
+ /**
4
+ * 将Buffer编码为base64字符串
5
+ * >查看定义:@see {@link base64Encode}
6
+ * @param {Buffer} buffer 要转换的Buffer
7
+ * @returns {String} base64编码的字符串
8
+ */
9
+ const base64Encode = buffer => {
10
+ if (typeof buffer === 'string') buffer = Buffer.from(buffer, 'utf-8');
11
+ return buffer.toString('base64');
12
+ },
13
+
14
+ /**
15
+ * 为base64字符串添加软换行
16
+ *
17
+ * @param {String} str 可能需要换行的base64编码字符串
18
+ * @param {Number} [lineLength=76] 每行的最大允许长度
19
+ * @returns {String} 软换行的base64编码字符串
20
+ */
21
+ base64Wrap = (str = '', lineLength = 76) => {
22
+ str = str.toString();
23
+ if (str.length <= lineLength) return str;
24
+
25
+ let pos = 0;
26
+ const result = [], chunkLength = lineLength * 1024;
27
+
28
+ // 每行最多允许lineLength个字符,每1024行进行一次处理
29
+ while (pos < str.length) {
30
+ const end = pos + chunkLength, wLines = str.substring(pos, end).replace(new RegExp(`.{${lineLength}}`, 'g'), '$&\r\n');
31
+ result.push(wLines), pos = end;
32
+ }
33
+
34
+ return result.join('');
35
+ };
36
+
37
+ /**
38
+ * 创建用于将数据编码为base64的转换流
39
+ * >查看定义:@see {@link Base64Encoder}
40
+ * @constructor
41
+ * @param {Object} options 流选项
42
+ * @param {Number} [options.lineLength=76] 行最大长度,设置为false可禁用换行
43
+ */
44
+ class Base64Encoder extends Transform {
45
+ constructor(options = {}) {
46
+ super();
47
+ this.options = options;
48
+
49
+ // 默认每行76个字符
50
+ const { lineLength } = this.options;
51
+ if (lineLength !== false) this.options.lineLength = lineLength || 76;
52
+ // 当前行内容,上次剩余的字节,输入字节数,输出字节数
53
+ this._curLine = '', this._remainingBytes = false, this.inputBytes = 0, this.outputBytes = 0;
54
+ }
55
+
56
+ _transform(chunk, encoding, done) {
57
+ if (encoding !== 'buffer') chunk = Buffer.from(chunk, encoding); // 如果不是Buffer,则按指定编码转换为Buffer
58
+ if (!chunk?.length) return setImmediate(done); // 如果没有数据,直接返回
59
+
60
+ this.inputBytes += chunk.length;
61
+ // 如果上次有剩余的字节,先补齐
62
+ const rLen = this._remainingBytes?.length ?? 0;
63
+ if (rLen)
64
+ chunk = Buffer.concat([this._remainingBytes, chunk], rLen + chunk.length), this._remainingBytes = false;
65
+
66
+ // base64每3个字节编码为4个字符,如果不是3的倍数,则剩余的字节留到下次处理
67
+ const remain = chunk.length % 3;
68
+ if (remain) {
69
+ const chunkEnd = chunk.length - remain;
70
+ this._remainingBytes = chunk.slice(chunkEnd), chunk = chunk.slice(0, chunkEnd);
71
+ }
72
+ else this._remainingBytes = false;
73
+
74
+ let b64 = this._curLine + base64Encode(chunk);
75
+ const lineLength = this.options.lineLength;
76
+ if (lineLength) {
77
+ b64 = base64Wrap(b64, lineLength);
78
+ const indexLF = b64.lastIndexOf('\n'); // 最后一个换行符位置
79
+ if (indexLF < 0) this._curLine = b64, b64 = '';
80
+ else {
81
+ const nextIndex = indexLF + 1;
82
+ this._curLine = b64.substring(nextIndex), b64 = b64.substring(0, nextIndex);
83
+ if (b64 && !b64.endsWith('\r\n')) b64 += '\r\n'; // 确保以换行符结尾
84
+ }
85
+ }
86
+ else this._curLine = ''; // 不换行
87
+
88
+ if (b64) this.outputBytes += b64.length, this.push(Buffer.from(b64, 'ascii')); // 输出编码后的数据
89
+ setImmediate(done);
90
+ }
91
+
92
+ // 处理剩余的字节(​Nodejs流机制在适当时候自动调用_flush)
93
+ _flush(done) {
94
+ const _rBytes = this._remainingBytes;
95
+ if (_rBytes?.length) this._curLine += base64Encode(_rBytes);
96
+ const _curLine = this._curLine;
97
+ // 输出剩余的数据
98
+ if (_curLine) this.outputBytes += _curLine.length, this.push(Buffer.from(_curLine, 'ascii')), this._curLine = '';
99
+ done();
100
+ }
101
+ }
102
+
103
+ export { base64Encode, Base64Encoder };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * 用于邮件服务器或邮件发送系统,为外发邮件添加DKIM签名,
3
+ * 提高邮件的可信度和防伪造能力,帮助通过SPF、DKIM、DMARC等反垃圾邮件验证
4
+ */
5
+ import { unlink, createReadStream, createWriteStream } from 'fs';
6
+ import { join as pJoin } from 'path';
7
+ import { PassThrough } from 'stream';
8
+ import { randomBytes } from 'crypto';
9
+ import { MessageParser } from './message-parser.js';
10
+ import { RelaxedBody } from './relaxed-body.js';
11
+ import { generateDKIMSignature as signer } from './sign.js'; // 用于生成DKIM签名字段
12
+
13
+ // DKIM签名的默认哈希算法,最大保存消息;
14
+ const DKIM_ALGO = 'sha256', MAX_MESSAGE_SIZE = 2 * 1024 * 1024;
15
+
16
+ /**
17
+ * DKIMSigner 类 - 实现 DKIM 签名的流式处理
18
+ *
19
+ * @class DKIMSigner
20
+ * @description 该类负责在邮件传输过程中对邮件内容进行 DKIM 签名;
21
+ * 它通过管道流的方式读取原始邮件数据,解析邮件头和邮件体,
22
+ * 计算邮件体的哈希值,并使用提供的私钥生成 DKIM 签名头,
23
+ * 最终将签名后的邮件数据写入输出流,支持多密钥签名、缓存
24
+ * 溢出处理及宽松正文规范化;
25
+ */
26
+ class DKIMSigner {
27
+ // 初始化 DKIM 签名器实例
28
+ constructor(options = {}, keys, input, output) {
29
+ this.options = options;
30
+ const { cacheTreshold, hashAlgo, cacheDir } = this.options;
31
+ this.cacheTreshold = Number(cacheTreshold) || MAX_MESSAGE_SIZE;
32
+ this.hashAlgo = hashAlgo || DKIM_ALGO, this.cacheDir = cacheDir || false;
33
+ this.keys = keys, this.chunks = [], this.chunklen = 0, this.readPos = 0;
34
+ this.cachePath =
35
+ this.cacheDir ? pJoin(this.cacheDir, `message.${Date.now()}-${randomBytes(14).toString('hex')}`) : false;
36
+
37
+ this.input = input, this.output = output;
38
+ this.cache = false, this.headers = false, this.bodyHash = false, this.parser = false, this.relaxedBody = false;
39
+ this.output.usingCache = false, this.hasErrored = false;
40
+ this.input.on('error', err => {
41
+ this.hasErrored = true, this.cleanup(), output.emit('error', err);
42
+ });
43
+ }
44
+
45
+ // 清理缓存文件
46
+ cleanup() {
47
+ if (!this.cache || !this.cachePath) return; // 如果没有缓存文件则不需要清理
48
+ unlink(this.cachePath, () => false); // 删除缓存文件(忽略错误)
49
+ }
50
+
51
+ // 创建读取缓存流
52
+ createReadCache() {
53
+ // 将剩余数据管道传输到缓存文件
54
+ this.cache = createReadStream(this.cachePath);
55
+ this.cache.once('error', err => {
56
+ this.cleanup(), this.output.emit('error', err);
57
+ }).once('close', () => this.cleanup());
58
+ this.cache.pipe(this.output);
59
+ }
60
+
61
+ // 发送下一个数据块
62
+ sendNextChunk() {
63
+ if (this.hasErrored) return; // 如果已经出错则不继续发送数据
64
+
65
+ // 如果没有缓存文件则结束输出,否则创建读取缓存流
66
+ if (this.readPos >= this.chunks.length) return !this.cache ? this.output.end() : this.createReadCache();
67
+ // 写入数据,如果返回false则等待'drain'事件
68
+ const chunk = this.chunks[this.readPos++];
69
+ if (this.output.write(chunk) === false) return this.output.once('drain', () => this.sendNextChunk());
70
+ setImmediate(() => this.sendNextChunk());
71
+ }
72
+
73
+ // 发送签名后的输出
74
+ sendSignedOutput() {
75
+ let keyPos = 0;
76
+ const signNextKey = () => {
77
+ // 所有密钥都已处理,发送原始头部并继续发送数据块
78
+ if (keyPos >= this.keys.length) {
79
+ this.output.write(this.parser.rawHeaders);
80
+ return setImmediate(() => this.sendNextChunk());
81
+ }
82
+ const { domainName, keySelector, privateKey } = this.keys[keyPos++], { headerFieldNames, skipFields } = this.options,
83
+ // 定义 DKIM 签名字段
84
+ dkimField = signer(this.headers, this.hashAlgo, this.bodyHash,
85
+ { domainName, keySelector, privateKey, headerFieldNames, skipFields });
86
+ if (dkimField) this.output.write(Buffer.from(`${dkimField}\r\n`)); // 如果签名成功则写入 DKIM 字段
87
+ return setImmediate(signNextKey);
88
+ };
89
+
90
+ if (this.bodyHash && this.headers) return signNextKey(); // 如果已经有bodyHash和headers则直接签名
91
+ this.output.write(this.parser.rawHeaders), this.sendNextChunk();
92
+ }
93
+
94
+ // 创建写入缓存流
95
+ createWriteCache() {
96
+ this.output.usingCache = true, this.cache = createWriteStream(this.cachePath); // 将剩余数据管道传输到缓存文件
97
+ // 处理缓存流的错误和关闭事件
98
+ this.cache.once('error', err => {
99
+ this.cleanup(), this.relaxedBody.unpipe(this.cache); // 清理缓存文件,停止传输到缓存
100
+ this.relaxedBody.resume(); // 自动消耗剩余数据
101
+ this.hasErrored = true, this.output.emit('error', err); // 触发错误事件
102
+ }).once('close', () => this.sendSignedOutput());
103
+ this.relaxedBody.removeAllListeners('readable'), this.relaxedBody.pipe(this.cache);
104
+ }
105
+
106
+ // 开始签名流处理
107
+ signStream() {
108
+ this.parser = new MessageParser();
109
+ this.relaxedBody = new RelaxedBody({ hashAlgo: this.hashAlgo }); // 使用宽松的正文处理
110
+ this.parser.on('headers', value => this.headers = value); // 监听解析器的头部事件
111
+ this.relaxedBody.on('hash', value => this.bodyHash = value); // 监听宽松正文的哈希事件
112
+
113
+ // 监听宽松正文的可读事件
114
+ this.relaxedBody.on('readable', () => {
115
+ let chunk;
116
+ if (this.cache) return; // 如果已经是缓存模式则不继续处理
117
+ while ((chunk = this.relaxedBody.read()) !== null) {
118
+ this.chunks.push(chunk), this.chunklen += chunk.length;
119
+ // 如果数据块长度超过阈值且有缓存路径则切换到缓存模式
120
+ if (this.chunklen >= this.cacheTreshold && this.cachePath) return this.createWriteCache();
121
+ }
122
+ });
123
+
124
+ // 监听宽松正文的结束事件
125
+ this.relaxedBody.on('end', () => {
126
+ if (this.cache) return; // 如果已经是缓存模式则不继续处理
127
+ this.sendSignedOutput();
128
+ });
129
+
130
+ this.parser.pipe(this.relaxedBody), setImmediate(() => this.input.pipe(this.parser));
131
+ }
132
+ }
133
+
134
+ /**
135
+ * DKIM 类 - DomainKeys Identified Mail 签名处理类
136
+ * >查看定义:@see {@link DKIM}
137
+ * @class DKIM
138
+ * @description 用于对邮件内容进行 DKIM 签名,支持从配置中加载多个域名密钥;
139
+ * 将字符串、Buffer 或流形式的邮件输入转换为经过签名的输出流;
140
+ * 签名过程通过内部 DKIMSigner 完成;通常与邮件发送流程集成使用;
141
+ *
142
+ * @example
143
+ * ```js
144
+ * // 1. 创建DKIM实例
145
+ * const dkim = new DKIM({
146
+ * domainName: 'example.com', // 域名,用于标识签名来源
147
+ * keySelector: 'key-selector', // 密钥选择器,用于在DNS中查找公钥
148
+ * privateKey, // RSA 私钥,PEM 格式字符串(包含 -----BEGIN RSA PRIVATE KEY----- 等),用于生成 DKIM 签名
149
+ * cacheDir: '/tmp' // 缓存目录,用于处理大邮件时的临时存储
150
+ * });
151
+ *
152
+ * // 2. 对邮件进行签名
153
+ * dkim.sign(input).pipe(process.stdout);
154
+ * ```
155
+ * 参数说明:
156
+ * input: 输入邮件内容,可以是以下类型:
157
+ * Stream (流):可读流,包含RFC822格式的原始邮件
158
+ * String (字符串):邮件内容的字符串形式
159
+ * Buffer (缓冲区):邮件内容的二进制缓冲区
160
+ *
161
+ * - 返回值:一个可读流,包含已添加DKIM签名的完整邮件
162
+ *
163
+ * 工作流程:
164
+ * 1. 解析输入邮件的头部和正文
165
+ * 2. 对邮件正文进行规范化处理并计算哈希值
166
+ * 3. 使用私钥对指定邮件头字段和正文哈希进行签名
167
+ * 4. 在邮件头部添加DKIM-Signature字段
168
+ * 5. 输出完整的已签名邮件
169
+ */
170
+ class DKIM {
171
+ // 初始化 DKIM 实例
172
+ constructor(options = {}) {
173
+ this.options = options;
174
+ const { keys, domainName, keySelector, privateKey } = this.options;
175
+ this.keys = [].concat(keys || { domainName, keySelector, privateKey });
176
+ }
177
+
178
+ // 签名输入数据
179
+ sign(input, extraOptions = {}) {
180
+ const output = new PassThrough();
181
+ let inputStream = input, writeValue = false;
182
+
183
+ // 如果输入是字符串或Buffer,则转换为流
184
+ if (Buffer.isBuffer(input)) writeValue = input, inputStream = new PassThrough();
185
+ // 否则如果输入是字符串,则转换为Buffer
186
+ else if (typeof input === 'string') writeValue = Buffer.from(input), inputStream = new PassThrough();
187
+
188
+ const options = this.options;
189
+ let newOptions = {};
190
+ // 如果有额外选项,则合并选项
191
+ if (extraOptions && Object.keys(extraOptions).length) {
192
+ newOptions = { ...options };
193
+ // 仅添加不存在的选项
194
+ Object.keys(extraOptions).forEach(key => {
195
+ if (!(key in newOptions)) newOptions[key] = extraOptions[key]
196
+ });
197
+ }
198
+ else newOptions = options;
199
+
200
+ const signer = new DKIMSigner(newOptions, this.keys, inputStream, output); // 创建 DKIM 签名器实例,使用最终的合并选项
201
+ // 异步启动签名流处理
202
+ setImmediate(() => {
203
+ signer.signStream();
204
+ if (writeValue) setImmediate(() => inputStream.end(writeValue)); // 如果有写入值,则在下一个事件循环中结束输入流
205
+ });
206
+
207
+ return output;
208
+ }
209
+ }
210
+
211
+ export { DKIM };