nodemailer 9.0.6 → 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 +18 -0
- 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 +1 -1
- package/lib/mime-node/index.js +105 -32
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
3
21
|
## [9.0.6](https://github.com/nodemailer/nodemailer/compare/v9.0.5...v9.0.6) (2026-08-27)
|
|
4
22
|
|
|
5
23
|
|
|
@@ -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,7 +31,7 @@ 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
|
}
|
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
|
|
@@ -872,9 +912,10 @@ class MimeNode {
|
|
|
872
912
|
this._envelope.from = list[0].address;
|
|
873
913
|
}
|
|
874
914
|
}
|
|
915
|
+
const seenRecipients = new Set();
|
|
875
916
|
['to', 'cc', 'bcc'].forEach(key => {
|
|
876
917
|
if (envelope[key]) {
|
|
877
|
-
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to);
|
|
918
|
+
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to, seenRecipients);
|
|
878
919
|
}
|
|
879
920
|
});
|
|
880
921
|
|
|
@@ -893,15 +934,17 @@ class MimeNode {
|
|
|
893
934
|
*/
|
|
894
935
|
getAddresses() {
|
|
895
936
|
const addresses = {};
|
|
937
|
+
const seenByKey = new Map();
|
|
896
938
|
|
|
897
939
|
this._headers.forEach(header => {
|
|
898
940
|
const key = header.key.toLowerCase();
|
|
899
941
|
if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
|
|
900
942
|
if (!Array.isArray(addresses[key])) {
|
|
901
943
|
addresses[key] = [];
|
|
944
|
+
seenByKey.set(key, new Set());
|
|
902
945
|
}
|
|
903
946
|
|
|
904
|
-
this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
|
|
947
|
+
this._convertAddresses(this._parseAddresses(header.value), addresses[key], seenByKey.get(key));
|
|
905
948
|
}
|
|
906
949
|
});
|
|
907
950
|
|
|
@@ -922,6 +965,12 @@ class MimeNode {
|
|
|
922
965
|
from: false,
|
|
923
966
|
to: []
|
|
924
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
|
+
|
|
925
974
|
this._headers.forEach(header => {
|
|
926
975
|
const list = [];
|
|
927
976
|
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
|
|
@@ -930,7 +979,7 @@ class MimeNode {
|
|
|
930
979
|
envelope.from = list[0].address;
|
|
931
980
|
}
|
|
932
981
|
} else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
|
|
933
|
-
this._convertAddresses(this._parseAddresses(header.value), envelope.to);
|
|
982
|
+
this._convertAddresses(this._parseAddresses(header.value), envelope.to, seenRecipients);
|
|
934
983
|
}
|
|
935
984
|
});
|
|
936
985
|
|
|
@@ -1057,28 +1106,38 @@ class MimeNode {
|
|
|
1057
1106
|
* @return {Array} An array of address objects
|
|
1058
1107
|
*/
|
|
1059
1108
|
_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];
|
|
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;
|
|
1078
1121
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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;
|
|
1082
1141
|
}
|
|
1083
1142
|
|
|
1084
1143
|
/**
|
|
@@ -1267,11 +1326,22 @@ class MimeNode {
|
|
|
1267
1326
|
* @param {Array} [uniqueList] An array to be populated with addresses
|
|
1268
1327
|
* @return {String} address string
|
|
1269
1328
|
*/
|
|
1270
|
-
_convertAddresses(addresses, uniqueList) {
|
|
1329
|
+
_convertAddresses(addresses, uniqueList, seenAddresses) {
|
|
1271
1330
|
const values = [];
|
|
1272
1331
|
|
|
1273
1332
|
uniqueList = uniqueList || [];
|
|
1274
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
|
+
|
|
1275
1345
|
[].concat(addresses || []).forEach(address => {
|
|
1276
1346
|
if (address.address) {
|
|
1277
1347
|
address.address = this._normalizeAddress(address.address);
|
|
@@ -1286,11 +1356,14 @@ class MimeNode {
|
|
|
1286
1356
|
values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
|
|
1287
1357
|
}
|
|
1288
1358
|
|
|
1289
|
-
if (!
|
|
1359
|
+
if (!seenAddresses.has(address.address)) {
|
|
1360
|
+
seenAddresses.add(address.address);
|
|
1290
1361
|
uniqueList.push(address);
|
|
1291
1362
|
}
|
|
1292
1363
|
} else if (address.group) {
|
|
1293
|
-
const groupListAddresses = (
|
|
1364
|
+
const groupListAddresses = (
|
|
1365
|
+
address.group.length ? this._convertAddresses(address.group, uniqueList, seenAddresses) : ''
|
|
1366
|
+
).trim();
|
|
1294
1367
|
values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
|
|
1295
1368
|
}
|
|
1296
1369
|
});
|
|
@@ -1333,12 +1406,12 @@ class MimeNode {
|
|
|
1333
1406
|
|
|
1334
1407
|
let encodedDomain = domain;
|
|
1335
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
|
+
|
|
1336
1413
|
try {
|
|
1337
|
-
|
|
1338
|
-
encodedDomain = punycode.toUnicode(domain.toLowerCase());
|
|
1339
|
-
} else {
|
|
1340
|
-
encodedDomain = punycode.toASCII(domain.toLowerCase());
|
|
1341
|
-
}
|
|
1414
|
+
encodedDomain = normalizeDomain(domain.toLowerCase(), smtputf8);
|
|
1342
1415
|
} catch (_err) {
|
|
1343
1416
|
// keep domain as supplied
|
|
1344
1417
|
}
|
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,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"
|