nodemailer 8.0.7 → 8.0.8

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
+ ## [8.0.8](https://github.com/nodemailer/nodemailer/compare/v8.0.7...v8.0.8) (2026-05-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * enforce strict TLS for OAuth2 and Ethereal credential requests ([#1818](https://github.com/nodemailer/nodemailer/issues/1818)) ([833d6e5](https://github.com/nodemailer/nodemailer/commit/833d6e58c8b717962bbb1b23e16923cd267c3bc9))
9
+ * four listener/stream leaks in SMTP transport, connection, pool ([#1817](https://github.com/nodemailer/nodemailer/issues/1817)) ([850bb91](https://github.com/nodemailer/nodemailer/commit/850bb91bff7707ed498c1424df01c4e5b30ea14b))
10
+
3
11
  ## [8.0.7](https://github.com/nodemailer/nodemailer/compare/v8.0.6...v8.0.7) (2026-04-27)
4
12
 
5
13
 
package/lib/nodemailer.js CHANGED
@@ -95,12 +95,22 @@ module.exports.createTestAccount = function (apiUrl, callback) {
95
95
  requestHeaders.Authorization = 'Bearer ' + ETHEREAL_API_KEY;
96
96
  }
97
97
 
98
- const req = nmfetch(apiUrl + '/user', {
98
+ const fetchOptions = {
99
99
  contentType: 'application/json',
100
100
  method: 'POST',
101
101
  headers: requestHeaders,
102
102
  body: Buffer.from(JSON.stringify(requestBody))
103
- });
103
+ };
104
+
105
+ // Credential-bearing request — opt back into strict cert validation when
106
+ // the API URL is HTTPS. lib/fetch defaults to rejectUnauthorized:false
107
+ // for attachment hosts that may be self-signed; the Ethereal API has a
108
+ // real cert, so the lax default was a free attack surface.
109
+ if (/^https:/i.test(apiUrl)) {
110
+ fetchOptions.tls = { rejectUnauthorized: true };
111
+ }
112
+
113
+ const req = nmfetch(apiUrl + '/user', fetchOptions);
104
114
 
105
115
  req.on('readable', () => {
106
116
  let chunk;
@@ -211,6 +211,13 @@ class SMTPConnection extends EventEmitter {
211
211
  */
212
212
  this._closing = false;
213
213
 
214
+ /**
215
+ * Message DATA stream currently piped to the socket, if any. Tracked so
216
+ * close() can unpipe it before tearing the socket down.
217
+ * @private
218
+ */
219
+ this._currentDataStream = false;
220
+
214
221
  /**
215
222
  * Callbacks for socket's listeners
216
223
  */
@@ -470,6 +477,17 @@ class SMTPConnection extends EventEmitter {
470
477
 
471
478
  const socket = (this._socket && this._socket.socket) || this._socket;
472
479
 
480
+ // Detach any in-flight DATA stream from the socket so the source stream
481
+ // can be garbage-collected once the socket is gone.
482
+ if (this._currentDataStream) {
483
+ try {
484
+ this._currentDataStream.unpipe(this._socket);
485
+ } catch (_E) {
486
+ // ignore
487
+ }
488
+ this._currentDataStream = false;
489
+ }
490
+
473
491
  if (socket && !socket.destroyed) {
474
492
  try {
475
493
  // Clear socket timeout to prevent timer leaks
@@ -953,7 +971,9 @@ class SMTPConnection extends EventEmitter {
953
971
  */
954
972
  _onEnd() {
955
973
  if (this._socket && !this._socket.destroyed) {
956
- this._socket.destroy();
974
+ // Peer sent FIN — finish our half of the close gracefully rather
975
+ // than destroying. 'close' fires after the OS finalizes teardown.
976
+ this._socket.end();
957
977
  }
958
978
  }
959
979
 
@@ -1005,6 +1025,15 @@ class SMTPConnection extends EventEmitter {
1005
1025
  opts.servername = this.servername;
1006
1026
  }
1007
1027
 
1028
+ // Remove all listeners from the plain socket to allow proper garbage
1029
+ // collection. Used on both the TLS-success path and the synchronous
1030
+ // tls.connect() throw path; either way the plain socket is done.
1031
+ const removePlainSocketListeners = () => {
1032
+ socketPlain.removeListener('close', this._onSocketClose);
1033
+ socketPlain.removeListener('end', this._onSocketEnd);
1034
+ socketPlain.removeListener('error', this._onSocketError);
1035
+ };
1036
+
1008
1037
  this.upgrading = true;
1009
1038
  // tls.connect is not an asynchronous function however it may still throw errors and requires to be wrapped with try/catch
1010
1039
  try {
@@ -1013,14 +1042,12 @@ class SMTPConnection extends EventEmitter {
1013
1042
  this.upgrading = false;
1014
1043
  this._socket.on('data', this._onSocketData);
1015
1044
 
1016
- // Remove all listeners from the plain socket to allow proper garbage collection
1017
- socketPlain.removeListener('close', this._onSocketClose);
1018
- socketPlain.removeListener('end', this._onSocketEnd);
1019
- socketPlain.removeListener('error', this._onSocketError);
1045
+ removePlainSocketListeners();
1020
1046
 
1021
1047
  return callback(null, true);
1022
1048
  });
1023
1049
  } catch (err) {
1050
+ removePlainSocketListeners();
1024
1051
  return callback(err);
1025
1052
  }
1026
1053
 
@@ -1297,6 +1324,7 @@ class SMTPConnection extends EventEmitter {
1297
1324
  });
1298
1325
  }
1299
1326
 
1327
+ this._currentDataStream = dataStream;
1300
1328
  dataStream.pipe(this._socket, {
1301
1329
  end: false
1302
1330
  });
@@ -1318,6 +1346,9 @@ class SMTPConnection extends EventEmitter {
1318
1346
  }
1319
1347
 
1320
1348
  dataStream.once('end', () => {
1349
+ if (this._currentDataStream === dataStream) {
1350
+ this._currentDataStream = false;
1351
+ }
1321
1352
  this.logger.info(
1322
1353
  {
1323
1354
  tnx: 'message',
@@ -151,11 +151,20 @@ class SMTPTransport extends EventEmitter {
151
151
 
152
152
  const connection = new SMTPConnection(options);
153
153
 
154
+ let perCallAuth;
155
+ const cleanupPerCallAuth = () => {
156
+ if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) {
157
+ perCallAuth.oauth2.removeAllListeners();
158
+ }
159
+ perCallAuth = null;
160
+ };
161
+
154
162
  connection.once('error', err => {
155
163
  if (returned) {
156
164
  return;
157
165
  }
158
166
  returned = true;
167
+ cleanupPerCallAuth();
159
168
  connection.close();
160
169
  return callback(err);
161
170
  });
@@ -170,6 +179,7 @@ class SMTPTransport extends EventEmitter {
170
179
  return;
171
180
  }
172
181
  returned = true;
182
+ cleanupPerCallAuth();
173
183
  // still have not returned, this means we have an unexpected connection close
174
184
  const err = new Error('Unexpected socket close');
175
185
  if (connection && connection._socket && connection._socket.upgrading) {
@@ -216,6 +226,7 @@ class SMTPTransport extends EventEmitter {
216
226
 
217
227
  connection.send(envelope, mail.message.createReadStream(), (err, info) => {
218
228
  returned = true;
229
+ cleanupPerCallAuth();
219
230
  connection.close();
220
231
  if (err) {
221
232
  this.logger.error(
@@ -255,13 +266,11 @@ class SMTPTransport extends EventEmitter {
255
266
  return;
256
267
  }
257
268
 
258
- const auth = this.getAuth(mail.data.auth);
269
+ perCallAuth = this.getAuth(mail.data.auth);
259
270
 
260
- if (auth && (connection.allowsAuth || options.forceAuth)) {
261
- connection.login(auth, err => {
262
- if (auth && auth !== this.auth && auth.oauth2) {
263
- auth.oauth2.removeAllListeners();
264
- }
271
+ if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
272
+ connection.login(perCallAuth, err => {
273
+ cleanupPerCallAuth();
265
274
  if (returned) {
266
275
  return;
267
276
  }
@@ -323,12 +332,20 @@ class SMTPTransport extends EventEmitter {
323
332
 
324
333
  const connection = new SMTPConnection(options);
325
334
  let returned = false;
335
+ let perCallAuth;
336
+ const cleanupPerCallAuth = () => {
337
+ if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) {
338
+ perCallAuth.oauth2.removeAllListeners();
339
+ }
340
+ perCallAuth = null;
341
+ };
326
342
 
327
343
  connection.once('error', err => {
328
344
  if (returned) {
329
345
  return;
330
346
  }
331
347
  returned = true;
348
+ cleanupPerCallAuth();
332
349
  connection.close();
333
350
  return callback(err);
334
351
  });
@@ -338,6 +355,7 @@ class SMTPTransport extends EventEmitter {
338
355
  return;
339
356
  }
340
357
  returned = true;
358
+ cleanupPerCallAuth();
341
359
  return callback(new Error('Connection closed'));
342
360
  });
343
361
 
@@ -346,6 +364,7 @@ class SMTPTransport extends EventEmitter {
346
364
  return;
347
365
  }
348
366
  returned = true;
367
+ cleanupPerCallAuth();
349
368
  connection.quit();
350
369
  return callback(null, true);
351
370
  };
@@ -355,10 +374,11 @@ class SMTPTransport extends EventEmitter {
355
374
  return;
356
375
  }
357
376
 
358
- const authData = this.getAuth({});
377
+ perCallAuth = this.getAuth({});
359
378
 
360
- if (authData && (connection.allowsAuth || options.forceAuth)) {
361
- connection.login(authData, err => {
379
+ if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
380
+ connection.login(perCallAuth, err => {
381
+ cleanupPerCallAuth();
362
382
  if (returned) {
363
383
  return;
364
384
  }
@@ -371,11 +391,12 @@ class SMTPTransport extends EventEmitter {
371
391
 
372
392
  finalize();
373
393
  });
374
- } else if (!authData && connection.allowsAuth && options.forceAuth) {
394
+ } else if (!perCallAuth && connection.allowsAuth && options.forceAuth) {
375
395
  const err = new Error('Authentication info was not provided');
376
396
  err.code = errors.ENOAUTH;
377
397
 
378
398
  returned = true;
399
+ cleanupPerCallAuth();
379
400
  connection.close();
380
401
  return callback(err);
381
402
  } else {
@@ -33,6 +33,7 @@ const errors = require('../errors');
33
33
  * @param {Number} options.expires Optional Access Token expire time in ms
34
34
  * @param {Number} options.timeout Optional TTL for Access Token in seconds
35
35
  * @param {Function} options.provisionCallback Function to run when a new access token is required
36
+ * @param {Object} options.tls Optional TLS options forwarded to the HTTPS token request. Defaults to strict cert validation; supply { rejectUnauthorized: false } only for self-hosted OAuth providers on private CAs.
36
37
  */
37
38
  class XOAuth2 extends Stream {
38
39
  constructor(options, logger) {
@@ -370,12 +371,24 @@ class XOAuth2 extends Stream {
370
371
  const chunks = [];
371
372
  let chunklen = 0;
372
373
 
373
- const req = nmfetch(url, {
374
+ const fetchOptions = {
374
375
  method: 'post',
375
376
  headers: params.customHeaders,
376
377
  body: payload,
377
378
  allowErrorResponse: true
378
- });
379
+ };
380
+
381
+ // OAuth2 token endpoints are credential-bearing. lib/fetch defaults to
382
+ // rejectUnauthorized:false (intentional for self-signed attachment
383
+ // hosts), so opt back in to strict cert validation here when the
384
+ // access URL is HTTPS. params.tls (the user's options.tls) is layered
385
+ // on top so callers with a self-hosted provider on a private CA can
386
+ // still override.
387
+ if (/^https:/i.test(url)) {
388
+ fetchOptions.tls = Object.assign({ rejectUnauthorized: true }, params.tls || {});
389
+ }
390
+
391
+ const req = nmfetch(url, fetchOptions);
379
392
 
380
393
  req.on('readable', () => {
381
394
  let chunk;
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "8.0.7",
3
+ "version": "8.0.8",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "main": "lib/nodemailer.js",
6
6
  "scripts": {
7
- "test": "node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
8
- "test:coverage": "c8 node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
7
+ "test": "node --test --test-concurrency=1 $(find test \\( -name '*-test.js' -o -name '*.test.js' \\))",
8
+ "test:coverage": "c8 node --test --test-concurrency=1 $(find test \\( -name '*-test.js' -o -name '*.test.js' \\))",
9
9
  "format": "prettier --write \"**/*.{js,json,md}\"",
10
10
  "format:check": "prettier --check \"**/*.{js,json,md}\"",
11
11
  "lint": "eslint .",