nodemailer 10.0.8 → 10.0.9

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,13 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [10.0.9](https://github.com/nodemailer/nodemailer/compare/v10.0.8...v10.0.9) (2026-09-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **addressparser:** bound the '@' probe to the run being scanned ([1465c3f](https://github.com/nodemailer/nodemailer/commit/1465c3f5ff74a7c4fbbe9853bd01448bb92bf5b7))
9
+ * **addressparser:** keep the text after a comment out of a quoted local part address ([2f36eb1](https://github.com/nodemailer/nodemailer/commit/2f36eb1aa1dd33e312411dc9b888548e14db54ee))
10
+
3
11
  ## [10.0.8](https://github.com/nodemailer/nodemailer/compare/v10.0.7...v10.0.8) (2026-09-11)
4
12
 
5
13
 
@@ -88,6 +88,29 @@ function _isWordCode(code) {
88
88
  function _isBoundary(text, at) {
89
89
  return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
90
90
  }
91
+ /**
92
+ * Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
93
+ *
94
+ * indexOf would scan on to the end of the value, and the value here is a whole header. The
95
+ * walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits
96
+ * inside the run it is on, so an unbounded probe rescans everything behind that run once per
97
+ * run and grows with the square of the header: 400KB of free text carrying no '@' took a
98
+ * quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left
99
+ * the probe unbounded behind it.
100
+ *
101
+ * @param text Text to look in
102
+ * @param from Offset to start at
103
+ * @param to Offset to stop before
104
+ * @return Offset of the '@', or -1
105
+ */
106
+ function _indexOfAt(text, from, to) {
107
+ for (let i = from; i < to; i++) {
108
+ if (text.charCodeAt(i) === 0x40) {
109
+ return i;
110
+ }
111
+ }
112
+ return -1;
113
+ }
91
114
  /**
92
115
  * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
93
116
  *
@@ -118,8 +141,8 @@ function _looseAddressStart(text) {
118
141
  while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
119
142
  runEnd++;
120
143
  }
121
- let at = text.indexOf('@', runStart);
122
- if (at >= 0 && at < runEnd) {
144
+ let at = _indexOfAt(text, runStart, runEnd);
145
+ if (at >= 0) {
123
146
  let lastBoundary = -1;
124
147
  for (let k = runEnd; k > runStart; k--) {
125
148
  if (_isBoundary(text, k)) {
@@ -128,7 +151,7 @@ function _looseAddressStart(text) {
128
151
  }
129
152
  }
130
153
  let atomStart = runStart;
131
- while (lastBoundary >= 0 && at >= 0 && at < runEnd) {
154
+ while (lastBoundary >= 0 && at >= 0) {
132
155
  // '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
133
156
  // and the boundary that ends the match has to sit past both
134
157
  if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
@@ -148,7 +171,7 @@ function _looseAddressStart(text) {
148
171
  }
149
172
  }
150
173
  atomStart = at + 1;
151
- at = text.indexOf('@', atomStart);
174
+ at = _indexOfAt(text, atomStart, runEnd);
152
175
  }
153
176
  }
154
177
  pos = runEnd;
@@ -275,6 +298,19 @@ function _handleAddress(tokens, depth) {
275
298
  }
276
299
  }
277
300
  else if (token.value) {
301
+ // An empty quoted string is dropped by the tokenizer, leaving no text token of its
302
+ // own for textWasQuoted to be recorded on, so the pair of quote operators right in
303
+ // front of this token is all that is left of it and the run it opens carries the
304
+ // quoting instead. Without this '""@example.com' reads as the bare '@example.com',
305
+ // the quotes never go back on, and the value is no longer an addr-spec a trailing
306
+ // comment can be peeled off of. It only ever opens a run: a run that already holds
307
+ // material collected outside the quotes is not a quoted string, whatever follows it
308
+ const prevPrevToken = i > 1 ? tokens[i - 2] : null;
309
+ const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
310
+ prevToken.value === '"' &&
311
+ !!prevToken.noBreak &&
312
+ prevPrevToken?.type === 'operator' &&
313
+ prevPrevToken.value === '"';
278
314
  if (state === 'address') {
279
315
  // Handle unquoted name that includes a "<".
280
316
  // Apple Mail truncates everything between an unexpected < and an address.
@@ -302,7 +338,7 @@ function _handleAddress(tokens, depth) {
302
338
  data[state].push(token.value);
303
339
  lastChars[state] = token.value.charAt(token.value.length - 1);
304
340
  if (state === 'text') {
305
- data.textWasQuoted.push(insideQuotes);
341
+ data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
306
342
  }
307
343
  }
308
344
  }
@@ -387,6 +423,18 @@ function _handleAddress(tokens, depth) {
387
423
  // Join values with spaces
388
424
  data.text = data.text.join(' ');
389
425
  data.address = data.address.join(' ');
426
+ if (addressFromQuotedText && data.text) {
427
+ // The mailbox is still sitting in the text, so it moves over here and is quoted
428
+ // before the recovery below rather than after it. Anything else the text holds
429
+ // came along with it: a comment ends the domain but leaves the atoms behind it in
430
+ // the same text, and '"user"@example.com(x)evil.com' was handed on as the address
431
+ // 'user@example.com evil.com', a second domain riding into the envelope recipient
432
+ // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
433
+ // back first is what lets the recovery tell the whitespace an addr-spec may carry
434
+ // from the wreckage trailing one, as only a quoted local part may hold whitespace
435
+ data.address = _quoteLocalPart(data.text);
436
+ data.text = '';
437
+ }
390
438
  _recoverAddrSpec(data);
391
439
  const address = {
392
440
  address: data.address || data.text || '',
@@ -400,9 +448,6 @@ function _handleAddress(tokens, depth) {
400
448
  address.address = '';
401
449
  }
402
450
  }
403
- if (addressFromQuotedText && address.address) {
404
- address.address = _quoteLocalPart(address.address);
405
- }
406
451
  addresses.push(address);
407
452
  }
408
453
  return addresses;
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.0.8";
2
+ export declare const version = "10.0.9";
3
3
  export declare const homepage = "https://nodemailer.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = 'nodemailer';
6
- exports.version = '10.0.8';
6
+ exports.version = '10.0.9';
7
7
  exports.homepage = 'https://nodemailer.com/';
@@ -85,6 +85,29 @@ function _isWordCode(code) {
85
85
  function _isBoundary(text, at) {
86
86
  return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
87
87
  }
88
+ /**
89
+ * Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
90
+ *
91
+ * indexOf would scan on to the end of the value, and the value here is a whole header. The
92
+ * walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits
93
+ * inside the run it is on, so an unbounded probe rescans everything behind that run once per
94
+ * run and grows with the square of the header: 400KB of free text carrying no '@' took a
95
+ * quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left
96
+ * the probe unbounded behind it.
97
+ *
98
+ * @param text Text to look in
99
+ * @param from Offset to start at
100
+ * @param to Offset to stop before
101
+ * @return Offset of the '@', or -1
102
+ */
103
+ function _indexOfAt(text, from, to) {
104
+ for (let i = from; i < to; i++) {
105
+ if (text.charCodeAt(i) === 0x40) {
106
+ return i;
107
+ }
108
+ }
109
+ return -1;
110
+ }
88
111
  /**
89
112
  * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
90
113
  *
@@ -115,8 +138,8 @@ function _looseAddressStart(text) {
115
138
  while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
116
139
  runEnd++;
117
140
  }
118
- let at = text.indexOf('@', runStart);
119
- if (at >= 0 && at < runEnd) {
141
+ let at = _indexOfAt(text, runStart, runEnd);
142
+ if (at >= 0) {
120
143
  let lastBoundary = -1;
121
144
  for (let k = runEnd; k > runStart; k--) {
122
145
  if (_isBoundary(text, k)) {
@@ -125,7 +148,7 @@ function _looseAddressStart(text) {
125
148
  }
126
149
  }
127
150
  let atomStart = runStart;
128
- while (lastBoundary >= 0 && at >= 0 && at < runEnd) {
151
+ while (lastBoundary >= 0 && at >= 0) {
129
152
  // '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
130
153
  // and the boundary that ends the match has to sit past both
131
154
  if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
@@ -145,7 +168,7 @@ function _looseAddressStart(text) {
145
168
  }
146
169
  }
147
170
  atomStart = at + 1;
148
- at = text.indexOf('@', atomStart);
171
+ at = _indexOfAt(text, atomStart, runEnd);
149
172
  }
150
173
  }
151
174
  pos = runEnd;
@@ -272,6 +295,19 @@ function _handleAddress(tokens, depth) {
272
295
  }
273
296
  }
274
297
  else if (token.value) {
298
+ // An empty quoted string is dropped by the tokenizer, leaving no text token of its
299
+ // own for textWasQuoted to be recorded on, so the pair of quote operators right in
300
+ // front of this token is all that is left of it and the run it opens carries the
301
+ // quoting instead. Without this '""@example.com' reads as the bare '@example.com',
302
+ // the quotes never go back on, and the value is no longer an addr-spec a trailing
303
+ // comment can be peeled off of. It only ever opens a run: a run that already holds
304
+ // material collected outside the quotes is not a quoted string, whatever follows it
305
+ const prevPrevToken = i > 1 ? tokens[i - 2] : null;
306
+ const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
307
+ prevToken.value === '"' &&
308
+ !!prevToken.noBreak &&
309
+ prevPrevToken?.type === 'operator' &&
310
+ prevPrevToken.value === '"';
275
311
  if (state === 'address') {
276
312
  // Handle unquoted name that includes a "<".
277
313
  // Apple Mail truncates everything between an unexpected < and an address.
@@ -299,7 +335,7 @@ function _handleAddress(tokens, depth) {
299
335
  data[state].push(token.value);
300
336
  lastChars[state] = token.value.charAt(token.value.length - 1);
301
337
  if (state === 'text') {
302
- data.textWasQuoted.push(insideQuotes);
338
+ data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
303
339
  }
304
340
  }
305
341
  }
@@ -384,6 +420,18 @@ function _handleAddress(tokens, depth) {
384
420
  // Join values with spaces
385
421
  data.text = data.text.join(' ');
386
422
  data.address = data.address.join(' ');
423
+ if (addressFromQuotedText && data.text) {
424
+ // The mailbox is still sitting in the text, so it moves over here and is quoted
425
+ // before the recovery below rather than after it. Anything else the text holds
426
+ // came along with it: a comment ends the domain but leaves the atoms behind it in
427
+ // the same text, and '"user"@example.com(x)evil.com' was handed on as the address
428
+ // 'user@example.com evil.com', a second domain riding into the envelope recipient
429
+ // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
430
+ // back first is what lets the recovery tell the whitespace an addr-spec may carry
431
+ // from the wreckage trailing one, as only a quoted local part may hold whitespace
432
+ data.address = _quoteLocalPart(data.text);
433
+ data.text = '';
434
+ }
387
435
  _recoverAddrSpec(data);
388
436
  const address = {
389
437
  address: data.address || data.text || '',
@@ -397,9 +445,6 @@ function _handleAddress(tokens, depth) {
397
445
  address.address = '';
398
446
  }
399
447
  }
400
- if (addressFromQuotedText && address.address) {
401
- address.address = _quoteLocalPart(address.address);
402
- }
403
448
  addresses.push(address);
404
449
  }
405
450
  return addresses;
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.0.8";
2
+ export declare const version = "10.0.9";
3
3
  export declare const homepage = "https://nodemailer.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = 'nodemailer';
3
- export const version = '10.0.8';
3
+ export const version = '10.0.9';
4
4
  export const homepage = 'https://nodemailer.com/';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "10.0.8",
3
+ "version": "10.0.9",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/nodemailer.js",
@@ -147,24 +147,24 @@
147
147
  },
148
148
  "homepage": "https://nodemailer.com/",
149
149
  "devDependencies": {
150
- "@aws-sdk/client-sesv2": "3.1124.0",
150
+ "@aws-sdk/client-sesv2": "3.1131.0",
151
151
  "@types/node": "20.19.43",
152
152
  "bunyan": "1.8.15",
153
153
  "c8": "12.0.0",
154
- "eslint": "10.9.1",
154
+ "eslint": "10.10.0",
155
155
  "eslint-config-prettier": "10.1.8",
156
156
  "globals": "17.12.0",
157
157
  "libbase64": "1.3.0",
158
158
  "libmime": "5.4.3",
159
159
  "libqp": "2.1.1",
160
- "mailauth": "5.0.2",
160
+ "mailauth": "5.0.3",
161
161
  "prettier": "3.9.6",
162
162
  "proxy": "1.0.2",
163
163
  "proxy-test-server": "1.0.0",
164
- "smtp-server": "3.19.7",
164
+ "smtp-server": "3.19.11",
165
165
  "tsx": "4.23.13",
166
166
  "typescript": "6.0.3",
167
- "typescript-eslint": "8.69.0"
167
+ "typescript-eslint": "8.70.0"
168
168
  },
169
169
  "engines": {
170
170
  "node": ">=20.0.0"