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 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://nodejs.org/en/docs/meta/topics/dependencies/#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.
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
- | 8.x | :white_check_mark: |
15
- | < 8.0 | :x: |
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
- 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,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(...args) {
42
- return shared.resolveContent(...args);
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) {
@@ -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
- 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];
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
- return this._normalizeParsedAddresses(addressparser(address));
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 (!uniqueList.some(a => a.address === address.address)) {
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 = (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim();
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
- if (/[\x80-\uFFFF]/.test(user)) {
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.0.6",
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.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"