nodemailer 9.0.6 → 9.1.1
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 +27 -0
- package/README.md +1 -1
- package/SECURITY.md +2 -2
- package/lib/addressparser/index.js +26 -7
- package/lib/errors.js +1 -0
- package/lib/mailer/index.js +27 -0
- package/lib/mailer/mail-message.js +29 -3
- package/lib/mime-node/index.js +135 -34
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [9.1.1](https://github.com/nodemailer/nodemailer/compare/v9.1.0...v9.1.1) (2026-09-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **mailer:** apply the message access policy in resolveContent ([dc48ed3](https://github.com/nodemailer/nodemailer/commit/dc48ed395c4d6c79ee5c95eb6eff17bafe391474))
|
|
9
|
+
* **mailer:** keep message data from reopening the access sandbox ([ab7ef34](https://github.com/nodemailer/nodemailer/commit/ab7ef348b9a97b1fd70e7bfbeb56d4ea4a07946b))
|
|
10
|
+
* **mime-node:** inherit the access policy from the tree a node hangs in ([262d550](https://github.com/nodemailer/nodemailer/commit/262d550b1e121e3ff4ef6675d6771a5b2b4ddcec))
|
|
11
|
+
|
|
12
|
+
## [9.1.0](https://github.com/nodemailer/nodemailer/compare/v9.0.6...v9.1.0) (2026-08-31)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **mailer:** cap recipients per message with maxRecipients ([7279ac8](https://github.com/nodemailer/nodemailer/commit/7279ac8dee4f66c032981e6e51e3e7210ad0dcbf))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **addressparser:** handle address lists in linear time ([9116da9](https://github.com/nodemailer/nodemailer/commit/9116da9528c6524cefaed75185602a7e85d20434))
|
|
23
|
+
* **addressparser:** terminate the domain at an RFC 5322 comment ([902b63e](https://github.com/nodemailer/nodemailer/commit/902b63e935435c30f4025901c0902dce64cd8880))
|
|
24
|
+
* **mime-node:** apply UTS-46 mapping when encoding a domain ([259c32d](https://github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148))
|
|
25
|
+
* **mime-node:** dedupe envelope recipients in linear time ([7cc38af](https://github.com/nodemailer/nodemailer/commit/7cc38af418ffa6fc7e86085195ca5ca681694b3e))
|
|
26
|
+
* **mime-node:** flatten parsed addresses without concat.apply ([83b8c48](https://github.com/nodemailer/nodemailer/commit/83b8c48cbdb8b3116f2e1ba84af755b2c5661c0f))
|
|
27
|
+
* **mime-node:** keep the recipient dedupe linear across address headers ([34da642](https://github.com/nodemailer/nodemailer/commit/34da64282dcdc9b0581c721a27ab2fa226673150))
|
|
28
|
+
* **mime-node:** keep URL delimiters away from the domain mapper ([b212ac4](https://github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e))
|
|
29
|
+
|
|
3
30
|
## [9.0.6](https://github.com/nodemailer/nodemailer/compare/v9.0.5...v9.0.6) (2026-08-27)
|
|
4
31
|
|
|
5
32
|
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ let configOptions = {
|
|
|
52
52
|
|
|
53
53
|
#### I have issues with DNS / hosts file
|
|
54
54
|
|
|
55
|
-
Node.js uses [c-ares](https://
|
|
55
|
+
Node.js uses [c-ares](https://github.com/c-ares/c-ares) to resolve domain names, not the DNS library provided by the system, so if you have some custom DNS routing set up, it might be ignored. Nodemailer runs [dns.resolve4()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve4hostname-options-callback) and [dns.resolve6()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve6hostname-options-callback) to resolve hostname into an IP address. If both calls fail, then Nodemailer will fall back to [dns.lookup()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnslookuphostname-options-callback). If this does not work for you, you can hard code the IP address into the configuration like shown below. In that case, Nodemailer would not perform any DNS lookups.
|
|
56
56
|
|
|
57
57
|
```js
|
|
58
58
|
let configOptions = {
|
package/SECURITY.md
CHANGED
|
@@ -11,8 +11,8 @@ supported way to receive security updates.
|
|
|
11
11
|
|
|
12
12
|
| Version | Supported |
|
|
13
13
|
| ------- | ------------------ |
|
|
14
|
-
|
|
|
15
|
-
| <
|
|
14
|
+
| 9.x | :white_check_mark: |
|
|
15
|
+
| < 9.0 | :x: |
|
|
16
16
|
|
|
17
17
|
If you are on an older major, please upgrade. See the migration notes at
|
|
18
18
|
<https://nodemailer.com/> before updating.
|
|
@@ -184,7 +184,18 @@ function _handleAddress(tokens, depth) {
|
|
|
184
184
|
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
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) {
|
|
188
199
|
data[state][data[state].length - 1] += token.value;
|
|
189
200
|
if (state === 'text' && insideQuotes) {
|
|
190
201
|
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
|
@@ -499,8 +510,10 @@ function addressparser(str, options) {
|
|
|
499
510
|
|
|
500
511
|
addresses.forEach(addr => {
|
|
501
512
|
const handled = _handleAddress(addr, depth);
|
|
502
|
-
|
|
503
|
-
|
|
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]);
|
|
504
517
|
}
|
|
505
518
|
});
|
|
506
519
|
|
|
@@ -508,14 +521,20 @@ function addressparser(str, options) {
|
|
|
508
521
|
// "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
|
509
522
|
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
|
|
510
523
|
// Recombine: a name-only entry followed by an entry with both name and address.
|
|
511
|
-
|
|
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--) {
|
|
512
528
|
const current = parsedAddresses[i];
|
|
513
|
-
const next =
|
|
514
|
-
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) {
|
|
515
531
|
next.name = current.name + ', ' + next.name;
|
|
516
|
-
|
|
532
|
+
} else {
|
|
533
|
+
mergedAddresses.push(current);
|
|
517
534
|
}
|
|
518
535
|
}
|
|
536
|
+
mergedAddresses.reverse();
|
|
537
|
+
parsedAddresses = mergedAddresses;
|
|
519
538
|
|
|
520
539
|
if (options.flatten) {
|
|
521
540
|
const flatAddresses = [];
|
package/lib/errors.js
CHANGED
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(
|
|
@@ -31,15 +31,41 @@ class MailMessage {
|
|
|
31
31
|
shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
|
|
32
32
|
|
|
33
33
|
// force specific keys from transporter options
|
|
34
|
-
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => {
|
|
34
|
+
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
|
|
35
35
|
if (key in options) {
|
|
36
36
|
this.data[key] = options[key];
|
|
37
37
|
}
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
// The access flags are a sandbox rather than a message field, so `defaults` counts as
|
|
41
|
+
// transporter configuration for them. For a transporter plugin it is the only channel
|
|
42
|
+
// there is, createTransport leaves `options` undefined for one, and the defaults copy
|
|
43
|
+
// above yields to anything the message already set, which let message data switch the
|
|
44
|
+
// sandbox back off. Closing is one way here, same as in resolveContent below: either
|
|
45
|
+
// side may switch a flag on, neither can switch off what the other closed.
|
|
46
|
+
['disableFileAccess', 'disableUrlAccess'].forEach(key => {
|
|
47
|
+
if (!(key in options) && hasOwn(defaults, key)) {
|
|
48
|
+
this.data[key] = this.data[key] || defaults[key];
|
|
49
|
+
}
|
|
50
|
+
});
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
resolveContent(
|
|
42
|
-
|
|
53
|
+
resolveContent(data, key, options, callback) {
|
|
54
|
+
// Most plugins call this with the legacy (data, key, callback) signature, which carries
|
|
55
|
+
// no access policy. The policy belongs to the message, so apply it here. Explicit
|
|
56
|
+
// options may only tighten it, never reopen what the transporter closed.
|
|
57
|
+
if (!callback && typeof options === 'function') {
|
|
58
|
+
callback = options;
|
|
59
|
+
options = false;
|
|
60
|
+
}
|
|
61
|
+
options = options || {};
|
|
62
|
+
|
|
63
|
+
const policy = {
|
|
64
|
+
disableFileAccess: this.data.disableFileAccess || options.disableFileAccess,
|
|
65
|
+
disableUrlAccess: this.data.disableUrlAccess || options.disableUrlAccess
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return shared.resolveContent(data, key, policy, callback);
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
resolveAll(callback) {
|
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
|
|
@@ -204,6 +244,14 @@ class MimeNode {
|
|
|
204
244
|
* @return {Object} Appended node object
|
|
205
245
|
*/
|
|
206
246
|
appendChild(childNode) {
|
|
247
|
+
// Take the node out of the tree it is in first. Leaving it there keeps it in that
|
|
248
|
+
// parent's childNodes, so it still streams as part of the old tree while parentNode
|
|
249
|
+
// already points at the new one, and anything read off the parent chain answers for
|
|
250
|
+
// the wrong tree.
|
|
251
|
+
if (childNode.parentNode && childNode.parentNode !== this) {
|
|
252
|
+
childNode.remove();
|
|
253
|
+
}
|
|
254
|
+
|
|
207
255
|
if (childNode.rootNode !== this.rootNode) {
|
|
208
256
|
childNode.rootNode = this.rootNode;
|
|
209
257
|
childNode._nodeId = ++this.rootNode.nodeCounter;
|
|
@@ -872,9 +920,10 @@ class MimeNode {
|
|
|
872
920
|
this._envelope.from = list[0].address;
|
|
873
921
|
}
|
|
874
922
|
}
|
|
923
|
+
const seenRecipients = new Set();
|
|
875
924
|
['to', 'cc', 'bcc'].forEach(key => {
|
|
876
925
|
if (envelope[key]) {
|
|
877
|
-
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to);
|
|
926
|
+
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to, seenRecipients);
|
|
878
927
|
}
|
|
879
928
|
});
|
|
880
929
|
|
|
@@ -893,15 +942,17 @@ class MimeNode {
|
|
|
893
942
|
*/
|
|
894
943
|
getAddresses() {
|
|
895
944
|
const addresses = {};
|
|
945
|
+
const seenByKey = new Map();
|
|
896
946
|
|
|
897
947
|
this._headers.forEach(header => {
|
|
898
948
|
const key = header.key.toLowerCase();
|
|
899
949
|
if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
|
|
900
950
|
if (!Array.isArray(addresses[key])) {
|
|
901
951
|
addresses[key] = [];
|
|
952
|
+
seenByKey.set(key, new Set());
|
|
902
953
|
}
|
|
903
954
|
|
|
904
|
-
this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
|
|
955
|
+
this._convertAddresses(this._parseAddresses(header.value), addresses[key], seenByKey.get(key));
|
|
905
956
|
}
|
|
906
957
|
});
|
|
907
958
|
|
|
@@ -922,6 +973,12 @@ class MimeNode {
|
|
|
922
973
|
from: false,
|
|
923
974
|
to: []
|
|
924
975
|
};
|
|
976
|
+
|
|
977
|
+
// Built once and carried across the headers. Letting _convertAddresses seed it per
|
|
978
|
+
// call would cost O(headers x recipients), and a message can carry many address
|
|
979
|
+
// headers: `headers: { to: [...] }` emits one To per entry.
|
|
980
|
+
const seenRecipients = new Set();
|
|
981
|
+
|
|
925
982
|
this._headers.forEach(header => {
|
|
926
983
|
const list = [];
|
|
927
984
|
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
|
|
@@ -930,7 +987,7 @@ class MimeNode {
|
|
|
930
987
|
envelope.from = list[0].address;
|
|
931
988
|
}
|
|
932
989
|
} else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
|
|
933
|
-
this._convertAddresses(this._parseAddresses(header.value), envelope.to);
|
|
990
|
+
this._convertAddresses(this._parseAddresses(header.value), envelope.to, seenRecipients);
|
|
934
991
|
}
|
|
935
992
|
});
|
|
936
993
|
|
|
@@ -977,6 +1034,26 @@ class MimeNode {
|
|
|
977
1034
|
|
|
978
1035
|
/////// PRIVATE METHODS
|
|
979
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* Checks an access policy flag for this node and every node above it. The flags are set
|
|
1039
|
+
* from the options the node was built with, and createChild only ever sees the options
|
|
1040
|
+
* the caller passed, so a child of a closed tree starts out open. Reading the answer off
|
|
1041
|
+
* the parent chain keeps it right whatever order the tree was assembled in.
|
|
1042
|
+
*
|
|
1043
|
+
* @param {String} flag Either 'disableFileAccess' or 'disableUrlAccess'
|
|
1044
|
+
* @return {Boolean} true if this node or an ancestor closed that access
|
|
1045
|
+
*/
|
|
1046
|
+
_accessDisabled(flag) {
|
|
1047
|
+
let node = this;
|
|
1048
|
+
while (node) {
|
|
1049
|
+
if (node[flag]) {
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
node = node.parentNode;
|
|
1053
|
+
}
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
980
1057
|
/**
|
|
981
1058
|
* Detects and returns handle to a stream related with the content.
|
|
982
1059
|
*
|
|
@@ -1007,7 +1084,7 @@ class MimeNode {
|
|
|
1007
1084
|
}
|
|
1008
1085
|
|
|
1009
1086
|
if (content && typeof content.path === 'string' && !content.href) {
|
|
1010
|
-
if (this.disableFileAccess) {
|
|
1087
|
+
if (this._accessDisabled('disableFileAccess')) {
|
|
1011
1088
|
contentStream = new PassThrough();
|
|
1012
1089
|
setImmediate(() => {
|
|
1013
1090
|
const err = new Error('File access rejected for ' + content.path);
|
|
@@ -1021,7 +1098,7 @@ class MimeNode {
|
|
|
1021
1098
|
}
|
|
1022
1099
|
|
|
1023
1100
|
if (content && typeof content.href === 'string') {
|
|
1024
|
-
if (this.disableUrlAccess) {
|
|
1101
|
+
if (this._accessDisabled('disableUrlAccess')) {
|
|
1025
1102
|
contentStream = new PassThrough();
|
|
1026
1103
|
setImmediate(() => {
|
|
1027
1104
|
const err = new Error('Url access rejected for ' + content.href);
|
|
@@ -1057,28 +1134,38 @@ class MimeNode {
|
|
|
1057
1134
|
* @return {Array} An array of address objects
|
|
1058
1135
|
*/
|
|
1059
1136
|
_parseAddresses(addresses) {
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
// "__proto__" key would make the copy inherit from caller data, and
|
|
1073
|
-
// _convertAddresses reads `group` off it straight into the envelope
|
|
1074
|
-
const copy = shared.copyOwnKeys({}, address);
|
|
1075
|
-
copy.address = normalized;
|
|
1076
|
-
copy.name = address.name || '';
|
|
1077
|
-
return [copy];
|
|
1137
|
+
// Collected into one list as we go. concat.apply spreads the entries into arguments
|
|
1138
|
+
// and throws a RangeError once a recipient array is long enough to pass the
|
|
1139
|
+
// argument limit, which a large Bcc list reaches on its own.
|
|
1140
|
+
const flattened = [];
|
|
1141
|
+
|
|
1142
|
+
[].concat(addresses).forEach(address => {
|
|
1143
|
+
if (address && address.address) {
|
|
1144
|
+
const normalized = this._normalizeAddress(address.address);
|
|
1145
|
+
if (normalized === address.address && typeof address.name === 'string') {
|
|
1146
|
+
// there is nothing to rewrite, so there is nothing to keep off the original
|
|
1147
|
+
flattened.push(address);
|
|
1148
|
+
return;
|
|
1078
1149
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1150
|
+
|
|
1151
|
+
// rewriting would land on the object the caller passed in and might
|
|
1152
|
+
// still hold a reference to, so rewrite a copy of it instead. An own
|
|
1153
|
+
// "__proto__" key would make the copy inherit from caller data, and
|
|
1154
|
+
// _convertAddresses reads `group` off it straight into the envelope
|
|
1155
|
+
const copy = shared.copyOwnKeys({}, address);
|
|
1156
|
+
copy.address = normalized;
|
|
1157
|
+
copy.name = address.name || '';
|
|
1158
|
+
flattened.push(copy);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const parsed = this._normalizeParsedAddresses(addressparser(address));
|
|
1163
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
1164
|
+
flattened.push(parsed[i]);
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
return flattened;
|
|
1082
1169
|
}
|
|
1083
1170
|
|
|
1084
1171
|
/**
|
|
@@ -1267,11 +1354,22 @@ class MimeNode {
|
|
|
1267
1354
|
* @param {Array} [uniqueList] An array to be populated with addresses
|
|
1268
1355
|
* @return {String} address string
|
|
1269
1356
|
*/
|
|
1270
|
-
_convertAddresses(addresses, uniqueList) {
|
|
1357
|
+
_convertAddresses(addresses, uniqueList, seenAddresses) {
|
|
1271
1358
|
const values = [];
|
|
1272
1359
|
|
|
1273
1360
|
uniqueList = uniqueList || [];
|
|
1274
1361
|
|
|
1362
|
+
// Membership is checked once per address, so scanning uniqueList itself would make
|
|
1363
|
+
// a recipient list cost O(n^2). Groups recurse with the same set so that a nested
|
|
1364
|
+
// group still dedupes against the addresses collected around it, and a caller that
|
|
1365
|
+
// passes a partly filled list (To, then Cc, then Bcc) keeps deduping across headers.
|
|
1366
|
+
if (!seenAddresses) {
|
|
1367
|
+
seenAddresses = new Set();
|
|
1368
|
+
for (let i = 0; i < uniqueList.length; i++) {
|
|
1369
|
+
seenAddresses.add(uniqueList[i].address);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1275
1373
|
[].concat(addresses || []).forEach(address => {
|
|
1276
1374
|
if (address.address) {
|
|
1277
1375
|
address.address = this._normalizeAddress(address.address);
|
|
@@ -1286,11 +1384,14 @@ class MimeNode {
|
|
|
1286
1384
|
values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
|
|
1287
1385
|
}
|
|
1288
1386
|
|
|
1289
|
-
if (!
|
|
1387
|
+
if (!seenAddresses.has(address.address)) {
|
|
1388
|
+
seenAddresses.add(address.address);
|
|
1290
1389
|
uniqueList.push(address);
|
|
1291
1390
|
}
|
|
1292
1391
|
} else if (address.group) {
|
|
1293
|
-
const groupListAddresses = (
|
|
1392
|
+
const groupListAddresses = (
|
|
1393
|
+
address.group.length ? this._convertAddresses(address.group, uniqueList, seenAddresses) : ''
|
|
1394
|
+
).trim();
|
|
1294
1395
|
values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
|
|
1295
1396
|
}
|
|
1296
1397
|
});
|
|
@@ -1333,12 +1434,12 @@ class MimeNode {
|
|
|
1333
1434
|
|
|
1334
1435
|
let encodedDomain = domain;
|
|
1335
1436
|
|
|
1437
|
+
// A non-ASCII local part already requires SMTPUTF8, so the domain stays UTF-8 for
|
|
1438
|
+
// symmetry on both sides of the '@' rather than being encoded to an A-label
|
|
1439
|
+
const smtputf8 = /[\x80-\uFFFF]/.test(user);
|
|
1440
|
+
|
|
1336
1441
|
try {
|
|
1337
|
-
|
|
1338
|
-
encodedDomain = punycode.toUnicode(domain.toLowerCase());
|
|
1339
|
-
} else {
|
|
1340
|
-
encodedDomain = punycode.toASCII(domain.toLowerCase());
|
|
1341
|
-
}
|
|
1442
|
+
encodedDomain = normalizeDomain(domain.toLowerCase(), smtputf8);
|
|
1342
1443
|
} catch (_err) {
|
|
1343
1444
|
// keep domain as supplied
|
|
1344
1445
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodemailer",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.1.1",
|
|
4
4
|
"description": "Easy as cake e-mail sending from your Node.js applications",
|
|
5
5
|
"main": "lib/nodemailer.js",
|
|
6
6
|
"scripts": {
|
|
@@ -27,7 +27,7 @@
|
|
|
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
33
|
"eslint": "10.9.1",
|
|
@@ -39,7 +39,7 @@
|
|
|
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"
|