nodemailer 10.1.0 → 10.1.2
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 +14 -0
- package/SECURITY.md +105 -29
- package/dist/cjs/dkim/index.js +3 -2
- package/dist/cjs/fetch/index.d.ts +5 -0
- package/dist/cjs/fetch/index.js +46 -10
- package/dist/cjs/mailer/index.js +4 -1
- package/dist/cjs/mailer/mail-message.d.ts +5 -1
- package/dist/cjs/mime-funcs/index.js +25 -13
- package/dist/cjs/mime-node/index.js +49 -6
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/smtp-connection/index.js +16 -1
- package/dist/cjs/smtp-transport/index.js +12 -1
- package/dist/cjs/well-known/services.js +7 -0
- package/dist/cjs/xoauth2/index.js +8 -2
- package/dist/esm/dkim/index.js +3 -2
- package/dist/esm/fetch/index.d.ts +5 -0
- package/dist/esm/fetch/index.js +46 -10
- package/dist/esm/mailer/index.js +4 -1
- package/dist/esm/mailer/mail-message.d.ts +5 -1
- package/dist/esm/mime-funcs/index.js +25 -13
- package/dist/esm/mime-node/index.js +49 -6
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/smtp-connection/index.js +16 -1
- package/dist/esm/smtp-transport/index.js +12 -1
- package/dist/esm/well-known/services.js +7 -0
- package/dist/esm/xoauth2/index.js +8 -2
- package/dist/well-known/services.json +8 -0
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [10.1.2](https://github.com/nodemailer/nodemailer/compare/v10.1.1...v10.1.2) (2026-10-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* bound the work a message can make the mailer do and keep per-message auth off the transport's token endpoint ([e347fa2](https://github.com/nodemailer/nodemailer/commit/e347fa2260a7e342e772a7e187937aa87ac96364))
|
|
9
|
+
|
|
10
|
+
## [10.1.1](https://github.com/nodemailer/nodemailer/compare/v10.1.0...v10.1.1) (2026-10-14)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **fetch:** strip credentials on any cross-origin redirect ([a15360c](https://github.com/nodemailer/nodemailer/commit/a15360cd2cd12e4670e96ab0b7290e8c862391cf))
|
|
16
|
+
|
|
3
17
|
## [10.1.0](https://github.com/nodemailer/nodemailer/compare/v10.0.16...v10.1.0) (2026-10-10)
|
|
4
18
|
|
|
5
19
|
|
package/SECURITY.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# Security Policy
|
|
2
2
|
|
|
3
|
-
Nodemailer is a widely deployed, zero-dependency
|
|
4
|
-
reports
|
|
3
|
+
Nodemailer is a widely deployed, zero-dependency email library. We welcome
|
|
4
|
+
responsible reports of vulnerabilities that present a credible security risk to
|
|
5
|
+
users. Please distinguish security vulnerabilities from ordinary bugs, unusual
|
|
6
|
+
edge cases, and hardening suggestions.
|
|
5
7
|
|
|
6
8
|
## Supported Versions
|
|
7
9
|
|
|
@@ -17,45 +19,119 @@ the supported way to receive security updates.
|
|
|
17
19
|
If you are on an older major, please upgrade. See the migration notes at
|
|
18
20
|
<https://nodemailer.com/> before updating.
|
|
19
21
|
|
|
20
|
-
##
|
|
22
|
+
## What Qualifies as a Security Vulnerability
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
A security report should identify a realistic way for an attacker to cross a
|
|
25
|
+
security boundary in a supported version of Nodemailer. Unexpected behavior,
|
|
26
|
+
a failing test, or a technically correct proof of concept is not sufficient on
|
|
27
|
+
its own to establish security impact.
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
In particular, explain:
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
- **Attacker capabilities:** Who is the attacker, and what inputs, systems, or
|
|
32
|
+
responses can they control _without_ already having the privileges or secrets
|
|
33
|
+
they are trying to gain?
|
|
34
|
+
- **Attack path:** How does attacker-controlled input reach the affected code
|
|
35
|
+
through a supported Nodemailer API or a realistic application integration?
|
|
36
|
+
- **Prerequisites:** What configuration and user actions are required? If the
|
|
37
|
+
attack depends on unusual options or deliberately malformed input, explain
|
|
38
|
+
why those conditions could arise in a real application.
|
|
39
|
+
- **Impact:** What confidentiality, integrity, or availability boundary is
|
|
40
|
+
crossed, and what can the attacker achieve as a result?
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
A vulnerability involving an optional or less common feature can still qualify
|
|
43
|
+
if these conditions are met. Conversely, the existence of a defensive check in
|
|
44
|
+
the code does not, by itself, establish that every bypass of that check has a
|
|
45
|
+
realistic security impact.
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
- The component involved (e.g. SMTP connection, address parsing, MIME/header
|
|
37
|
-
generation, DKIM).
|
|
38
|
-
- A clear description of the issue and its impact (e.g. header/SMTP command
|
|
39
|
-
injection, information disclosure, DoS).
|
|
40
|
-
- A minimal proof of concept or reproduction steps.
|
|
41
|
-
- Any suggested remediation, if you have one.
|
|
47
|
+
## Reporting a Vulnerability
|
|
42
48
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Accepted issues are fixed in a new release and coordinated through a GitHub
|
|
46
|
-
Security Advisory, and reporters who wish to be named are credited.
|
|
49
|
+
**Do not disclose suspected exploitable vulnerabilities through public GitHub
|
|
50
|
+
issues, pull requests, or discussions before coordinating with the maintainer.**
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
Report privately through one of the following channels:
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
1. **GitHub Security Advisories (preferred):**
|
|
55
|
+
<https://github.com/nodemailer/nodemailer/security/advisories/new>.
|
|
56
|
+
2. **Email:** **andris@reinman.eu**, as listed in
|
|
57
|
+
[`SECURITY.txt`](SECURITY.txt). Encrypt sensitive details if possible.
|
|
58
|
+
|
|
59
|
+
### Required Information
|
|
60
|
+
|
|
61
|
+
Please provide enough information to evaluate both the defect and its security
|
|
62
|
+
impact. Reports should include:
|
|
63
|
+
|
|
64
|
+
1. The affected Nodemailer version or commit, Node.js version, and relevant
|
|
65
|
+
configuration.
|
|
66
|
+
2. A concise description of the issue and the affected public API or code path.
|
|
67
|
+
3. A minimal, tested reproduction, including steps to run it, expected behavior,
|
|
68
|
+
and observed behavior.
|
|
69
|
+
4. A concrete threat model: attacker control, prerequisites, trust boundary, and
|
|
70
|
+
resulting impact. Clearly distinguish what your reproduction proves from what
|
|
71
|
+
you believe might be possible.
|
|
72
|
+
5. An explanation of why the issue affects applications beyond a deliberately
|
|
73
|
+
constructed test setup, where this is not obvious.
|
|
74
|
+
|
|
75
|
+
A suggested fix is welcome but does not replace an explanation of exploitability.
|
|
76
|
+
A complete real-world exploit is not always necessary, but the report must make
|
|
77
|
+
a credible security impact clear.
|
|
78
|
+
|
|
79
|
+
### Reports That May Be Treated as Ordinary Bugs
|
|
80
|
+
|
|
81
|
+
The following generally do **not** qualify for private security handling unless
|
|
82
|
+
you can demonstrate an additional, meaningful security boundary crossing:
|
|
83
|
+
|
|
84
|
+
- Issues that require the attacker to control trusted application code,
|
|
85
|
+
administrator-only configuration, or the secret they supposedly extract.
|
|
86
|
+
- Problems caused solely by application-level misuse, exposed credentials, or
|
|
87
|
+
insecure deployment choices outside Nodemailer's control.
|
|
88
|
+
- Contrived proof-of-concept setups where the tester controls both sides of a
|
|
89
|
+
request, without explaining how a separate attacker could trigger the issue.
|
|
90
|
+
- Parser quirks, unexpected behavior, and bypasses of internal checks with no
|
|
91
|
+
demonstrated security impact.
|
|
92
|
+
- Speculative findings based only on static analysis or an automated tool's
|
|
93
|
+
assessment, without a verified reproduction and credible attack scenario.
|
|
94
|
+
|
|
95
|
+
These may still be valid bugs and may be considered through the normal issue
|
|
96
|
+
tracker. Please coordinate before making any potentially exploitable details
|
|
97
|
+
public. Classification as a bug does not guarantee a fix or a particular
|
|
98
|
+
priority.
|
|
99
|
+
|
|
100
|
+
### Automated and AI-Assisted Reports
|
|
101
|
+
|
|
102
|
+
Automated analysis and AI tools are welcome as research aids, but the person
|
|
103
|
+
submitting a report is responsible for verifying its claims. **Do not submit
|
|
104
|
+
bulk, unverified, or speculative AI-generated findings.** Test each finding
|
|
105
|
+
against a supported release and establish the actual security impact. Reports
|
|
106
|
+
that do not meet the requirements above may be closed without further
|
|
107
|
+
investigation or individual feedback; they can be resubmitted with stronger
|
|
108
|
+
evidence.
|
|
109
|
+
|
|
110
|
+
## Triage and Disclosure
|
|
111
|
+
|
|
112
|
+
Nodemailer is maintained by a single person, and there is **no guaranteed
|
|
113
|
+
response or resolution time**. Reports are prioritized based on demonstrated
|
|
114
|
+
impact, exploitability, affected usage, and available maintenance resources.
|
|
115
|
+
|
|
116
|
+
A private report does not automatically become a security advisory. Confirmed
|
|
117
|
+
vulnerabilities may be fixed and disclosed through GitHub Security Advisories;
|
|
118
|
+
other findings may be closed, deferred, or handled as ordinary bugs. Please do
|
|
119
|
+
not disclose details of an unresolved, potentially exploitable issue publicly
|
|
120
|
+
without coordinating with the maintainer.
|
|
121
|
+
|
|
122
|
+
## CVEs and Credit
|
|
123
|
+
|
|
124
|
+
We do not request or manage CVE identifiers. A report being acknowledged, a bug
|
|
125
|
+
being fixed, or a pull request being merged does **not** mean that Nodemailer
|
|
126
|
+
has confirmed a security vulnerability or endorsed a CVE assignment. We do not
|
|
127
|
+
guarantee advisories, CVE identifiers, or public credit for reports. For
|
|
128
|
+
confirmed vulnerabilities disclosed through an advisory, substantive reporters
|
|
129
|
+
may be credited if they wish.
|
|
54
130
|
|
|
55
131
|
## Scope
|
|
56
132
|
|
|
57
133
|
In scope: the `nodemailer` package source in this repository: message and MIME
|
|
58
|
-
generation, SMTP/LMTP client
|
|
134
|
+
generation, SMTP/LMTP client behavior, address parsing, header handling, DKIM
|
|
59
135
|
signing, and the bundled transports.
|
|
60
136
|
|
|
61
137
|
Out of scope: vulnerabilities in your own application code, misconfiguration of
|
package/dist/cjs/dkim/index.js
CHANGED
|
@@ -133,8 +133,9 @@ class DKIMSigner {
|
|
|
133
133
|
}
|
|
134
134
|
createWriteCache() {
|
|
135
135
|
this.output.usingCache = true;
|
|
136
|
-
// pipe remainings to cache file
|
|
137
|
-
|
|
136
|
+
// pipe remainings to cache file. It holds the message body and cacheDir is often a
|
|
137
|
+
// shared directory such as /tmp, so nobody but the owner gets to read it
|
|
138
|
+
this.cache = node_fs_1.default.createWriteStream(this.cachePath, { mode: 0o600 });
|
|
138
139
|
this.cache.once('error', err => {
|
|
139
140
|
// drain input
|
|
140
141
|
this.relaxedBody.unpipe(this.cache);
|
|
@@ -27,6 +27,8 @@ export interface FetchOptions {
|
|
|
27
27
|
} | undefined;
|
|
28
28
|
/** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
|
|
29
29
|
timeout?: number | undefined;
|
|
30
|
+
/** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */
|
|
31
|
+
maxTime?: number | undefined;
|
|
30
32
|
/** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
|
|
31
33
|
maxBytes?: number | undefined;
|
|
32
34
|
/** Maximum number of redirects to follow (default 5) */
|
|
@@ -37,6 +39,8 @@ export interface FetchOptions {
|
|
|
37
39
|
redirects?: number | undefined;
|
|
38
40
|
/** Response stream shared across redirects, set by nmfetch itself */
|
|
39
41
|
fetchRes?: FetchResponse | undefined;
|
|
42
|
+
/** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */
|
|
43
|
+
deadline?: number | undefined;
|
|
40
44
|
}
|
|
41
45
|
/**
|
|
42
46
|
* The stream nmfetch returns. The response body is piped into it, the status code and
|
|
@@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
|
|
|
50
54
|
declare namespace nmfetch {
|
|
51
55
|
var Cookies: typeof import("./cookies.js").default;
|
|
52
56
|
var DEFAULT_TIMEOUT: number;
|
|
57
|
+
var DEFAULT_MAX_TIME: number;
|
|
53
58
|
}
|
|
54
59
|
type CookiesJar = Cookies;
|
|
55
60
|
/**
|
package/dist/cjs/fetch/index.js
CHANGED
|
@@ -49,6 +49,10 @@ const objects_js_1 = require("../shared/objects.js");
|
|
|
49
49
|
const MAX_REDIRECTS = 5;
|
|
50
50
|
// a stalled server would otherwise hold the request, and whatever waits on it, open forever
|
|
51
51
|
const DEFAULT_TIMEOUT = 60 * 1000;
|
|
52
|
+
// the inactivity timeout above is reset by every byte, so a server dripping one within each
|
|
53
|
+
// window could still hold the request, and the message being built from it, open for as
|
|
54
|
+
// long as it liked. The whole request, redirects included, has to be done within this
|
|
55
|
+
const DEFAULT_MAX_TIME = 2 * 60 * 1000;
|
|
52
56
|
// the body is usually buffered in memory by the caller, so an unbounded download is refused
|
|
53
57
|
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
54
58
|
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
|
|
@@ -116,12 +120,22 @@ function parseFetchUrl(url) {
|
|
|
116
120
|
}
|
|
117
121
|
return parsed;
|
|
118
122
|
}
|
|
123
|
+
// the port a request to the URL goes to, explicit or the default of its scheme
|
|
124
|
+
function fetchPort(parsed) {
|
|
125
|
+
return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
|
126
|
+
}
|
|
119
127
|
function nmfetch(url, options) {
|
|
120
128
|
options = options || {};
|
|
121
129
|
options.fetchRes = options.fetchRes || new node_stream_1.PassThrough();
|
|
122
130
|
options.cookies = options.cookies || new cookies_js_1.default();
|
|
123
131
|
options.redirects = options.redirects || 0;
|
|
124
132
|
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
|
|
133
|
+
if (!options.deadline) {
|
|
134
|
+
options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME;
|
|
135
|
+
// the one deadline is shared with the requests the redirects lead to, every hop
|
|
136
|
+
// gets the time that is left rather than starting over
|
|
137
|
+
options.deadline = options.maxTime ? Date.now() + options.maxTime : 0;
|
|
138
|
+
}
|
|
125
139
|
const fetchRes = options.fetchRes;
|
|
126
140
|
const parsed = parseFetchUrl(url);
|
|
127
141
|
if (!parsed) {
|
|
@@ -232,7 +246,7 @@ function nmfetch(url, options) {
|
|
|
232
246
|
method,
|
|
233
247
|
host: parsed.hostname,
|
|
234
248
|
path: parsed.path,
|
|
235
|
-
port: parsed
|
|
249
|
+
port: fetchPort(parsed),
|
|
236
250
|
headers,
|
|
237
251
|
// Validate TLS certificates by default. Callers that genuinely need to
|
|
238
252
|
// reach a self-signed/internal host opt out explicitly with
|
|
@@ -267,12 +281,22 @@ function nmfetch(url, options) {
|
|
|
267
281
|
});
|
|
268
282
|
return fetchRes;
|
|
269
283
|
}
|
|
284
|
+
// the whole request has to be done by options.deadline, see maxTime. The timer is
|
|
285
|
+
// released on every way out of the request, so it never outlives it
|
|
286
|
+
let deadlineTimer;
|
|
287
|
+
const clearDeadline = () => {
|
|
288
|
+
if (deadlineTimer) {
|
|
289
|
+
clearTimeout(deadlineTimer);
|
|
290
|
+
deadlineTimer = undefined;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
270
293
|
// reports the first failure of this request on fetchRes and releases the request
|
|
271
294
|
const fail = (err, sourceUrl = url) => {
|
|
272
295
|
if (finished) {
|
|
273
296
|
return;
|
|
274
297
|
}
|
|
275
298
|
finished = true;
|
|
299
|
+
clearDeadline();
|
|
276
300
|
setFetchCode(err);
|
|
277
301
|
err.sourceUrl = sourceUrl;
|
|
278
302
|
fetchRes.emit('error', err);
|
|
@@ -282,10 +306,16 @@ function nmfetch(url, options) {
|
|
|
282
306
|
if (timeout) {
|
|
283
307
|
req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
|
|
284
308
|
}
|
|
309
|
+
if (options.deadline) {
|
|
310
|
+
deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1));
|
|
311
|
+
// a pending deadline is no reason to keep the process alive
|
|
312
|
+
deadlineTimer.unref();
|
|
313
|
+
}
|
|
285
314
|
req.on('error', (err) => fail(err));
|
|
286
315
|
// a consumer that destroys the response stream before it ended does not want the rest of
|
|
287
316
|
// the body, release the request now instead of when the timeout fires
|
|
288
317
|
fetchRes.once('close', () => {
|
|
318
|
+
clearDeadline();
|
|
289
319
|
if (finished || fetchRes.readableEnded) {
|
|
290
320
|
return;
|
|
291
321
|
}
|
|
@@ -334,17 +364,19 @@ function nmfetch(url, options) {
|
|
|
334
364
|
// the shared fetchRes once it times out. Callers listen with req.once().
|
|
335
365
|
return fail(new Error('Unsupported protocol for URL ' + redirectUrl), redirectUrl);
|
|
336
366
|
}
|
|
337
|
-
// Do not forward credentials when the redirect leaves the
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
367
|
+
// Do not forward credentials when the redirect leaves the origin: a different
|
|
368
|
+
// scheme, host or port is a different security context (an https to http
|
|
369
|
+
// downgrade would also put them on the wire in cleartext). Strip sensitive
|
|
370
|
+
// request headers so an attacker who controls the redirect target cannot
|
|
371
|
+
// harvest them.
|
|
372
|
+
const crossOrigin = redirectParsed.protocol !== parsed.protocol ||
|
|
373
|
+
redirectParsed.hostname !== parsed.hostname ||
|
|
374
|
+
fetchPort(redirectParsed) !== fetchPort(parsed);
|
|
375
|
+
if (options.headers && crossOrigin) {
|
|
345
376
|
const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
|
|
346
377
|
Object.keys(options.headers).forEach(key => {
|
|
347
|
-
|
|
378
|
+
// normalized the way the request headers are built above, a padded name is sent too
|
|
379
|
+
if (sensitive.includes(key.toLowerCase().trim())) {
|
|
348
380
|
delete options.headers[key];
|
|
349
381
|
}
|
|
350
382
|
});
|
|
@@ -352,6 +384,7 @@ function nmfetch(url, options) {
|
|
|
352
384
|
// this request is done with, release it so its socket and timeout do not
|
|
353
385
|
// outlive it and report a late error on the shared fetchRes
|
|
354
386
|
finished = true;
|
|
387
|
+
clearDeadline();
|
|
355
388
|
res.resume();
|
|
356
389
|
req.abort();
|
|
357
390
|
return nmfetch(redirectUrl, options);
|
|
@@ -362,6 +395,8 @@ function nmfetch(url, options) {
|
|
|
362
395
|
return fail(new Error('Invalid status code ' + res.statusCode));
|
|
363
396
|
}
|
|
364
397
|
res.on('error', (err) => fail(err));
|
|
398
|
+
// the response is in, whatever is left of it is in memory and no longer on the network
|
|
399
|
+
res.once('end', () => clearDeadline());
|
|
365
400
|
const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
|
|
366
401
|
const source = inflate || res;
|
|
367
402
|
let received = 0;
|
|
@@ -399,6 +434,7 @@ function nmfetch(url, options) {
|
|
|
399
434
|
}
|
|
400
435
|
nmfetch.Cookies = cookies_js_1.default;
|
|
401
436
|
nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
|
|
437
|
+
nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
|
|
402
438
|
exports.default = nmfetch;
|
|
403
439
|
module.exports = exports.default;
|
|
404
440
|
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
package/dist/cjs/mailer/index.js
CHANGED
|
@@ -398,9 +398,12 @@ class Mail extends node_events_1.EventEmitter {
|
|
|
398
398
|
return callback(err);
|
|
399
399
|
}
|
|
400
400
|
let cidCounter = 0;
|
|
401
|
+
// the media type is a bounded token: a quote, whitespace, '>' or '<' ends the
|
|
402
|
+
// attribute value and with it the type, so one img tag never scans ahead to a
|
|
403
|
+
// ';' that belongs to a later tag, which was quadratic
|
|
401
404
|
html = (html || '')
|
|
402
405
|
.toString()
|
|
403
|
-
.replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]
|
|
406
|
+
.replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
|
|
404
407
|
const cid = node_crypto_1.default.randomBytes(10).toString('hex') + '@localhost';
|
|
405
408
|
if (!mail.data.attachments) {
|
|
406
409
|
mail.data.attachments = [];
|
|
@@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions {
|
|
|
23
23
|
dsn?: SMTPEnvelopeDsn | undefined;
|
|
24
24
|
/** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */
|
|
25
25
|
requireTLSExtensionEnabled?: boolean | undefined;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* SMTP transports: per-message authentication settings, used instead of the transport level auth.
|
|
28
|
+
* Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires),
|
|
29
|
+
* the token endpoint and client settings stay those of the transport
|
|
30
|
+
*/
|
|
27
31
|
auth?: SMTPTransportAuthOptions | undefined;
|
|
28
32
|
/** SES transport: extra SendEmailCommand parameters merged into the API call */
|
|
29
33
|
ses?: SESSendEmailRequest | undefined;
|
|
@@ -186,26 +186,38 @@ function encodeWord(data, mimeWordEncoding, maxLength) {
|
|
|
186
186
|
*/
|
|
187
187
|
function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
|
|
188
188
|
maxLength = maxLength || 0;
|
|
189
|
-
// find first
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
// find the first and the last character that needs encoding, a quote or anything outside
|
|
190
|
+
// printable ascii. A scan rather than a regex: matching the last such word with a
|
|
191
|
+
// backtracking pattern took quadratic time on a long word of non-ascii characters
|
|
192
|
+
let firstSpecial = -1;
|
|
193
|
+
let lastSpecial = -1;
|
|
194
|
+
for (let i = 0; i < value.length; i++) {
|
|
195
|
+
const code = value.charCodeAt(i);
|
|
196
|
+
if (code === 0x22 || code >= 0x80) {
|
|
197
|
+
if (firstSpecial < 0) {
|
|
198
|
+
firstSpecial = i;
|
|
199
|
+
}
|
|
200
|
+
lastSpecial = i;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (firstSpecial < 0) {
|
|
192
204
|
return value;
|
|
193
205
|
}
|
|
194
206
|
if (encodeAll) {
|
|
195
207
|
// if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
|
|
196
208
|
return encodeWord(value, mimeWordEncoding, maxLength);
|
|
197
209
|
}
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
210
|
+
// encode from the start of the word holding the first such character to the end of the
|
|
211
|
+
// word holding the last one, so the encoded words stay separated from the plain text by
|
|
212
|
+
// the whitespace that was already there
|
|
213
|
+
let startIndex = firstSpecial;
|
|
214
|
+
while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) {
|
|
215
|
+
startIndex--;
|
|
216
|
+
}
|
|
217
|
+
let endIndex = lastSpecial + 1;
|
|
218
|
+
while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) {
|
|
219
|
+
endIndex++;
|
|
203
220
|
}
|
|
204
|
-
const startIndex = firstMatch.index +
|
|
205
|
-
(firstMatch[0].match(/[^\s]/) || {
|
|
206
|
-
index: 0
|
|
207
|
-
}).index;
|
|
208
|
-
const endIndex = lastMatch.index + (lastMatch[1] || '').length;
|
|
209
221
|
return ((startIndex ? value.substr(0, startIndex) : '') +
|
|
210
222
|
encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
|
|
211
223
|
(endIndex < value.length ? value.substr(endIndex) : ''));
|
|
@@ -71,6 +71,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
|
|
|
71
71
|
// unroutable garbage into mail for a domain the sender never named. None of these
|
|
72
72
|
// characters are legal in a domain, so keep them away from the mapper.
|
|
73
73
|
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
|
|
74
|
+
// RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
|
|
75
|
+
// the code points it encodes, so anything past these bounds could not have become a valid
|
|
76
|
+
// domain whichever way it is encoded. The bundled codec is super-linear in the length of a
|
|
77
|
+
// label, a 60 KB label of distinct code points took seconds to encode, several times per
|
|
78
|
+
// address, so it is only handed what could be a domain
|
|
79
|
+
const MAX_DOMAIN_LENGTH = 253;
|
|
80
|
+
const MAX_LABEL_LENGTH = 63;
|
|
74
81
|
// pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
|
|
75
82
|
const PIPELINE_NOOP = () => false;
|
|
76
83
|
// The error a node streaming into an output that was destroyed stops with
|
|
@@ -79,6 +86,27 @@ function abortedError() {
|
|
|
79
86
|
err.code = errors.ESTREAM;
|
|
80
87
|
return err;
|
|
81
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Checks a domain against the DNS length limits, counted in code points per label and in
|
|
91
|
+
* total. The label separators are the ones the bundled codec splits on.
|
|
92
|
+
*
|
|
93
|
+
* @param domain Domain to check
|
|
94
|
+
* @return true if a label or the domain as a whole is too long to be a valid domain
|
|
95
|
+
*/
|
|
96
|
+
function exceedsDomainLimits(domain) {
|
|
97
|
+
let total = 0;
|
|
98
|
+
let label = 0;
|
|
99
|
+
for (const char of domain) {
|
|
100
|
+
total++;
|
|
101
|
+
if (char === '.' || char === '。' || char === '.' || char === '。') {
|
|
102
|
+
label = 0;
|
|
103
|
+
}
|
|
104
|
+
else if (++label > MAX_LABEL_LENGTH) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return total > MAX_DOMAIN_LENGTH;
|
|
109
|
+
}
|
|
82
110
|
/**
|
|
83
111
|
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
|
|
84
112
|
* which is with UTS-46 mapping applied before the Punycode step.
|
|
@@ -110,6 +138,10 @@ function normalizeDomain(domain, toUnicode) {
|
|
|
110
138
|
return mapped;
|
|
111
139
|
}
|
|
112
140
|
}
|
|
141
|
+
if (exceedsDomainLimits(domain)) {
|
|
142
|
+
// too long to be a domain whichever way it is encoded, keep it as supplied
|
|
143
|
+
return domain;
|
|
144
|
+
}
|
|
113
145
|
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
|
|
114
146
|
}
|
|
115
147
|
/**
|
|
@@ -614,6 +646,17 @@ class MimeNode {
|
|
|
614
646
|
/[\u0080-\uFFFF]/.test(this.content)) {
|
|
615
647
|
structured.params.charset = 'utf-8';
|
|
616
648
|
}
|
|
649
|
+
if (this.filename) {
|
|
650
|
+
// the name parameter is derived from the filename below, so a name that
|
|
651
|
+
// arrived inside the content type is dropped first (a plain parameter or
|
|
652
|
+
// rfc2231 continuation sections), otherwise the header carries two and a
|
|
653
|
+
// receiver that reads the first sees a name the filename does not match
|
|
654
|
+
for (const paramKey of Object.keys(structured.params)) {
|
|
655
|
+
if (/^name(\*|$)/i.test(paramKey)) {
|
|
656
|
+
delete structured.params[paramKey];
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
617
660
|
value = mimeFuncs.buildHeaderValue(structured);
|
|
618
661
|
if (this.filename) {
|
|
619
662
|
// add support for non-compliant clients like QQ webmail
|
|
@@ -1223,12 +1266,12 @@ class MimeNode {
|
|
|
1223
1266
|
_normalizeHeaderKey(key) {
|
|
1224
1267
|
key = (key || '')
|
|
1225
1268
|
.toString()
|
|
1226
|
-
//
|
|
1227
|
-
|
|
1228
|
-
// a field name
|
|
1229
|
-
//
|
|
1230
|
-
|
|
1231
|
-
.
|
|
1269
|
+
// a field name is printable ascii without the colon, so a control char, DEL,
|
|
1270
|
+
// whitespace or a colon can only be dropped, there is no quoting construct
|
|
1271
|
+
// around a field name. A colon or a space left in place ends the name early on
|
|
1272
|
+
// the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
|
|
1273
|
+
// second From header that neither setHeader() nor the Bcc filter could match
|
|
1274
|
+
.replace(/[\s\x00-\x20\x7f:]/g, '')
|
|
1232
1275
|
.toLowerCase()
|
|
1233
1276
|
// use uppercase words, except MIME
|
|
1234
1277
|
.replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
|
package/dist/cjs/package-info.js
CHANGED
|
@@ -114,6 +114,21 @@ function isPartialResponse(str) {
|
|
|
114
114
|
function isPartialLine(line) {
|
|
115
115
|
return /^\d+-/.test(line);
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The log form of a command a custom authentication handler sends. Such a command carries
|
|
119
|
+
* the credentials in whatever shape its mechanism uses, so only an AUTH verb with its
|
|
120
|
+
* mechanism name is kept, the way the built-in mechanisms hide their own secrets.
|
|
121
|
+
*
|
|
122
|
+
* @param cmd Command the handler sends
|
|
123
|
+
* @return What the transaction log gets instead
|
|
124
|
+
*/
|
|
125
|
+
function maskAuthCommand(cmd) {
|
|
126
|
+
const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i);
|
|
127
|
+
if (!match) {
|
|
128
|
+
return '/* secret */';
|
|
129
|
+
}
|
|
130
|
+
return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */';
|
|
131
|
+
}
|
|
117
132
|
/**
|
|
118
133
|
* Generates a SMTP connection object
|
|
119
134
|
*
|
|
@@ -601,7 +616,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
|
|
|
601
616
|
}
|
|
602
617
|
done(null, data);
|
|
603
618
|
});
|
|
604
|
-
setImmediate(() => this._sendCommand(cmd));
|
|
619
|
+
setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd)));
|
|
605
620
|
return promise;
|
|
606
621
|
};
|
|
607
622
|
const handlerResponse = handler({
|
|
@@ -43,6 +43,13 @@ const shared = __importStar(require("../shared/index.js"));
|
|
|
43
43
|
const index_js_3 = __importDefault(require("../xoauth2/index.js"));
|
|
44
44
|
const errors = __importStar(require("../errors.js"));
|
|
45
45
|
const packageData = __importStar(require("../package-info.js"));
|
|
46
|
+
// The keys a message's `auth` may set on top of the transport's auth settings: the ones that
|
|
47
|
+
// say who logs in and with what. Everything else in the auth block (accessUrl, clientId,
|
|
48
|
+
// clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest)
|
|
49
|
+
// names the endpoint the credentials go to or is a secret of the application itself, and
|
|
50
|
+
// a message that could set `auth.accessUrl` and force a refresh would have the transport's
|
|
51
|
+
// client secret and refresh token posted to a URL of its choosing
|
|
52
|
+
const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires'];
|
|
46
53
|
/**
|
|
47
54
|
* Creates a SMTP transport object for Nodemailer
|
|
48
55
|
*
|
|
@@ -102,7 +109,11 @@ class SMTPTransport extends node_events_1.EventEmitter {
|
|
|
102
109
|
}
|
|
103
110
|
return this.auth;
|
|
104
111
|
}
|
|
105
|
-
const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}
|
|
112
|
+
const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {});
|
|
113
|
+
if (typeof authOpts === 'object') {
|
|
114
|
+
// see MESSAGE_AUTH_KEYS
|
|
115
|
+
shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key));
|
|
116
|
+
}
|
|
106
117
|
if (Object.keys(authData).length === 0) {
|
|
107
118
|
return false;
|
|
108
119
|
}
|
|
@@ -363,6 +363,13 @@ exports.services = {
|
|
|
363
363
|
"port": 587,
|
|
364
364
|
"secure": false
|
|
365
365
|
},
|
|
366
|
+
"Oveyon": {
|
|
367
|
+
"description": "Oveyon",
|
|
368
|
+
"host": "smtp.oveyon.com",
|
|
369
|
+
"port": 587,
|
|
370
|
+
"secure": false,
|
|
371
|
+
"requireTLS": true
|
|
372
|
+
},
|
|
366
373
|
"Postmark": {
|
|
367
374
|
"description": "Postmark",
|
|
368
375
|
"aliases": [
|
|
@@ -264,8 +264,14 @@ class XOAuth2 extends node_stream_1.Stream {
|
|
|
264
264
|
grant_type: 'refresh_token'
|
|
265
265
|
};
|
|
266
266
|
}
|
|
267
|
-
|
|
268
|
-
Object.assign(
|
|
267
|
+
const customParams = this.options.customParams || {};
|
|
268
|
+
Object.assign(urlOptions, customParams);
|
|
269
|
+
// a custom param is as likely to be a client assertion or an API key as a tenant
|
|
270
|
+
// name, so the log gets the same glimpse of it that it gets of the client secret
|
|
271
|
+
for (const key of Object.keys(customParams)) {
|
|
272
|
+
const param = customParams[key];
|
|
273
|
+
loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...';
|
|
274
|
+
}
|
|
269
275
|
this.logger.debug({
|
|
270
276
|
tnx: 'OAUTH2',
|
|
271
277
|
user: this.options.user,
|
package/dist/esm/dkim/index.js
CHANGED
|
@@ -128,8 +128,9 @@ class DKIMSigner {
|
|
|
128
128
|
}
|
|
129
129
|
createWriteCache() {
|
|
130
130
|
this.output.usingCache = true;
|
|
131
|
-
// pipe remainings to cache file
|
|
132
|
-
|
|
131
|
+
// pipe remainings to cache file. It holds the message body and cacheDir is often a
|
|
132
|
+
// shared directory such as /tmp, so nobody but the owner gets to read it
|
|
133
|
+
this.cache = fs.createWriteStream(this.cachePath, { mode: 0o600 });
|
|
133
134
|
this.cache.once('error', err => {
|
|
134
135
|
// drain input
|
|
135
136
|
this.relaxedBody.unpipe(this.cache);
|
|
@@ -27,6 +27,8 @@ export interface FetchOptions {
|
|
|
27
27
|
} | undefined;
|
|
28
28
|
/** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
|
|
29
29
|
timeout?: number | undefined;
|
|
30
|
+
/** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */
|
|
31
|
+
maxTime?: number | undefined;
|
|
30
32
|
/** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
|
|
31
33
|
maxBytes?: number | undefined;
|
|
32
34
|
/** Maximum number of redirects to follow (default 5) */
|
|
@@ -37,6 +39,8 @@ export interface FetchOptions {
|
|
|
37
39
|
redirects?: number | undefined;
|
|
38
40
|
/** Response stream shared across redirects, set by nmfetch itself */
|
|
39
41
|
fetchRes?: FetchResponse | undefined;
|
|
42
|
+
/** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */
|
|
43
|
+
deadline?: number | undefined;
|
|
40
44
|
}
|
|
41
45
|
/**
|
|
42
46
|
* The stream nmfetch returns. The response body is piped into it, the status code and
|
|
@@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
|
|
|
50
54
|
declare namespace nmfetch {
|
|
51
55
|
var Cookies: typeof import("./cookies.js").default;
|
|
52
56
|
var DEFAULT_TIMEOUT: number;
|
|
57
|
+
var DEFAULT_MAX_TIME: number;
|
|
53
58
|
}
|
|
54
59
|
type CookiesJar = Cookies;
|
|
55
60
|
/**
|
package/dist/esm/fetch/index.js
CHANGED
|
@@ -11,6 +11,10 @@ import { isProtoKey } from '../shared/objects.js';
|
|
|
11
11
|
const MAX_REDIRECTS = 5;
|
|
12
12
|
// a stalled server would otherwise hold the request, and whatever waits on it, open forever
|
|
13
13
|
const DEFAULT_TIMEOUT = 60 * 1000;
|
|
14
|
+
// the inactivity timeout above is reset by every byte, so a server dripping one within each
|
|
15
|
+
// window could still hold the request, and the message being built from it, open for as
|
|
16
|
+
// long as it liked. The whole request, redirects included, has to be done within this
|
|
17
|
+
const DEFAULT_MAX_TIME = 2 * 60 * 1000;
|
|
14
18
|
// the body is usually buffered in memory by the caller, so an unbounded download is refused
|
|
15
19
|
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
16
20
|
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
|
|
@@ -78,12 +82,22 @@ function parseFetchUrl(url) {
|
|
|
78
82
|
}
|
|
79
83
|
return parsed;
|
|
80
84
|
}
|
|
85
|
+
// the port a request to the URL goes to, explicit or the default of its scheme
|
|
86
|
+
function fetchPort(parsed) {
|
|
87
|
+
return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
|
88
|
+
}
|
|
81
89
|
function nmfetch(url, options) {
|
|
82
90
|
options = options || {};
|
|
83
91
|
options.fetchRes = options.fetchRes || new PassThrough();
|
|
84
92
|
options.cookies = options.cookies || new Cookies();
|
|
85
93
|
options.redirects = options.redirects || 0;
|
|
86
94
|
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
|
|
95
|
+
if (!options.deadline) {
|
|
96
|
+
options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME;
|
|
97
|
+
// the one deadline is shared with the requests the redirects lead to, every hop
|
|
98
|
+
// gets the time that is left rather than starting over
|
|
99
|
+
options.deadline = options.maxTime ? Date.now() + options.maxTime : 0;
|
|
100
|
+
}
|
|
87
101
|
const fetchRes = options.fetchRes;
|
|
88
102
|
const parsed = parseFetchUrl(url);
|
|
89
103
|
if (!parsed) {
|
|
@@ -194,7 +208,7 @@ function nmfetch(url, options) {
|
|
|
194
208
|
method,
|
|
195
209
|
host: parsed.hostname,
|
|
196
210
|
path: parsed.path,
|
|
197
|
-
port: parsed
|
|
211
|
+
port: fetchPort(parsed),
|
|
198
212
|
headers,
|
|
199
213
|
// Validate TLS certificates by default. Callers that genuinely need to
|
|
200
214
|
// reach a self-signed/internal host opt out explicitly with
|
|
@@ -229,12 +243,22 @@ function nmfetch(url, options) {
|
|
|
229
243
|
});
|
|
230
244
|
return fetchRes;
|
|
231
245
|
}
|
|
246
|
+
// the whole request has to be done by options.deadline, see maxTime. The timer is
|
|
247
|
+
// released on every way out of the request, so it never outlives it
|
|
248
|
+
let deadlineTimer;
|
|
249
|
+
const clearDeadline = () => {
|
|
250
|
+
if (deadlineTimer) {
|
|
251
|
+
clearTimeout(deadlineTimer);
|
|
252
|
+
deadlineTimer = undefined;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
232
255
|
// reports the first failure of this request on fetchRes and releases the request
|
|
233
256
|
const fail = (err, sourceUrl = url) => {
|
|
234
257
|
if (finished) {
|
|
235
258
|
return;
|
|
236
259
|
}
|
|
237
260
|
finished = true;
|
|
261
|
+
clearDeadline();
|
|
238
262
|
setFetchCode(err);
|
|
239
263
|
err.sourceUrl = sourceUrl;
|
|
240
264
|
fetchRes.emit('error', err);
|
|
@@ -244,10 +268,16 @@ function nmfetch(url, options) {
|
|
|
244
268
|
if (timeout) {
|
|
245
269
|
req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
|
|
246
270
|
}
|
|
271
|
+
if (options.deadline) {
|
|
272
|
+
deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1));
|
|
273
|
+
// a pending deadline is no reason to keep the process alive
|
|
274
|
+
deadlineTimer.unref();
|
|
275
|
+
}
|
|
247
276
|
req.on('error', (err) => fail(err));
|
|
248
277
|
// a consumer that destroys the response stream before it ended does not want the rest of
|
|
249
278
|
// the body, release the request now instead of when the timeout fires
|
|
250
279
|
fetchRes.once('close', () => {
|
|
280
|
+
clearDeadline();
|
|
251
281
|
if (finished || fetchRes.readableEnded) {
|
|
252
282
|
return;
|
|
253
283
|
}
|
|
@@ -296,17 +326,19 @@ function nmfetch(url, options) {
|
|
|
296
326
|
// the shared fetchRes once it times out. Callers listen with req.once().
|
|
297
327
|
return fail(new Error('Unsupported protocol for URL ' + redirectUrl), redirectUrl);
|
|
298
328
|
}
|
|
299
|
-
// Do not forward credentials when the redirect leaves the
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
329
|
+
// Do not forward credentials when the redirect leaves the origin: a different
|
|
330
|
+
// scheme, host or port is a different security context (an https to http
|
|
331
|
+
// downgrade would also put them on the wire in cleartext). Strip sensitive
|
|
332
|
+
// request headers so an attacker who controls the redirect target cannot
|
|
333
|
+
// harvest them.
|
|
334
|
+
const crossOrigin = redirectParsed.protocol !== parsed.protocol ||
|
|
335
|
+
redirectParsed.hostname !== parsed.hostname ||
|
|
336
|
+
fetchPort(redirectParsed) !== fetchPort(parsed);
|
|
337
|
+
if (options.headers && crossOrigin) {
|
|
307
338
|
const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
|
|
308
339
|
Object.keys(options.headers).forEach(key => {
|
|
309
|
-
|
|
340
|
+
// normalized the way the request headers are built above, a padded name is sent too
|
|
341
|
+
if (sensitive.includes(key.toLowerCase().trim())) {
|
|
310
342
|
delete options.headers[key];
|
|
311
343
|
}
|
|
312
344
|
});
|
|
@@ -314,6 +346,7 @@ function nmfetch(url, options) {
|
|
|
314
346
|
// this request is done with, release it so its socket and timeout do not
|
|
315
347
|
// outlive it and report a late error on the shared fetchRes
|
|
316
348
|
finished = true;
|
|
349
|
+
clearDeadline();
|
|
317
350
|
res.resume();
|
|
318
351
|
req.abort();
|
|
319
352
|
return nmfetch(redirectUrl, options);
|
|
@@ -324,6 +357,8 @@ function nmfetch(url, options) {
|
|
|
324
357
|
return fail(new Error('Invalid status code ' + res.statusCode));
|
|
325
358
|
}
|
|
326
359
|
res.on('error', (err) => fail(err));
|
|
360
|
+
// the response is in, whatever is left of it is in memory and no longer on the network
|
|
361
|
+
res.once('end', () => clearDeadline());
|
|
327
362
|
const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
|
|
328
363
|
const source = inflate || res;
|
|
329
364
|
let received = 0;
|
|
@@ -361,4 +396,5 @@ function nmfetch(url, options) {
|
|
|
361
396
|
}
|
|
362
397
|
nmfetch.Cookies = Cookies;
|
|
363
398
|
nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
|
|
399
|
+
nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
|
|
364
400
|
export default nmfetch;
|
package/dist/esm/mailer/index.js
CHANGED
|
@@ -360,9 +360,12 @@ class Mail extends EventEmitter {
|
|
|
360
360
|
return callback(err);
|
|
361
361
|
}
|
|
362
362
|
let cidCounter = 0;
|
|
363
|
+
// the media type is a bounded token: a quote, whitespace, '>' or '<' ends the
|
|
364
|
+
// attribute value and with it the type, so one img tag never scans ahead to a
|
|
365
|
+
// ';' that belongs to a later tag, which was quadratic
|
|
363
366
|
html = (html || '')
|
|
364
367
|
.toString()
|
|
365
|
-
.replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]
|
|
368
|
+
.replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
|
|
366
369
|
const cid = crypto.randomBytes(10).toString('hex') + '@localhost';
|
|
367
370
|
if (!mail.data.attachments) {
|
|
368
371
|
mail.data.attachments = [];
|
|
@@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions {
|
|
|
23
23
|
dsn?: SMTPEnvelopeDsn | undefined;
|
|
24
24
|
/** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */
|
|
25
25
|
requireTLSExtensionEnabled?: boolean | undefined;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* SMTP transports: per-message authentication settings, used instead of the transport level auth.
|
|
28
|
+
* Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires),
|
|
29
|
+
* the token endpoint and client settings stay those of the transport
|
|
30
|
+
*/
|
|
27
31
|
auth?: SMTPTransportAuthOptions | undefined;
|
|
28
32
|
/** SES transport: extra SendEmailCommand parameters merged into the API call */
|
|
29
33
|
ses?: SESSendEmailRequest | undefined;
|
|
@@ -137,26 +137,38 @@ export function encodeWord(data, mimeWordEncoding, maxLength) {
|
|
|
137
137
|
*/
|
|
138
138
|
export function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
|
|
139
139
|
maxLength = maxLength || 0;
|
|
140
|
-
// find first
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// find the first and the last character that needs encoding, a quote or anything outside
|
|
141
|
+
// printable ascii. A scan rather than a regex: matching the last such word with a
|
|
142
|
+
// backtracking pattern took quadratic time on a long word of non-ascii characters
|
|
143
|
+
let firstSpecial = -1;
|
|
144
|
+
let lastSpecial = -1;
|
|
145
|
+
for (let i = 0; i < value.length; i++) {
|
|
146
|
+
const code = value.charCodeAt(i);
|
|
147
|
+
if (code === 0x22 || code >= 0x80) {
|
|
148
|
+
if (firstSpecial < 0) {
|
|
149
|
+
firstSpecial = i;
|
|
150
|
+
}
|
|
151
|
+
lastSpecial = i;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (firstSpecial < 0) {
|
|
143
155
|
return value;
|
|
144
156
|
}
|
|
145
157
|
if (encodeAll) {
|
|
146
158
|
// if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
|
|
147
159
|
return encodeWord(value, mimeWordEncoding, maxLength);
|
|
148
160
|
}
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
// encode from the start of the word holding the first such character to the end of the
|
|
162
|
+
// word holding the last one, so the encoded words stay separated from the plain text by
|
|
163
|
+
// the whitespace that was already there
|
|
164
|
+
let startIndex = firstSpecial;
|
|
165
|
+
while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) {
|
|
166
|
+
startIndex--;
|
|
167
|
+
}
|
|
168
|
+
let endIndex = lastSpecial + 1;
|
|
169
|
+
while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) {
|
|
170
|
+
endIndex++;
|
|
154
171
|
}
|
|
155
|
-
const startIndex = firstMatch.index +
|
|
156
|
-
(firstMatch[0].match(/[^\s]/) || {
|
|
157
|
-
index: 0
|
|
158
|
-
}).index;
|
|
159
|
-
const endIndex = lastMatch.index + (lastMatch[1] || '').length;
|
|
160
172
|
return ((startIndex ? value.substr(0, startIndex) : '') +
|
|
161
173
|
encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
|
|
162
174
|
(endIndex < value.length ? value.substr(endIndex) : ''));
|
|
@@ -33,6 +33,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
|
|
|
33
33
|
// unroutable garbage into mail for a domain the sender never named. None of these
|
|
34
34
|
// characters are legal in a domain, so keep them away from the mapper.
|
|
35
35
|
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
|
|
36
|
+
// RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
|
|
37
|
+
// the code points it encodes, so anything past these bounds could not have become a valid
|
|
38
|
+
// domain whichever way it is encoded. The bundled codec is super-linear in the length of a
|
|
39
|
+
// label, a 60 KB label of distinct code points took seconds to encode, several times per
|
|
40
|
+
// address, so it is only handed what could be a domain
|
|
41
|
+
const MAX_DOMAIN_LENGTH = 253;
|
|
42
|
+
const MAX_LABEL_LENGTH = 63;
|
|
36
43
|
// pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
|
|
37
44
|
const PIPELINE_NOOP = () => false;
|
|
38
45
|
// The error a node streaming into an output that was destroyed stops with
|
|
@@ -41,6 +48,27 @@ function abortedError() {
|
|
|
41
48
|
err.code = errors.ESTREAM;
|
|
42
49
|
return err;
|
|
43
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Checks a domain against the DNS length limits, counted in code points per label and in
|
|
53
|
+
* total. The label separators are the ones the bundled codec splits on.
|
|
54
|
+
*
|
|
55
|
+
* @param domain Domain to check
|
|
56
|
+
* @return true if a label or the domain as a whole is too long to be a valid domain
|
|
57
|
+
*/
|
|
58
|
+
function exceedsDomainLimits(domain) {
|
|
59
|
+
let total = 0;
|
|
60
|
+
let label = 0;
|
|
61
|
+
for (const char of domain) {
|
|
62
|
+
total++;
|
|
63
|
+
if (char === '.' || char === '。' || char === '.' || char === '。') {
|
|
64
|
+
label = 0;
|
|
65
|
+
}
|
|
66
|
+
else if (++label > MAX_LABEL_LENGTH) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return total > MAX_DOMAIN_LENGTH;
|
|
71
|
+
}
|
|
44
72
|
/**
|
|
45
73
|
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
|
|
46
74
|
* which is with UTS-46 mapping applied before the Punycode step.
|
|
@@ -72,6 +100,10 @@ function normalizeDomain(domain, toUnicode) {
|
|
|
72
100
|
return mapped;
|
|
73
101
|
}
|
|
74
102
|
}
|
|
103
|
+
if (exceedsDomainLimits(domain)) {
|
|
104
|
+
// too long to be a domain whichever way it is encoded, keep it as supplied
|
|
105
|
+
return domain;
|
|
106
|
+
}
|
|
75
107
|
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
|
|
76
108
|
}
|
|
77
109
|
/**
|
|
@@ -576,6 +608,17 @@ class MimeNode {
|
|
|
576
608
|
/[\u0080-\uFFFF]/.test(this.content)) {
|
|
577
609
|
structured.params.charset = 'utf-8';
|
|
578
610
|
}
|
|
611
|
+
if (this.filename) {
|
|
612
|
+
// the name parameter is derived from the filename below, so a name that
|
|
613
|
+
// arrived inside the content type is dropped first (a plain parameter or
|
|
614
|
+
// rfc2231 continuation sections), otherwise the header carries two and a
|
|
615
|
+
// receiver that reads the first sees a name the filename does not match
|
|
616
|
+
for (const paramKey of Object.keys(structured.params)) {
|
|
617
|
+
if (/^name(\*|$)/i.test(paramKey)) {
|
|
618
|
+
delete structured.params[paramKey];
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
579
622
|
value = mimeFuncs.buildHeaderValue(structured);
|
|
580
623
|
if (this.filename) {
|
|
581
624
|
// add support for non-compliant clients like QQ webmail
|
|
@@ -1185,12 +1228,12 @@ class MimeNode {
|
|
|
1185
1228
|
_normalizeHeaderKey(key) {
|
|
1186
1229
|
key = (key || '')
|
|
1187
1230
|
.toString()
|
|
1188
|
-
//
|
|
1189
|
-
|
|
1190
|
-
// a field name
|
|
1191
|
-
//
|
|
1192
|
-
|
|
1193
|
-
.
|
|
1231
|
+
// a field name is printable ascii without the colon, so a control char, DEL,
|
|
1232
|
+
// whitespace or a colon can only be dropped, there is no quoting construct
|
|
1233
|
+
// around a field name. A colon or a space left in place ends the name early on
|
|
1234
|
+
// the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
|
|
1235
|
+
// second From header that neither setHeader() nor the Bcc filter could match
|
|
1236
|
+
.replace(/[\s\x00-\x20\x7f:]/g, '')
|
|
1194
1237
|
.toLowerCase()
|
|
1195
1238
|
// use uppercase words, except MIME
|
|
1196
1239
|
.replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
|
package/dist/esm/package-info.js
CHANGED
|
@@ -76,6 +76,21 @@ function isPartialResponse(str) {
|
|
|
76
76
|
function isPartialLine(line) {
|
|
77
77
|
return /^\d+-/.test(line);
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* The log form of a command a custom authentication handler sends. Such a command carries
|
|
81
|
+
* the credentials in whatever shape its mechanism uses, so only an AUTH verb with its
|
|
82
|
+
* mechanism name is kept, the way the built-in mechanisms hide their own secrets.
|
|
83
|
+
*
|
|
84
|
+
* @param cmd Command the handler sends
|
|
85
|
+
* @return What the transaction log gets instead
|
|
86
|
+
*/
|
|
87
|
+
function maskAuthCommand(cmd) {
|
|
88
|
+
const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i);
|
|
89
|
+
if (!match) {
|
|
90
|
+
return '/* secret */';
|
|
91
|
+
}
|
|
92
|
+
return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */';
|
|
93
|
+
}
|
|
79
94
|
/**
|
|
80
95
|
* Generates a SMTP connection object
|
|
81
96
|
*
|
|
@@ -563,7 +578,7 @@ class SMTPConnection extends EventEmitter {
|
|
|
563
578
|
}
|
|
564
579
|
done(null, data);
|
|
565
580
|
});
|
|
566
|
-
setImmediate(() => this._sendCommand(cmd));
|
|
581
|
+
setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd)));
|
|
567
582
|
return promise;
|
|
568
583
|
};
|
|
569
584
|
const handlerResponse = handler({
|
|
@@ -5,6 +5,13 @@ import * as shared from '../shared/index.js';
|
|
|
5
5
|
import XOAuth2 from '../xoauth2/index.js';
|
|
6
6
|
import * as errors from '../errors.js';
|
|
7
7
|
import * as packageData from '../package-info.js';
|
|
8
|
+
// The keys a message's `auth` may set on top of the transport's auth settings: the ones that
|
|
9
|
+
// say who logs in and with what. Everything else in the auth block (accessUrl, clientId,
|
|
10
|
+
// clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest)
|
|
11
|
+
// names the endpoint the credentials go to or is a secret of the application itself, and
|
|
12
|
+
// a message that could set `auth.accessUrl` and force a refresh would have the transport's
|
|
13
|
+
// client secret and refresh token posted to a URL of its choosing
|
|
14
|
+
const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires'];
|
|
8
15
|
/**
|
|
9
16
|
* Creates a SMTP transport object for Nodemailer
|
|
10
17
|
*
|
|
@@ -64,7 +71,11 @@ class SMTPTransport extends EventEmitter {
|
|
|
64
71
|
}
|
|
65
72
|
return this.auth;
|
|
66
73
|
}
|
|
67
|
-
const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}
|
|
74
|
+
const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {});
|
|
75
|
+
if (typeof authOpts === 'object') {
|
|
76
|
+
// see MESSAGE_AUTH_KEYS
|
|
77
|
+
shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key));
|
|
78
|
+
}
|
|
68
79
|
if (Object.keys(authData).length === 0) {
|
|
69
80
|
return false;
|
|
70
81
|
}
|
|
@@ -360,6 +360,13 @@ export const services = {
|
|
|
360
360
|
"port": 587,
|
|
361
361
|
"secure": false
|
|
362
362
|
},
|
|
363
|
+
"Oveyon": {
|
|
364
|
+
"description": "Oveyon",
|
|
365
|
+
"host": "smtp.oveyon.com",
|
|
366
|
+
"port": 587,
|
|
367
|
+
"secure": false,
|
|
368
|
+
"requireTLS": true
|
|
369
|
+
},
|
|
363
370
|
"Postmark": {
|
|
364
371
|
"description": "Postmark",
|
|
365
372
|
"aliases": [
|
|
@@ -226,8 +226,14 @@ class XOAuth2 extends Stream {
|
|
|
226
226
|
grant_type: 'refresh_token'
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
|
-
|
|
230
|
-
Object.assign(
|
|
229
|
+
const customParams = this.options.customParams || {};
|
|
230
|
+
Object.assign(urlOptions, customParams);
|
|
231
|
+
// a custom param is as likely to be a client assertion or an API key as a tenant
|
|
232
|
+
// name, so the log gets the same glimpse of it that it gets of the client secret
|
|
233
|
+
for (const key of Object.keys(customParams)) {
|
|
234
|
+
const param = customParams[key];
|
|
235
|
+
loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...';
|
|
236
|
+
}
|
|
231
237
|
this.logger.debug({
|
|
232
238
|
tnx: 'OAUTH2',
|
|
233
239
|
user: this.options.user,
|
|
@@ -333,6 +333,14 @@
|
|
|
333
333
|
"secure": false
|
|
334
334
|
},
|
|
335
335
|
|
|
336
|
+
"Oveyon": {
|
|
337
|
+
"description": "Oveyon",
|
|
338
|
+
"host": "smtp.oveyon.com",
|
|
339
|
+
"port": 587,
|
|
340
|
+
"secure": false,
|
|
341
|
+
"requireTLS": true
|
|
342
|
+
},
|
|
343
|
+
|
|
336
344
|
"Postmark": {
|
|
337
345
|
"description": "Postmark",
|
|
338
346
|
"aliases": ["PostmarkApp"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodemailer",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.2",
|
|
4
4
|
"description": "Easy as cake e-mail sending from your Node.js applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/nodemailer.js",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
},
|
|
152
152
|
"homepage": "https://nodemailer.com/",
|
|
153
153
|
"devDependencies": {
|
|
154
|
-
"@aws-sdk/client-sesv2": "3.
|
|
154
|
+
"@aws-sdk/client-sesv2": "3.1151.0",
|
|
155
155
|
"@types/node": "20.19.43",
|
|
156
156
|
"bunyan": "1.8.15",
|
|
157
157
|
"c8": "12.0.0",
|
|
@@ -161,14 +161,14 @@
|
|
|
161
161
|
"libbase64": "1.3.2",
|
|
162
162
|
"libmime": "5.4.7",
|
|
163
163
|
"libqp": "2.1.2",
|
|
164
|
-
"mailauth": "7.
|
|
165
|
-
"prettier": "3.9.
|
|
164
|
+
"mailauth": "7.3.0",
|
|
165
|
+
"prettier": "3.9.10",
|
|
166
166
|
"proxy": "1.0.2",
|
|
167
167
|
"proxy-test-server": "1.0.0",
|
|
168
|
-
"smtp-server": "3.19.
|
|
168
|
+
"smtp-server": "3.19.21",
|
|
169
169
|
"tsx": "4.23.15",
|
|
170
170
|
"typescript": "6.0.3",
|
|
171
|
-
"typescript-eslint": "8.71.
|
|
171
|
+
"typescript-eslint": "8.71.2"
|
|
172
172
|
},
|
|
173
173
|
"engines": {
|
|
174
174
|
"node": ">=20.0.0"
|