@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/lib/xoauth2.js ADDED
@@ -0,0 +1,249 @@
1
+ import { Stream } from 'stream';
2
+ import { createSign } from 'crypto';
3
+ import { nmfetch, getLogger, resolveStream } from './shared.js';
4
+
5
+ /**
6
+ * Gmail 的 XOAUTH2 access_token 生成器;用于生成基于 OAuth2 的访问令牌,支持自动刷新功能;
7
+ * 在 Google API 控制台中为 Web 应用程序创建客户端 ID 以便使用此类;
8
+ * 有关获取用户所需 refreshToken 的离线访问,请参阅离线访问
9
+ * https://developers.google.com/accounts/docs/OAuth2WebServer#offline
10
+ *
11
+ * 使用 provisionCallback 自定义方法生成访问令牌的用法:
12
+ * provisionCallback(user, renew, callback);
13
+ * * user 是要获取令牌的用户名
14
+ * * renew 是一个布尔值,如果为 true 表示现有令牌失败需要更新
15
+ * * callback 是带有 (error, accessToken [, expires]) 的回调函数
16
+ * * accessToken 是一个字符串
17
+ * * expires 是可选的过期时间(毫秒)
18
+ * 如果使用了 provisionCallback,则 @flun/mailer 不会尝试自行生成令牌
19
+ * >查看定义:@see {@link XOAuth2}
20
+ * @constructor
21
+ * @param {Object} options 令牌生成的客户端信息
22
+ * @param {String} options.user 用户电子邮件地址
23
+ * @param {String} options.clientId 客户端 ID 值
24
+ * @param {String} options.clientSecret 客户端密钥值
25
+ * @param {String} options.refreshToken 用户的刷新令牌
26
+ * @param {String} options.accessUrl 令牌生成端点,默认为 'https://accounts.google.com/o/oauth2/token'
27
+ * @param {String} options.accessToken 现有的有效访问令牌
28
+ * @param {String} options.privateKey JWT 私钥
29
+ * @param {Number} options.expires 可选的访问令牌过期时间(毫秒)
30
+ * @param {Number} options.timeout 可选的访问令牌 TTL(秒)
31
+ * @param {Function} options.provisionCallback 需要新访问令牌时运行的函数
32
+ */
33
+ class XOAuth2 extends Stream {
34
+ constructor(options = {}, logger) {
35
+ super();
36
+ this.options = options;
37
+
38
+ const { privateKey, user, serviceRequestTimeout, component = 'OAuth2', provisionCallback, accessToken = '',
39
+ expires, timeout, accessUrl, customHeaders = {}, customParams = {} } = this.options;
40
+ // 服务账户验证
41
+ if (this.options?.serviceClient) {
42
+ if (!privateKey || !user) return setImmediate(() => this.emit('error', new Error('服务账户需要 "privateKey" 和 "user" 选项!')));
43
+ this.options.serviceRequestTimeout = Math.min(Math.max(Number(serviceRequestTimeout) || 0, 0), 3600) || 5 * 60;
44
+ }
45
+
46
+ this.options.accessUrl = accessUrl || 'https://accounts.google.com/o/oauth2/token';
47
+ this.options.customHeaders = customHeaders; this.options.customParams = customParams;
48
+ this.renewing = false, this.renewalQueue = []; // 跟踪是否正在续订及续订期间待处理请求的队列
49
+ this.logger = getLogger({ logger }, { component }), this.accessToken = accessToken;
50
+ this.provisionCallback = typeof provisionCallback === 'function' ? provisionCallback : false;
51
+
52
+ const ttlMs = (Number(timeout) || 0) * 1000;
53
+ // 如果指定了过期时间,则使用它,否则使用 TTL,否则,如果指定了 TTL,则使用它,否则使用默认值
54
+ this.expires = Number(expires) || (ttlMs ? Date.now() + ttlMs : 0);
55
+ }
56
+
57
+ /**
58
+ * 返回或生成(如果之前的已过期)XOAuth2 令牌
59
+ *
60
+ * @param {Boolean} renew 如果为 false 则使用缓存的访问令牌(如果可用)
61
+ * @param {Function} callback 带有错误对象和令牌字符串的回调函数
62
+ */
63
+ getToken(renew, callback) {
64
+ const { user, refreshToken, serviceClient } = this.options, { accessToken, expires, provisionCallback, renewing } = this,
65
+ // 重用令牌
66
+ reuseToken = message => {
67
+ this.logger.debug({ tnx: 'OAUTH2', user, action: 'reuse' }, message, user);
68
+ return callback(null, accessToken);
69
+ };
70
+
71
+ // 如果不需要续订且令牌有效,则直接重用
72
+ if (!renew && accessToken && (!expires || expires > Date.now())) return reuseToken('为 %s 重用现有访问令牌');
73
+ // 检查是否可以续订,如果不能,返回当前令牌或错误
74
+ if (!provisionCallback && !refreshToken && !serviceClient) {
75
+ if (accessToken) return reuseToken('为 %s 重用现有访问令牌(无刷新能力)');
76
+ this.logger.error({ tnx: 'OAUTH2', user, action: 'renew' }, '无法为 %s 续订访问令牌:无可用刷新机制', user);
77
+ return callback(new Error("无法为用户创建新的访问令牌"));
78
+ }
79
+
80
+ // 如果已经在续订中,将此请求加入队列而不是开始另一个
81
+ if (renewing) return this.renewalQueue.push({ renew, callback });
82
+ this.renewing = true;
83
+
84
+ // 处理令牌续订完成 - 处理队列中的请求并清理
85
+ const generateCallback = (err, accessToken) => {
86
+ this.renewalQueue.forEach(item => item.callback(err, accessToken)), this.renewalQueue = [], this.renewing = false;
87
+
88
+ if (err) this.logger.error({ err, tnx: 'OAUTH2', user, action: 'renew' }, '为 %s 生成新访问令牌失败', user);
89
+ else this.logger.info({ tnx: 'OAUTH2', user, action: 'renew' }, '为 %s 生成新访问令牌', user);
90
+ callback(err, accessToken); // 完成原始请求
91
+ };
92
+
93
+ if (provisionCallback)
94
+ provisionCallback(user, !!renew, (err, accessToken, expires = 0) => {
95
+ if (!err && accessToken) this.accessToken = accessToken, this.expires = expires;
96
+ generateCallback(err, accessToken);
97
+ });
98
+ else this.generateToken(generateCallback);
99
+ }
100
+
101
+ /**
102
+ * 更新令牌值
103
+ *
104
+ * @param {String} accessToken 新的访问令牌
105
+ * @param {Number} timeout 访问令牌生命周期(秒)
106
+ *
107
+ * 触发 'token' 事件:{ user: 用户电子邮件地址, accessToken: 新的访问令牌, timeout: TTL(秒)}
108
+ */
109
+ updateToken(accessToken = '', timeout) {
110
+ this.accessToken = accessToken, timeout = Math.max(Number(timeout) || 0, 0);
111
+ this.expires = (timeout && Date.now() + timeout * 1000) || 0;
112
+ this.emit('token', { user: this.options.user, accessToken, expires: this.expires });
113
+ }
114
+
115
+ /**
116
+ * 使用初始化时提供的凭据生成新的 XOAuth2 令牌
117
+ *
118
+ * @param {Function} callback 带有错误对象和令牌字符串的回调函数
119
+ */
120
+ generateToken(callback) {
121
+ let urlOptions, loggedUrlOptions;
122
+ const { serviceClient, scope = 'https://mail.google.com/', user, serviceRequestTimeout, clientId = '',
123
+ clientSecret, refreshToken = '', accessUrl, customParams } = this.options;
124
+ if (serviceClient) {
125
+ // 服务账户 - https://developers.google.com/identity/protocols/OAuth2ServiceAccount
126
+ const iat = Math.floor(Date.now() / 1000), // unix 时间
127
+ tokenData = { iss: serviceClient, scope, sub: user, aud: accessUrl, iat, exp: iat + serviceRequestTimeout };
128
+ let assertion;
129
+ try {
130
+ assertion = this.jwtSignRS256(tokenData);
131
+ } catch (err) {
132
+ return callback(new Error(`JWT签名失败: ${err.message}`));
133
+ }
134
+ const grant_type = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
135
+ urlOptions = { grant_type, assertion }, loggedUrlOptions = { grant_type, assertion: tokenData };
136
+ }
137
+ else {
138
+ if (!refreshToken) return callback(new Error("无法为用户创建新的访问令牌"));
139
+ const grant_type = 'refresh_token';
140
+ // Web 应用程序 - https://developers.google.com/identity/protocols/OAuth2WebServer
141
+ urlOptions = { client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type };
142
+
143
+ loggedUrlOptions = {
144
+ client_id: clientId, client_secret: `${clientSecret.slice(0, 6)}...`,
145
+ refresh_token: `${refreshToken.slice(0, 6)}...`, grant_type
146
+ };
147
+ }
148
+
149
+ // 添加自定义参数
150
+ Object.assign(urlOptions, customParams), Object.assign(loggedUrlOptions, customParams);
151
+ const tnx = 'OAUTH2', action = 'post';
152
+ this.logger.debug({ tnx, user, action: 'generate' }, '请求令牌使用: %s', JSON.stringify(loggedUrlOptions));
153
+
154
+ this.postRequest(accessUrl, urlOptions, this.options, (error, body = '') => {
155
+ if (error) return callback(error);
156
+ let data;
157
+ try {
158
+ data = JSON.parse(body.toString());
159
+ } catch (E) {
160
+ return callback(E);
161
+ }
162
+
163
+ if (!data || typeof data !== 'object') {
164
+ this.logger.debug({ tnx, user, action }, '响应: %s', body.toString());
165
+ return callback(new Error('无效的身份验证响应'));
166
+ }
167
+
168
+ // 记录响应数据(隐藏访问令牌的完整内容)
169
+ const logData = Object.fromEntries(
170
+ Object.entries(data).map(([k, v = '']) => [k, k === 'access_token' ? `${v.toString().slice(0, 6)}...` : v])
171
+ );
172
+
173
+ this.logger.debug({ tnx, user, action }, '响应: %s', JSON.stringify(logData));
174
+
175
+ if (data.error) {
176
+ let errorMessage = data.error; // 错误响应:https://tools.ietf.org/html/rfc6749#section-5.2
177
+
178
+ if (data.error_description) errorMessage += `: ${data.error_description}`;
179
+ if (data.error_uri) errorMessage += ` (${data.error_uri})`;
180
+
181
+ return callback(new Error(errorMessage));
182
+ }
183
+
184
+ if (data.access_token) {
185
+ this.updateToken(data.access_token, data.expires_in);
186
+ return callback(null, this.accessToken);
187
+ }
188
+
189
+ return callback(new Error('没有访问令牌'));
190
+ });
191
+ }
192
+
193
+ /**
194
+ * 将 access_token 和用户 ID 转换为 base64 编码的 XOAuth2 令牌
195
+ *
196
+ * @param {String} [accessToken] 访问令牌字符串
197
+ * @return {String} 用于 IMAP 或 SMTP 登录的 Base64 编码令牌
198
+ */
199
+ buildXOAuth2Token(accessToken) {
200
+ const authData = [`user=${(this.options.user || '')}`, `auth=Bearer ${(accessToken || this.accessToken)}`, '', ''];
201
+ return Buffer.from(authData.join('\x01'), 'utf-8').toString('base64');
202
+ }
203
+
204
+ /**
205
+ * 自定义 POST 请求处理程序。
206
+ * 这仅在需要保持 Windows 中路径短时才需要;通常此模块
207
+ * 是依赖项的依赖项,如果它尝试 require 某些东西
208
+ * 比如 request 模块,路径对 Windows 来说会变得太长而无法处理。
209
+ * 由于只进行简单的 POST 请求,所以实际上不需要复杂的
210
+ * 逻辑支持(没有重定向,没有其他任何东西)。
211
+ *
212
+ * @param {String} url 要 POST 的 URL
213
+ * @param {String|Buffer} payload 要 POST 的有效负载
214
+ * @param {Function} callback 带有 (err, buff) 的回调函数
215
+ */
216
+ postRequest(url, payload, params, callback) {
217
+ const req = nmfetch(url, { method: 'post', headers: params.customHeaders, body: payload, allowErrorResponse: true });
218
+ resolveStream(req, callback); // 将流数据读取到Buffer中
219
+ }
220
+
221
+ /**
222
+ * 将缓冲区或字符串编码为 Base64url 格式
223
+ *
224
+ * @param {Buffer|String} data 要转换的数据
225
+ * @return {String} 编码后的字符串
226
+ */
227
+ toBase64URL(data) {
228
+ if (typeof data === 'string') data = Buffer.from(data);
229
+
230
+ return data.toString('base64')
231
+ .replace(/[=]+/g, '') // 移除 '='
232
+ .replace(/\+/g, '-') // '+' → '-'
233
+ .replace(/\//g, '_'); // '/' → '_'
234
+ }
235
+
236
+ /**
237
+ * 创建使用 RS256 (SHA256 + RSA) 签名的 JSON Web Token
238
+ *
239
+ * @param {Object} payload 要包含在生成令牌中的有效负载
240
+ * @return {String} 生成并签名的令牌
241
+ */
242
+ jwtSignRS256(payload) {
243
+ payload = ['{"alg":"RS256","typ":"JWT"}', JSON.stringify(payload)].map(val => this.toBase64URL(val)).join('.');
244
+ const signature = createSign('RSA-SHA256').update(payload).sign(this.options.privateKey);
245
+ return `${payload}.${this.toBase64URL(signature)}`;
246
+ }
247
+ }
248
+
249
+ export { XOAuth2 };
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@flun/mailer",
3
+ "version": "2.1.4",
4
+ "description": "A powerful and flexible email sending library for Node.js with multiple transport support (SMTP, SMTP Pool, Sendmail, Stream, JSON, SES)",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
9
+ "main": "index.js",
10
+ "types": "index.d.ts",
11
+ "scripts": {
12
+ "postinstall": "node copy-files.js 2>&1",
13
+ "test": "echo \"Error: no test specified\" && exit 1",
14
+ "start": "node index.js",
15
+ "dev": "node --watch index.js",
16
+ "lint": "eslint .",
17
+ "build": "echo 'No build process required'"
18
+ },
19
+ "bin": {
20
+ "mailer-init": "copy-files.js",
21
+ "mailer": "index.js"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "index.d.ts",
26
+ "lib/",
27
+ "copy-files.js",
28
+ "Mail.js",
29
+ "CHANGELOG.md",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "keywords": [
34
+ "mail",
35
+ "email",
36
+ "flun",
37
+ "smtp",
38
+ "sendmail",
39
+ "ses",
40
+ "aws",
41
+ "transport",
42
+ "smtp-pool",
43
+ "email-sender",
44
+ "mailer"
45
+ ],
46
+ "author": "flun <cn@flun.top>",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/flunGit/mailer.git"
53
+ },
54
+ "homepage": "https://www.npmjs.com/package/@flun/mailer#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/flunGit/mailer/issues"
57
+ },
58
+ "license": "ISC",
59
+ "engines": {
60
+ "node": ">=22.12.0",
61
+ "npm": ">=10.0.0"
62
+ },
63
+ "dependencies": {
64
+ "@aws-sdk/client-sesv2": "^3.1029.0"
65
+ },
66
+ "devDependencies": {
67
+ "eslint": "^10.2.0",
68
+ "jest": "^30.3.0"
69
+ },
70
+ "peerDependencies": {
71
+ "node": ">=16.0.0"
72
+ },
73
+ "directories": {
74
+ "lib": "lib",
75
+ "example": "examples",
76
+ "test": "tests"
77
+ }
78
+ }