nodemailer 9.0.5 → 9.1.0
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 +26 -0
- package/README.md +2 -5
- package/lib/addressparser/index.js +124 -8
- package/lib/dkim/index.js +6 -1
- package/lib/errors.js +1 -0
- package/lib/fetch/index.js +115 -5
- package/lib/mail-composer/index.js +5 -3
- package/lib/mailer/index.js +27 -0
- package/lib/mailer/mail-message.js +22 -20
- package/lib/mime-funcs/index.js +34 -7
- package/lib/mime-node/index.js +113 -41
- package/lib/ses-transport/index.js +3 -2
- package/lib/shared/index.js +27 -11
- package/lib/shared/objects.js +42 -0
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [9.1.0](https://github.com/nodemailer/nodemailer/compare/v9.0.6...v9.1.0) (2026-08-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **mailer:** cap recipients per message with maxRecipients ([7279ac8](https://github.com/nodemailer/nodemailer/commit/7279ac8dee4f66c032981e6e51e3e7210ad0dcbf))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **addressparser:** handle address lists in linear time ([9116da9](https://github.com/nodemailer/nodemailer/commit/9116da9528c6524cefaed75185602a7e85d20434))
|
|
14
|
+
* **addressparser:** terminate the domain at an RFC 5322 comment ([902b63e](https://github.com/nodemailer/nodemailer/commit/902b63e935435c30f4025901c0902dce64cd8880))
|
|
15
|
+
* **mime-node:** apply UTS-46 mapping when encoding a domain ([259c32d](https://github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148))
|
|
16
|
+
* **mime-node:** dedupe envelope recipients in linear time ([7cc38af](https://github.com/nodemailer/nodemailer/commit/7cc38af418ffa6fc7e86085195ca5ca681694b3e))
|
|
17
|
+
* **mime-node:** flatten parsed addresses without concat.apply ([83b8c48](https://github.com/nodemailer/nodemailer/commit/83b8c48cbdb8b3116f2e1ba84af755b2c5661c0f))
|
|
18
|
+
* **mime-node:** keep the recipient dedupe linear across address headers ([34da642](https://github.com/nodemailer/nodemailer/commit/34da64282dcdc9b0581c721a27ab2fa226673150))
|
|
19
|
+
* **mime-node:** keep URL delimiters away from the domain mapper ([b212ac4](https://github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e))
|
|
20
|
+
|
|
21
|
+
## [9.0.6](https://github.com/nodemailer/nodemailer/compare/v9.0.5...v9.0.6) (2026-08-27)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **addressparser:** recover the addr-spec from an angle-addr holding whitespace ([e989a22](https://github.com/nodemailer/nodemailer/commit/e989a22ca4f5161929bf37be8fb07de635016fa7))
|
|
27
|
+
* harden copies of user supplied keys and URL fetching ([2f667f4](https://github.com/nodemailer/nodemailer/commit/2f667f4272cb2d7cb479b2e3903ab10600fc0eae))
|
|
28
|
+
|
|
3
29
|
## [9.0.5](https://github.com/nodemailer/nodemailer/compare/v9.0.4...v9.0.5) (2026-08-07)
|
|
4
30
|
|
|
5
31
|
|
package/README.md
CHANGED
|
@@ -8,10 +8,7 @@ Send emails from Node.js – easy as cake! 🍰✉️
|
|
|
8
8
|
|
|
9
9
|
See [nodemailer.com](https://nodemailer.com/) for documentation and terms.
|
|
10
10
|
|
|
11
|
-
> [
|
|
12
|
-
> Check out **[EmailEngine](https://emailengine.app/?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link)** – a self-hosted email gateway that allows making **REST requests against IMAP and SMTP servers**. EmailEngine also sends webhooks whenever something changes on the registered accounts.\
|
|
13
|
-
> \
|
|
14
|
-
> Using the email accounts registered with EmailEngine, you can receive and [send emails](https://emailengine.app/sending-emails?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link). EmailEngine supports OAuth2, delayed sends, opens and clicks tracking, bounce detection, etc. All on top of regular email accounts without an external MTA service.
|
|
11
|
+
> Nodemailer is developed by the team behind **[EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs)**, a self-hosted email API that turns any Gmail, Microsoft 365, or IMAP account into a REST endpoint, with managed OAuth2, webhooks for incoming mail, and built-in [sending](https://emailengine.app/sending-emails?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs). If you would rather call an HTTP API than maintain IMAP and SMTP connections yourself, that is what it is for.
|
|
15
12
|
|
|
16
13
|
## Having an issue?
|
|
17
14
|
|
|
@@ -25,7 +22,7 @@ You are using an older Node.js version than v6.0. Upgrade Node.js to get support
|
|
|
25
22
|
|
|
26
23
|
#### I'm having issues with Gmail
|
|
27
24
|
|
|
28
|
-
Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/).
|
|
25
|
+
Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/). If the blocker is OAuth2 setup rather than Gmail itself, [EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=faq-gmail) handles the OAuth2 flow and token refresh for you.
|
|
29
26
|
|
|
30
27
|
#### I get ETIMEDOUT errors
|
|
31
28
|
|
|
@@ -34,6 +34,101 @@ function _quoteLocalPart(address) {
|
|
|
34
34
|
return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Reached for every parsed address, so it is built once rather than per call.
|
|
39
|
+
*/
|
|
40
|
+
const HAS_WHITESPACE = /\s/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
|
|
44
|
+
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
|
|
45
|
+
*/
|
|
46
|
+
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
|
50
|
+
*/
|
|
51
|
+
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The looser reading applied once the strict one finds nothing, which tolerates the
|
|
55
|
+
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
56
|
+
*/
|
|
57
|
+
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
|
61
|
+
*
|
|
62
|
+
* A malformed header can put more than a mailbox between the angle brackets, most often
|
|
63
|
+
* because the generator wrote the recipient twice: '<user@example.com user@example.com>'
|
|
64
|
+
* or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
|
|
65
|
+
* never be a mailbox anyone could deliver to, and passing it on as the address loses the
|
|
66
|
+
* recipient that is sitting right there in the header.
|
|
67
|
+
*
|
|
68
|
+
* The run that still reads as an addr-spec is kept and whatever is left over becomes
|
|
69
|
+
* display text rather than being dropped. Candidates are read strictly first and then
|
|
70
|
+
* under the looser grammar, the same two tiers the unquoted-text branch below applies to
|
|
71
|
+
* the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
|
|
72
|
+
* recipient. When several runs qualify the first wins, which is what that branch's looser
|
|
73
|
+
* tier does within a token.
|
|
74
|
+
*
|
|
75
|
+
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
|
|
76
|
+
* '<"user name"@example.com>' is well formed and means exactly what it says.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} data Collected address parts, mutated in place
|
|
79
|
+
*/
|
|
80
|
+
function _recoverAddrSpec(data) {
|
|
81
|
+
if (!HAS_WHITESPACE.test(data.address)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let address;
|
|
86
|
+
let rest;
|
|
87
|
+
|
|
88
|
+
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
|
|
89
|
+
if (quoted) {
|
|
90
|
+
if (!quoted[2]) {
|
|
91
|
+
// the whitespace sits inside the quoted local part, this is a well formed mailbox
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
|
|
96
|
+
// than splitting into the quotes
|
|
97
|
+
address = quoted[1];
|
|
98
|
+
rest = [quoted[2]];
|
|
99
|
+
} else {
|
|
100
|
+
if (data.address.indexOf('"') >= 0) {
|
|
101
|
+
// Splitting on whitespace loses track of where the quoted string starts and ends,
|
|
102
|
+
// and this module does not take addresses out of quoted strings: the run picked out
|
|
103
|
+
// of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
|
|
104
|
+
// quotes were hiding. Every well formed shape was already handled above, so what is
|
|
105
|
+
// left is wreckage either way and the original is the honest answer
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parts = data.address.split(/\s+/);
|
|
110
|
+
|
|
111
|
+
let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
|
|
112
|
+
if (addrIndex < 0) {
|
|
113
|
+
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (addrIndex < 0) {
|
|
117
|
+
// nothing in there reads as an address, there is no better answer than the original
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
address = parts.splice(addrIndex, 1)[0];
|
|
122
|
+
rest = parts;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
data.address = address;
|
|
126
|
+
data.text = [data.text]
|
|
127
|
+
.concat(rest)
|
|
128
|
+
.filter(part => part)
|
|
129
|
+
.join(' ');
|
|
130
|
+
}
|
|
131
|
+
|
|
37
132
|
/**
|
|
38
133
|
* Converts tokens for a single address into an address object
|
|
39
134
|
*
|
|
@@ -89,7 +184,18 @@ function _handleAddress(tokens, depth) {
|
|
|
89
184
|
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
|
90
185
|
}
|
|
91
186
|
|
|
92
|
-
|
|
187
|
+
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
|
188
|
+
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
|
189
|
+
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
|
190
|
+
// deliver to a domain the sender never named.
|
|
191
|
+
const parts = data[state];
|
|
192
|
+
const joins =
|
|
193
|
+
prevToken &&
|
|
194
|
+
prevToken.noBreak &&
|
|
195
|
+
parts.length &&
|
|
196
|
+
(prevToken.value !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');
|
|
197
|
+
|
|
198
|
+
if (joins) {
|
|
93
199
|
data[state][data[state].length - 1] += token.value;
|
|
94
200
|
if (state === 'text' && insideQuotes) {
|
|
95
201
|
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
|
@@ -137,7 +243,7 @@ function _handleAddress(tokens, depth) {
|
|
|
137
243
|
// Security: Do not extract email addresses from quoted strings.
|
|
138
244
|
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
|
139
245
|
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
|
140
|
-
if (!data.textWasQuoted[i] &&
|
|
246
|
+
if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
|
|
141
247
|
data.address = data.text.splice(i, 1);
|
|
142
248
|
data.textWasQuoted.splice(i, 1);
|
|
143
249
|
break;
|
|
@@ -187,6 +293,8 @@ function _handleAddress(tokens, depth) {
|
|
|
187
293
|
data.text = data.text.join(' ');
|
|
188
294
|
data.address = data.address.join(' ');
|
|
189
295
|
|
|
296
|
+
_recoverAddrSpec(data);
|
|
297
|
+
|
|
190
298
|
const address = {
|
|
191
299
|
address: data.address || data.text || '',
|
|
192
300
|
name: data.text || data.address || ''
|
|
@@ -402,8 +510,10 @@ function addressparser(str, options) {
|
|
|
402
510
|
|
|
403
511
|
addresses.forEach(addr => {
|
|
404
512
|
const handled = _handleAddress(addr, depth);
|
|
405
|
-
|
|
406
|
-
|
|
513
|
+
// Appended in place. Rebuilding the accumulator with concat() would copy every
|
|
514
|
+
// entry collected so far on each address, making a flat list cost O(n^2).
|
|
515
|
+
for (let i = 0; i < handled.length; i++) {
|
|
516
|
+
parsedAddresses.push(handled[i]);
|
|
407
517
|
}
|
|
408
518
|
});
|
|
409
519
|
|
|
@@ -411,14 +521,20 @@ function addressparser(str, options) {
|
|
|
411
521
|
// "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
|
412
522
|
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
|
|
413
523
|
// Recombine: a name-only entry followed by an entry with both name and address.
|
|
414
|
-
|
|
524
|
+
// Walked back to front so that a run of fragments folds into one entry in a single
|
|
525
|
+
// pass. Splicing each fragment out of the list instead would cost O(n^2).
|
|
526
|
+
const mergedAddresses = [];
|
|
527
|
+
for (let i = parsedAddresses.length - 1; i >= 0; i--) {
|
|
415
528
|
const current = parsedAddresses[i];
|
|
416
|
-
const next =
|
|
417
|
-
if (current.address === '' && current.name && !current.group && next.address && next.name) {
|
|
529
|
+
const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
|
|
530
|
+
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
|
418
531
|
next.name = current.name + ', ' + next.name;
|
|
419
|
-
|
|
532
|
+
} else {
|
|
533
|
+
mergedAddresses.push(current);
|
|
420
534
|
}
|
|
421
535
|
}
|
|
536
|
+
mergedAddresses.reverse();
|
|
537
|
+
parsedAddresses = mergedAddresses;
|
|
422
538
|
|
|
423
539
|
if (options.flatten) {
|
|
424
540
|
const flatAddresses = [];
|
package/lib/dkim/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const { PassThrough } = require('stream');
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const crypto = require('crypto');
|
|
13
|
+
const { copyOwnKeys } = require('../shared/objects');
|
|
13
14
|
|
|
14
15
|
const DKIM_ALGO = 'sha256';
|
|
15
16
|
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
|
|
@@ -225,7 +226,11 @@ class DKIM {
|
|
|
225
226
|
|
|
226
227
|
let options = this.options;
|
|
227
228
|
if (extraOptions && Object.keys(extraOptions).length) {
|
|
228
|
-
|
|
229
|
+
// extraOptions is mail.data._dkim, caller supplied message data. An own
|
|
230
|
+
// "__proto__" key there would let every option this signer reads and the
|
|
231
|
+
// transport did not set, such as skipFields, answer from the caller
|
|
232
|
+
options = copyOwnKeys({}, extraOptions);
|
|
233
|
+
copyOwnKeys(options, this.options);
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
const signer = new DKIMSigner(options, this.keys, inputStream, output);
|
package/lib/errors.js
CHANGED
package/lib/fetch/index.js
CHANGED
|
@@ -9,9 +9,67 @@ const Cookies = require('./cookies');
|
|
|
9
9
|
const packageData = require('../../package.json');
|
|
10
10
|
const net = require('net');
|
|
11
11
|
const errors = require('../errors');
|
|
12
|
+
const { isProtoKey } = require('../shared/objects');
|
|
12
13
|
|
|
13
14
|
const MAX_REDIRECTS = 5;
|
|
14
15
|
|
|
16
|
+
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
|
|
17
|
+
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
|
|
18
|
+
// or lookup would otherwise repoint the request at a destination that never went through
|
|
19
|
+
// the URL checks below.
|
|
20
|
+
//
|
|
21
|
+
// The source of truth is the tls.connect() option list in the Node docs. A key missing
|
|
22
|
+
// here is dropped silently, so extend this list rather than working around it.
|
|
23
|
+
const TLS_OPTION_KEYS = [
|
|
24
|
+
'ALPNProtocols',
|
|
25
|
+
'ca',
|
|
26
|
+
'cert',
|
|
27
|
+
'checkServerIdentity',
|
|
28
|
+
'ciphers',
|
|
29
|
+
'crl',
|
|
30
|
+
'dhparam',
|
|
31
|
+
'ecdhCurve',
|
|
32
|
+
'honorCipherOrder',
|
|
33
|
+
'key',
|
|
34
|
+
'maxVersion',
|
|
35
|
+
'minVersion',
|
|
36
|
+
'passphrase',
|
|
37
|
+
'pfx',
|
|
38
|
+
'rejectUnauthorized',
|
|
39
|
+
'secureContext',
|
|
40
|
+
'secureOptions',
|
|
41
|
+
'secureProtocol',
|
|
42
|
+
'servername',
|
|
43
|
+
'sessionIdContext',
|
|
44
|
+
'sigalgs'
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolves a URL only if it is one this module is willing to request.
|
|
49
|
+
*
|
|
50
|
+
* urllib.parse throws for a host that contains forbidden bytes, and it is called for
|
|
51
|
+
* every URL that reaches nmfetch, including ones that arrive from a message attachment
|
|
52
|
+
* or from a redirect Location header. An uncaught throw here takes the process down,
|
|
53
|
+
* so a URL that does not parse is reported the same way as one with a scheme we refuse.
|
|
54
|
+
*
|
|
55
|
+
* @param {String} url URL to parse
|
|
56
|
+
* @returns {Object|Boolean} Parsed URL, or false if it is not a usable http(s) URL
|
|
57
|
+
*/
|
|
58
|
+
function parseFetchUrl(url) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = urllib.parse(url);
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
|
|
15
73
|
module.exports = function (url, options) {
|
|
16
74
|
return nmfetch(url, options);
|
|
17
75
|
};
|
|
@@ -26,6 +84,30 @@ function nmfetch(url, options) {
|
|
|
26
84
|
options.redirects = options.redirects || 0;
|
|
27
85
|
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
|
|
28
86
|
|
|
87
|
+
const fetchRes = options.fetchRes;
|
|
88
|
+
const parsed = parseFetchUrl(url);
|
|
89
|
+
|
|
90
|
+
if (!parsed) {
|
|
91
|
+
// Only http(s) URLs can be fetched. Any other scheme (file:, gopher:, a
|
|
92
|
+
// protocol-relative redirect target etc.) would otherwise be silently served over
|
|
93
|
+
// plain HTTP, possibly against an unintended host. Bail out before the cookie jar
|
|
94
|
+
// is touched so a refused URL can not seed it, and release a caller supplied body:
|
|
95
|
+
// this is the one exit that runs before the error handler below is attached to it,
|
|
96
|
+
// so an error on that stream would have nowhere to go and the fd or socket behind
|
|
97
|
+
// it would never be released.
|
|
98
|
+
if (options.body && typeof options.body.destroy === 'function') {
|
|
99
|
+
options.body.on('error', () => false);
|
|
100
|
+
options.body.destroy();
|
|
101
|
+
}
|
|
102
|
+
setImmediate(() => {
|
|
103
|
+
const err = new Error('Unsupported protocol for URL ' + url);
|
|
104
|
+
err.code = errors.EFETCH;
|
|
105
|
+
err.sourceUrl = url;
|
|
106
|
+
fetchRes.emit('error', err);
|
|
107
|
+
});
|
|
108
|
+
return fetchRes;
|
|
109
|
+
}
|
|
110
|
+
|
|
29
111
|
if (options.cookie) {
|
|
30
112
|
[].concat(options.cookie || []).forEach(cookie => {
|
|
31
113
|
options.cookies.set(cookie, url);
|
|
@@ -33,8 +115,6 @@ function nmfetch(url, options) {
|
|
|
33
115
|
options.cookie = false;
|
|
34
116
|
}
|
|
35
117
|
|
|
36
|
-
const fetchRes = options.fetchRes;
|
|
37
|
-
const parsed = urllib.parse(url);
|
|
38
118
|
let method = (options.method || '').toString().trim().toUpperCase() || 'GET';
|
|
39
119
|
let finished = false;
|
|
40
120
|
let cookies;
|
|
@@ -48,6 +128,10 @@ function nmfetch(url, options) {
|
|
|
48
128
|
};
|
|
49
129
|
|
|
50
130
|
Object.keys(options.headers || {}).forEach(key => {
|
|
131
|
+
// options.headers is the caller's httpHeaders, straight off an attachment
|
|
132
|
+
if (isProtoKey(key.toLowerCase().trim())) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
51
135
|
headers[key.toLowerCase().trim()] = options.headers[key];
|
|
52
136
|
});
|
|
53
137
|
|
|
@@ -131,7 +215,12 @@ function nmfetch(url, options) {
|
|
|
131
215
|
};
|
|
132
216
|
|
|
133
217
|
if (options.tls) {
|
|
134
|
-
|
|
218
|
+
// see TLS_OPTION_KEYS
|
|
219
|
+
Object.keys(options.tls).forEach(key => {
|
|
220
|
+
if (TLS_OPTION_KEYS.includes(key)) {
|
|
221
|
+
reqOptions[key] = options.tls[key];
|
|
222
|
+
}
|
|
223
|
+
});
|
|
135
224
|
}
|
|
136
225
|
|
|
137
226
|
if (
|
|
@@ -216,8 +305,29 @@ function nmfetch(url, options) {
|
|
|
216
305
|
options.method = 'GET';
|
|
217
306
|
options.body = false;
|
|
218
307
|
|
|
219
|
-
|
|
220
|
-
|
|
308
|
+
let redirectUrl;
|
|
309
|
+
try {
|
|
310
|
+
redirectUrl = urllib.resolve(url, res.headers.location);
|
|
311
|
+
} catch (_err) {
|
|
312
|
+
// the legacy resolver throws on a Location the WHATWG parser also refused,
|
|
313
|
+
// so fall through to the check below with what the server actually sent
|
|
314
|
+
redirectUrl = res.headers.location;
|
|
315
|
+
}
|
|
316
|
+
const redirectParsed = parseFetchUrl(redirectUrl);
|
|
317
|
+
|
|
318
|
+
if (!redirectParsed) {
|
|
319
|
+
// Refuse the redirect target here rather than leaving it to the recursive
|
|
320
|
+
// call: that call gets its own `finished` flag and no handle on this
|
|
321
|
+
// request, so this one would stay open and could emit a second error on
|
|
322
|
+
// the shared fetchRes once it times out. Callers listen with req.once().
|
|
323
|
+
finished = true;
|
|
324
|
+
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
|
|
325
|
+
err.code = errors.EFETCH;
|
|
326
|
+
err.sourceUrl = redirectUrl;
|
|
327
|
+
fetchRes.emit('error', err);
|
|
328
|
+
req.abort();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
221
331
|
|
|
222
332
|
// Do not forward credentials when the redirect leaves the original
|
|
223
333
|
// security context: a different host, or a downgrade from https to
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const MimeNode = require('../mime-node');
|
|
6
6
|
const mimeFuncs = require('../mime-funcs');
|
|
7
|
-
const { parseDataURI } = require('../shared');
|
|
7
|
+
const { parseDataURI, copyOwnKeys } = require('../shared');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Creates the object for composing a MimeNode instance out from the mail options
|
|
@@ -205,7 +205,9 @@ class MailComposer {
|
|
|
205
205
|
typeof this.mail.icalEvent === 'object' &&
|
|
206
206
|
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
|
|
207
207
|
) {
|
|
208
|
-
|
|
208
|
+
// an own "__proto__" key would make the copy inherit path/href from caller
|
|
209
|
+
// data, and the mapping below then replaces the content the caller did set
|
|
210
|
+
icalEvent = copyOwnKeys({}, this.mail.icalEvent);
|
|
209
211
|
} else {
|
|
210
212
|
icalEvent = {
|
|
211
213
|
content: this.mail.icalEvent
|
|
@@ -600,7 +602,7 @@ class MailComposer {
|
|
|
600
602
|
}
|
|
601
603
|
|
|
602
604
|
// Return empty content for excessively long data URLs
|
|
603
|
-
return Object.assign({}, element, {
|
|
605
|
+
return Object.assign(copyOwnKeys({}, element), {
|
|
604
606
|
path: false,
|
|
605
607
|
href: false,
|
|
606
608
|
content: Buffer.alloc(0),
|
package/lib/mailer/index.js
CHANGED
|
@@ -15,6 +15,13 @@ const net = require('net');
|
|
|
15
15
|
const dns = require('dns');
|
|
16
16
|
const crypto = require('crypto');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Recipients allowed on one message unless the caller sets its own maxRecipients. A backstop
|
|
20
|
+
* against a runaway or hostile recipient list rather than a delivery policy: RFC 5321 only
|
|
21
|
+
* asks a server to accept 100, so a real send is bounded far below this.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_MAX_RECIPIENTS = 100000;
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* Creates an object for exposing the Mail API
|
|
20
27
|
*
|
|
@@ -191,6 +198,26 @@ class Mail extends EventEmitter {
|
|
|
191
198
|
mail.setPriorityHeaders();
|
|
192
199
|
mail.setListHeaders();
|
|
193
200
|
|
|
201
|
+
const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients;
|
|
202
|
+
const recipientCount = mail.message.getEnvelope().to.length;
|
|
203
|
+
|
|
204
|
+
if (maxRecipients && recipientCount > maxRecipients) {
|
|
205
|
+
const err = new Error(
|
|
206
|
+
`Message has ${recipientCount} recipients, which is over the ${maxRecipients} allowed by maxRecipients`
|
|
207
|
+
);
|
|
208
|
+
err.code = errors.EMAXRECIPIENTS;
|
|
209
|
+
this.logger.error(
|
|
210
|
+
{
|
|
211
|
+
err,
|
|
212
|
+
tnx: 'transport',
|
|
213
|
+
action: 'send'
|
|
214
|
+
},
|
|
215
|
+
'Send Error: %s',
|
|
216
|
+
err.message
|
|
217
|
+
);
|
|
218
|
+
return callback(err);
|
|
219
|
+
}
|
|
220
|
+
|
|
194
221
|
this._processPlugins('stream', mail, err => {
|
|
195
222
|
if (err) {
|
|
196
223
|
this.logger.error(
|
|
@@ -4,6 +4,11 @@ const shared = require('../shared');
|
|
|
4
4
|
const MimeNode = require('../mime-node');
|
|
5
5
|
const mimeFuncs = require('../mime-funcs');
|
|
6
6
|
|
|
7
|
+
// Only an own key counts as already set. `key in obj` also matches every member of
|
|
8
|
+
// Object.prototype, which silently drops a transporter default legitimately named
|
|
9
|
+
// toString or constructor.
|
|
10
|
+
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
11
|
+
|
|
7
12
|
class MailMessage {
|
|
8
13
|
constructor(mailer, data) {
|
|
9
14
|
this.mailer = mailer;
|
|
@@ -14,26 +19,19 @@ class MailMessage {
|
|
|
14
19
|
const options = mailer.options || {};
|
|
15
20
|
const defaults = mailer._defaults || {};
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
shared.copyOwnKeys(this.data, data);
|
|
18
23
|
|
|
19
24
|
this.data.headers = this.data.headers || {};
|
|
20
25
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Object.keys(defaults.headers).forEach(key => {
|
|
28
|
-
if (!(key in this.data.headers)) {
|
|
29
|
-
this.data.headers[key] = defaults.headers[key];
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
});
|
|
26
|
+
// Apply defaults. `_defaults` is caller supplied too, it is the second argument of
|
|
27
|
+
// createTransport, so it needs the same treatment as `data` above
|
|
28
|
+
shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
|
|
29
|
+
|
|
30
|
+
// headers is a special case. Allow setting individual default headers
|
|
31
|
+
shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
|
|
34
32
|
|
|
35
33
|
// force specific keys from transporter options
|
|
36
|
-
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => {
|
|
34
|
+
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
|
|
37
35
|
if (key in options) {
|
|
38
36
|
this.data[key] = options[key];
|
|
39
37
|
}
|
|
@@ -123,11 +121,12 @@ class MailMessage {
|
|
|
123
121
|
content: value
|
|
124
122
|
};
|
|
125
123
|
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
// The keys are the caller's, so copying them takes the same "__proto__"
|
|
125
|
+
// rule as the constructor. `key in node` stays as the already-set test
|
|
126
|
+
// here, unlike for the defaults: it also skips the Object.prototype
|
|
127
|
+
// member names, and letting message data land a `toString` string on a
|
|
128
|
+
// node only buys a TypeError the first time something stringifies it.
|
|
129
|
+
shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
args[0][args[1]] = node;
|
|
@@ -186,6 +185,9 @@ class MailMessage {
|
|
|
186
185
|
|
|
187
186
|
data.normalizedHeaders = {};
|
|
188
187
|
Object.keys(data.headers || {}).forEach(key => {
|
|
188
|
+
if (shared.isProtoKey(key)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
189
191
|
let value = [].concat(data.headers[key] || []).shift();
|
|
190
192
|
value = (value && value.value) || value;
|
|
191
193
|
if (value) {
|
package/lib/mime-funcs/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const base64 = require('../base64');
|
|
6
6
|
const qp = require('../qp');
|
|
7
7
|
const mimeTypes = require('./mime-types');
|
|
8
|
+
const { isProtoKey } = require('../shared/objects');
|
|
8
9
|
|
|
9
10
|
module.exports = {
|
|
10
11
|
/**
|
|
@@ -381,6 +382,16 @@ module.exports = {
|
|
|
381
382
|
value: false,
|
|
382
383
|
params: {}
|
|
383
384
|
};
|
|
385
|
+
|
|
386
|
+
// Parameter names come from a caller supplied contentType/contentDisposition. A
|
|
387
|
+
// "__proto__" name would target the prototype chain of the params object instead of
|
|
388
|
+
// an own property of it, and read back as Object.prototype, so it is dropped.
|
|
389
|
+
const setParam = (name, value) => {
|
|
390
|
+
if (!isProtoKey(name)) {
|
|
391
|
+
response.params[name] = value;
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
384
395
|
let key = false;
|
|
385
396
|
let value = '';
|
|
386
397
|
let type = 'value';
|
|
@@ -412,7 +423,7 @@ module.exports = {
|
|
|
412
423
|
if (key === false) {
|
|
413
424
|
response.value = value.trim();
|
|
414
425
|
} else {
|
|
415
|
-
|
|
426
|
+
setParam(key, value.trim());
|
|
416
427
|
}
|
|
417
428
|
type = 'key';
|
|
418
429
|
value = '';
|
|
@@ -427,10 +438,10 @@ module.exports = {
|
|
|
427
438
|
if (key === false) {
|
|
428
439
|
response.value = value.trim();
|
|
429
440
|
} else {
|
|
430
|
-
|
|
441
|
+
setParam(key, value.trim());
|
|
431
442
|
}
|
|
432
443
|
} else if (value.trim()) {
|
|
433
|
-
|
|
444
|
+
setParam(value.trim().toLowerCase(), '');
|
|
434
445
|
}
|
|
435
446
|
|
|
436
447
|
// handle parameter value continuations
|
|
@@ -443,6 +454,14 @@ module.exports = {
|
|
|
443
454
|
actualKey = key.substr(0, match.index);
|
|
444
455
|
nr = Number(match[2] || match[3]) || 0;
|
|
445
456
|
|
|
457
|
+
if (isProtoKey(actualKey)) {
|
|
458
|
+
// see setParam. Reading it back would yield Object.prototype, which is
|
|
459
|
+
// an object, so the initializer below would be skipped and the write
|
|
460
|
+
// that follows would throw out of a header build the caller can not catch
|
|
461
|
+
delete response.params[key];
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
446
465
|
if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
|
|
447
466
|
response.params[actualKey] = {
|
|
448
467
|
charset: false,
|
|
@@ -569,7 +588,7 @@ module.exports = {
|
|
|
569
588
|
*/
|
|
570
589
|
splitMimeEncodedString: (str, maxlen) => {
|
|
571
590
|
const lines = [];
|
|
572
|
-
let curLine, match, chr, done;
|
|
591
|
+
let curLine, fallbackLine, match, chr, done;
|
|
573
592
|
|
|
574
593
|
// require at least 12 symbols to fit possible 4 octet UTF-8 sequences
|
|
575
594
|
maxlen = Math.max(maxlen || 0, 12);
|
|
@@ -582,8 +601,14 @@ module.exports = {
|
|
|
582
601
|
curLine = curLine.substr(0, match.index);
|
|
583
602
|
}
|
|
584
603
|
|
|
604
|
+
// Malformed input (a run of stray UTF-8 continuation bytes) has no split point
|
|
605
|
+
// that keeps a character sequence whole, so the loop below walks back to an
|
|
606
|
+
// empty line looking for one. Keep the widest chunk that at least does not cut
|
|
607
|
+
// a "=XX" escape in half, so the part stays a decodable encoded word.
|
|
608
|
+
fallbackLine = curLine.length ? curLine : str.substr(0, maxlen);
|
|
609
|
+
|
|
585
610
|
done = false;
|
|
586
|
-
while (!done) {
|
|
611
|
+
while (!done && curLine.length) {
|
|
587
612
|
done = true;
|
|
588
613
|
// check if not middle of a unicode char sequence
|
|
589
614
|
if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
|
|
@@ -596,9 +621,11 @@ module.exports = {
|
|
|
596
621
|
}
|
|
597
622
|
}
|
|
598
623
|
|
|
599
|
-
if (curLine.length) {
|
|
600
|
-
|
|
624
|
+
if (!curLine.length) {
|
|
625
|
+
curLine = fallbackLine;
|
|
601
626
|
}
|
|
627
|
+
|
|
628
|
+
lines.push(curLine);
|
|
602
629
|
str = str.substr(curLine.length);
|
|
603
630
|
}
|
|
604
631
|
|
package/lib/mime-node/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const punycode = require('../punycode');
|
|
8
8
|
const { PassThrough } = require('stream');
|
|
9
9
|
const shared = require('../shared');
|
|
10
|
+
const urlModule = require('url');
|
|
10
11
|
|
|
11
12
|
const mimeFuncs = require('../mime-funcs');
|
|
12
13
|
const qp = require('../qp');
|
|
@@ -35,6 +36,45 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
|
|
|
35
36
|
// the envelope carries
|
|
36
37
|
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
|
|
37
38
|
|
|
39
|
+
// domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
|
|
40
|
+
// mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
|
|
41
|
+
// controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
|
|
42
|
+
// deliverable 'evil.example', which would turn a value the bundled codec leaves as
|
|
43
|
+
// unroutable garbage into mail for a domain the sender never named. None of these
|
|
44
|
+
// characters are legal in a domain, so keep them away from the mapper.
|
|
45
|
+
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
|
|
49
|
+
* which is with UTS-46 mapping applied before the Punycode step.
|
|
50
|
+
*
|
|
51
|
+
* The bundled codec is plain RFC 3492 and maps nothing, so it disagrees with every
|
|
52
|
+
* conformant parser on any domain holding a mapped or ignored code point. An invisible
|
|
53
|
+
* U+00AD in 'compa\u00ADny.com' encoded to 'xn--company-pka.com' where a validator reads
|
|
54
|
+
* 'company.com', which let an allow-listed domain be checked and a different one mailed.
|
|
55
|
+
*
|
|
56
|
+
* Anything the URL parser does not accept as a hostname, an address literal such as
|
|
57
|
+
* '[127.0.0.1]' included, comes back empty and falls through to the bundled codec, which
|
|
58
|
+
* leaves those as they were supplied.
|
|
59
|
+
*
|
|
60
|
+
* @param {String} domain Domain to encode, already lowercased by the caller
|
|
61
|
+
* @param {Boolean} toUnicode Return the U-label form instead of the A-label form
|
|
62
|
+
* @return {String} Encoded domain
|
|
63
|
+
*/
|
|
64
|
+
function normalizeDomain(domain, toUnicode) {
|
|
65
|
+
// domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6
|
|
66
|
+
const mapper = toUnicode ? urlModule.domainToUnicode : urlModule.domainToASCII;
|
|
67
|
+
|
|
68
|
+
if (typeof mapper === 'function' && !URL_PARSER_UNSAFE.test(domain)) {
|
|
69
|
+
const mapped = mapper(domain);
|
|
70
|
+
if (mapped) {
|
|
71
|
+
return mapped;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
|
|
76
|
+
}
|
|
77
|
+
|
|
38
78
|
/**
|
|
39
79
|
* Creates a new mime tree node. Assumes 'multipart/*' as the content type
|
|
40
80
|
* if it is a branch, anything else counts as leaf. If rootNode is missing from
|
|
@@ -533,11 +573,10 @@ class MimeNode {
|
|
|
533
573
|
const formattedHeaders = FORMATTED_HEADERS;
|
|
534
574
|
|
|
535
575
|
if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
});
|
|
576
|
+
// the keys come from a caller supplied header object and `options.prepared`
|
|
577
|
+
// below decides whether the value is emitted raw, so an own "__proto__" key
|
|
578
|
+
// here would turn an unfolded value into header injection
|
|
579
|
+
shared.copyOwnKeys(options, value, optionKey => optionKey === 'value');
|
|
541
580
|
value = (value.value || '').toString();
|
|
542
581
|
if (!value.trim()) {
|
|
543
582
|
return;
|
|
@@ -873,20 +912,17 @@ class MimeNode {
|
|
|
873
912
|
this._envelope.from = list[0].address;
|
|
874
913
|
}
|
|
875
914
|
}
|
|
915
|
+
const seenRecipients = new Set();
|
|
876
916
|
['to', 'cc', 'bcc'].forEach(key => {
|
|
877
917
|
if (envelope[key]) {
|
|
878
|
-
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to);
|
|
918
|
+
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to, seenRecipients);
|
|
879
919
|
}
|
|
880
920
|
});
|
|
881
921
|
|
|
882
922
|
this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
|
|
883
923
|
|
|
884
924
|
const standardFields = ['to', 'cc', 'bcc', 'from'];
|
|
885
|
-
|
|
886
|
-
if (!standardFields.includes(key)) {
|
|
887
|
-
this._envelope[key] = envelope[key];
|
|
888
|
-
}
|
|
889
|
-
});
|
|
925
|
+
shared.copyOwnKeys(this._envelope, envelope, key => standardFields.includes(key));
|
|
890
926
|
|
|
891
927
|
return this;
|
|
892
928
|
}
|
|
@@ -898,15 +934,17 @@ class MimeNode {
|
|
|
898
934
|
*/
|
|
899
935
|
getAddresses() {
|
|
900
936
|
const addresses = {};
|
|
937
|
+
const seenByKey = new Map();
|
|
901
938
|
|
|
902
939
|
this._headers.forEach(header => {
|
|
903
940
|
const key = header.key.toLowerCase();
|
|
904
941
|
if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
|
|
905
942
|
if (!Array.isArray(addresses[key])) {
|
|
906
943
|
addresses[key] = [];
|
|
944
|
+
seenByKey.set(key, new Set());
|
|
907
945
|
}
|
|
908
946
|
|
|
909
|
-
this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
|
|
947
|
+
this._convertAddresses(this._parseAddresses(header.value), addresses[key], seenByKey.get(key));
|
|
910
948
|
}
|
|
911
949
|
});
|
|
912
950
|
|
|
@@ -927,6 +965,12 @@ class MimeNode {
|
|
|
927
965
|
from: false,
|
|
928
966
|
to: []
|
|
929
967
|
};
|
|
968
|
+
|
|
969
|
+
// Built once and carried across the headers. Letting _convertAddresses seed it per
|
|
970
|
+
// call would cost O(headers x recipients), and a message can carry many address
|
|
971
|
+
// headers: `headers: { to: [...] }` emits one To per entry.
|
|
972
|
+
const seenRecipients = new Set();
|
|
973
|
+
|
|
930
974
|
this._headers.forEach(header => {
|
|
931
975
|
const list = [];
|
|
932
976
|
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
|
|
@@ -935,7 +979,7 @@ class MimeNode {
|
|
|
935
979
|
envelope.from = list[0].address;
|
|
936
980
|
}
|
|
937
981
|
} else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
|
|
938
|
-
this._convertAddresses(this._parseAddresses(header.value), envelope.to);
|
|
982
|
+
this._convertAddresses(this._parseAddresses(header.value), envelope.to, seenRecipients);
|
|
939
983
|
}
|
|
940
984
|
});
|
|
941
985
|
|
|
@@ -1035,7 +1079,9 @@ class MimeNode {
|
|
|
1035
1079
|
});
|
|
1036
1080
|
return contentStream;
|
|
1037
1081
|
}
|
|
1038
|
-
// fetch URL
|
|
1082
|
+
// fetch URL. nmfetch refuses any scheme that is not http(s), and it decides
|
|
1083
|
+
// that on the parsed URL. Testing the raw string here instead would reject
|
|
1084
|
+
// forms the parser accepts, such as a leading space or a slash-less authority
|
|
1039
1085
|
return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
|
|
1040
1086
|
}
|
|
1041
1087
|
|
|
@@ -1060,26 +1106,38 @@ class MimeNode {
|
|
|
1060
1106
|
* @return {Array} An array of address objects
|
|
1061
1107
|
*/
|
|
1062
1108
|
_parseAddresses(addresses) {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const copy = Object.assign({}, address);
|
|
1076
|
-
copy.address = normalized;
|
|
1077
|
-
copy.name = address.name || '';
|
|
1078
|
-
return [copy];
|
|
1109
|
+
// Collected into one list as we go. concat.apply spreads the entries into arguments
|
|
1110
|
+
// and throws a RangeError once a recipient array is long enough to pass the
|
|
1111
|
+
// argument limit, which a large Bcc list reaches on its own.
|
|
1112
|
+
const flattened = [];
|
|
1113
|
+
|
|
1114
|
+
[].concat(addresses).forEach(address => {
|
|
1115
|
+
if (address && address.address) {
|
|
1116
|
+
const normalized = this._normalizeAddress(address.address);
|
|
1117
|
+
if (normalized === address.address && typeof address.name === 'string') {
|
|
1118
|
+
// there is nothing to rewrite, so there is nothing to keep off the original
|
|
1119
|
+
flattened.push(address);
|
|
1120
|
+
return;
|
|
1079
1121
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1122
|
+
|
|
1123
|
+
// rewriting would land on the object the caller passed in and might
|
|
1124
|
+
// still hold a reference to, so rewrite a copy of it instead. An own
|
|
1125
|
+
// "__proto__" key would make the copy inherit from caller data, and
|
|
1126
|
+
// _convertAddresses reads `group` off it straight into the envelope
|
|
1127
|
+
const copy = shared.copyOwnKeys({}, address);
|
|
1128
|
+
copy.address = normalized;
|
|
1129
|
+
copy.name = address.name || '';
|
|
1130
|
+
flattened.push(copy);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const parsed = this._normalizeParsedAddresses(addressparser(address));
|
|
1135
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
1136
|
+
flattened.push(parsed[i]);
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
return flattened;
|
|
1083
1141
|
}
|
|
1084
1142
|
|
|
1085
1143
|
/**
|
|
@@ -1268,11 +1326,22 @@ class MimeNode {
|
|
|
1268
1326
|
* @param {Array} [uniqueList] An array to be populated with addresses
|
|
1269
1327
|
* @return {String} address string
|
|
1270
1328
|
*/
|
|
1271
|
-
_convertAddresses(addresses, uniqueList) {
|
|
1329
|
+
_convertAddresses(addresses, uniqueList, seenAddresses) {
|
|
1272
1330
|
const values = [];
|
|
1273
1331
|
|
|
1274
1332
|
uniqueList = uniqueList || [];
|
|
1275
1333
|
|
|
1334
|
+
// Membership is checked once per address, so scanning uniqueList itself would make
|
|
1335
|
+
// a recipient list cost O(n^2). Groups recurse with the same set so that a nested
|
|
1336
|
+
// group still dedupes against the addresses collected around it, and a caller that
|
|
1337
|
+
// passes a partly filled list (To, then Cc, then Bcc) keeps deduping across headers.
|
|
1338
|
+
if (!seenAddresses) {
|
|
1339
|
+
seenAddresses = new Set();
|
|
1340
|
+
for (let i = 0; i < uniqueList.length; i++) {
|
|
1341
|
+
seenAddresses.add(uniqueList[i].address);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1276
1345
|
[].concat(addresses || []).forEach(address => {
|
|
1277
1346
|
if (address.address) {
|
|
1278
1347
|
address.address = this._normalizeAddress(address.address);
|
|
@@ -1287,11 +1356,14 @@ class MimeNode {
|
|
|
1287
1356
|
values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
|
|
1288
1357
|
}
|
|
1289
1358
|
|
|
1290
|
-
if (!
|
|
1359
|
+
if (!seenAddresses.has(address.address)) {
|
|
1360
|
+
seenAddresses.add(address.address);
|
|
1291
1361
|
uniqueList.push(address);
|
|
1292
1362
|
}
|
|
1293
1363
|
} else if (address.group) {
|
|
1294
|
-
const groupListAddresses = (
|
|
1364
|
+
const groupListAddresses = (
|
|
1365
|
+
address.group.length ? this._convertAddresses(address.group, uniqueList, seenAddresses) : ''
|
|
1366
|
+
).trim();
|
|
1295
1367
|
values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
|
|
1296
1368
|
}
|
|
1297
1369
|
});
|
|
@@ -1334,12 +1406,12 @@ class MimeNode {
|
|
|
1334
1406
|
|
|
1335
1407
|
let encodedDomain = domain;
|
|
1336
1408
|
|
|
1409
|
+
// A non-ASCII local part already requires SMTPUTF8, so the domain stays UTF-8 for
|
|
1410
|
+
// symmetry on both sides of the '@' rather than being encoded to an A-label
|
|
1411
|
+
const smtputf8 = /[\x80-\uFFFF]/.test(user);
|
|
1412
|
+
|
|
1337
1413
|
try {
|
|
1338
|
-
|
|
1339
|
-
encodedDomain = punycode.toUnicode(domain.toLowerCase());
|
|
1340
|
-
} else {
|
|
1341
|
-
encodedDomain = punycode.toASCII(domain.toLowerCase());
|
|
1342
|
-
}
|
|
1414
|
+
encodedDomain = normalizeDomain(domain.toLowerCase(), smtputf8);
|
|
1343
1415
|
} catch (_err) {
|
|
1344
1416
|
// keep domain as supplied
|
|
1345
1417
|
}
|
|
@@ -130,7 +130,8 @@ class SESTransport extends EventEmitter {
|
|
|
130
130
|
return callback(err);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// mail.data.ses is caller supplied message data, so copy its own keys only
|
|
134
|
+
const sesMessage = shared.copyOwnKeys(
|
|
134
135
|
{
|
|
135
136
|
Content: {
|
|
136
137
|
Raw: {
|
|
@@ -143,7 +144,7 @@ class SESTransport extends EventEmitter {
|
|
|
143
144
|
ToAddresses: envelope.to
|
|
144
145
|
}
|
|
145
146
|
},
|
|
146
|
-
mail.data.ses
|
|
147
|
+
mail.data.ses
|
|
147
148
|
);
|
|
148
149
|
|
|
149
150
|
this.getRegion((err, region) => {
|
package/lib/shared/index.js
CHANGED
|
@@ -7,10 +7,15 @@ const util = require('util');
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const nmfetch = require('../fetch');
|
|
9
9
|
const errors = require('../errors');
|
|
10
|
+
const objects = require('./objects');
|
|
10
11
|
const dns = require('dns');
|
|
11
12
|
const net = require('net');
|
|
12
13
|
const os = require('os');
|
|
13
14
|
|
|
15
|
+
// re-exported for the callers that already depend on this module, see ./objects
|
|
16
|
+
const isProtoKey = (module.exports.isProtoKey = objects.isProtoKey);
|
|
17
|
+
module.exports.copyOwnKeys = objects.copyOwnKeys;
|
|
18
|
+
|
|
14
19
|
const DNS_TTL = 5 * 60 * 1000;
|
|
15
20
|
const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
|
|
16
21
|
const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
|
|
@@ -355,7 +360,9 @@ module.exports.parseConnectionUrl = str => {
|
|
|
355
360
|
return;
|
|
356
361
|
}
|
|
357
362
|
|
|
358
|
-
|
|
363
|
+
// `in` already keeps "__proto__" out, but only as a side effect of it being an
|
|
364
|
+
// Object.prototype member. Say it, so the protection survives a change to the check
|
|
365
|
+
if (!isProtoKey(lKey) && !(lKey in obj)) {
|
|
359
366
|
obj[lKey] = value;
|
|
360
367
|
}
|
|
361
368
|
});
|
|
@@ -470,7 +477,7 @@ module.exports.parseDataURI = uri => {
|
|
|
470
477
|
// Ensure there's a key before the '='
|
|
471
478
|
const key = entry.substring(0, sepPos).trim();
|
|
472
479
|
const value = entry.substring(sepPos + 1).trim();
|
|
473
|
-
if (key) {
|
|
480
|
+
if (key && !isProtoKey(key)) {
|
|
474
481
|
params[key] = value;
|
|
475
482
|
}
|
|
476
483
|
}
|
|
@@ -561,19 +568,24 @@ function resolveContentValue(data, key, options, callback) {
|
|
|
561
568
|
}
|
|
562
569
|
callback(null, value);
|
|
563
570
|
});
|
|
564
|
-
} else if (/^
|
|
571
|
+
} else if (/^data:/i.test(content.path || content.href)) {
|
|
572
|
+
const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
|
|
573
|
+
|
|
574
|
+
return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
|
|
575
|
+
} else if (content.href || /^https?:\/\//i.test(content.path)) {
|
|
576
|
+
// An href is always a URL, and so is a path that looks like one. Let nmfetch
|
|
577
|
+
// decide whether it is fetchable, it validates the parsed URL. Testing the raw
|
|
578
|
+
// string here instead would let a file: href fall through to the "return as is"
|
|
579
|
+
// default below and travel on inside the resolved message.
|
|
580
|
+
const url = content.href || content.path;
|
|
565
581
|
if (options.disableUrlAccess) {
|
|
566
582
|
return setImmediate(() => {
|
|
567
|
-
const err = new Error('Url access rejected for ' +
|
|
583
|
+
const err = new Error('Url access rejected for ' + url);
|
|
568
584
|
err.code = errors.EURLACCESS;
|
|
569
585
|
callback(err);
|
|
570
586
|
});
|
|
571
587
|
}
|
|
572
|
-
return resolveStream(nmfetch(
|
|
573
|
-
} else if (/^data:/i.test(content.path || content.href)) {
|
|
574
|
-
const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
|
|
575
|
-
|
|
576
|
-
return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
|
|
588
|
+
return resolveStream(nmfetch(url, { headers: content.httpHeaders, tls: content.tls }), callback);
|
|
577
589
|
} else if (content.path) {
|
|
578
590
|
if (options.disableFileAccess) {
|
|
579
591
|
return setImmediate(() => {
|
|
@@ -603,10 +615,14 @@ module.exports.assign = function (/* target, ... sources */) {
|
|
|
603
615
|
|
|
604
616
|
args.forEach(source => {
|
|
605
617
|
Object.keys(source || {}).forEach(key => {
|
|
618
|
+
if (isProtoKey(key)) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
606
621
|
if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
|
|
607
622
|
// tls and auth are special keys that need to be enumerated separately
|
|
608
|
-
// other objects are passed as is
|
|
609
|
-
|
|
623
|
+
// other objects are passed as is. Enumerating is a copy of user supplied
|
|
624
|
+
// keys just like the loop above, so it gets the same treatment
|
|
625
|
+
target[key] = module.exports.copyOwnKeys(target[key] || {}, source[key]);
|
|
610
626
|
} else {
|
|
611
627
|
target[key] = source[key];
|
|
612
628
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Safe copying of objects whose keys come from the caller.
|
|
4
|
+
//
|
|
5
|
+
// This lives in its own leaf module, like ./url.js, so that every layer can reach it.
|
|
6
|
+
// lib/shared/index.js requires lib/fetch, so lib/fetch can not require lib/shared back,
|
|
7
|
+
// and lib/mime-funcs is a leaf that would otherwise pull in dns/net/os/fs for a string
|
|
8
|
+
// comparison. lib/shared/index.js re-exports both functions for the callers that already
|
|
9
|
+
// depend on it.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detects a key that can not be copied onto a plain object with `target[key] = value`.
|
|
13
|
+
*
|
|
14
|
+
* "__proto__" is the only one: assigning it runs the inherited setter and replaces the
|
|
15
|
+
* prototype of the target instead of adding a property to it, so a caller can smuggle
|
|
16
|
+
* values past validation that only inspects own keys. JSON.parse produces such a key
|
|
17
|
+
* where an object literal can not. "constructor" and "prototype" have no such setter and
|
|
18
|
+
* become ordinary own properties, so dropping them would only discard legitimate values.
|
|
19
|
+
*
|
|
20
|
+
* @param {String} key Key to check
|
|
21
|
+
* @returns {Boolean} true if the key must not be copied
|
|
22
|
+
*/
|
|
23
|
+
module.exports.isProtoKey = key => key === '__proto__';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Copies own enumerable keys from a source object to a target object. Every copy that
|
|
27
|
+
* walks the keys of user supplied data goes through here, see isProtoKey.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} target Object to copy the keys to
|
|
30
|
+
* @param {Object} source Object to copy the keys from
|
|
31
|
+
* @param {Function} [skip] Optional predicate, return true to leave a key out
|
|
32
|
+
* @returns {Object} The target object
|
|
33
|
+
*/
|
|
34
|
+
module.exports.copyOwnKeys = (target, source, skip) => {
|
|
35
|
+
Object.keys(source || {}).forEach(key => {
|
|
36
|
+
if (module.exports.isProtoKey(key) || (skip && skip(key))) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
target[key] = source[key];
|
|
40
|
+
});
|
|
41
|
+
return target;
|
|
42
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodemailer",
|
|
3
|
-
"version": "9.0
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"description": "Easy as cake e-mail sending from your Node.js applications",
|
|
5
5
|
"main": "lib/nodemailer.js",
|
|
6
6
|
"scripts": {
|
|
@@ -27,19 +27,19 @@
|
|
|
27
27
|
},
|
|
28
28
|
"homepage": "https://nodemailer.com/",
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@aws-sdk/client-sesv2": "3.
|
|
30
|
+
"@aws-sdk/client-sesv2": "3.1121.0",
|
|
31
31
|
"bunyan": "1.8.15",
|
|
32
32
|
"c8": "12.0.0",
|
|
33
|
-
"eslint": "10.
|
|
33
|
+
"eslint": "10.9.1",
|
|
34
34
|
"eslint-config-prettier": "10.1.8",
|
|
35
|
-
"globals": "17.
|
|
35
|
+
"globals": "17.11.0",
|
|
36
36
|
"libbase64": "1.3.0",
|
|
37
|
-
"libmime": "5.4.
|
|
37
|
+
"libmime": "5.4.2",
|
|
38
38
|
"libqp": "2.1.1",
|
|
39
39
|
"prettier": "3.9.6",
|
|
40
40
|
"proxy": "1.0.2",
|
|
41
41
|
"proxy-test-server": "1.0.0",
|
|
42
|
-
"smtp-server": "3.19.
|
|
42
|
+
"smtp-server": "3.19.4"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=6.0.0"
|