@flun/mailer 2.1.23 → 2.1.25
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 +12 -9
- package/LICENSE +19 -0
- package/Mail.js +2 -2
- package/README.md +35 -31
- package/copy-files.js +2 -2
- package/index.d.ts +27 -24
- package/index.js +24 -19
- package/lib/addressparser.js +21 -17
- package/lib/base64.js +8 -7
- package/lib/dkim/index.js +20 -17
- package/lib/dkim/message-parser.js +15 -12
- package/lib/dkim/relaxed-body.js +16 -15
- package/lib/dkim/sign.js +14 -13
- package/lib/fetch/cookies.js +24 -22
- package/lib/fetch/index.js +20 -17
- package/lib/http-proxy-client.js +14 -9
- package/lib/json-transport.js +8 -4
- package/lib/mail-composer.js +56 -39
- package/lib/mailer/index.js +17 -16
- package/lib/mailer/mail-message.js +28 -24
- package/lib/mime-funcs/index.js +69 -64
- package/lib/mime-funcs/mime-types.js +12 -6
- package/lib/mime-node/index.js +90 -77
- package/lib/mime-node/last-newline.js +4 -4
- package/lib/mime-node/le-unix.js +2 -2
- package/lib/mime-node/le-windows.js +3 -3
- package/lib/punycode.js +46 -40
- package/lib/qp.js +19 -15
- package/lib/regexs.js +30 -30
- package/lib/sendmail-transport.js +3 -3
- package/lib/ses-transport.js +12 -8
- package/lib/shared.js +74 -59
- package/lib/smtp-connection/data-stream.js +10 -9
- package/lib/smtp-connection/index.js +104 -86
- package/lib/smtp-pool/index.js +25 -22
- package/lib/smtp-pool/pool-resource.js +1 -1
- package/lib/smtp-transport.js +3 -2
- package/lib/stream-transport.js +12 -10
- package/lib/well-known/index.js +5 -4
- package/lib/xoauth2.js +31 -26
- package/package.json +5 -5
package/lib/addressparser.js
CHANGED
|
@@ -9,7 +9,7 @@ class Tokenizer {
|
|
|
9
9
|
constructor(str = '') {
|
|
10
10
|
this.str = str.toString(), this.operatorExpecting = '', this.node = null, this.escaped = false, this.list = [];
|
|
11
11
|
|
|
12
|
-
//
|
|
12
|
+
// 操作符映射:开始操作符到期望的结束操作符(引号,注释,邮件地址,地址分隔符,组定义开始,组定义结束)
|
|
13
13
|
this.operators = { '"': '"', '(': ')', '<': '>', ',': '', ':': ';', ';': '' };
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -35,9 +35,9 @@ class Tokenizer {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
38
|
+
* 处理单个字符,识别操作符或文本内容
|
|
39
39
|
* @param {String} chr 当前字符
|
|
40
|
-
* @param {String} nextChr
|
|
40
|
+
* @param {String} nextChr 下一个字符(用于前瞻)
|
|
41
41
|
*/
|
|
42
42
|
#processChar(chr, nextChr) {
|
|
43
43
|
const { escaped, operatorExpecting: oExpecting, operators } = this;
|
|
@@ -47,7 +47,7 @@ class Tokenizer {
|
|
|
47
47
|
// 检查是否是新的开始操作符
|
|
48
48
|
else if (!escaped && !oExpecting && chr in operators) return this.#addOperatorToken(chr, nextChr, operators[chr]);
|
|
49
49
|
|
|
50
|
-
//
|
|
50
|
+
// 检查转义字符(在引号或注释中)
|
|
51
51
|
else if (!escaped && ['"', "'"].includes(oExpecting) && chr === '\\') return this.escaped = true;
|
|
52
52
|
this.#addTextChar(chr), this.escaped = false; // 处理文本字符并重置转义标志
|
|
53
53
|
}
|
|
@@ -55,12 +55,12 @@ class Tokenizer {
|
|
|
55
55
|
/**
|
|
56
56
|
* 添加操作符令牌到列表
|
|
57
57
|
* @param {String} chr 操作符字符
|
|
58
|
-
* @param {String} nextChr
|
|
58
|
+
* @param {String} nextChr 下一个字符(用于设置noBreak标志)
|
|
59
59
|
* @param {String} newExpecting 新的期望操作符
|
|
60
60
|
*/
|
|
61
61
|
#addOperatorToken(chr, nextChr, newExpecting = '') {
|
|
62
62
|
const node = { type: 'operator', value: chr };
|
|
63
|
-
//
|
|
63
|
+
// 如果下一个字符不是空白或分隔符,设置noBreak标志
|
|
64
64
|
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) node.noBreak = true;
|
|
65
65
|
this.list.push(node), this.node = null, this.escaped = false, this.operatorExpecting = newExpecting;
|
|
66
66
|
}
|
|
@@ -70,15 +70,17 @@ class Tokenizer {
|
|
|
70
70
|
* @param {String} chr 文本字符
|
|
71
71
|
*/
|
|
72
72
|
#addTextChar(chr) {
|
|
73
|
-
if (!this.node)
|
|
73
|
+
if (!this.node)
|
|
74
|
+
this.node = { type: 'text', value: '' }, this.list.push(this.node); // 如果没有当前文本节点,则创建一个新的
|
|
74
75
|
|
|
75
76
|
const processedChr = chr === '\n' ? ' ' : chr; // 规范化空白字符
|
|
76
|
-
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr))
|
|
77
|
+
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr))
|
|
78
|
+
this.node.value += processedChr; // 如果是可见字符或空格,则添加
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
|
-
*
|
|
83
|
+
* 如果文本数据为空但存在注释数据,则用注释数据替换文本数据
|
|
82
84
|
* @param {Object} data 包含文本和注释数据的对象
|
|
83
85
|
* @return {Object} 处理后的数据对象
|
|
84
86
|
*/
|
|
@@ -127,7 +129,7 @@ const _replaceTextWithComment = data => {
|
|
|
127
129
|
let isGroup = false, state = 'text';
|
|
128
130
|
const data = { address: [], comment: [], group: [], text: [] };
|
|
129
131
|
|
|
130
|
-
//
|
|
132
|
+
// 解析令牌,分类到地址、注释、组或文本
|
|
131
133
|
for (let i = 0; i < tokens.length; i++) {
|
|
132
134
|
const token = tokens[i], prevToken = i ? tokens[i - 1] : null;
|
|
133
135
|
if (token.type === 'operator')
|
|
@@ -150,24 +152,26 @@ const _replaceTextWithComment = data => {
|
|
|
150
152
|
// 清理地址令牌中的多余字符(如未引用的名称中的"<")
|
|
151
153
|
if (state === 'address') token.value = token.value.replace(regexs.ADDRESS_CLEAN, '');
|
|
152
154
|
|
|
153
|
-
// 如果前一个令牌设置了noBreak
|
|
155
|
+
// 如果前一个令牌设置了noBreak,则附加到最后一个元素,否则创建新的元素
|
|
154
156
|
const dLen = data[state].length;
|
|
155
157
|
prevToken?.noBreak && dLen ? data[state][dLen - 1] += token.value : data[state].push(token.value);
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
|
-
const { text, group, address } = _replaceTextWithComment(data); //
|
|
159
|
-
//
|
|
160
|
+
const { text, group, address } = _replaceTextWithComment(data); // 如果没有文本但有注释,用注释替换文本
|
|
161
|
+
// 如果组存在,处理组地址:组名和组成员列表
|
|
160
162
|
if (isGroup) return [{ name: text.join(' ') || '', group: group.length ? addressparser(group.join(',')) : [] }];
|
|
161
163
|
else {
|
|
162
|
-
if (!address.length && text.length)
|
|
164
|
+
if (!address.length && text.length)
|
|
165
|
+
_extractEmailFromText(data); // 如果没有地址,从文本中提取电子邮件地址
|
|
163
166
|
|
|
164
167
|
const { text: newTxt, address: newAdd } = _replaceTextWithComment(data); // 再次检查是否需要将注释转换为文本
|
|
165
|
-
if (newAdd.length > 1)
|
|
168
|
+
if (newAdd.length > 1)
|
|
169
|
+
newTxt.push(...newAdd.splice(1)); // 如果找到多个地址,只保留第一个,其余的转到文本数组
|
|
166
170
|
const addressText = newTxt.join(' ').trim(), addressValue = newAdd.join(' ').trim(),// 合并文本和地址数据
|
|
167
171
|
// 创建地址对象
|
|
168
172
|
addressObj = { address: addressValue || addressText || '', name: addressText || addressValue || '' };
|
|
169
173
|
|
|
170
|
-
//
|
|
174
|
+
// 如果地址和名称相同,根据内容决定清除哪个(如果是邮件地址,清除名称,否则,清除地址)
|
|
171
175
|
if (addressObj.address === addressObj.name)
|
|
172
176
|
addressObj.address.includes('@') ? addressObj.name = '' : addressObj.address = '';
|
|
173
177
|
|
|
@@ -213,7 +217,7 @@ const addressparser = (str, options = {}) => {
|
|
|
213
217
|
if (address.length) parsedAddresses = parsedAddresses.concat(address);
|
|
214
218
|
});
|
|
215
219
|
|
|
216
|
-
//
|
|
220
|
+
// 如果要求展平,将组地址转换为平面列表
|
|
217
221
|
if (options.flatten) {
|
|
218
222
|
const flattenedAddresses = [],
|
|
219
223
|
walkAddressList = list => {
|
package/lib/base64.js
CHANGED
|
@@ -25,9 +25,10 @@ const base64Encode = buffer => {
|
|
|
25
25
|
let pos = 0;
|
|
26
26
|
const result = [], chunkLength = lineLength * 1024;
|
|
27
27
|
|
|
28
|
-
// 每行最多允许lineLength
|
|
28
|
+
// 每行最多允许lineLength个字符,每1024行进行一次处理
|
|
29
29
|
while (pos < str.length) {
|
|
30
|
-
const end = pos + chunkLength,
|
|
30
|
+
const end = pos + chunkLength,
|
|
31
|
+
wLines = str.substring(pos, end).replace(new RegExp(`.{${lineLength}}`, 'g'), '$&\r\n');
|
|
31
32
|
result.push(wLines), pos = end;
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -39,7 +40,7 @@ const base64Encode = buffer => {
|
|
|
39
40
|
* >查看定义:@see {@link Base64Encoder}
|
|
40
41
|
* @constructor
|
|
41
42
|
* @param {Object} options 流选项
|
|
42
|
-
* @param {Number} [options.lineLength=76]
|
|
43
|
+
* @param {Number} [options.lineLength=76] 行最大长度,设置为false可禁用换行
|
|
43
44
|
*/
|
|
44
45
|
class Base64Encoder extends Transform {
|
|
45
46
|
constructor(options = {}) {
|
|
@@ -54,16 +55,16 @@ class Base64Encoder extends Transform {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
_transform(chunk, encoding, done) {
|
|
57
|
-
if (encoding !== 'buffer') chunk = Buffer.from(chunk, encoding); // 如果不是Buffer
|
|
58
|
-
if (!chunk?.length) return setImmediate(done); //
|
|
58
|
+
if (encoding !== 'buffer') chunk = Buffer.from(chunk, encoding); // 如果不是Buffer,则按指定编码转换为Buffer
|
|
59
|
+
if (!chunk?.length) return setImmediate(done); // 如果没有数据,直接返回
|
|
59
60
|
|
|
60
61
|
this.inputBytes += chunk.length;
|
|
61
|
-
//
|
|
62
|
+
// 如果上次有剩余的字节,先补齐
|
|
62
63
|
const rLen = this._remainingBytes?.length ?? 0;
|
|
63
64
|
if (rLen)
|
|
64
65
|
chunk = Buffer.concat([this._remainingBytes, chunk], rLen + chunk.length), this._remainingBytes = false;
|
|
65
66
|
|
|
66
|
-
// base64每3个字节编码为4
|
|
67
|
+
// base64每3个字节编码为4个字符,如果不是3的倍数,则剩余的字节留到下次处理
|
|
67
68
|
const remain = chunk.length % 3;
|
|
68
69
|
if (remain) {
|
|
69
70
|
const chunkEnd = chunk.length - remain;
|
package/lib/dkim/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 用于邮件服务器或邮件发送系统,为外发邮件添加DKIM
|
|
2
|
+
* 用于邮件服务器或邮件发送系统,为外发邮件添加DKIM签名,
|
|
3
3
|
* 提高邮件的可信度和防伪造能力,帮助通过SPF、DKIM、DMARC等反垃圾邮件验证
|
|
4
4
|
*/
|
|
5
5
|
import { unlink, createReadStream, createWriteStream } from 'fs';
|
|
@@ -64,7 +64,7 @@ class DKIMSigner {
|
|
|
64
64
|
|
|
65
65
|
// 如果没有缓存文件则结束输出,否则创建读取缓存流
|
|
66
66
|
if (this.readPos >= this.chunks.length) return !this.cache ? this.output.end() : this.createReadCache();
|
|
67
|
-
//
|
|
67
|
+
// 写入数据,如果返回false则等待'drain'事件
|
|
68
68
|
const chunk = this.chunks[this.readPos++];
|
|
69
69
|
if (this.output.write(chunk) === false) return this.output.once('drain', () => this.sendNextChunk());
|
|
70
70
|
setImmediate(() => this.sendNextChunk());
|
|
@@ -74,12 +74,13 @@ class DKIMSigner {
|
|
|
74
74
|
sendSignedOutput() {
|
|
75
75
|
let keyPos = 0;
|
|
76
76
|
const signNextKey = () => {
|
|
77
|
-
//
|
|
77
|
+
// 所有密钥都已处理,发送原始头部并继续发送数据块
|
|
78
78
|
if (keyPos >= this.keys.length) {
|
|
79
79
|
this.output.write(this.parser.rawHeaders);
|
|
80
80
|
return setImmediate(() => this.sendNextChunk());
|
|
81
81
|
}
|
|
82
|
-
const { domainName, keySelector, privateKey } = this.keys[keyPos++],
|
|
82
|
+
const { domainName, keySelector, privateKey } = this.keys[keyPos++],
|
|
83
|
+
{ headerFieldNames, skipFields } = this.options,
|
|
83
84
|
// 定义 DKIM 签名字段
|
|
84
85
|
dkimField = signer(this.headers, this.hashAlgo, this.bodyHash,
|
|
85
86
|
{ domainName, keySelector, privateKey, headerFieldNames, skipFields });
|
|
@@ -145,22 +146,22 @@ class DKIMSigner {
|
|
|
145
146
|
* const dkim = new DKIM({
|
|
146
147
|
* domainName: 'example.com', // 域名,用于标识签名来源
|
|
147
148
|
* keySelector: 'key-selector', // 密钥选择器,用于在DNS中查找公钥
|
|
148
|
-
* privateKey, // RSA 私钥,PEM
|
|
149
|
+
* privateKey, // RSA 私钥,PEM 格式字符串(包含 -----BEGIN RSA PRIVATE KEY----- 等),用于生成 DKIM 签名
|
|
149
150
|
* cacheDir: '/tmp' // 缓存目录,用于处理大邮件时的临时存储
|
|
150
151
|
* });
|
|
151
152
|
*
|
|
152
153
|
* // 2. 对邮件进行签名
|
|
153
154
|
* dkim.sign(input).pipe(process.stdout);
|
|
154
155
|
* ```
|
|
155
|
-
*
|
|
156
|
-
* input:
|
|
157
|
-
* Stream (流)
|
|
158
|
-
* String (字符串)
|
|
159
|
-
* Buffer (缓冲区)
|
|
156
|
+
* 参数说明:
|
|
157
|
+
* input: 输入邮件内容,可以是以下类型:
|
|
158
|
+
* Stream (流):可读流,包含RFC822格式的原始邮件
|
|
159
|
+
* String (字符串):邮件内容的字符串形式
|
|
160
|
+
* Buffer (缓冲区):邮件内容的二进制缓冲区
|
|
160
161
|
*
|
|
161
|
-
* -
|
|
162
|
+
* - 返回值:一个可读流,包含已添加DKIM签名的完整邮件
|
|
162
163
|
*
|
|
163
|
-
*
|
|
164
|
+
* 工作流程:
|
|
164
165
|
* 1. 解析输入邮件的头部和正文
|
|
165
166
|
* 2. 对邮件正文进行规范化处理并计算哈希值
|
|
166
167
|
* 3. 使用私钥对指定邮件头字段和正文哈希进行签名
|
|
@@ -180,14 +181,14 @@ class DKIM {
|
|
|
180
181
|
const output = new PassThrough();
|
|
181
182
|
let inputStream = input, writeValue = false;
|
|
182
183
|
|
|
183
|
-
// 如果输入是字符串或Buffer
|
|
184
|
+
// 如果输入是字符串或Buffer,则转换为流
|
|
184
185
|
if (Buffer.isBuffer(input)) writeValue = input, inputStream = new PassThrough();
|
|
185
|
-
//
|
|
186
|
+
// 否则如果输入是字符串,则转换为Buffer
|
|
186
187
|
else if (typeof input === 'string') writeValue = Buffer.from(input), inputStream = new PassThrough();
|
|
187
188
|
|
|
188
189
|
const options = this.options;
|
|
189
190
|
let newOptions = {};
|
|
190
|
-
//
|
|
191
|
+
// 如果有额外选项,则合并选项
|
|
191
192
|
if (extraOptions && Object.keys(extraOptions).length) {
|
|
192
193
|
newOptions = { ...options };
|
|
193
194
|
// 仅添加不存在的选项
|
|
@@ -197,11 +198,13 @@ class DKIM {
|
|
|
197
198
|
}
|
|
198
199
|
else newOptions = options;
|
|
199
200
|
|
|
200
|
-
|
|
201
|
+
// 创建 DKIM 签名器实例,使用最终的合并选项
|
|
202
|
+
const signer = new DKIMSigner(newOptions, this.keys, inputStream, output);
|
|
201
203
|
// 异步启动签名流处理
|
|
202
204
|
setImmediate(() => {
|
|
203
205
|
signer.signStream();
|
|
204
|
-
|
|
206
|
+
// 如果有写入值,则在下一个事件循环中结束输入流
|
|
207
|
+
if (writeValue) setImmediate(() => inputStream.end(writeValue));
|
|
205
208
|
});
|
|
206
209
|
|
|
207
210
|
return output;
|
|
@@ -29,7 +29,7 @@ class MessageParser extends Transform {
|
|
|
29
29
|
|
|
30
30
|
// 在流结束时处理任何剩余的头信息
|
|
31
31
|
_flush(callback) {
|
|
32
|
-
//
|
|
32
|
+
// 如果还有未处理的头信息块,则推送它们
|
|
33
33
|
if (this.headerChunks) {
|
|
34
34
|
const chunk = Buffer.concat(this.headerChunks, this.headerBytes);
|
|
35
35
|
this.bodySize += chunk.length, this.push(chunk), this.headerChunks = null;
|
|
@@ -38,7 +38,7 @@ class MessageParser extends Transform {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* 更新记录最后4
|
|
41
|
+
* 更新记录最后4个字节,以便在块边界检测换行符
|
|
42
42
|
*
|
|
43
43
|
* @param {Buffer} data 来自流的下一个数据块
|
|
44
44
|
*/
|
|
@@ -52,27 +52,29 @@ class MessageParser extends Transform {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* 从剩余的消息体中查找并移除消息头;将头信息保持分离,直到最终投递,以便修改
|
|
56
56
|
*
|
|
57
57
|
* @param {Buffer} data 下一个数据块
|
|
58
|
-
* @return {Boolean} 如果已经找到头信息则返回true
|
|
58
|
+
* @return {Boolean} 如果已经找到头信息则返回true,否则返回false
|
|
59
59
|
*/
|
|
60
60
|
checkHeaders(data) {
|
|
61
|
-
if (this.headersParsed) return true; //
|
|
61
|
+
if (this.headersParsed) return true; // 如果已经找到头信息,则不需要检查
|
|
62
62
|
|
|
63
63
|
const lblen = this.lastBytes.length;
|
|
64
64
|
let headerPos = 0;
|
|
65
65
|
this.curLinePos = 0;
|
|
66
|
-
//
|
|
66
|
+
// 检查是否有两个连续的换行符,表示头信息的结尾
|
|
67
67
|
for (let i = 0; i < lblen + data.length; i++) {
|
|
68
|
-
|
|
68
|
+
// 如果小于长度,使用lastBytes中的字节,否则使用data中的字节
|
|
69
|
+
const chr = i < lblen ? this.lastBytes[i] : data[i - lblen];
|
|
69
70
|
// 检查是否有两个连续的换行符
|
|
70
71
|
if (chr === 0x0a && i) {
|
|
71
|
-
|
|
72
|
+
// 定义pr1为当前字节的前一个字节的值
|
|
73
|
+
const pr1 = i - 1 < lblen ? this.lastBytes[i - 1] : data[i - 1 - lblen],
|
|
72
74
|
// 定义pr2为当前字节的前两个字节的值(如果存在)
|
|
73
75
|
pr2 = i > 1 ? (i - 2 < lblen ? this.lastBytes[i - 2] : data[i - 2 - lblen]) : false;
|
|
74
76
|
|
|
75
|
-
// 如果前一个字符是0x0a
|
|
77
|
+
// 如果前一个字符是0x0a,或者前两个字符是0x0d 0x0a,则表示头信息结束
|
|
76
78
|
if (pr1 === 0x0a || (pr1 === 0x0d && pr2 === 0x0a)) {
|
|
77
79
|
this.headersParsed = true, headerPos = i - lblen + 1, this.headerBytes += headerPos;
|
|
78
80
|
break;
|
|
@@ -82,9 +84,10 @@ class MessageParser extends Transform {
|
|
|
82
84
|
|
|
83
85
|
// 如果找到了头信息
|
|
84
86
|
if (this.headersParsed) {
|
|
85
|
-
this.headerChunks.push(data.subarray(0, headerPos)),
|
|
87
|
+
this.headerChunks.push(data.subarray(0, headerPos)),
|
|
88
|
+
this.rawHeaders = Buffer.concat(this.headerChunks, this.headerBytes);
|
|
86
89
|
this.headerChunks = null, this.emit('headers', this.parseHeaders());
|
|
87
|
-
//
|
|
90
|
+
// 如果数据块中还有剩余的消息体,则推送剩余的消息体
|
|
88
91
|
if (data.length - 1 > headerPos) {
|
|
89
92
|
const chunk = data.subarray(headerPos);
|
|
90
93
|
this.bodySize += chunk.length, setImmediate(() => this.push(chunk));
|
|
@@ -103,7 +106,7 @@ class MessageParser extends Transform {
|
|
|
103
106
|
const lines = (this.rawHeaders || '').toString().split(regexs.LINE_SEPARATOR); // 按行拆分(丢弃换行符)
|
|
104
107
|
// 处理折叠的头信息行
|
|
105
108
|
for (let i = lines.length - 1; i > 0; i--)
|
|
106
|
-
//
|
|
109
|
+
// 如果当前行是空白行,则将其与前一行合并
|
|
107
110
|
if (regexs.FOLDED_HEADER_LINE.test(lines[i])) lines[i - 1] += `\n${lines[i]}`, lines.splice(i, 1);
|
|
108
111
|
|
|
109
112
|
return lines.filter(line => line.trim())
|
package/lib/dkim/relaxed-body.js
CHANGED
|
@@ -4,7 +4,7 @@ import { regexs, resetRegex } from '../regexs.js';
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* 宽松规范化邮件正文的转换流,实现DKIM签名中的relaxed body canonicalization
|
|
7
|
-
* -
|
|
7
|
+
* - 主要功能:
|
|
8
8
|
* 1. 将所有的行结束符统一为CRLF
|
|
9
9
|
* 2. 移除每行末尾的空白字符(WSP)
|
|
10
10
|
* 3. 将行内的多个空白字符替换为单个空格
|
|
@@ -15,7 +15,7 @@ class RelaxedBody extends Transform {
|
|
|
15
15
|
/**
|
|
16
16
|
* 构造函数
|
|
17
17
|
* @param {Object} options - 配置选项
|
|
18
|
-
* @param {string} options.hashAlgo -
|
|
18
|
+
* @param {string} options.hashAlgo - 哈希算法,默认为'sha1'
|
|
19
19
|
* @param {boolean} options.debug - 是否启用调试模式
|
|
20
20
|
*/
|
|
21
21
|
constructor(options = {}) {
|
|
@@ -28,7 +28,7 @@ class RelaxedBody extends Transform {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* 处理数据块并更新哈希值
|
|
31
|
-
*
|
|
31
|
+
* 应用宽松规范化规则:
|
|
32
32
|
* 1. 将所有行结束符统一为CRLF
|
|
33
33
|
* 2. 移除行尾空白字符(WSP)
|
|
34
34
|
* 3. 将行内多个空白字符替换为单个空格
|
|
@@ -37,15 +37,16 @@ class RelaxedBody extends Transform {
|
|
|
37
37
|
updateHash(chunk) {
|
|
38
38
|
// 将当前数据块转换为字符串并与之前的余数合并
|
|
39
39
|
const fullStr = this.remainder + chunk.toString('latin1');
|
|
40
|
-
if (fullStr.length === 0) return; //
|
|
41
|
-
this.remainder = ''; //
|
|
42
|
-
const lines = fullStr.split(regexs.SPLIT_KEEP_NEWLINE); //
|
|
43
|
-
|
|
40
|
+
if (fullStr.length === 0) return; // 如果合并后字符串为空,无需处理
|
|
41
|
+
this.remainder = ''; // 清空余数,将在处理过程中重新收集
|
|
42
|
+
const lines = fullStr.split(regexs.SPLIT_KEEP_NEWLINE); // 使用正则表达式按行分割,保留行结束符信息
|
|
43
|
+
// 如最后不是完整的行结束符,保留作为余数
|
|
44
|
+
if (!regexs.TRAILING_LINE_BREAK.test(fullStr)) this.remainder = lines.pop() || '';
|
|
44
45
|
|
|
45
|
-
//
|
|
46
|
+
// 处理每一行内容(跳过行结束符部分)
|
|
46
47
|
const processedLines = [];
|
|
47
48
|
for (let i = 0; i < lines.length; i++) {
|
|
48
|
-
//
|
|
49
|
+
// 如果是行结束符,直接添加
|
|
49
50
|
if (i % 2 === 1) {
|
|
50
51
|
processedLines.push(lines[i]);
|
|
51
52
|
continue;
|
|
@@ -61,9 +62,9 @@ class RelaxedBody extends Transform {
|
|
|
61
62
|
|
|
62
63
|
// 重新组装处理后的内容
|
|
63
64
|
let processedStr = processedLines.join('');
|
|
64
|
-
//
|
|
65
|
+
// 如果有余数,需要特殊处理(可能是不完整的行)
|
|
65
66
|
if (this.remainder) {
|
|
66
|
-
//
|
|
67
|
+
// 余数中的空白字符处理(但不移除行尾空白,因为可能不是完整行)
|
|
67
68
|
const rMW = resetRegex(regexs.MULTIPLE_WSP), pRemainder = this.remainder.replace(rMW, ' ');
|
|
68
69
|
processedStr += pRemainder;
|
|
69
70
|
}
|
|
@@ -75,14 +76,14 @@ class RelaxedBody extends Transform {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
*
|
|
79
|
+
* 转换流的核心方法,处理输入数据
|
|
79
80
|
* @param {Buffer|string} chunk - 输入数据块
|
|
80
81
|
* @param {string} encoding - 编码方式
|
|
81
82
|
* @param {function} callback - 完成回调
|
|
82
83
|
*/
|
|
83
84
|
_transform(chunk, encoding, callback) {
|
|
84
85
|
try {
|
|
85
|
-
//
|
|
86
|
+
// 如果是空块,更新哈希并完成
|
|
86
87
|
if (!chunk?.length) {
|
|
87
88
|
this.updateHash(Buffer.alloc(0));
|
|
88
89
|
return callback();
|
|
@@ -110,12 +111,12 @@ class RelaxedBody extends Transform {
|
|
|
110
111
|
pRemainder = this.remainder.replace(regexs.TRAILING_WSP, '').replace(rMW, ' '),
|
|
111
112
|
remainderChunk = Buffer.from(pRemainder, 'latin1');
|
|
112
113
|
|
|
113
|
-
if (this.debug) this._debugBody.push(remainderChunk); //
|
|
114
|
+
if (this.debug) this._debugBody.push(remainderChunk); // 调试模式:记录处理后的余数
|
|
114
115
|
// 更新哈希值,将处理后的余数推送到输出流,然后更新已处理的字节长
|
|
115
116
|
this.bodyHash.update(remainderChunk); this.push(remainderChunk); this.byteLength += remainderChunk.length;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
// 确保以CRLF
|
|
119
|
+
// 确保以CRLF结束(如果消息体为空,或者余数不以CRLF结束)
|
|
119
120
|
if (this.byteLength === 0 || !this.remainder?.endsWith('\r\n')) {
|
|
120
121
|
const crlf = Buffer.from('\r\n');
|
|
121
122
|
this.bodyHash.update(crlf), this.push(crlf);
|
package/lib/dkim/sign.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// 引入必要的模块
|
|
2
|
-
import { createSign } from 'crypto'; // Node.js
|
|
2
|
+
import { createSign } from 'crypto'; // Node.js加密模块,用于生成签名
|
|
3
3
|
import { toASCII } from '../punycode.js'; // 用于处理Unicode域名的punycode转换
|
|
4
4
|
import { foldLines } from '../mime-funcs/index.js'; // 提供MIME相关工具函数
|
|
5
5
|
import { regexs, resetRegex } from '../regexs.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* 生成DKIM
|
|
8
|
+
* 生成DKIM签名头(不含签名值部分)
|
|
9
9
|
*
|
|
10
10
|
* @param {String} domainName 域名
|
|
11
11
|
* @param {String} keySelector 密钥选择器
|
|
@@ -19,8 +19,8 @@ const _generateDKIMHeader = (domainName, keySelector, fieldNames, hashAlgo, body
|
|
|
19
19
|
let dkim = [
|
|
20
20
|
'v=1', // 版本
|
|
21
21
|
`a=rsa-${hashAlgo}`, // 算法
|
|
22
|
-
'c=relaxed/relaxed', //
|
|
23
|
-
`d=${toASCII(domainName)}`, //
|
|
22
|
+
'c=relaxed/relaxed', // 规范化方法(头/正文)
|
|
23
|
+
`d=${toASCII(domainName)}`, // 域名(转换为ASCII)
|
|
24
24
|
'q=dns/txt', // 查询方式
|
|
25
25
|
`s=${keySelector}`, // 密钥选择器
|
|
26
26
|
`bh=${bodyHash}`, // 正文哈希
|
|
@@ -34,8 +34,8 @@ const _generateDKIMHeader = (domainName, keySelector, fieldNames, hashAlgo, body
|
|
|
34
34
|
* 从邮件头中提取并规范化指定字段的数据
|
|
35
35
|
*
|
|
36
36
|
* @param {Array} headers 邮件头数组
|
|
37
|
-
* @param {String} fieldNames
|
|
38
|
-
* @param {String} skipFields
|
|
37
|
+
* @param {String} fieldNames 需要处理的字段列表(冒号分隔)
|
|
38
|
+
* @param {String} skipFields 需要跳过的字段列表(冒号分隔)
|
|
39
39
|
* @return {Object} 包含规范化后的头数据和字段列表的对象
|
|
40
40
|
*/
|
|
41
41
|
_relaxedHeaders = (headers, fieldNames = '', skipFields = '') => {
|
|
@@ -45,21 +45,21 @@ const _generateDKIMHeader = (domainName, keySelector, fieldNames, hashAlgo, body
|
|
|
45
45
|
// 处理需要跳过的字段列表(去除空白并添加到跳过集合)
|
|
46
46
|
skipFields.toLowerCase().split(':').forEach(field => skip.add(field.trim()));
|
|
47
47
|
|
|
48
|
-
//
|
|
48
|
+
// 处理需要包含的字段,过滤掉跳过的字段(添加到包含字段集合)
|
|
49
49
|
fieldNames.toLowerCase().split(':').filter(field => !skip.has(field.trim()))
|
|
50
50
|
.forEach(field => includedFields.add(field.trim()));
|
|
51
51
|
|
|
52
|
-
//
|
|
52
|
+
// 从后向前遍历头(确保获取每个字段的最后出现值)
|
|
53
53
|
for (let i = headers.length - 1; i >= 0; i--) {
|
|
54
54
|
const { key, line } = headers[i];
|
|
55
|
-
//
|
|
55
|
+
// 只包含第一个遇到的值(从底部到顶部)
|
|
56
56
|
if (includedFields.has(key) && !headerFields.has(key)) headerFields.set(key, _relaxedHeaderLine(line));
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// 构建规范化后的头数据行和字段列表
|
|
60
60
|
const headersList = [], fields = [];
|
|
61
61
|
includedFields.forEach(field => {
|
|
62
|
-
//
|
|
62
|
+
// 如果字段在头中存在,则将其添加到规范化数据中
|
|
63
63
|
if (headerFields.has(field)) fields.push(field), headersList.push(`${field}:${headerFields.get(field)}`);
|
|
64
64
|
});
|
|
65
65
|
|
|
@@ -103,12 +103,13 @@ const generateDKIMSignature = (headers, hashAlgo, bodyHash, options = {}) => {
|
|
|
103
103
|
fieldNames = headerFieldNames || defaultFieldNames, // 使用选项中的头字段列表或默认列表
|
|
104
104
|
result = _relaxedHeaders(headers, fieldNames, skipFields), // 获取经过"relaxed"规范化的头数据及字段列表
|
|
105
105
|
|
|
106
|
-
// 生成DKIM
|
|
106
|
+
// 生成DKIM签名头(不包含签名值部分)
|
|
107
107
|
dkimHeader = _generateDKIMHeader(domainName, keySelector, result.fieldNames, hashAlgo, bodyHash);
|
|
108
108
|
|
|
109
109
|
let signer, signature;
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// 将DKIM签名头自身添加到规范化数据中(用于生成签名)
|
|
111
|
+
result.headers += `dkim-signature:${_relaxedHeaderLine(dkimHeader)}`;
|
|
112
|
+
// 创建签名对象,使用指定的哈希算法;更新要签名的数据(规范化后的头数据)
|
|
112
113
|
signer = createSign((`rsa-${hashAlgo}`).toUpperCase()), signer.update(result.headers);
|
|
113
114
|
try {
|
|
114
115
|
signature = signer.sign(privateKey, 'base64'); // 使用私钥进行签名,输出Base64格式
|
package/lib/fetch/cookies.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Cookie处理模块
|
|
2
|
-
const SESSION_TIMEOUT = 1800; //
|
|
2
|
+
const SESSION_TIMEOUT = 1800; // 会话默认超时时间:30分钟(1800秒)
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 创建一个Biskviit Cookie存储容器,用于内存中的Cookie管理
|
|
@@ -15,14 +15,14 @@ class Cookies {
|
|
|
15
15
|
// 检查主机名是否匹配Cookie域名
|
|
16
16
|
#isDomainMatch(hostname, domain) {
|
|
17
17
|
if (hostname === domain) return true; // 精确匹配
|
|
18
|
-
//
|
|
18
|
+
// 如果域名以点开头,则匹配所有子域名,否则返回false
|
|
19
19
|
return domain.startsWith('.') ? (`.${hostname}`).endsWith(domain) : false;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* 尝试将给定的字符串解析为URL对象
|
|
24
|
-
* @param {string} str - 需要解析的URL
|
|
25
|
-
* @returns {URL|boolean} 如果解析成功返回URL
|
|
24
|
+
* @param {string} str - 需要解析的URL字符串,默认为空字符串
|
|
25
|
+
* @returns {URL|boolean} 如果解析成功返回URL对象,否则返回false
|
|
26
26
|
*/
|
|
27
27
|
#newURL(str = '') {
|
|
28
28
|
try {
|
|
@@ -45,21 +45,22 @@ class Cookies {
|
|
|
45
45
|
const cookie = this.parse(cookieStr), // 解析Cookie字符串
|
|
46
46
|
{ hostname, pathname } = urlObj, { domain, path, expires } = cookie;
|
|
47
47
|
|
|
48
|
-
//
|
|
48
|
+
// 当域名不存在时或域名存在但不匹配时,直接使用主机名;否则当域名存在且匹配时,保留原始值
|
|
49
49
|
cookie.domain = !domain || !this.#isDomainMatch(hostname, domain) ? hostname : domain;
|
|
50
50
|
|
|
51
|
-
if (!path) cookie.path = this.getPath(pathname); //
|
|
52
|
-
//
|
|
53
|
-
if (!expires) cookie.expires =
|
|
51
|
+
if (!path) cookie.path = this.getPath(pathname); // 如果没有设置路径,使用URL的路径
|
|
52
|
+
// 如果没有过期时间,使用会话超时时间
|
|
53
|
+
if (!expires) cookie.expires =
|
|
54
|
+
new Date(Date.now() + Number(this.options.sessionTimeout ?? SESSION_TIMEOUT) * 1000);
|
|
54
55
|
|
|
55
56
|
return this.add(cookie); // 添加Cookie到存储
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* 获取适用于指定URL的Cookie
|
|
60
|
+
* 获取适用于指定URL的Cookie字符串,用于'Cookie:'请求头
|
|
60
61
|
*
|
|
61
62
|
* @param {String} url 要检查的URL
|
|
62
|
-
* @returns {String} Cookie
|
|
63
|
+
* @returns {String} Cookie头字符串,如果没有匹配则返回空字符串
|
|
63
64
|
*/
|
|
64
65
|
get(url) {
|
|
65
66
|
// 将Cookie对象转换为name=value格式,用分号和空格连接所有Cookie
|
|
@@ -76,7 +77,7 @@ class Cookies {
|
|
|
76
77
|
const result = [];
|
|
77
78
|
let cookie;
|
|
78
79
|
|
|
79
|
-
// 倒序遍历Cookie
|
|
80
|
+
// 倒序遍历Cookie数组(便于删除过期Cookie)
|
|
80
81
|
for (let i = this.cookies.length - 1; i >= 0; i--) {
|
|
81
82
|
cookie = this.cookies[i];
|
|
82
83
|
|
|
@@ -118,11 +119,11 @@ class Cookies {
|
|
|
118
119
|
break;
|
|
119
120
|
case 'domain': // 域名
|
|
120
121
|
domain = value.toLowerCase();
|
|
121
|
-
//
|
|
122
|
+
// 如果域名存在且没有前导点,则添加前导点
|
|
122
123
|
if (domain.length && !domain.startsWith('.')) domain = `.${domain}`;
|
|
123
124
|
cookie.domain = domain;
|
|
124
125
|
break;
|
|
125
|
-
case 'max-age': //
|
|
126
|
+
case 'max-age': // 最大存活时间(秒)
|
|
126
127
|
cookie.expires = new Date(Date.now() + (Number(value) || 0) * 1000);
|
|
127
128
|
break;
|
|
128
129
|
case 'secure': // 安全标志
|
|
@@ -132,7 +133,7 @@ class Cookies {
|
|
|
132
133
|
cookie.httponly = true;
|
|
133
134
|
break;
|
|
134
135
|
default:
|
|
135
|
-
//
|
|
136
|
+
// 如果没有匹配的属性,则默认处理Cookie的名称和值
|
|
136
137
|
if (!cookie.name) cookie.name = key, cookie.value = value;
|
|
137
138
|
}
|
|
138
139
|
});
|
|
@@ -153,22 +154,23 @@ class Cookies {
|
|
|
153
154
|
if (!this.#isDomainMatch(urlObj.hostname, cookie.domain)) return false; // 检查主机名是否不匹配Cookie域名
|
|
154
155
|
|
|
155
156
|
const path = this.getPath(urlObj.pathname); // 检查路径是否匹配
|
|
156
|
-
if (!path.startsWith(cookie.path)) return false; // 如果路径不以Cookie
|
|
157
|
-
|
|
157
|
+
if (!path.startsWith(cookie.path)) return false; // 如果路径不以Cookie路径开头,则不匹配
|
|
158
|
+
// 如果Cookie是安全的但URL不是https,则不匹配,否则匹配
|
|
159
|
+
return cookie.secure && urlObj.protocol !== 'https:' ? false : true;
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
/**
|
|
161
|
-
*
|
|
163
|
+
* 添加(或更新/删除)Cookie对象到Cookie存储
|
|
162
164
|
*
|
|
163
165
|
* @param {Object} cookie 要存储的Cookie值
|
|
164
166
|
*/
|
|
165
167
|
add(cookie) {
|
|
166
168
|
if (!cookie || !cookie.name) return false; // 如果Cookie无效则直接返回
|
|
167
169
|
|
|
168
|
-
// 检查是否有相同参数的Cookie
|
|
170
|
+
// 检查是否有相同参数的Cookie(用于更新)
|
|
169
171
|
for (let i = 0; i < this.cookies.length; i++) {
|
|
170
172
|
if (this.compare(this.cookies[i], cookie)) {
|
|
171
|
-
// 检查是否需要删除Cookie
|
|
173
|
+
// 检查是否需要删除Cookie(如果已过期)
|
|
172
174
|
if (this.isExpired(cookie)) {
|
|
173
175
|
this.cookies.splice(i, 1);
|
|
174
176
|
return false;
|
|
@@ -179,7 +181,7 @@ class Cookies {
|
|
|
179
181
|
}
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
if (!this.isExpired(cookie)) this.cookies.push(cookie); //
|
|
184
|
+
if (!this.isExpired(cookie)) this.cookies.push(cookie); // 如果没有过期,则添加为新Cookie
|
|
183
185
|
return true;
|
|
184
186
|
}
|
|
185
187
|
|
|
@@ -211,9 +213,9 @@ class Cookies {
|
|
|
211
213
|
* @returns {String} 规范化后的路径
|
|
212
214
|
*/
|
|
213
215
|
getPath(pathname) {
|
|
214
|
-
let path = pathname || '/'; //
|
|
216
|
+
let path = pathname || '/'; // 处理空值,默认为根路径
|
|
215
217
|
|
|
216
|
-
//
|
|
218
|
+
// 直接找到最后一个斜杠的位置,保留到该位置(包含斜杠)
|
|
217
219
|
const lastIndex = path.lastIndexOf('/');
|
|
218
220
|
path = path.substring(0, lastIndex + 1);
|
|
219
221
|
|