@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,114 @@
1
+ import { Transform } from 'stream';
2
+ import { regexs } from '../regexs.js';
3
+
4
+ /**
5
+ * MessageParser实例是一个转换流,用于将消息头与消息体分离;
6
+ * 头信息通过'headers'事件发射,消息体作为结果流传递;
7
+ * >查看定义:@see {@link MessageParser}
8
+ */
9
+ class MessageParser extends Transform {
10
+ constructor(options) {
11
+ super(options);
12
+ this.lastBytes = Buffer.alloc(4), this.headersParsed = false, this.rawHeaders = false;
13
+ this.bodySize = 0, this.headerBytes = 0, this.headerChunks = [];
14
+ }
15
+
16
+ // 处理下一个数据块
17
+ _transform(chunk, encoding, callback) {
18
+ if (!chunk?.length) return callback(); // 忽略空块
19
+ if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding); // 确保块是一个Buffer
20
+
21
+ try {
22
+ const headersFound = this.checkHeaders(chunk); // 检查并处理头信息
23
+ if (headersFound) this.bodySize += chunk.length, this.push(chunk); // 如果找到了头信息,推送剩余的消息体
24
+ setImmediate(callback);
25
+ } catch (E) {
26
+ return callback(E);
27
+ }
28
+ }
29
+
30
+ // 在流结束时处理任何剩余的头信息
31
+ _flush(callback) {
32
+ // 如果还有未处理的头信息块,则推送它们
33
+ if (this.headerChunks) {
34
+ const chunk = Buffer.concat(this.headerChunks, this.headerBytes);
35
+ this.bodySize += chunk.length, this.push(chunk), this.headerChunks = null;
36
+ }
37
+ callback(); // 完成
38
+ }
39
+
40
+ /**
41
+ * 更新记录最后4个字节,以便在块边界检测换行符
42
+ *
43
+ * @param {Buffer} data 来自流的下一个数据块
44
+ */
45
+ updateLastBytes(data) {
46
+ const lblen = this.lastBytes.length, nblen = Math.min(data.length, lblen);
47
+
48
+ // 移动现有字节
49
+ for (let i = 0, len = lblen - nblen; i < len; i++) this.lastBytes[i] = this.lastBytes[i + nblen];
50
+ // 添加新字节
51
+ for (let i = 1; i <= nblen; i++) this.lastBytes[lblen - i] = data[data.length - i];
52
+ }
53
+
54
+ /**
55
+ * 从剩余的消息体中查找并移除消息头。将头信息保持分离,直到最终投递,以便修改
56
+ *
57
+ * @param {Buffer} data 下一个数据块
58
+ * @return {Boolean} 如果已经找到头信息则返回true,否则返回false
59
+ */
60
+ checkHeaders(data) {
61
+ if (this.headersParsed) return true; // 如果已经找到头信息,则不需要检查
62
+
63
+ const lblen = this.lastBytes.length;
64
+ let headerPos = 0;
65
+ this.curLinePos = 0;
66
+ // 检查是否有两个连续的换行符,表示头信息的结尾
67
+ for (let i = 0; i < lblen + data.length; i++) {
68
+ const chr = i < lblen ? this.lastBytes[i] : data[i - lblen]; // 如果小于长度,使用lastBytes中的字节,否则使用data中的字节
69
+ // 检查是否有两个连续的换行符
70
+ if (chr === 0x0a && i) {
71
+ const pr1 = i - 1 < lblen ? this.lastBytes[i - 1] : data[i - 1 - lblen], // 定义pr1为当前字节的前一个字节的值
72
+ // 定义pr2为当前字节的前两个字节的值(如果存在)
73
+ pr2 = i > 1 ? (i - 2 < lblen ? this.lastBytes[i - 2] : data[i - 2 - lblen]) : false;
74
+
75
+ // 如果前一个字符是0x0a,或者前两个字符是0x0d 0x0a,则表示头信息结束
76
+ if (pr1 === 0x0a || (pr1 === 0x0d && pr2 === 0x0a)) {
77
+ this.headersParsed = true, headerPos = i - lblen + 1, this.headerBytes += headerPos;
78
+ break;
79
+ }
80
+ }
81
+ }
82
+
83
+ // 如果找到了头信息
84
+ if (this.headersParsed) {
85
+ this.headerChunks.push(data.subarray(0, headerPos)), this.rawHeaders = Buffer.concat(this.headerChunks, this.headerBytes);
86
+ this.headerChunks = null, this.emit('headers', this.parseHeaders());
87
+ // 如果数据块中还有剩余的消息体,则推送剩余的消息体
88
+ if (data.length - 1 > headerPos) {
89
+ const chunk = data.subarray(headerPos);
90
+ this.bodySize += chunk.length, setImmediate(() => this.push(chunk));
91
+ }
92
+ return true;
93
+ }
94
+ // 否则将整个数据块添加到头信息块中
95
+ else this.headerBytes += data.length, this.headerChunks.push(data);
96
+
97
+ this.updateLastBytes(data); // 更新最后的字节
98
+ return false;
99
+ }
100
+
101
+ // 解析原始头信息为键值对数组
102
+ parseHeaders() {
103
+ const lines = (this.rawHeaders || '').toString().split(regexs.LINE_SEPARATOR); // 按行拆分(丢弃换行符)
104
+ // 处理折叠的头信息行
105
+ for (let i = lines.length - 1; i > 0; i--)
106
+ // 如果当前行是空白行,则将其与前一行合并
107
+ if (regexs.FOLDED_HEADER_LINE.test(lines[i])) lines[i - 1] += `\n${lines[i]}`, lines.splice(i, 1);
108
+
109
+ return lines.filter(line => line.trim())
110
+ .map(line => { key: line.slice(0, line.indexOf(':')).trim().toLowerCase(), line });
111
+ }
112
+ }
113
+
114
+ export { MessageParser };
@@ -0,0 +1,133 @@
1
+ import { Transform } from 'stream';
2
+ import { createHash } from 'crypto';
3
+ import { regexs, resetRegex } from '../regexs.js';
4
+
5
+ /**
6
+ * 宽松规范化邮件正文的转换流,实现DKIM签名中的relaxed body canonicalization
7
+ * - 主要功能:
8
+ * 1. 将所有的行结束符统一为CRLF
9
+ * 2. 移除每行末尾的空白字符(WSP)
10
+ * 3. 将行内的多个空白字符替换为单个空格
11
+ * 4. 计算规范化后内容的哈希值
12
+ * >查看定义:@see {@link RelaxedBody}
13
+ */
14
+ class RelaxedBody extends Transform {
15
+ /**
16
+ * 构造函数
17
+ * @param {Object} options - 配置选项
18
+ * @param {string} options.hashAlgo - 哈希算法,默认为'sha1'
19
+ * @param {boolean} options.debug - 是否启用调试模式
20
+ */
21
+ constructor(options = {}) {
22
+ super(options);
23
+ const { hashAlgo, debug } = options;
24
+ // 初始化哈希计算器,存储未处理完的数据片段(跨块的未完成行),记录已处理的字节长度
25
+ this.bodyHash = createHash(hashAlgo || 'sha1'), this.remainder = '', this.byteLength = 0;
26
+ this.debug = debug, this._debugBody = debug ? [] : false; // 调试模式相关设置
27
+ }
28
+
29
+ /**
30
+ * 处理数据块并更新哈希值
31
+ * 应用宽松规范化规则:
32
+ * 1. 将所有行结束符统一为CRLF
33
+ * 2. 移除行尾空白字符(WSP)
34
+ * 3. 将行内多个空白字符替换为单个空格
35
+ * @param {Buffer} chunk - 输入数据块
36
+ */
37
+ updateHash(chunk) {
38
+ // 将当前数据块转换为字符串并与之前的余数合并
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
+ if (!regexs.TRAILING_LINE_BREAK.test(fullStr)) this.remainder = lines.pop() || ''; // 如最后不是完整的行结束符,保留作为余数
44
+
45
+ // 处理每一行内容(跳过行结束符部分)
46
+ const processedLines = [];
47
+ for (let i = 0; i < lines.length; i++) {
48
+ // 如果是行结束符,直接添加
49
+ if (i % 2 === 1) {
50
+ processedLines.push(lines[i]);
51
+ continue;
52
+ }
53
+
54
+ // 处理行内容
55
+ let line = lines[i];
56
+ line = line.replace(regexs.TRAILING_WSP, ''); // 移除行尾空白字符(WSP)
57
+ const M_WPS = resetRegex(regexs.MULTIPLE_WSP);
58
+ line = line.replace(M_WPS, ' '), processedLines.push(line); // 将行内多个空白字符替换为单个空格并添加到结果中
59
+
60
+ }
61
+
62
+ // 重新组装处理后的内容
63
+ let processedStr = processedLines.join('');
64
+ // 如果有余数,需要特殊处理(可能是不完整的行)
65
+ if (this.remainder) {
66
+ // 余数中的空白字符处理(但不移除行尾空白,因为可能不是完整行)
67
+ const rMW = resetRegex(regexs.MULTIPLE_WSP), pRemainder = this.remainder.replace(rMW, ' ');
68
+ processedStr += pRemainder;
69
+ }
70
+
71
+ const processedChunk = Buffer.from(processedStr, 'latin1'); // 将处理后的字符串转换回Buffer
72
+ if (this.debug) this._debugBody.push(processedChunk); // 如果启用调试模,则记录处理后的数据块
73
+ this.bodyHash.update(processedChunk), this.push(processedChunk); // 更新哈希值并将处理后的数据推送到输出流
74
+
75
+ }
76
+
77
+ /**
78
+ * 转换流的核心方法,处理输入数据
79
+ * @param {Buffer|string} chunk - 输入数据块
80
+ * @param {string} encoding - 编码方式
81
+ * @param {function} callback - 完成回调
82
+ */
83
+ _transform(chunk, encoding, callback) {
84
+ try {
85
+ // 如果是空块,更新哈希并完成
86
+ if (!chunk?.length) {
87
+ this.updateHash(Buffer.alloc(0));
88
+ return callback();
89
+ }
90
+ if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding); // 如果块是字符串,转换为Buffer
91
+
92
+ // 处理数据块并更新哈希,更新已处理的字节长度,最后通知转换完成
93
+ this.updateHash(chunk); this.byteLength += chunk.length; callback();
94
+ } catch (err) {
95
+ callback(err);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 流结束时的处理逻辑
101
+ * 处理剩余数据并发出哈希值
102
+ * @param {function} callback - 完成回调
103
+ */
104
+ _flush(callback) {
105
+ try {
106
+ // 处理剩余的余数
107
+ if (this.remainder) {
108
+ // 对余数应用规范化规则
109
+ const rMW = resetRegex(regexs.MULTIPLE_WSP),
110
+ pRemainder = this.remainder.replace(regexs.TRAILING_WSP, '').replace(rMW, ' '),
111
+ remainderChunk = Buffer.from(pRemainder, 'latin1');
112
+
113
+ if (this.debug) this._debugBody.push(remainderChunk); // 调试模式:记录处理后的余数
114
+ // 更新哈希值,将处理后的余数推送到输出流,然后更新已处理的字节长
115
+ this.bodyHash.update(remainderChunk); this.push(remainderChunk); this.byteLength += remainderChunk.length;
116
+ }
117
+
118
+ // 确保以CRLF结束(如果消息体为空,或者余数不以CRLF结束)
119
+ if (this.byteLength === 0 || !this.remainder?.endsWith('\r\n')) {
120
+ const crlf = Buffer.from('\r\n');
121
+ this.bodyHash.update(crlf), this.push(crlf);
122
+ }
123
+
124
+ // 发出哈希计算完成事件
125
+ this.emit('hash', this.bodyHash.digest('base64'), this.debug ? Buffer.concat(this._debugBody) : false);
126
+ callback(); // 通知刷新完成
127
+ } catch (err) {
128
+ callback(err);
129
+ }
130
+ }
131
+ }
132
+
133
+ export { RelaxedBody };
@@ -0,0 +1,121 @@
1
+ // 引入必要的模块
2
+ import { createSign } from 'crypto'; // Node.js加密模块,用于生成签名
3
+ import { toASCII } from '../punycode.js'; // 用于处理Unicode域名的punycode转换
4
+ import { foldLines } from '../mime-funcs/index.js'; // 提供MIME相关工具函数
5
+ import { regexs, resetRegex } from '../regexs.js';
6
+
7
+ /**
8
+ * 生成DKIM签名头(不含签名值部分)
9
+ *
10
+ * @param {String} domainName 域名
11
+ * @param {String} keySelector 密钥选择器
12
+ * @param {String} fieldNames 参与签名的头字段列表
13
+ * @param {String} hashAlgo 哈希算法
14
+ * @param {String} bodyHash 正文哈希值
15
+ * @return {String} 生成的DKIM签名头字符串
16
+ */
17
+ const _generateDKIMHeader = (domainName, keySelector, fieldNames, hashAlgo, bodyHash) => {
18
+ // 构建DKIM签名头的各个部分
19
+ let dkim = [
20
+ 'v=1', // 版本
21
+ `a=rsa-${hashAlgo}`, // 算法
22
+ 'c=relaxed/relaxed', // 规范化方法(头/正文)
23
+ `d=${toASCII(domainName)}`, // 域名(转换为ASCII)
24
+ 'q=dns/txt', // 查询方式
25
+ `s=${keySelector}`, // 密钥选择器
26
+ `bh=${bodyHash}`, // 正文哈希
27
+ `h=${fieldNames}` // 参与签名的头字段
28
+ ].join('; '); // 用分号加空格连接
29
+
30
+ return `${foldLines(`DKIM-Signature:${dkim}`, 76)};\r\n b=`; // 对长行进行折行处理,并添加头名称和签名开始标记
31
+ },
32
+
33
+ /**
34
+ * 从邮件头中提取并规范化指定字段的数据
35
+ *
36
+ * @param {Array} headers 邮件头数组
37
+ * @param {String} fieldNames 需要处理的字段列表(冒号分隔)
38
+ * @param {String} skipFields 需要跳过的字段列表(冒号分隔)
39
+ * @return {Object} 包含规范化后的头数据和字段列表的对象
40
+ */
41
+ _relaxedHeaders = (headers, fieldNames = '', skipFields = '') => {
42
+ // 存储最终需要包含的字段,需要跳过的字段,字段名和对应的规范化值
43
+ const includedFields = new Set(), skip = new Set(), headerFields = new Map();
44
+
45
+ // 处理需要跳过的字段列表(去除空白并添加到跳过集合)
46
+ skipFields.toLowerCase().split(':').forEach(field => skip.add(field.trim()));
47
+
48
+ // 处理需要包含的字段,过滤掉跳过的字段(添加到包含字段集合)
49
+ fieldNames.toLowerCase().split(':').filter(field => !skip.has(field.trim()))
50
+ .forEach(field => includedFields.add(field.trim()));
51
+
52
+ // 从后向前遍历头(确保获取每个字段的最后出现值)
53
+ for (let i = headers.length - 1; i >= 0; i--) {
54
+ const { key, line } = headers[i];
55
+ // 只包含第一个遇到的值(从底部到顶部)
56
+ if (includedFields.has(key) && !headerFields.has(key)) headerFields.set(key, _relaxedHeaderLine(line));
57
+ }
58
+
59
+ // 构建规范化后的头数据行和字段列表
60
+ const headersList = [], fields = [];
61
+ includedFields.forEach(field => {
62
+ // 如果字段在头中存在,则将其添加到规范化数据中
63
+ if (headerFields.has(field)) fields.push(field), headersList.push(`${field}:${headerFields.get(field)}`);
64
+ });
65
+
66
+ // 用CRLF连接所有头行,用冒号连接字段名
67
+ return { headers: `${headersList.join('\r\n')}\r\n`, fieldNames: fields.join(':') };
68
+ },
69
+
70
+ /**
71
+ * 对单行头数据进行"relaxed"规范化
72
+ *
73
+ * @param {String} line 头行字符串
74
+ * @return {String} 规范化后的头值
75
+ */
76
+ _relaxedHeaderLine = line => {
77
+ const rGCOL = resetRegex(regexs.GLOBAL_CRLF_OR_LF), rGW = resetRegex(regexs.GLOBAL_WHITESPACE);
78
+ // 去除字段名部分,移除换行符,将连续空白压缩为单个空格,去除首尾空格
79
+ return line.substring(line.indexOf(':') + 1).replace(rGCOL, '').replace(rGW, ' ').trim();
80
+ };
81
+
82
+ /**
83
+ * 生成DKIM签名头行
84
+ * >查看定义:@see {@link generateDKIMSignature}
85
+ * @param {Object} headers 由MessageParser解析得到的邮件头对象
86
+ * @param {String} bodyHash 邮件正文的Base64编码哈希值
87
+ * @param {Object} options DKIM配置选项
88
+ * @param {String} options.domainName 要进行签名的域名
89
+ * @param {String} options.keySelector 使用的DKIM密钥选择器
90
+ * @param {String} options.privateKey 用于签名的DKIM私钥
91
+ * @return {String} 完整的DKIM签名头行
92
+ */
93
+ const generateDKIMSignature = (headers, hashAlgo, bodyHash, options = {}) => {
94
+ // RFC4871 #5.5中列出的默认签名头字段
95
+ const defaultFieldNames =
96
+ 'From:Sender:Reply-To:Subject:Date:Message-ID:To:' +
97
+ 'Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:' +
98
+ 'Content-Description:Resent-Date:Resent-From:Resent-Sender:' +
99
+ 'Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:' +
100
+ 'List-Id:List-Help:List-Unsubscribe:List-Subscribe:List-Post:List-Owner:List-Archive',
101
+
102
+ { headerFieldNames, skipFields, domainName, keySelector, privateKey } = options,
103
+ fieldNames = headerFieldNames || defaultFieldNames, // 使用选项中的头字段列表或默认列表
104
+ result = _relaxedHeaders(headers, fieldNames, skipFields), // 获取经过"relaxed"规范化的头数据及字段列表
105
+
106
+ // 生成DKIM签名头(不包含签名值部分)
107
+ dkimHeader = _generateDKIMHeader(domainName, keySelector, result.fieldNames, hashAlgo, bodyHash);
108
+
109
+ let signer, signature;
110
+ result.headers += `dkim-signature:${_relaxedHeaderLine(dkimHeader)}`; // 将DKIM签名头自身添加到规范化数据中(用于生成签名)
111
+ // 创建签名对象,使用指定的哈希算法;更新要签名的数据(规范化后的头数据)
112
+ signer = createSign((`rsa-${hashAlgo}`).toUpperCase()), signer.update(result.headers);
113
+ try {
114
+ signature = signer.sign(privateKey, 'base64'); // 使用私钥进行签名,输出Base64格式
115
+ } catch (E) { return false; }
116
+
117
+ const rSF = resetRegex(regexs.SIGNATURE_FOLDING);
118
+ return dkimHeader + signature.replace(rSF, '$&\r\n ').trim(); // 返回完整的DKIM签名头,并对长签名进行折行处理
119
+ };
120
+
121
+ export { generateDKIMSignature };
@@ -0,0 +1,224 @@
1
+ // Cookie处理模块
2
+ const SESSION_TIMEOUT = 1800; // 会话默认超时时间:30分钟(1800秒)
3
+
4
+ /**
5
+ * 创建一个Biskviit Cookie存储容器,用于内存中的Cookie管理
6
+ * >查看定义:@see {@link Cookies}
7
+ * @constructor
8
+ * @param {Object} [options] 可选的配置对象
9
+ */
10
+ class Cookies {
11
+ constructor(options = {}) {
12
+ this.options = options, this.cookies = [];
13
+ }
14
+
15
+ // 检查主机名是否匹配Cookie域名
16
+ #isDomainMatch(hostname, domain) {
17
+ if (hostname === domain) return true; // 精确匹配
18
+ // 如果域名以点开头,则匹配所有子域名,否则返回false
19
+ return domain.startsWith('.') ? (`.${hostname}`).endsWith(domain) : false;
20
+ }
21
+
22
+ /**
23
+ * 尝试将给定的字符串解析为URL对象
24
+ * @param {string} str - 需要解析的URL字符串,默认为空字符串
25
+ * @returns {URL|boolean} 如果解析成功返回URL对象,否则返回false
26
+ */
27
+ #newURL(str = '') {
28
+ try {
29
+ return str = new URL(str);
30
+ } catch (e) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 将Cookie字符串存储到Cookie存储中
37
+ *
38
+ * @param {String} cookieStr 来自'Set-Cookie:'头的值
39
+ * @param {String} url 当前URL
40
+ */
41
+ set(cookieStr, url) {
42
+ const urlObj = this.#newURL(url)
43
+ if (!urlObj) throw new Error('提供的URL无效');
44
+
45
+ const cookie = this.parse(cookieStr), // 解析Cookie字符串
46
+ { hostname, pathname } = urlObj, { domain, path, expires } = cookie;
47
+
48
+ // 当域名不存在时或域名存在但不匹配时,直接使用主机名;否则当域名存在且匹配时,保留原始值
49
+ cookie.domain = !domain || !this.#isDomainMatch(hostname, domain) ? hostname : domain;
50
+
51
+ if (!path) cookie.path = this.getPath(pathname); // 如果没有设置路径,使用URL的路径
52
+ // 如果没有过期时间,使用会话超时时间
53
+ if (!expires) cookie.expires = new Date(Date.now() + Number(this.options.sessionTimeout ?? SESSION_TIMEOUT) * 1000);
54
+
55
+ return this.add(cookie); // 添加Cookie到存储
56
+ }
57
+
58
+ /**
59
+ * 获取适用于指定URL的Cookie字符串,用于'Cookie:'请求头
60
+ *
61
+ * @param {String} url 要检查的URL
62
+ * @returns {String} Cookie头字符串,如果没有匹配则返回空字符串
63
+ */
64
+ get(url) {
65
+ // 将Cookie对象转换为name=value格式,用分号和空格连接所有Cookie
66
+ return this.list(url).map(cookie => `${cookie.name}=${cookie.value}`).join('; ');
67
+ }
68
+
69
+ /**
70
+ * 列出适用于指定URL的所有有效Cookie对象
71
+ *
72
+ * @param {String} url 要检查的URL
73
+ * @returns {Array} Cookie对象数组
74
+ */
75
+ list(url) {
76
+ const result = [];
77
+ let cookie;
78
+
79
+ // 倒序遍历Cookie数组(便于删除过期Cookie)
80
+ for (let i = this.cookies.length - 1; i >= 0; i--) {
81
+ cookie = this.cookies[i];
82
+
83
+ // 检查Cookie是否过期
84
+ if (this.isExpired(cookie)) {
85
+ this.cookies.splice(i, 1); // 移除过期Cookie
86
+ continue;
87
+ }
88
+ if (this.match(cookie, url)) result.unshift(cookie); // 如果Cookie匹配URL,则将匹配的Cookie添加到结果数组开头
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * 解析来自'Set-Cookie:'头的Cookie字符串
96
+ *
97
+ * @param {String} cookieStr 来自'Set-Cookie:'头的字符串
98
+ * @returns {Object} Cookie对象
99
+ */
100
+ parse(cookieStr = '') {
101
+ const cookie = {}; // 初始化空Cookie对象
102
+
103
+ cookieStr.toString().split(';')
104
+ .forEach(cookiePart => {
105
+ // 按等号分割键值对,获取键名并规范化并获取值
106
+ const valueParts = cookiePart.split('='), key = valueParts.shift().trim().toLowerCase();
107
+ let value = valueParts.join('=').trim(), domain;
108
+
109
+ if (!key) return; // 如果键名为空则跳过
110
+ // 根据键名处理不同的Cookie属性
111
+ switch (key) {
112
+ case 'expires': // 过期时间
113
+ value = new Date(value);
114
+ if (value.toString() !== 'Invalid Date') cookie.expires = value; // 如果日期解析失败则忽略
115
+ break;
116
+ case 'path':
117
+ cookie.path = value;
118
+ break;
119
+ case 'domain': // 域名
120
+ domain = value.toLowerCase();
121
+ // 如果域名存在且没有前导点,则添加前导点
122
+ if (domain.length && !domain.startsWith('.')) domain = `.${domain}`;
123
+ cookie.domain = domain;
124
+ break;
125
+ case 'max-age': // 最大存活时间(秒)
126
+ cookie.expires = new Date(Date.now() + (Number(value) || 0) * 1000);
127
+ break;
128
+ case 'secure': // 安全标志
129
+ cookie.secure = true;
130
+ break;
131
+ case 'httponly': // 仅HTTP标志
132
+ cookie.httponly = true;
133
+ break;
134
+ default:
135
+ // 如果没有匹配的属性,则默认处理Cookie的名称和值
136
+ if (!cookie.name) cookie.name = key, cookie.value = value;
137
+ }
138
+ });
139
+
140
+ return cookie;
141
+ }
142
+
143
+ /**
144
+ * 检查Cookie对象是否适用于指定URL
145
+ *
146
+ * @param {Object} cookie Cookie对象
147
+ * @param {String} url 要检查的URL
148
+ * @returns {Boolean} 如果Cookie适用于指定URL则返回true
149
+ */
150
+ match(cookie, url) {
151
+ const urlObj = _newURL(str)
152
+ if (!urlObj) return false;
153
+ if (!this.#isDomainMatch(urlObj.hostname, cookie.domain)) return false; // 检查主机名是否不匹配Cookie域名
154
+
155
+ const path = this.getPath(urlObj.pathname); // 检查路径是否匹配
156
+ if (!path.startsWith(cookie.path)) return false; // 如果路径不以Cookie路径开头,则不匹配
157
+ return cookie.secure && urlObj.protocol !== 'https:' ? false : true; // 如果Cookie是安全的但URL不是https,则不匹配,否则匹配
158
+ }
159
+
160
+ /**
161
+ * 添加(或更新/删除)Cookie对象到Cookie存储
162
+ *
163
+ * @param {Object} cookie 要存储的Cookie值
164
+ */
165
+ add(cookie) {
166
+ if (!cookie || !cookie.name) return false; // 如果Cookie无效则直接返回
167
+
168
+ // 检查是否有相同参数的Cookie(用于更新)
169
+ for (let i = 0; i < this.cookies.length; i++) {
170
+ if (this.compare(this.cookies[i], cookie)) {
171
+ // 检查是否需要删除Cookie(如果已过期)
172
+ if (this.isExpired(cookie)) {
173
+ this.cookies.splice(i, 1);
174
+ return false;
175
+ }
176
+
177
+ this.cookies[i] = cookie; // 更新现有Cookie
178
+ return true;
179
+ }
180
+ }
181
+
182
+ if (!this.isExpired(cookie)) this.cookies.push(cookie); // 如果没有过期,则添加为新Cookie
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * 检查两个Cookie对象是否相同
188
+ *
189
+ * @param {Object} a Cookie-对象a
190
+ * @param {Object} b Cookie-对象b
191
+ * @returns {Boolean} 如果Cookie相同则返回true
192
+ */
193
+ compare(a, b) {
194
+ return ['name', 'path', 'domain', 'secure', 'httponly'].every(key => a[key] === b[key]);
195
+ }
196
+
197
+ /**
198
+ * 检查Cookie是否已过期
199
+ *
200
+ * @param {Object} cookie 要检查的Cookie对象
201
+ * @returns {Boolean} 如果Cookie已过期则返回true
202
+ */
203
+ isExpired(cookie) {
204
+ return !cookie.value || (cookie.expires?.getTime() < Date.now())
205
+ }
206
+
207
+ /**
208
+ * 返回URL路径参数的规范化Cookie路径
209
+ *
210
+ * @param {String} pathname 路径名
211
+ * @returns {String} 规范化后的路径
212
+ */
213
+ getPath(pathname) {
214
+ let path = pathname || '/'; // 处理空值,默认为根路径
215
+
216
+ // 直接找到最后一个斜杠的位置,保留到该位置(包含斜杠)
217
+ const lastIndex = path.lastIndexOf('/');
218
+ path = path.substring(0, lastIndex + 1);
219
+
220
+ return path.startsWith('/') ? path : `/${path}`; // 确保以/开头
221
+ }
222
+ }
223
+
224
+ export { Cookies };