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 +8 -0
- package/lib/nodemailer.js +12 -2
- package/lib/smtp-connection/index.js +36 -5
- package/lib/smtp-transport/index.js +31 -10
- package/lib/xoauth2/index.js +15 -2
- package/package.json +3 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
+
perCallAuth = this.getAuth(mail.data.auth);
|
|
259
270
|
|
|
260
|
-
if (
|
|
261
|
-
connection.login(
|
|
262
|
-
|
|
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
|
-
|
|
377
|
+
perCallAuth = this.getAuth({});
|
|
359
378
|
|
|
360
|
-
if (
|
|
361
|
-
connection.login(
|
|
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 (!
|
|
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 {
|
package/lib/xoauth2/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
8
|
-
"test:coverage": "c8 node --test --test-concurrency=1 test
|
|
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 .",
|