nodemailer 10.0.12 → 10.0.13

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.13](https://github.com/nodemailer/nodemailer/compare/v10.0.12...v10.0.13) (2026-09-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * read the advertised SASL methods without backtracking regexes ([b5a896f](https://github.com/nodemailer/nodemailer/commit/b5a896fcb02df1628329d7cd665ca08490b14b56))
9
+ * strip comments inside an angle-addr before it becomes the address ([a502247](https://github.com/nodemailer/nodemailer/commit/a502247555fd81773eac337486781f3b6008e16a))
10
+
3
11
  ## [10.0.12](https://github.com/nodemailer/nodemailer/compare/v10.0.11...v10.0.12) (2026-09-28)
4
12
 
5
13
 
@@ -242,6 +242,81 @@ function _recoverAddrSpec(data) {
242
242
  .filter(part => part)
243
243
  .join(' ');
244
244
  }
245
+ /**
246
+ * Takes the RFC 5322 comments out of the contents of an angle-addr.
247
+ *
248
+ * The tokenizer tracks a single open operator, so once '<' is open a '(' is plain text and
249
+ * a comment inside the brackets reached the address verbatim: 'Name <user@example.com(x)evil.com>'
250
+ * put 'user@example.com(x)evil.com' into the envelope, a value that is no mailbox and that a
251
+ * receiver stripping the comment reads as 'user@example.comevil.com' (GHSA-g73g-hqqh-jr95).
252
+ * A comment is folding whitespace, so it is read as a space that splits two atoms and as
253
+ * nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
254
+ * strings and domain literals are copied through untouched, a '(' in there is not a comment.
255
+ *
256
+ * @param address Contents of the angle brackets
257
+ * @return The address with the comments removed, and the text of the comments
258
+ */
259
+ function _stripAddressComments(address) {
260
+ const comments = [];
261
+ let result = '';
262
+ let comment = '';
263
+ let depth = 0;
264
+ let closer = '';
265
+ // carried along rather than read back off the growing result, which would flatten it on
266
+ // every comment (see lastChars in _handleAddress)
267
+ let lastChar = '';
268
+ for (let i = 0, len = address.length; i < len; i++) {
269
+ const chr = address.charAt(i);
270
+ if (depth) {
271
+ if (chr === '\\' && i < len - 1) {
272
+ comment += address.charAt(++i);
273
+ }
274
+ else if (chr === '(') {
275
+ depth++;
276
+ comment += chr;
277
+ }
278
+ else if (chr === ')' && !--depth) {
279
+ comments.push(comment.trim());
280
+ comment = '';
281
+ if (lastChar !== '@' && address.charAt(i + 1) !== '@') {
282
+ result += ' ';
283
+ lastChar = ' ';
284
+ }
285
+ }
286
+ else {
287
+ comment += chr;
288
+ }
289
+ continue;
290
+ }
291
+ if (closer) {
292
+ if (chr === '\\' && closer === '"' && i < len - 1) {
293
+ result += chr + address.charAt(++i);
294
+ lastChar = address.charAt(i);
295
+ continue;
296
+ }
297
+ if (chr === closer) {
298
+ closer = '';
299
+ }
300
+ }
301
+ else if (chr === '"') {
302
+ closer = '"';
303
+ }
304
+ else if (chr === '[') {
305
+ closer = ']';
306
+ }
307
+ else if (chr === '(') {
308
+ depth = 1;
309
+ continue;
310
+ }
311
+ result += chr;
312
+ lastChar = chr;
313
+ }
314
+ if (depth) {
315
+ // an unterminated comment runs to the end of the address
316
+ comments.push(comment.trim());
317
+ }
318
+ return { address: result.trim(), comments: comments.filter(text => text) };
319
+ }
245
320
  /**
246
321
  * Converts tokens for a single address into an address object
247
322
  *
@@ -369,6 +444,24 @@ function _handleAddress(tokens, depth) {
369
444
  });
370
445
  }
371
446
  else {
447
+ // Comments come out of the angle-addr before anything asks whether one was found, so
448
+ // that brackets holding nothing but a comment read the same as empty ones
449
+ const addressComments = [];
450
+ const addressParts = [];
451
+ for (const part of data.address) {
452
+ if (part.indexOf('(') < 0) {
453
+ addressParts.push(part);
454
+ continue;
455
+ }
456
+ const stripped = _stripAddressComments(part);
457
+ for (const comment of stripped.comments) {
458
+ addressComments.push(comment);
459
+ }
460
+ if (stripped.address) {
461
+ addressParts.push(stripped.address);
462
+ }
463
+ }
464
+ data.address = addressParts;
372
465
  // If no address was found, try to detect one from regular text
373
466
  if (!data.address.length && data.text.length) {
374
467
  for (let i = data.text.length - 1; i >= 0; i--) {
@@ -435,6 +528,10 @@ function _handleAddress(tokens, depth) {
435
528
  data.text = '';
436
529
  }
437
530
  _recoverAddrSpec(data);
531
+ // a comment names the mailbox only when nothing else does, as one outside the brackets
532
+ if (!data.text && addressComments.length) {
533
+ data.text = addressComments.join(' ');
534
+ }
438
535
  const address = {
439
536
  address: data.address || data.text || '',
440
537
  name: data.text || data.address || ''
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.0.12";
2
+ export declare const version = "10.0.13";
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.12';
6
+ exports.version = '10.0.13';
7
7
  exports.homepage = 'https://nodemailer.com/';
@@ -1394,21 +1394,25 @@ class SMTPConnection extends node_events_1.EventEmitter {
1394
1394
  if (/[ -]AUTH\b/i.test(str)) {
1395
1395
  this.allowsAuth = true;
1396
1396
  }
1397
- // Detect if the server supports PLAIN auth
1398
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)PLAIN/i.test(str)) {
1399
- this._supportedAuth.push('PLAIN');
1400
- }
1401
- // Detect if the server supports LOGIN auth
1402
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)LOGIN/i.test(str)) {
1403
- this._supportedAuth.push('LOGIN');
1404
- }
1405
- // Detect if the server supports CRAM-MD5 auth
1406
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)CRAM-MD5/i.test(str)) {
1407
- this._supportedAuth.push('CRAM-MD5');
1397
+ // Detect the advertised SASL mechanisms. The list is split into whole tokens rather
1398
+ // than searched for each name with a pattern: the patterns this replaced let two
1399
+ // whitespace runs overlap and backtracked quadratically over an AUTH line padded with
1400
+ // spaces, so a hostile server could stall the event loop from its EHLO reply
1401
+ // (GHSA-4ffr-jq9g-5ffx).
1402
+ const authMechanisms = new Set();
1403
+ for (const line of this._ehloLines) {
1404
+ const authMatch = /^AUTH[\s=](.*)/i.exec(line);
1405
+ if (authMatch) {
1406
+ for (const mechanism of authMatch[1].split(/[\s=]+/)) {
1407
+ authMechanisms.add(mechanism.toUpperCase());
1408
+ }
1409
+ }
1408
1410
  }
1409
- // Detect if the server supports XOAUTH2 auth
1410
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)XOAUTH2/i.test(str)) {
1411
- this._supportedAuth.push('XOAUTH2');
1411
+ // listed in order of preference, the first one the credentials allow is used
1412
+ for (const mechanism of ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2']) {
1413
+ if (authMechanisms.has(mechanism)) {
1414
+ this._supportedAuth.push(mechanism);
1415
+ }
1412
1416
  }
1413
1417
  // Detect if the server supports SIZE extensions (and the max allowed size)
1414
1418
  if ((match = str.match(/[ -]SIZE(?:[ \t]+(\d+))?/im))) {
@@ -240,6 +240,81 @@ function _recoverAddrSpec(data) {
240
240
  .filter(part => part)
241
241
  .join(' ');
242
242
  }
243
+ /**
244
+ * Takes the RFC 5322 comments out of the contents of an angle-addr.
245
+ *
246
+ * The tokenizer tracks a single open operator, so once '<' is open a '(' is plain text and
247
+ * a comment inside the brackets reached the address verbatim: 'Name <user@example.com(x)evil.com>'
248
+ * put 'user@example.com(x)evil.com' into the envelope, a value that is no mailbox and that a
249
+ * receiver stripping the comment reads as 'user@example.comevil.com' (GHSA-g73g-hqqh-jr95).
250
+ * A comment is folding whitespace, so it is read as a space that splits two atoms and as
251
+ * nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
252
+ * strings and domain literals are copied through untouched, a '(' in there is not a comment.
253
+ *
254
+ * @param address Contents of the angle brackets
255
+ * @return The address with the comments removed, and the text of the comments
256
+ */
257
+ function _stripAddressComments(address) {
258
+ const comments = [];
259
+ let result = '';
260
+ let comment = '';
261
+ let depth = 0;
262
+ let closer = '';
263
+ // carried along rather than read back off the growing result, which would flatten it on
264
+ // every comment (see lastChars in _handleAddress)
265
+ let lastChar = '';
266
+ for (let i = 0, len = address.length; i < len; i++) {
267
+ const chr = address.charAt(i);
268
+ if (depth) {
269
+ if (chr === '\\' && i < len - 1) {
270
+ comment += address.charAt(++i);
271
+ }
272
+ else if (chr === '(') {
273
+ depth++;
274
+ comment += chr;
275
+ }
276
+ else if (chr === ')' && !--depth) {
277
+ comments.push(comment.trim());
278
+ comment = '';
279
+ if (lastChar !== '@' && address.charAt(i + 1) !== '@') {
280
+ result += ' ';
281
+ lastChar = ' ';
282
+ }
283
+ }
284
+ else {
285
+ comment += chr;
286
+ }
287
+ continue;
288
+ }
289
+ if (closer) {
290
+ if (chr === '\\' && closer === '"' && i < len - 1) {
291
+ result += chr + address.charAt(++i);
292
+ lastChar = address.charAt(i);
293
+ continue;
294
+ }
295
+ if (chr === closer) {
296
+ closer = '';
297
+ }
298
+ }
299
+ else if (chr === '"') {
300
+ closer = '"';
301
+ }
302
+ else if (chr === '[') {
303
+ closer = ']';
304
+ }
305
+ else if (chr === '(') {
306
+ depth = 1;
307
+ continue;
308
+ }
309
+ result += chr;
310
+ lastChar = chr;
311
+ }
312
+ if (depth) {
313
+ // an unterminated comment runs to the end of the address
314
+ comments.push(comment.trim());
315
+ }
316
+ return { address: result.trim(), comments: comments.filter(text => text) };
317
+ }
243
318
  /**
244
319
  * Converts tokens for a single address into an address object
245
320
  *
@@ -367,6 +442,24 @@ function _handleAddress(tokens, depth) {
367
442
  });
368
443
  }
369
444
  else {
445
+ // Comments come out of the angle-addr before anything asks whether one was found, so
446
+ // that brackets holding nothing but a comment read the same as empty ones
447
+ const addressComments = [];
448
+ const addressParts = [];
449
+ for (const part of data.address) {
450
+ if (part.indexOf('(') < 0) {
451
+ addressParts.push(part);
452
+ continue;
453
+ }
454
+ const stripped = _stripAddressComments(part);
455
+ for (const comment of stripped.comments) {
456
+ addressComments.push(comment);
457
+ }
458
+ if (stripped.address) {
459
+ addressParts.push(stripped.address);
460
+ }
461
+ }
462
+ data.address = addressParts;
370
463
  // If no address was found, try to detect one from regular text
371
464
  if (!data.address.length && data.text.length) {
372
465
  for (let i = data.text.length - 1; i >= 0; i--) {
@@ -433,6 +526,10 @@ function _handleAddress(tokens, depth) {
433
526
  data.text = '';
434
527
  }
435
528
  _recoverAddrSpec(data);
529
+ // a comment names the mailbox only when nothing else does, as one outside the brackets
530
+ if (!data.text && addressComments.length) {
531
+ data.text = addressComments.join(' ');
532
+ }
436
533
  const address = {
437
534
  address: data.address || data.text || '',
438
535
  name: data.text || data.address || ''
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.0.12";
2
+ export declare const version = "10.0.13";
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.12';
3
+ export const version = '10.0.13';
4
4
  export const homepage = 'https://nodemailer.com/';
@@ -1356,21 +1356,25 @@ class SMTPConnection extends EventEmitter {
1356
1356
  if (/[ -]AUTH\b/i.test(str)) {
1357
1357
  this.allowsAuth = true;
1358
1358
  }
1359
- // Detect if the server supports PLAIN auth
1360
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)PLAIN/i.test(str)) {
1361
- this._supportedAuth.push('PLAIN');
1362
- }
1363
- // Detect if the server supports LOGIN auth
1364
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)LOGIN/i.test(str)) {
1365
- this._supportedAuth.push('LOGIN');
1366
- }
1367
- // Detect if the server supports CRAM-MD5 auth
1368
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)CRAM-MD5/i.test(str)) {
1369
- this._supportedAuth.push('CRAM-MD5');
1359
+ // Detect the advertised SASL mechanisms. The list is split into whole tokens rather
1360
+ // than searched for each name with a pattern: the patterns this replaced let two
1361
+ // whitespace runs overlap and backtracked quadratically over an AUTH line padded with
1362
+ // spaces, so a hostile server could stall the event loop from its EHLO reply
1363
+ // (GHSA-4ffr-jq9g-5ffx).
1364
+ const authMechanisms = new Set();
1365
+ for (const line of this._ehloLines) {
1366
+ const authMatch = /^AUTH[\s=](.*)/i.exec(line);
1367
+ if (authMatch) {
1368
+ for (const mechanism of authMatch[1].split(/[\s=]+/)) {
1369
+ authMechanisms.add(mechanism.toUpperCase());
1370
+ }
1371
+ }
1370
1372
  }
1371
- // Detect if the server supports XOAUTH2 auth
1372
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)XOAUTH2/i.test(str)) {
1373
- this._supportedAuth.push('XOAUTH2');
1373
+ // listed in order of preference, the first one the credentials allow is used
1374
+ for (const mechanism of ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2']) {
1375
+ if (authMechanisms.has(mechanism)) {
1376
+ this._supportedAuth.push(mechanism);
1377
+ }
1374
1378
  }
1375
1379
  // Detect if the server supports SIZE extensions (and the max allowed size)
1376
1380
  if ((match = str.match(/[ -]SIZE(?:[ \t]+(\d+))?/im))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "10.0.12",
3
+ "version": "10.0.13",
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",
@@ -151,24 +151,24 @@
151
151
  },
152
152
  "homepage": "https://nodemailer.com/",
153
153
  "devDependencies": {
154
- "@aws-sdk/client-sesv2": "3.1141.0",
154
+ "@aws-sdk/client-sesv2": "3.1143.0",
155
155
  "@types/node": "20.19.43",
156
156
  "bunyan": "1.8.15",
157
157
  "c8": "12.0.0",
158
158
  "eslint": "10.11.0",
159
159
  "eslint-config-prettier": "10.1.8",
160
160
  "globals": "17.12.0",
161
- "libbase64": "1.3.0",
162
- "libmime": "5.4.5",
163
- "libqp": "2.1.1",
161
+ "libbase64": "1.3.1",
162
+ "libmime": "5.4.6",
163
+ "libqp": "2.1.2",
164
164
  "mailauth": "7.1.0",
165
165
  "prettier": "3.9.9",
166
166
  "proxy": "1.0.2",
167
167
  "proxy-test-server": "1.0.0",
168
- "smtp-server": "3.19.14",
168
+ "smtp-server": "3.19.15",
169
169
  "tsx": "4.23.15",
170
170
  "typescript": "6.0.3",
171
- "typescript-eslint": "8.70.1"
171
+ "typescript-eslint": "8.71.0"
172
172
  },
173
173
  "engines": {
174
174
  "node": ">=20.0.0"