nodemailer 7.0.5 → 7.0.6

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,14 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [7.0.6](https://github.com/nodemailer/nodemailer/compare/v7.0.5...v7.0.6) (2025-08-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **encoder:** avoid silent data loss by properly flushing trailing base64 ([#1747](https://github.com/nodemailer/nodemailer/issues/1747)) ([01ae76f](https://github.com/nodemailer/nodemailer/commit/01ae76f2cfe991c0c3fe80170f236da60531496b))
9
+ * handle multiple XOAUTH2 token requests correctly ([#1754](https://github.com/nodemailer/nodemailer/issues/1754)) ([dbe0028](https://github.com/nodemailer/nodemailer/commit/dbe00286351cddf012726a41a96ae613d30a34ee))
10
+ * ReDoS vulnerability in parseDataURI and _processDataUrl ([#1755](https://github.com/nodemailer/nodemailer/issues/1755)) ([90b3e24](https://github.com/nodemailer/nodemailer/commit/90b3e24d23929ebf9f4e16261049b40ee4055a39))
11
+
3
12
  ## [7.0.5](https://github.com/nodemailer/nodemailer/compare/v7.0.4...v7.0.5) (2025-07-07)
4
13
 
5
14
 
@@ -35,15 +35,12 @@ function wrap(str, lineLength) {
35
35
  let pos = 0;
36
36
  let chunkLength = lineLength * 1024;
37
37
  while (pos < str.length) {
38
- let wrappedLines = str
39
- .substr(pos, chunkLength)
40
- .replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n')
41
- .trim();
38
+ let wrappedLines = str.substr(pos, chunkLength).replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n');
42
39
  result.push(wrappedLines);
43
40
  pos += chunkLength;
44
41
  }
45
42
 
46
- return result.join('\r\n').trim();
43
+ return result.join('');
47
44
  }
48
45
 
49
46
  /**
@@ -56,7 +53,6 @@ function wrap(str, lineLength) {
56
53
  class Encoder extends Transform {
57
54
  constructor(options) {
58
55
  super();
59
- // init Transform
60
56
  this.options = options || {};
61
57
 
62
58
  if (this.options.lineLength !== false) {
@@ -98,17 +94,20 @@ class Encoder extends Transform {
98
94
  if (this.options.lineLength) {
99
95
  b64 = wrap(b64, this.options.lineLength);
100
96
 
101
- // remove last line as it is still most probably incomplete
102
97
  let lastLF = b64.lastIndexOf('\n');
103
98
  if (lastLF < 0) {
104
99
  this._curLine = b64;
105
100
  b64 = '';
106
- } else if (lastLF === b64.length - 1) {
107
- this._curLine = '';
108
101
  } else {
109
- this._curLine = b64.substr(lastLF + 1);
110
- b64 = b64.substr(0, lastLF + 1);
102
+ this._curLine = b64.substring(lastLF + 1);
103
+ b64 = b64.substring(0, lastLF + 1);
104
+
105
+ if (b64 && !b64.endsWith('\r\n')) {
106
+ b64 += '\r\n';
107
+ }
111
108
  }
109
+ } else {
110
+ this._curLine = '';
112
111
  }
113
112
 
114
113
  if (b64) {
@@ -125,16 +124,14 @@ class Encoder extends Transform {
125
124
  }
126
125
 
127
126
  if (this._curLine) {
128
- this._curLine = wrap(this._curLine, this.options.lineLength);
129
127
  this.outputBytes += this._curLine.length;
130
- this.push(this._curLine, 'ascii');
128
+ this.push(Buffer.from(this._curLine, 'ascii'));
131
129
  this._curLine = '';
132
130
  }
133
131
  done();
134
132
  }
135
133
  }
136
134
 
137
- // expose to the world
138
135
  module.exports = {
139
136
  encode,
140
137
  wrap,
@@ -550,9 +550,33 @@ class MailComposer {
550
550
  * @return {Object} Parsed element
551
551
  */
552
552
  _processDataUrl(element) {
553
+ const dataUrl = element.path || element.href;
554
+
555
+ // Early validation to prevent ReDoS
556
+ if (!dataUrl || typeof dataUrl !== 'string') {
557
+ return element;
558
+ }
559
+
560
+ if (!dataUrl.startsWith('data:')) {
561
+ return element;
562
+ }
563
+
564
+ if (dataUrl.length > 100000) {
565
+ // 100KB limit for data URL string
566
+ // Return empty content for excessively long data URLs
567
+ return Object.assign({}, element, {
568
+ path: false,
569
+ href: false,
570
+ content: Buffer.alloc(0),
571
+ contentType: element.contentType || 'application/octet-stream'
572
+ });
573
+ }
574
+
553
575
  let parsedDataUri;
554
- if ((element.path || element.href).match(/^data:/)) {
555
- parsedDataUri = parseDataURI(element.path || element.href);
576
+ try {
577
+ parsedDataUri = parseDataURI(dataUrl);
578
+ } catch (err) {
579
+ return element;
556
580
  }
557
581
 
558
582
  if (!parsedDataUri) {
@@ -269,7 +269,7 @@ module.exports = {
269
269
 
270
270
  // first line includes the charset and language info and needs to be encoded
271
271
  // even if it does not contain any unicode characters
272
- line = 'utf-8\x27\x27';
272
+ line = "utf-8''";
273
273
  let encoded = true;
274
274
  startPos = 0;
275
275
 
@@ -419,52 +419,74 @@ module.exports.callbackPromise = (resolve, reject) =>
419
419
  };
420
420
 
421
421
  module.exports.parseDataURI = uri => {
422
- let input = uri;
423
- let commaPos = input.indexOf(',');
424
- if (!commaPos) {
425
- return uri;
422
+ if (typeof uri !== 'string') {
423
+ return null;
426
424
  }
427
425
 
428
- let data = input.substring(commaPos + 1);
429
- let metaStr = input.substring('data:'.length, commaPos);
426
+ // Early return for non-data URIs to avoid unnecessary processing
427
+ if (!uri.startsWith('data:')) {
428
+ return null;
429
+ }
430
430
 
431
- let encoding;
431
+ // Find the first comma safely - this prevents ReDoS
432
+ const commaPos = uri.indexOf(',');
433
+ if (commaPos === -1) {
434
+ return null;
435
+ }
432
436
 
433
- let metaEntries = metaStr.split(';');
434
- let lastMetaEntry = metaEntries.length > 1 ? metaEntries[metaEntries.length - 1] : false;
435
- if (lastMetaEntry && lastMetaEntry.indexOf('=') < 0) {
436
- encoding = lastMetaEntry.toLowerCase();
437
- metaEntries.pop();
437
+ const data = uri.substring(commaPos + 1);
438
+ const metaStr = uri.substring('data:'.length, commaPos);
439
+
440
+ let encoding;
441
+ const metaEntries = metaStr.split(';');
442
+
443
+ if (metaEntries.length > 0) {
444
+ const lastEntry = metaEntries[metaEntries.length - 1].toLowerCase().trim();
445
+ // Only recognize valid encoding types to prevent manipulation
446
+ if (['base64', 'utf8', 'utf-8'].includes(lastEntry) && lastEntry.indexOf('=') === -1) {
447
+ encoding = lastEntry;
448
+ metaEntries.pop();
449
+ }
438
450
  }
439
451
 
440
- let contentType = metaEntries.shift() || 'application/octet-stream';
441
- let params = {};
442
- for (let entry of metaEntries) {
443
- let sep = entry.indexOf('=');
444
- if (sep >= 0) {
445
- let key = entry.substring(0, sep);
446
- let value = entry.substring(sep + 1);
447
- params[key] = value;
452
+ const contentType = metaEntries.length > 0 ? metaEntries.shift() : 'application/octet-stream';
453
+ const params = {};
454
+
455
+ for (let i = 0; i < metaEntries.length; i++) {
456
+ const entry = metaEntries[i];
457
+ const sepPos = entry.indexOf('=');
458
+ if (sepPos > 0) {
459
+ // Ensure there's a key before the '='
460
+ const key = entry.substring(0, sepPos).trim();
461
+ const value = entry.substring(sepPos + 1).trim();
462
+ if (key) {
463
+ params[key] = value;
464
+ }
448
465
  }
449
466
  }
450
467
 
451
- switch (encoding) {
452
- case 'base64':
453
- data = Buffer.from(data, 'base64');
454
- break;
455
- case 'utf8':
456
- data = Buffer.from(data);
457
- break;
458
- default:
468
+ // Decode data based on encoding with proper error handling
469
+ let bufferData;
470
+ try {
471
+ if (encoding === 'base64') {
472
+ bufferData = Buffer.from(data, 'base64');
473
+ } else {
459
474
  try {
460
- data = Buffer.from(decodeURIComponent(data));
461
- } catch (err) {
462
- data = Buffer.from(data);
475
+ bufferData = Buffer.from(decodeURIComponent(data));
476
+ } catch (decodeError) {
477
+ bufferData = Buffer.from(data);
463
478
  }
464
- data = Buffer.from(data);
479
+ }
480
+ } catch (bufferError) {
481
+ bufferData = Buffer.alloc(0);
465
482
  }
466
483
 
467
- return { data, encoding, contentType, params };
484
+ return {
485
+ data: bufferData,
486
+ encoding: encoding || null,
487
+ contentType: contentType || 'application/octet-stream',
488
+ params
489
+ };
468
490
  };
469
491
 
470
492
  /**
@@ -1619,7 +1619,7 @@ class SMTPConnection extends EventEmitter {
1619
1619
  }
1620
1620
 
1621
1621
  if (!this._envelope.rcptQueue.length) {
1622
- return callback(this._formatError('Can\x27t send mail - no recipients defined', 'EENVELOPE', false, 'API'));
1622
+ return callback(this._formatError("Can't send mail - no recipients defined", 'EENVELOPE', false, 'API'));
1623
1623
  } else {
1624
1624
  this._recipientQueue = [];
1625
1625
 
@@ -1675,7 +1675,7 @@ class SMTPConnection extends EventEmitter {
1675
1675
  });
1676
1676
  this._sendCommand('DATA');
1677
1677
  } else {
1678
- err = this._formatError('Can\x27t send mail - all recipients were rejected', 'EENVELOPE', str, 'RCPT TO');
1678
+ err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', str, 'RCPT TO');
1679
1679
  err.rejected = this._envelope.rejected;
1680
1680
  err.rejectedErrors = this._envelope.rejectedErrors;
1681
1681
  return callback(err);
@@ -43,6 +43,16 @@
43
43
  "port": 587
44
44
  },
45
45
 
46
+ "Aruba": {
47
+ "description": "Aruba PEC (Italian email provider)",
48
+ "domains": ["aruba.it", "pec.aruba.it"],
49
+ "aliases": ["Aruba PEC"],
50
+ "host": "smtps.aruba.it",
51
+ "port": 465,
52
+ "secure": true,
53
+ "authMethod": "LOGIN"
54
+ },
55
+
46
56
  "Bluewin": {
47
57
  "description": "Bluewin (Swiss email provider)",
48
58
  "host": "smtpauths.bluewin.ch",
@@ -50,12 +60,29 @@
50
60
  "port": 465
51
61
  },
52
62
 
63
+ "BOL": {
64
+ "description": "BOL Mail (Brazilian provider)",
65
+ "domains": ["bol.com.br"],
66
+ "host": "smtp.bol.com.br",
67
+ "port": 587,
68
+ "requireTLS": true
69
+ },
70
+
53
71
  "DebugMail": {
54
72
  "description": "DebugMail (email testing service)",
55
73
  "host": "debugmail.io",
56
74
  "port": 25
57
75
  },
58
76
 
77
+ "Disroot": {
78
+ "description": "Disroot (privacy-focused provider)",
79
+ "domains": ["disroot.org"],
80
+ "host": "disroot.org",
81
+ "port": 587,
82
+ "secure": false,
83
+ "authMethod": "LOGIN"
84
+ },
85
+
59
86
  "DynectEmail": {
60
87
  "description": "Dyn Email Delivery",
61
88
  "aliases": ["Dynect"],
@@ -173,6 +200,16 @@
173
200
  "port": 587
174
201
  },
175
202
 
203
+ "KolabNow": {
204
+ "description": "KolabNow (secure email service)",
205
+ "domains": ["kolabnow.com"],
206
+ "aliases": ["Kolab"],
207
+ "host": "smtp.kolabnow.com",
208
+ "port": 465,
209
+ "secure": true,
210
+ "authMethod": "LOGIN"
211
+ },
212
+
176
213
  "Loopia": {
177
214
  "description": "Loopia (Swedish hosting provider)",
178
215
  "host": "mailcluster.loopia.se",
@@ -328,6 +365,14 @@
328
365
  "secure": true
329
366
  },
330
367
 
368
+ "Runbox": {
369
+ "description": "Runbox (Norwegian email provider)",
370
+ "domains": ["runbox.com"],
371
+ "host": "smtp.runbox.com",
372
+ "port": 465,
373
+ "secure": true
374
+ },
375
+
331
376
  "SendCloud": {
332
377
  "description": "SendCloud (Chinese email delivery)",
333
378
  "host": "smtp.sendcloud.net",
@@ -548,6 +593,14 @@
548
593
  "secure": true
549
594
  },
550
595
 
596
+ "Zimbra": {
597
+ "description": "Zimbra Mail Server",
598
+ "aliases": ["Zimbra Collaboration"],
599
+ "host": "smtp.zimbra.com",
600
+ "port": 587,
601
+ "requireTLS": true
602
+ },
603
+
551
604
  "Zoho": {
552
605
  "description": "Zoho Mail",
553
606
  "host": "smtp.zoho.com",
@@ -72,6 +72,9 @@ class XOAuth2 extends Stream {
72
72
  let timeout = Math.max(Number(this.options.timeout) || 0, 0);
73
73
  this.expires = (timeout && Date.now() + timeout * 1000) || 0;
74
74
  }
75
+
76
+ this.renewing = false; // Track if renewal is in progress
77
+ this.renewalQueue = []; // Queue for pending requests during renewal
75
78
  }
76
79
 
77
80
  /**
@@ -82,14 +85,61 @@ class XOAuth2 extends Stream {
82
85
  */
83
86
  getToken(renew, callback) {
84
87
  if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
88
+ this.logger.debug(
89
+ {
90
+ tnx: 'OAUTH2',
91
+ user: this.options.user,
92
+ action: 'reuse'
93
+ },
94
+ 'Reusing existing access token for %s',
95
+ this.options.user
96
+ );
85
97
  return callback(null, this.accessToken);
86
98
  }
87
99
 
88
- let generateCallback = (...args) => {
89
- if (args[0]) {
100
+ // check if it is possible to renew, if not, return the current token or error
101
+ if (!this.provisionCallback && !this.options.refreshToken && !this.options.serviceClient) {
102
+ if (this.accessToken) {
103
+ this.logger.debug(
104
+ {
105
+ tnx: 'OAUTH2',
106
+ user: this.options.user,
107
+ action: 'reuse'
108
+ },
109
+ 'Reusing existing access token (no refresh capability) for %s',
110
+ this.options.user
111
+ );
112
+ return callback(null, this.accessToken);
113
+ }
114
+ this.logger.error(
115
+ {
116
+ tnx: 'OAUTH2',
117
+ user: this.options.user,
118
+ action: 'renew'
119
+ },
120
+ 'Cannot renew access token for %s: No refresh mechanism available',
121
+ this.options.user
122
+ );
123
+ return callback(new Error("Can't create new access token for user"));
124
+ }
125
+
126
+ // If renewal already in progress, queue this request instead of starting another
127
+ if (this.renewing) {
128
+ return this.renewalQueue.push({ renew, callback });
129
+ }
130
+
131
+ this.renewing = true;
132
+
133
+ // Handles token renewal completion - processes queued requests and cleans up
134
+ const generateCallback = (err, accessToken) => {
135
+ this.renewalQueue.forEach(item => item.callback(err, accessToken));
136
+ this.renewalQueue = [];
137
+ this.renewing = false;
138
+
139
+ if (err) {
90
140
  this.logger.error(
91
141
  {
92
- err: args[0],
142
+ err,
93
143
  tnx: 'OAUTH2',
94
144
  user: this.options.user,
95
145
  action: 'renew'
@@ -108,7 +158,8 @@ class XOAuth2 extends Stream {
108
158
  this.options.user
109
159
  );
110
160
  }
111
- callback(...args);
161
+ // Complete original request
162
+ callback(err, accessToken);
112
163
  };
113
164
 
114
165
  if (this.provisionCallback) {
@@ -167,7 +218,7 @@ class XOAuth2 extends Stream {
167
218
  try {
168
219
  token = this.jwtSignRS256(tokenData);
169
220
  } catch (err) {
170
- return callback(new Error('Can\x27t generate token. Check your auth options'));
221
+ return callback(new Error("Can't generate token. Check your auth options"));
171
222
  }
172
223
 
173
224
  urlOptions = {
@@ -181,7 +232,7 @@ class XOAuth2 extends Stream {
181
232
  };
182
233
  } else {
183
234
  if (!this.options.refreshToken) {
184
- return callback(new Error('Can\x27t create new access token for user'));
235
+ return callback(new Error("Can't create new access token for user"));
185
236
  }
186
237
 
187
238
  // web app - https://developers.google.com/identity/protocols/OAuth2WebServer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "7.0.5",
3
+ "version": "7.0.6",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "main": "lib/nodemailer.js",
6
6
  "scripts": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "homepage": "https://nodemailer.com/",
25
25
  "devDependencies": {
26
- "@aws-sdk/client-sesv2": "3.839.0",
26
+ "@aws-sdk/client-sesv2": "3.876.0",
27
27
  "bunyan": "1.8.15",
28
28
  "c8": "10.1.3",
29
29
  "eslint": "8.57.0",