nodemailer 8.0.11 → 9.0.0

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,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [9.0.0](https://github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.0) (2026-06-14)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * HTTPS requests made while fetching remote content (attachment href/path URLs, OAuth2 token endpoints, HTTP/HTTPS proxy CONNECT) now validate the server's TLS certificate by default. Requests to hosts with self-signed, expired, or hostname-mismatched certificates that previously succeeded will now fail. Opt back out per request with tls.rejectUnauthorized=false (transport options, or a per-attachment `tls` option).
9
+
10
+ ### Bug Fixes
11
+
12
+ * replace deprecated url.parse with a WHATWG URL wrapper ([0c080fb](https://github.com/nodemailer/nodemailer/commit/0c080fbf3278926f013a5c2ad06f5f6f0e18f5ed))
13
+ * validate TLS certificates by default when fetching remote content ([6a947ac](https://github.com/nodemailer/nodemailer/commit/6a947ac7114a16da1e6a50d9a6f4e17026ce145d))
14
+
3
15
  ## [8.0.11](https://github.com/nodemailer/nodemailer/compare/v8.0.10...v8.0.11) (2026-06-10)
4
16
 
5
17
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  // module to handle cookies
4
4
 
5
- const urllib = require('url');
5
+ const urllib = require('../shared/url');
6
6
 
7
7
  const SESSION_TIMEOUT = 1800; // 30 min
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  const http = require('http');
4
4
  const https = require('https');
5
- const urllib = require('url');
5
+ const urllib = require('../shared/url');
6
6
  const zlib = require('zlib');
7
7
  const { PassThrough } = require('stream');
8
8
  const Cookies = require('./cookies');
@@ -123,7 +123,10 @@ function nmfetch(url, options) {
123
123
  path: parsed.path,
124
124
  port: parsed.port ? parsed.port : parsed.protocol === 'https:' ? 443 : 80,
125
125
  headers,
126
- rejectUnauthorized: false,
126
+ // Validate TLS certificates by default. Callers that genuinely need to
127
+ // reach a self-signed/internal host opt out explicitly with
128
+ // options.tls = { rejectUnauthorized: false }.
129
+ rejectUnauthorized: true,
127
130
  agent: false
128
131
  };
129
132
 
@@ -212,7 +215,27 @@ function nmfetch(url, options) {
212
215
  // redirect does not include POST body
213
216
  options.method = 'GET';
214
217
  options.body = false;
215
- return nmfetch(urllib.resolve(url, res.headers.location), options);
218
+
219
+ const redirectUrl = urllib.resolve(url, res.headers.location);
220
+ const redirectParsed = urllib.parse(redirectUrl);
221
+
222
+ // Do not forward credentials when the redirect leaves the original
223
+ // security context: a different host, or a downgrade from https to
224
+ // http (which would otherwise put them on the wire in cleartext).
225
+ // Strip sensitive request headers so an attacker who controls the
226
+ // redirect target cannot harvest them.
227
+ const crossHost = redirectParsed.hostname !== parsed.hostname;
228
+ const downgrade = parsed.protocol === 'https:' && redirectParsed.protocol === 'http:';
229
+ if (options.headers && (crossHost || downgrade)) {
230
+ const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
231
+ Object.keys(options.headers).forEach(key => {
232
+ if (sensitive.includes(key.toLowerCase())) {
233
+ delete options.headers[key];
234
+ }
235
+ });
236
+ }
237
+
238
+ return nmfetch(redirectUrl, options);
216
239
  }
217
240
 
218
241
  fetchRes.statusCode = res.statusCode;
@@ -142,7 +142,8 @@ class MailComposer {
142
142
  } else if (attachment.href) {
143
143
  data.content = {
144
144
  href: attachment.href,
145
- httpHeaders: attachment.httpHeaders
145
+ httpHeaders: attachment.httpHeaders,
146
+ tls: attachment.tls
146
147
  };
147
148
  } else {
148
149
  data.content = attachment.content || '';
@@ -8,7 +8,7 @@ const DKIM = require('../dkim');
8
8
  const httpProxyClient = require('../smtp-connection/http-proxy-client');
9
9
  const errors = require('../errors');
10
10
  const util = require('util');
11
- const urllib = require('url');
11
+ const urllib = require('../shared/url');
12
12
  const packageData = require('../../package.json');
13
13
  const MailMessage = require('./mail-message');
14
14
  const net = require('net');
@@ -324,7 +324,7 @@ class Mail extends EventEmitter {
324
324
  // Connect using a HTTP CONNECT method
325
325
  case 'http':
326
326
  case 'https':
327
- httpProxyClient(proxy.href, options.port, options.host, (err, socket) => {
327
+ httpProxyClient(proxy.href, options.port, options.host, this.options.tls || {}, (err, socket) => {
328
328
  if (err) {
329
329
  return callback(err);
330
330
  }
@@ -1006,7 +1006,7 @@ class MimeNode {
1006
1006
  return contentStream;
1007
1007
  }
1008
1008
  // fetch URL
1009
- return nmfetch(content.href, { headers: content.httpHeaders });
1009
+ return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
1010
1010
  }
1011
1011
 
1012
1012
  // pass string or buffer content as a stream
package/lib/nodemailer.js CHANGED
@@ -102,10 +102,10 @@ module.exports.createTestAccount = function (apiUrl, callback) {
102
102
  body: Buffer.from(JSON.stringify(requestBody))
103
103
  };
104
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.
105
+ // Credential-bearing request to the Ethereal API. lib/fetch already
106
+ // validates certs by default; pin rejectUnauthorized:true here so this
107
+ // call stays strict regardless of any future default change and is never
108
+ // relaxed for a real-cert endpoint.
109
109
  if (/^https:/i.test(apiUrl)) {
110
110
  fetchOptions.tls = { rejectUnauthorized: true };
111
111
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  'use strict';
4
4
 
5
- const urllib = require('url');
5
+ const urllib = require('./url');
6
6
  const util = require('util');
7
7
  const fs = require('fs');
8
8
  const nmfetch = require('../fetch');
@@ -569,7 +569,7 @@ function resolveContentValue(data, key, options, callback) {
569
569
  callback(err);
570
570
  });
571
571
  }
572
- return resolveStream(nmfetch(content.path || content.href), callback);
572
+ return resolveStream(nmfetch(content.path || content.href, { headers: content.httpHeaders, tls: content.tls }), callback);
573
573
  } else if (/^data:/i.test(content.path || content.href)) {
574
574
  const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
575
575
 
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ // URL parsing wrapper. Prefers the WHATWG `URL` (a global on Node 10+, and
4
+ // available as `require('url').URL` since Node 6.13+) and only falls back to the
5
+ // legacy, deprecation-warning-emitting `url.parse()` / `url.resolve()` on ancient
6
+ // Node versions that predate the WHATWG implementation.
7
+ //
8
+ // The WHATWG `URL` exposes a different shape than the legacy parser, so results
9
+ // are normalized back into the legacy field names the rest of the codebase reads
10
+ // (`protocol`, `hostname`, `port`, `pathname`, `path`, `search`, `auth`, `query`,
11
+ // `href`). This keeps every existing call site unchanged.
12
+ //
13
+ // Known, accepted divergences from the legacy parser:
14
+ // - non-special schemes (smtp:/smtps:/direct:) are not host-lowercased by
15
+ // WHATWG; cosmetic only, SMTP/DNS hosts are case-insensitive. (IDNA mapping
16
+ // and IPv6 brackets are normalized back by normalizeHostname below.)
17
+ // - a literal unescaped ':' inside a password is percent-encoded by WHATWG;
18
+ // such passwords should be percent-encoded by the caller anyway.
19
+
20
+ const urllib = require('url');
21
+ const punycode = require('../punycode');
22
+
23
+ // WHATWG URL constructor if available, otherwise undefined (Node < 6.13).
24
+ const URLImpl = (typeof URL !== 'undefined' && URL) || urllib.URL;
25
+
26
+ // Matches a "scheme:" not followed by "//" (and with something after it), used
27
+ // to re-insert the authority separator the legacy parser did not require.
28
+ const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)(.+)$/;
29
+
30
+ // decodeURIComponent that never throws. Legacy url.parse() decodes the auth
31
+ // component but tolerates malformed percent sequences, so mirror that.
32
+ function safeDecode(str) {
33
+ try {
34
+ return decodeURIComponent(str);
35
+ } catch (_err) {
36
+ return str;
37
+ }
38
+ }
39
+
40
+ // Derives the legacy-shaped bare hostname from a WHATWG URL. WHATWG keeps IPv6
41
+ // literals bracketed ('[::1]') and, for non-special schemes (smtp:/smtps:/socks:),
42
+ // percent-encodes a non-ASCII host instead of IDNA-mapping it. Both forms are
43
+ // un-resolvable when handed to net/dns/http.request — which is what every call
44
+ // site does — so map them back to what legacy url.parse() returned: the bare
45
+ // address and the punycode form. Idempotent on plain ASCII and already-punycode
46
+ // hosts, so special-scheme hosts (already IDNA-mapped by WHATWG) pass through.
47
+ function normalizeHostname(raw) {
48
+ let hostname = raw || '';
49
+ if (!hostname) {
50
+ // Host-less URL (e.g. 'direct:'): legacy returned '' here, not null;
51
+ // consumers do `hostname.length` / `'.' + hostname`, so keep it a string.
52
+ return '';
53
+ }
54
+ if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') {
55
+ return hostname.slice(1, -1);
56
+ }
57
+ return punycode.toASCII(safeDecode(hostname));
58
+ }
59
+
60
+ module.exports.parse = (input, parseQueryString) => {
61
+ input = input || '';
62
+
63
+ if (!URLImpl) {
64
+ // Node < 6.13: no WHATWG URL available, use the legacy parser.
65
+ return urllib.parse(input, parseQueryString);
66
+ }
67
+
68
+ // Legacy url.parse() parses a "user:pass@host:port" authority that follows
69
+ // the scheme even without the "//" separator, for schemes outside its
70
+ // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG
71
+ // parser instead treats a scheme not followed by "//" as an opaque path.
72
+ // Re-insert the "//" so slash-less connection/proxy URLs keep resolving to
73
+ // an authority, as they did before. This assumes a slash-authority scheme,
74
+ // which every consumer here uses (http/https/smtp/smtps/socks/direct); an
75
+ // opaque scheme like mailto:/data:/tel: would be mis-split, but none reach
76
+ // this module.
77
+ const slashless = SLASHLESS_AUTHORITY.exec(input);
78
+ const normalized = slashless ? slashless[1] + '//' + slashless[2] : input;
79
+
80
+ let u;
81
+ try {
82
+ u = new URLImpl(normalized);
83
+ } catch (_err) {
84
+ // WHATWG rejects some input the legacy parser tolerated (empty/relative
85
+ // strings, scheme-relative '//host/path', out-of-range ports, ...). Fall
86
+ // back to the legacy parser so behavior — including the downstream errors
87
+ // callers rely on — is preserved. This is the only path that can still
88
+ // emit a deprecation warning; it fires for anything WHATWG cannot
89
+ // represent, including legitimate relative URLs, not just malformed input.
90
+ return urllib.parse(input, parseQueryString);
91
+ }
92
+
93
+ const hostname = normalizeHostname(u.hostname);
94
+ const port = u.port || null;
95
+ const pathname = u.pathname || null;
96
+ const search = u.search || null;
97
+
98
+ // Legacy `.auth` is the decoded "user[:pass]" string; WHATWG keeps the
99
+ // username/password percent-encoded, so decode to stay byte-compatible with
100
+ // existing consumers (parseConnectionUrl, Basic/Proxy-Authorization headers).
101
+ let auth = null;
102
+ if (u.username || u.password) {
103
+ // Gate on password too: legacy url.parse('smtps://:pass@host').auth was
104
+ // ':pass'. Dropping it would silently connect unauthenticated.
105
+ auth = safeDecode(u.username) + (u.password ? ':' + safeDecode(u.password) : '');
106
+ }
107
+
108
+ let query;
109
+ if (parseQueryString) {
110
+ // Mirror querystring.parse(): null-prototype object, repeated keys → array.
111
+ query = Object.create(null);
112
+ u.searchParams.forEach((value, key) => {
113
+ if (Object.prototype.hasOwnProperty.call(query, key)) {
114
+ if (Array.isArray(query[key])) {
115
+ query[key].push(value);
116
+ } else {
117
+ query[key] = [query[key], value];
118
+ }
119
+ } else {
120
+ query[key] = value;
121
+ }
122
+ });
123
+ } else {
124
+ query = search ? search.slice(1) : null;
125
+ }
126
+
127
+ return {
128
+ protocol: u.protocol || null,
129
+ host: u.host || null,
130
+ hostname,
131
+ port,
132
+ pathname,
133
+ search,
134
+ path: (pathname || '') + (search || '') || null,
135
+ href: u.href,
136
+ auth,
137
+ query
138
+ };
139
+ };
140
+
141
+ module.exports.resolve = (from, to) => {
142
+ if (!URLImpl) {
143
+ return urllib.resolve(from, to);
144
+ }
145
+ try {
146
+ return new URLImpl(to, from).href;
147
+ } catch (_err) {
148
+ // Malformed target — fall back to the legacy resolver.
149
+ return urllib.resolve(from, to);
150
+ }
151
+ };
@@ -6,7 +6,7 @@
6
6
 
7
7
  const net = require('net');
8
8
  const tls = require('tls');
9
- const urllib = require('url');
9
+ const urllib = require('../shared/url');
10
10
  const errors = require('../errors');
11
11
 
12
12
  /**
@@ -19,20 +19,29 @@ const errors = require('../errors');
19
19
  * @param {String} proxyUrl proxy configuration, etg "http://proxy.host:3128/"
20
20
  * @param {Number} destinationPort Port to open in destination host
21
21
  * @param {String} destinationHost Destination hostname
22
+ * @param {Object} [tlsOptions] Optional TLS options for an HTTPS proxy (e.g. { rejectUnauthorized: false })
22
23
  * @param {Function} callback Callback to run with the rocket object once connection is established
23
24
  */
24
- function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
25
+ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) {
26
+ if (typeof tlsOptions === 'function') {
27
+ callback = tlsOptions;
28
+ tlsOptions = {};
29
+ }
30
+ tlsOptions = tlsOptions || {};
31
+
25
32
  const proxy = urllib.parse(proxyUrl);
26
33
 
27
- const options = {
34
+ const connectOptions = {
28
35
  host: proxy.hostname,
29
36
  port: Number(proxy.port) ? Number(proxy.port) : proxy.protocol === 'https:' ? 443 : 80
30
37
  };
31
38
 
32
39
  let connect;
33
40
  if (proxy.protocol === 'https:') {
34
- // we can use untrusted proxies as long as we verify actual SMTP certificates
35
- options.rejectUnauthorized = false;
41
+ // Validate the proxy's TLS certificate by default. A caller that uses a
42
+ // self-signed proxy (e.g. integration tests) opts out explicitly with
43
+ // tls.rejectUnauthorized === false.
44
+ connectOptions.rejectUnauthorized = tlsOptions.rejectUnauthorized !== false;
36
45
  connect = tls.connect.bind(tls);
37
46
  } else {
38
47
  connect = net.connect.bind(net);
@@ -62,7 +71,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
62
71
  tempSocketErr(err);
63
72
  };
64
73
 
65
- socket = connect(options, () => {
74
+ socket = connect(connectOptions, () => {
66
75
  if (finished) {
67
76
  return;
68
77
  }
@@ -378,12 +378,11 @@ class XOAuth2 extends Stream {
378
378
  allowErrorResponse: true
379
379
  };
380
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.
381
+ // OAuth2 token endpoints are credential-bearing. lib/fetch already
382
+ // validates certs by default; pin rejectUnauthorized:true here so the
383
+ // token fetch stays strict, while still layering params.tls (the
384
+ // user's options.tls) on top so callers with a self-hosted provider on
385
+ // a private CA can override.
387
386
  if (/^https:/i.test(url)) {
388
387
  fetchOptions.tls = Object.assign({ rejectUnauthorized: true }, params.tls || {});
389
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "8.0.11",
3
+ "version": "9.0.0",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "main": "lib/nodemailer.js",
6
6
  "scripts": {