nodemailer 9.0.1 → 9.0.3

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,23 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [9.0.3](https://github.com/nodemailer/nodemailer/compare/v9.0.2...v9.0.3) (2026-06-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **smtp-connection:** harden STARTTLS upgrade and secure socket handling ([#1835](https://github.com/nodemailer/nodemailer/issues/1835)) ([07d8253](https://github.com/nodemailer/nodemailer/commit/07d8253326ecefff9f7d92c157429ce8bc7335f8))
9
+
10
+ ## [9.0.2](https://github.com/nodemailer/nodemailer/compare/v9.0.1...v9.0.2) (2026-06-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **addressparser:** keep operator chars inside an address-literal as text ([#1829](https://github.com/nodemailer/nodemailer/issues/1829)) ([9ba1064](https://github.com/nodemailer/nodemailer/commit/9ba1064f3b115cd60cb63aa954a3c0961e86fbb7))
16
+ * harden smtp-connection low-severity issues ([22ddcea](https://github.com/nodemailer/nodemailer/commit/22ddcea8ed043e4ea23b8971b75a2df196b5a581))
17
+ * harden smtp-connection response parsing and socket lifecycle ([68860b9](https://github.com/nodemailer/nodemailer/commit/68860b94311b5b6837754e5e790f186ca8a00b70))
18
+ * prevent SES transport callback double-invocation and hang on sync errors ([#1831](https://github.com/nodemailer/nodemailer/issues/1831)) ([9517bc5](https://github.com/nodemailer/nodemailer/commit/9517bc5dc94e77907bb157e3466bf73a2b327f5c))
19
+ * reject CRLF in HTTP proxy CONNECT destination to prevent request injection ([6347b47](https://github.com/nodemailer/nodemailer/commit/6347b47c7d12f9d3acf53d391b921e836f400640))
20
+
3
21
  ## [9.0.1](https://github.com/nodemailer/nodemailer/compare/v9.0.0...v9.0.1) (2026-06-17)
4
22
 
5
23
 
@@ -181,6 +181,7 @@ class Tokenizer {
181
181
  this.operatorExpecting = '';
182
182
  this.node = null;
183
183
  this.escaped = false;
184
+ this.inDomainLiteral = false;
184
185
 
185
186
  this.list = [];
186
187
  /**
@@ -232,6 +233,21 @@ class Tokenizer {
232
233
  * @param {String} chr Character from the address field
233
234
  */
234
235
  checkChar(chr, nextChr) {
236
+ // Track RFC 5322 domain-literals ("[" *dtext "]"). Operator characters such
237
+ // as the ":" of an IPv6 address-literal (user@[IPv6:2001:db8::1]) are dtext
238
+ // and must not be treated as the group delimiter while inside the brackets.
239
+ // Quoted strings and comments are handled separately via operatorExpecting,
240
+ // so only enter this state when no operator is open. The list separators ","
241
+ // and ";" are the exception: they always end the literal (and split the
242
+ // address list) so that an unclosed "[" cannot swallow later recipients.
243
+ if (!this.escaped && !this.operatorExpecting) {
244
+ if (!this.inDomainLiteral && chr === '[') {
245
+ this.inDomainLiteral = true;
246
+ } else if (this.inDomainLiteral && (chr === ']' || chr === ',' || chr === ';')) {
247
+ this.inDomainLiteral = false;
248
+ }
249
+ }
250
+
235
251
  if (this.escaped) {
236
252
  // ignore next condition blocks
237
253
  } else if (chr === this.operatorExpecting) {
@@ -250,7 +266,7 @@ class Tokenizer {
250
266
  this.escaped = false;
251
267
 
252
268
  return;
253
- } else if (!this.operatorExpecting && chr in this.operators) {
269
+ } else if (!this.operatorExpecting && !this.inDomainLiteral && chr in this.operators) {
254
270
  this.node = {
255
271
  type: 'operator',
256
272
  value: chr
@@ -11,7 +11,7 @@ class RelaxedBody extends Transform {
11
11
  options = options || {};
12
12
  this.chunkBuffer = [];
13
13
  this.chunkBufferLen = 0;
14
- this.bodyHash = crypto.createHash(options.hashAlgo || 'sha1');
14
+ this.bodyHash = crypto.createHash(options.hashAlgo || 'sha256');
15
15
  this.remainder = '';
16
16
  this.byteLength = 0;
17
17
 
@@ -43,11 +43,12 @@ class SESTransport extends EventEmitter {
43
43
 
44
44
  getRegion(cb) {
45
45
  if (this.ses.sesClient.config && typeof this.ses.sesClient.config.region === 'function') {
46
- // promise
47
- return this.ses.sesClient.config
48
- .region()
49
- .then(region => cb(null, region))
50
- .catch(err => cb(err));
46
+ // Resolve the region provider. Use the two-argument form of then() so that a
47
+ // synchronous throw from cb is not recaught here and used to invoke cb a second time.
48
+ return this.ses.sesClient.config.region().then(
49
+ region => cb(null, region),
50
+ err => cb(err)
51
+ );
51
52
  }
52
53
  return cb(null, false);
53
54
  }
@@ -150,8 +151,27 @@ class SESTransport extends EventEmitter {
150
151
  region = 'us-east-1';
151
152
  }
152
153
 
153
- const command = new this.ses.SendEmailCommand(sesMessage);
154
- const sendPromise = this.ses.sesClient.send(command);
154
+ let sendPromise;
155
+ try {
156
+ // command construction or dispatch can throw synchronously on a
157
+ // misconfigured SDK; surface it as a single error callback instead
158
+ // of letting it escape into getRegion's promise chain
159
+ const command = new this.ses.SendEmailCommand(sesMessage);
160
+ sendPromise = this.ses.sesClient.send(command);
161
+ } catch (err) {
162
+ tagSesError(err);
163
+ this.logger.error(
164
+ {
165
+ err,
166
+ tnx: 'send'
167
+ },
168
+ 'Send error for %s: %s',
169
+ messageId,
170
+ err.message
171
+ );
172
+ setImmediate(() => callback(err));
173
+ return;
174
+ }
155
175
 
156
176
  sendPromise
157
177
  .then(data => {
@@ -159,7 +179,7 @@ class SESTransport extends EventEmitter {
159
179
  region = 'email';
160
180
  }
161
181
 
162
- callback(null, {
182
+ const info = {
163
183
  envelope: {
164
184
  from: envelope.from,
165
185
  to: envelope.to
@@ -167,7 +187,11 @@ class SESTransport extends EventEmitter {
167
187
  messageId: '<' + data.MessageId + (!/@/.test(data.MessageId) ? '@' + region + '.amazonses.com' : '') + '>',
168
188
  response: data.MessageId,
169
189
  raw
170
- });
190
+ };
191
+
192
+ // invoke the callback outside the promise chain so a throw from it
193
+ // is not recaught by .catch() and used to call it a second time
194
+ setImmediate(() => callback(null, info));
171
195
  })
172
196
  .catch(err => {
173
197
  tagSesError(err);
@@ -180,7 +204,7 @@ class SESTransport extends EventEmitter {
180
204
  messageId,
181
205
  err.message
182
206
  );
183
- callback(err);
207
+ setImmediate(() => callback(err));
184
208
  });
185
209
  });
186
210
  })
@@ -222,10 +246,16 @@ class SESTransport extends EventEmitter {
222
246
  // the region value is not used for anything when verifying, but the lookup
223
247
  // exercises the client configuration the same way as send() does
224
248
  this.getRegion(() => {
225
- const command = new this.ses.SendEmailCommand(sesMessage);
226
- const sendPromise = this.ses.sesClient.send(command);
249
+ let sendPromise;
250
+ try {
251
+ const command = new this.ses.SendEmailCommand(sesMessage);
252
+ sendPromise = this.ses.sesClient.send(command);
253
+ } catch (err) {
254
+ setImmediate(() => cb(err));
255
+ return;
256
+ }
227
257
 
228
- sendPromise.then(() => cb(null)).catch(err => cb(err));
258
+ sendPromise.then(() => setImmediate(() => cb(null))).catch(err => setImmediate(() => cb(err)));
229
259
  });
230
260
 
231
261
  return promise;
@@ -9,6 +9,10 @@ const tls = require('tls');
9
9
  const urllib = require('../shared/url');
10
10
  const errors = require('../errors');
11
11
 
12
+ // Cap the CONNECT response we buffer before the header terminator, so a proxy that
13
+ // never sends \r\n\r\n cannot grow memory unboundedly before the socket times out.
14
+ const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
15
+
12
16
  /**
13
17
  * Establishes proxied connection to destinationPort
14
18
  *
@@ -29,6 +33,16 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
29
33
  }
30
34
  tlsOptions = tlsOptions || {};
31
35
 
36
+ // Reject CRLF in the destination before it reaches the CONNECT request line
37
+ // and Host header. A tainted host/port could otherwise inject additional
38
+ // request headers into the proxy connection (HTTP request splitting).
39
+ destinationPort = Number(destinationPort) || 0;
40
+ if (!destinationPort || /[\r\n]/.test(destinationHost)) {
41
+ const err = new Error('Invalid proxy destination');
42
+ err.code = errors.EPROXY;
43
+ return setImmediate(() => callback(err));
44
+ }
45
+
32
46
  const proxy = urllib.parse(proxyUrl);
33
47
 
34
48
  const connectOptions = {
@@ -140,6 +154,13 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
140
154
 
141
155
  return callback(null, socket);
142
156
  }
157
+
158
+ if (headers.length > MAX_RESPONSE_HEADER_BYTES) {
159
+ socket.removeListener('data', onSocketData);
160
+ const err = new Error('Proxy response headers too large');
161
+ err.code = errors.EPROXY;
162
+ return tempSocketErr(err);
163
+ }
143
164
  };
144
165
  socket.on('data', onSocketData);
145
166
  });
@@ -298,6 +298,20 @@ class SMTPConnection extends EventEmitter {
298
298
  try {
299
299
  this._socket.connect(this.port, this.host, () => {
300
300
  this._socket.setKeepAlive(true);
301
+
302
+ // a `secure` connection over a caller-provided socket must still
303
+ // perform the TLS handshake, otherwise AUTH and the message body
304
+ // would be sent in cleartext despite the caller requesting TLS
305
+ if (this.secureConnection && !this.alreadySecured) {
306
+ return this._upgradeConnection(err => {
307
+ if (err) {
308
+ this._onError(new Error('Error initiating TLS - ' + (err.message || err)), 'ETLS', false, 'CONN');
309
+ return;
310
+ }
311
+ this._onConnect();
312
+ });
313
+ }
314
+
301
315
  this._onConnect();
302
316
  });
303
317
  this._setupConnectionHandlers();
@@ -365,6 +379,14 @@ class SMTPConnection extends EventEmitter {
365
379
  * @param {Boolean} secure Whether to use TLS
366
380
  */
367
381
  _connectToHost(opts, secure) {
382
+ // If the client was closed while DNS resolution was in flight, do not open
383
+ // a socket here: close() ran with this._socket still unset and so had
384
+ // nothing to tear down, and _onConnect's remedial close() is a no-op once
385
+ // _closing is set — the freshly connected socket would leak.
386
+ if (this._destroyed || this._closing) {
387
+ return;
388
+ }
389
+
368
390
  this._connectionAttemptId++;
369
391
  const currentAttemptId = this._connectionAttemptId;
370
392
 
@@ -431,6 +453,9 @@ class SMTPConnection extends EventEmitter {
431
453
  if (this._socket) {
432
454
  try {
433
455
  this._socket.removeListener('error', this._onConnectionSocketError);
456
+ // Absorb any late teardown error (e.g. a TLS fallback socket emitting
457
+ // after destroy), mirroring the guard used in close()
458
+ this._socket.on('error', TEARDOWN_NOOP);
434
459
  this._socket.destroy();
435
460
  } catch (_E) {
436
461
  // ignore
@@ -757,6 +782,11 @@ class SMTPConnection extends EventEmitter {
757
782
  * @param {Function} callback Callback to return once connection is reset
758
783
  */
759
784
  reset(callback) {
785
+ const isDestroyedMessage = this._isDestroyedMessage('reset');
786
+ if (isDestroyedMessage) {
787
+ return callback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'API'));
788
+ }
789
+
760
790
  this._sendCommand('RSET');
761
791
  this._responseActions.push(str => {
762
792
  if (str.charAt(0) !== '2') {
@@ -805,6 +835,9 @@ class SMTPConnection extends EventEmitter {
805
835
  this._socket.removeListener('end', this._onSocketEnd);
806
836
  // Switch from connection-phase error handler to normal error handler
807
837
  this._socket.removeListener('error', this._onConnectionSocketError);
838
+ // _upgradeConnection (options.connection + secure) may already have attached
839
+ // the normal handler; remove it first so we never end up with a duplicate
840
+ this._socket.removeListener('error', this._onSocketError);
808
841
 
809
842
  this._socket.on('error', this._onSocketError);
810
843
  this._socket.on('data', this._onSocketData);
@@ -994,6 +1027,8 @@ class SMTPConnection extends EventEmitter {
994
1027
  return;
995
1028
  }
996
1029
  this._destroyed = true;
1030
+ // keep the documented public flag in sync with the private state
1031
+ this.destroyed = true;
997
1032
  this.emit('end');
998
1033
  }
999
1034
 
@@ -1004,6 +1039,15 @@ class SMTPConnection extends EventEmitter {
1004
1039
  * has been secured
1005
1040
  */
1006
1041
  _upgradeConnection(callback) {
1042
+ // RFC 3207 section 6: the client MUST discard any knowledge obtained from
1043
+ // the server that was not received over the TLS-protected session. Drop any
1044
+ // buffered input received before the handshake so a man-in-the-middle cannot
1045
+ // inject plaintext bytes after the "220" reply (e.g. a CRLF-free fragment that
1046
+ // would otherwise be prepended to the first post-TLS response and parsed as
1047
+ // part of the secured EHLO capabilities). STARTTLS response injection.
1048
+ this._remainder = '';
1049
+ this._responseQueue = [];
1050
+
1007
1051
  // do not remove all listeners or it breaks node v0.10 as there's
1008
1052
  // apparently a 'finish' event set that would be cleared as well
1009
1053
 
@@ -1032,6 +1076,9 @@ class SMTPConnection extends EventEmitter {
1032
1076
  socketPlain.removeListener('close', this._onSocketClose);
1033
1077
  socketPlain.removeListener('end', this._onSocketEnd);
1034
1078
  socketPlain.removeListener('error', this._onSocketError);
1079
+ // the connection-phase handler is attached when upgrading a pre-opened
1080
+ // options.connection socket; strip it so nothing lingers on the plain socket
1081
+ socketPlain.removeListener('error', this._onConnectionSocketError);
1035
1082
  };
1036
1083
 
1037
1084
  this.upgrading = true;
@@ -1064,18 +1111,27 @@ class SMTPConnection extends EventEmitter {
1064
1111
 
1065
1112
  /**
1066
1113
  * Processes queued responses from the server
1067
- *
1068
- * @param {Boolean} force If true, ignores _processing flag
1069
1114
  */
1070
1115
  _processResponse() {
1071
1116
  if (!this._responseQueue.length) {
1072
1117
  return false;
1073
1118
  }
1074
1119
 
1075
- let str = (this.lastServerResponse = decodeServerResponse((this._responseQueue.shift() || '').toString()));
1120
+ const raw = (this._responseQueue.shift() || '').toString();
1121
+
1122
+ // Skip unexpected empty lines without consuming a response action or
1123
+ // overwriting lastServerResponse; reprocess whatever else is queued.
1124
+ if (!raw.trim()) {
1125
+ setImmediate(() => this._processResponse());
1126
+ return;
1127
+ }
1128
+
1129
+ let str = (this.lastServerResponse = decodeServerResponse(raw));
1076
1130
 
1077
1131
  if (/^\d+-/.test(str.split('\n').pop())) {
1078
- // keep waiting for the final part of multiline response
1132
+ // last line is still a continuation: put the partial response back on the
1133
+ // queue and wait for the rest rather than dropping it
1134
+ this._responseQueue.unshift(raw);
1079
1135
  return;
1080
1136
  }
1081
1137
 
@@ -1088,11 +1144,6 @@ class SMTPConnection extends EventEmitter {
1088
1144
  );
1089
1145
  }
1090
1146
 
1091
- if (!str.trim()) {
1092
- // skip unexpected empty lines
1093
- setImmediate(() => this._processResponse());
1094
- }
1095
-
1096
1147
  const action = this._responseActions.shift();
1097
1148
 
1098
1149
  if (typeof action === 'function') {
@@ -1189,6 +1240,23 @@ class SMTPConnection extends EventEmitter {
1189
1240
  }
1190
1241
  }
1191
1242
 
1243
+ // RFC 8689: validate REQUIRETLS eligibility before queuing the MAIL FROM
1244
+ // response action, so a rejection here cannot leave an orphaned action in
1245
+ // _responseActions (which would consume the next reply and desync a reused
1246
+ // connection).
1247
+ if (this._envelope.requireTLSExtensionEnabled) {
1248
+ if (!this.secure) {
1249
+ return callback(
1250
+ this._formatError('REQUIRETLS can only be used over TLS connections (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
1251
+ );
1252
+ }
1253
+ if (!this._supportedExtensions.includes('REQUIRETLS')) {
1254
+ return callback(
1255
+ this._formatError('Server does not support REQUIRETLS extension (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
1256
+ );
1257
+ }
1258
+ }
1259
+
1192
1260
  this._responseActions.push(str => {
1193
1261
  this._actionMAIL(str, callback);
1194
1262
  });
@@ -1225,20 +1293,10 @@ class SMTPConnection extends EventEmitter {
1225
1293
  }
1226
1294
  }
1227
1295
 
1228
- // RFC 8689: If the envelope requests REQUIRETLS extension
1229
- // then append REQUIRETLS keyword to the MAIL FROM command
1230
- // Note: REQUIRETLS can only be used over TLS connections and requires server support
1296
+ // RFC 8689: append the REQUIRETLS keyword to MAIL FROM. Eligibility
1297
+ // (TLS connection + server support) was already validated above, before
1298
+ // the response action was queued.
1231
1299
  if (this._envelope.requireTLSExtensionEnabled) {
1232
- if (!this.secure) {
1233
- return callback(
1234
- this._formatError('REQUIRETLS can only be used over TLS connections (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
1235
- );
1236
- }
1237
- if (!this._supportedExtensions.includes('REQUIRETLS')) {
1238
- return callback(
1239
- this._formatError('Server does not support REQUIRETLS extension (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
1240
- );
1241
- }
1242
1300
  args.push('REQUIRETLS');
1243
1301
  }
1244
1302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "9.0.1",
3
+ "version": "9.0.3",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "main": "lib/nodemailer.js",
6
6
  "scripts": {