@flun/mailer 2.1.24 → 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 +7 -5
- 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 +88 -74
- 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 +4 -4
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
|
|
package/lib/fetch/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const MAX_REDIRECTS = 5, // 最大重定向次数常量
|
|
|
35
35
|
let processedBody = null;
|
|
36
36
|
const { contentType, body, headers } = options;
|
|
37
37
|
|
|
38
|
-
//
|
|
38
|
+
// 设置内容类型(除非明确禁止)
|
|
39
39
|
if (contentType !== false) headers['Content-Type'] = contentType || 'application/x-www-form-urlencoded';
|
|
40
40
|
|
|
41
41
|
// 流式数据处理
|
|
@@ -76,7 +76,7 @@ const MAX_REDIRECTS = 5, // 最大重定向次数常量
|
|
|
76
76
|
return true;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
//
|
|
79
|
+
// 准备重定向请求(GET方法,无请求体)
|
|
80
80
|
options.method = 'GET', options.body = false;
|
|
81
81
|
|
|
82
82
|
try {
|
|
@@ -94,23 +94,26 @@ const MAX_REDIRECTS = 5, // 最大重定向次数常量
|
|
|
94
94
|
* >查看定义:@see {@link nmfetch}
|
|
95
95
|
* @param {string} url - 请求的目标 URL;
|
|
96
96
|
* @param {Object} [options={}] - 可选配置对象;
|
|
97
|
-
* @param {stream.Writable} [options.fetchRes] -
|
|
97
|
+
* @param {stream.Writable} [options.fetchRes] - 用于写入响应数据的可写流,默认创建一个 PassThrough 流;
|
|
98
98
|
* @param {Cookies|Array|string} [options.cookies] - Cookie 管理器实例、Cookie 字符串数组或单个 Cookie 字符串,
|
|
99
99
|
* 用于自动管理请求和响应的 Cookie;
|
|
100
|
-
* @param {number} [options.redirects=0] -
|
|
101
|
-
* @param {number} [options.maxRedirects] -
|
|
102
|
-
* @param {string} [options.method=''] - HTTP
|
|
100
|
+
* @param {number} [options.redirects=0] - 已发生的重定向次数(内部使用);
|
|
101
|
+
* @param {number} [options.maxRedirects] - 允许的最大重定向次数,默认为 MAX_REDIRECTS 常量;
|
|
102
|
+
* @param {string} [options.method=''] - HTTP 请求方法(如 'GET'、'POST' 等),不提供时若存在请求体则默认为 'POST',
|
|
103
|
+
*否则为 'GET';
|
|
103
104
|
* @param {Object} [options.headers] - 自定义请求头对象,键名会转为小写;
|
|
104
105
|
* @param {string} [options.userAgent] - 自定义 User-Agent,会覆盖默认值;
|
|
105
|
-
* @param {Object} [options.tls] - TLS/SSL
|
|
106
|
-
* @param {number} [options.timeout] -
|
|
107
|
-
* @param {*} [options.body] -
|
|
106
|
+
* @param {Object} [options.tls] - TLS/SSL 相关选项,直接传递给底层请求的 tls 配置;
|
|
107
|
+
* @param {number} [options.timeout] - 请求超时时间(毫秒),超时后触发错误并销毁请求;
|
|
108
|
+
* @param {*} [options.body] - 请求体内容,可以是字符串、Buffer 或可读流;若存在且 method 未指定,则方法自动设为 'POST';
|
|
108
109
|
* @param {boolean} [options.allowErrorResponse=false] - 是否允许状态码 ≥300 的响应被视为成功,若为 false 则触发错误;
|
|
109
110
|
*
|
|
110
|
-
* @returns {stream.Writable}
|
|
111
|
+
* @returns {stream.Writable} 返回用于接收响应数据的可写流(通常为 PassThrough 实例),
|
|
112
|
+
*可通过监听 'finish' 或 'error' 事件获取结果;
|
|
111
113
|
*/
|
|
112
114
|
const nmfetch = (url, options = {}) => {
|
|
113
|
-
const { fetchRes: fetchRes_, cookies, redirects = 0, maxRedirects,
|
|
115
|
+
const { fetchRes: fetchRes_, cookies, redirects = 0, maxRedirects,
|
|
116
|
+
method = '', headers: headers_, userAgent, } = options,
|
|
114
117
|
// 设置默认响应流、Cookie管理器和重定向计数器
|
|
115
118
|
fetchRes = fetchRes_ || new PassThrough(), newCookies = cookies || new Cookies();
|
|
116
119
|
options.redirects = redirects, options.maxRedirects = isNaN(maxRedirects) ? MAX_REDIRECTS : maxRedirects;
|
|
@@ -123,7 +126,7 @@ const nmfetch = (url, options = {}) => {
|
|
|
123
126
|
if (!parsedResult.success) return fetchRes;
|
|
124
127
|
const { protocol, hostname: host, pathname, search = '', port, username, password } = parsedResult.result;
|
|
125
128
|
|
|
126
|
-
// 确定HTTP
|
|
129
|
+
// 确定HTTP方法,默认为GET
|
|
127
130
|
let newMethod = method.toString().trim().toUpperCase() || 'GET';
|
|
128
131
|
const handler = protocol === 'https:' ? https : http,
|
|
129
132
|
// 设置默认请求头
|
|
@@ -132,8 +135,8 @@ const nmfetch = (url, options = {}) => {
|
|
|
132
135
|
// 合并用户自定义头部
|
|
133
136
|
Object.keys(headers_ || {}).forEach(key => headers[key.toLowerCase().trim()] = headers_[key]);
|
|
134
137
|
|
|
135
|
-
if (userAgent) headers['user-agent'] = userAgent; // 设置自定义User-Agent
|
|
136
|
-
// 如果URL
|
|
138
|
+
if (userAgent) headers['user-agent'] = userAgent; // 设置自定义User-Agent(如果提供)
|
|
139
|
+
// 如果URL中包含认证信息,则设置Authorization头
|
|
137
140
|
if (username || password) {
|
|
138
141
|
const auth = `${username}:${password}`;
|
|
139
142
|
headers.Authorization = `Basic ${Buffer.from(auth).toString('base64')}`;
|
|
@@ -187,7 +190,7 @@ const nmfetch = (url, options = {}) => {
|
|
|
187
190
|
break;
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
// 如果响应中的Set-Cookie
|
|
193
|
+
// 如果响应中的Set-Cookie头为真,则存储Cookie
|
|
191
194
|
const setCookie = res.headers['set-cookie'];
|
|
192
195
|
if (setCookie) [].concat(setCookie).forEach(cookie => newCookies.set(cookie, url));
|
|
193
196
|
if (_handleRedirect(res, url, options, fetchRes, finishedFlag, req)) return; // 处理重定向
|
|
@@ -204,7 +207,7 @@ const nmfetch = (url, options = {}) => {
|
|
|
204
207
|
_markFinishedWithError(finishedFlag, fetchRes, err, url), req.destroy();
|
|
205
208
|
});
|
|
206
209
|
|
|
207
|
-
// 将响应流pipe
|
|
210
|
+
// 将响应流pipe到输出流,支持解压
|
|
208
211
|
if (inflate) {
|
|
209
212
|
res.pipe(inflate).pipe(fetchRes);
|
|
210
213
|
inflate.on('error', err => {
|
|
@@ -214,7 +217,7 @@ const nmfetch = (url, options = {}) => {
|
|
|
214
217
|
else res.pipe(fetchRes);
|
|
215
218
|
});
|
|
216
219
|
|
|
217
|
-
//
|
|
220
|
+
// 发送请求体(如果有)
|
|
218
221
|
setImmediate(() => {
|
|
219
222
|
const body = bodyResult.body;
|
|
220
223
|
if (body) {
|