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 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
- if (prevToken && prevToken.noBreak && data[state].length) {
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
- if (handled.length) {
503
- parsedAddresses = parsedAddresses.concat(handled);
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
- for (let i = parsedAddresses.length - 2; i >= 0; i--) {
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 = parsedAddresses[i + 1];
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
- parsedAddresses.splice(i, 1);
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
@@ -38,6 +38,7 @@ const ERROR_CODES = {
38
38
 
39
39
  // Resource errors
40
40
  EMAXLIMIT: 'Pool resource limit reached (max messages per connection)',
41
+ EMAXRECIPIENTS: 'Recipient count exceeds maxRecipients',
41
42
 
42
43
  // Transport-specific errors
43
44
  ESENDMAIL: 'Sendmail command error',
@@ -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
  }
@@ -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
- return [].concat.apply(
1061
- [],
1062
- [].concat(addresses).map(address => {
1063
- if (address && address.address) {
1064
- const normalized = this._normalizeAddress(address.address);
1065
- if (normalized === address.address && typeof address.name === 'string') {
1066
- // there is nothing to rewrite, so there is nothing to keep off the original
1067
- return [address];
1068
- }
1069
-
1070
- // rewriting would land on the object the caller passed in and might
1071
- // still hold a reference to, so rewrite a copy of it instead. An own
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
- return this._normalizeParsedAddresses(addressparser(address));
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 (!uniqueList.some(a => a.address === address.address)) {
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 = (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim();
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
- if (/[\x80-\uFFFF]/.test(user)) {
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.6",
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.1119.0",
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.3"
42
+ "smtp-server": "3.19.4"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=6.0.0"