nodemailer 8.0.10 → 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,30 @@
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
+
15
+ ## [8.0.11](https://github.com/nodemailer/nodemailer/compare/v8.0.10...v8.0.11) (2026-06-10)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * apply the transport-level newline option in stream and sendmail transports ([cb4f904](https://github.com/nodemailer/nodemailer/commit/cb4f904a53d2c2feeaf327203c92378d46304398))
21
+ * include icalEvent path/href content in the application/ics attachment ([b801c48](https://github.com/nodemailer/nodemailer/commit/b801c48fab8e9b71bc7e0ea1fb32ce6b34675b15))
22
+ * parse Ethereal response props without polynomial regex backtracking ([067aebe](https://github.com/nodemailer/nodemailer/commit/067aebec83b8cbe7682905e89b30ab19d260b503))
23
+ * resolve oauth2_provision_cb at send time for non-pooled SMTP transports ([203c8ec](https://github.com/nodemailer/nodemailer/commit/203c8ecf97594ac2e69919b0f3ba966c0f86750e))
24
+ * return the promise from every resolveContent branch ([07ffe8c](https://github.com/nodemailer/nodemailer/commit/07ffe8cfd97f0486b8c7b541f398922ddab47882))
25
+ * strip the url scheme from List-ID header values ([77e5885](https://github.com/nodemailer/nodemailer/commit/77e5885cfa0c6723ea7749c1ee74b1c11aeb78bd))
26
+ * tag AWS SES transport errors with the ESES code ([efa647a](https://github.com/nodemailer/nodemailer/commit/efa647a125dd698413a7cf6813b8e36881a06f91))
27
+
3
28
  ## [8.0.10](https://github.com/nodemailer/nodemailer/compare/v8.0.9...v8.0.10) (2026-05-29)
4
29
 
5
30
 
package/CLAUDE.md CHANGED
@@ -50,6 +50,7 @@ Conventional Commit prefixes used in this repo: `fix:`, `feat:`, `chore:`, `docs
50
50
  ## Security
51
51
 
52
52
  This is a widely-deployed library — security-sensitive changes get extra scrutiny:
53
+
53
54
  - SMTP command injection: any user-controllable value that flows into a written SMTP command (envelope addresses, sizes, the `name`/EHLO option, headers) must be CRLF-stripped or rejected at the boundary. Sanitize at the assignment, not at every call site.
54
55
  - Server reply parsing in `lib/smtp-connection/index.js` uses a `'binary'` byte-container intermediate to reassemble multi-byte UTF-8 across socket chunks; the actual decode happens at line boundaries via `decodeServerResponse`. Don't change the chunk-buffering encoding without understanding why.
55
56
  - Reference the GHSA ID in commit messages for advisories.
package/SECURITY.md CHANGED
@@ -26,8 +26,7 @@ Report privately through one of the following channels:
26
26
 
27
27
  1. **GitHub Security Advisories (preferred).** Open a private report at
28
28
  <https://github.com/nodemailer/nodemailer/security/advisories/new>. This keeps
29
- the discussion private until a fix is published and lets us coordinate a CVE
30
- and credit you.
29
+ the discussion private until a fix is published and lets us credit you.
31
30
  2. **Email.** Send details to **andris@reinman.eu** (the contact listed in
32
31
  [`SECURITY.txt`](SECURITY.txt)). Encrypt sensitive details if possible.
33
32
 
@@ -44,7 +43,14 @@ When reporting, please include as much of the following as you can:
44
43
  Nodemailer is maintained by a single person, so there is no guaranteed response
45
44
  time — sometimes reports are handled within hours, sometimes they take longer.
46
45
  Accepted issues are fixed in a new release and coordinated through a GitHub
47
- Security Advisory / CVE, and reporters who wish to be named are credited.
46
+ Security Advisory, and reporters who wish to be named are credited.
47
+
48
+ ## CVEs
49
+
50
+ We track and disclose vulnerabilities through GitHub Security Advisories. We do
51
+ not request or manage CVE identifiers ourselves. If you need a CVE assigned for a
52
+ reported issue, please request one yourself — for example, through GitHub's own
53
+ CVE request flow on the published advisory, or another CNA.
48
54
 
49
55
  ## Scope
50
56
 
@@ -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;
@@ -83,7 +83,7 @@ class MailComposer {
83
83
  * @returns {Object} An object of arrays (`related` and `attached`)
84
84
  */
85
85
  getAttachments(findRelated) {
86
- let icalEvent, eventObject;
86
+ let eventObject;
87
87
  const attachments = [].concat(this.mail.attachments || []).map((attachment, i) => {
88
88
  if (/^data:/i.test(attachment.path || attachment.href)) {
89
89
  attachment = this._processDataUrl(attachment);
@@ -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 || '';
@@ -160,18 +161,7 @@ class MailComposer {
160
161
  });
161
162
 
162
163
  if (this.mail.icalEvent) {
163
- if (
164
- typeof this.mail.icalEvent === 'object' &&
165
- (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
166
- ) {
167
- icalEvent = this.mail.icalEvent;
168
- } else {
169
- icalEvent = {
170
- content: this.mail.icalEvent
171
- };
172
- }
173
-
174
- eventObject = Object.assign({}, icalEvent);
164
+ eventObject = Object.assign({}, this._getIcalEvent());
175
165
 
176
166
  eventObject.contentType = 'application/ics';
177
167
  if (!eventObject.headers) {
@@ -195,6 +185,67 @@ class MailComposer {
195
185
  };
196
186
  }
197
187
 
188
+ /**
189
+ * Returns the icalEvent value with `path`/`href`/data uri input normalized into
190
+ * a `content` entry, the same way as for regular attachments. The same event is
191
+ * included twice (as a text/calendar alternative and as an application/ics
192
+ * attachment), so the shared content object is marked to be resolved just once
193
+ * and the buffered result is reused by the second node.
194
+ *
195
+ * @returns {Object} Normalized icalEvent data
196
+ */
197
+ _getIcalEvent() {
198
+ if (!this._icalEvent) {
199
+ let icalEvent;
200
+ if (
201
+ typeof this.mail.icalEvent === 'object' &&
202
+ (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
203
+ ) {
204
+ icalEvent = Object.assign({}, this.mail.icalEvent);
205
+ } else {
206
+ icalEvent = {
207
+ content: this.mail.icalEvent
208
+ };
209
+ }
210
+
211
+ if (/^data:/i.test(icalEvent.path || icalEvent.href)) {
212
+ icalEvent = this._processDataUrl(icalEvent);
213
+ }
214
+
215
+ if (/^https?:\/\//i.test(icalEvent.path)) {
216
+ icalEvent.href = icalEvent.path;
217
+ icalEvent.path = undefined;
218
+ }
219
+
220
+ if (!icalEvent.raw) {
221
+ // map file path and URL values into `content`, otherwise the content
222
+ // nodes would render an empty body
223
+ if (icalEvent.path) {
224
+ icalEvent.content = {
225
+ path: icalEvent.path
226
+ };
227
+ icalEvent.path = undefined;
228
+ } else if (icalEvent.href) {
229
+ icalEvent.content = {
230
+ href: icalEvent.href,
231
+ httpHeaders: icalEvent.httpHeaders
232
+ };
233
+ icalEvent.href = undefined;
234
+ }
235
+ }
236
+
237
+ if (icalEvent.content && typeof icalEvent.content === 'object') {
238
+ // we are going to have the same attachment twice, so mark this to be
239
+ // resolved just once
240
+ icalEvent.content._resolve = true;
241
+ }
242
+
243
+ this._icalEvent = icalEvent;
244
+ }
245
+
246
+ return this._icalEvent;
247
+ }
248
+
198
249
  /**
199
250
  * List alternatives. Resulting objects can be used as input for MimeNode nodes
200
251
  *
@@ -202,7 +253,7 @@ class MailComposer {
202
253
  */
203
254
  getAlternatives() {
204
255
  const alternatives = [];
205
- let text, html, watchHtml, amp, icalEvent, eventObject;
256
+ let text, html, watchHtml, amp, eventObject;
206
257
 
207
258
  if (this.mail.text) {
208
259
  if (
@@ -248,24 +299,7 @@ class MailComposer {
248
299
 
249
300
  // NB! when including attachments with a calendar alternative you might end up in a blank screen on some clients
250
301
  if (this.mail.icalEvent) {
251
- if (
252
- typeof this.mail.icalEvent === 'object' &&
253
- (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
254
- ) {
255
- icalEvent = this.mail.icalEvent;
256
- } else {
257
- icalEvent = {
258
- content: this.mail.icalEvent
259
- };
260
- }
261
-
262
- eventObject = Object.assign({}, icalEvent);
263
-
264
- if (eventObject.content && typeof eventObject.content === 'object') {
265
- // we are going to have the same attachment twice, so mark this to be
266
- // resolved just once
267
- eventObject.content._resolve = true;
268
- }
302
+ eventObject = Object.assign({}, this._getIcalEvent());
269
303
 
270
304
  eventObject.filename = false;
271
305
  eventObject.contentType =
@@ -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
  }
@@ -281,7 +281,11 @@ class MailMessage {
281
281
  comment = mimeFuncs.encodeWord(comment);
282
282
  }
283
283
 
284
- return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+\/{,2}/, '');
284
+ // List-ID expects a bare domain-like identifier, so strip the
285
+ // scheme prefix that _formatListUrl adds or passes through
286
+ return (
287
+ (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<')
288
+ );
285
289
  }
286
290
 
287
291
  // List-*: <http://domain> (comment)
@@ -2087,7 +2087,7 @@ module.exports = {
2087
2087
  if (!mimeType) {
2088
2088
  return defaultExtension;
2089
2089
  }
2090
- const parts = (mimeType || '').toLowerCase().trim().split('/');
2090
+ const parts = mimeType.toLowerCase().trim().split('/');
2091
2091
  const rootType = parts.shift().trim();
2092
2092
  const subType = parts.join('/').trim();
2093
2093
 
@@ -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
  }
@@ -147,11 +147,20 @@ module.exports.getTestMessageUrl = function (info) {
147
147
  }
148
148
 
149
149
  const infoProps = new Map();
150
- info.response.replace(/\[([^\]]+)\]$/, (m, props) => {
151
- props.replace(/\b([A-Z0-9]+)=([^\s]+)/g, (m, key, value) => {
152
- infoProps.set(key, value);
153
- });
154
- });
150
+
151
+ // Extract the trailing "[...]" part of the response (no "]" allowed inside)
152
+ // with linear string scanning; the equivalent regex /\[([^\]]+)\]$/ was
153
+ // flagged for polynomial backtracking on adversarial server responses
154
+ const response = info.response.toString();
155
+ if (response.length > 2 && response.charAt(response.length - 1) === ']') {
156
+ const open = response.indexOf('[', response.lastIndexOf(']', response.length - 2) + 1);
157
+ if (open >= 0 && open < response.length - 2) {
158
+ const props = response.substring(open + 1, response.length - 1);
159
+ props.replace(/\b([A-Z0-9]+)=([^\s]+)/g, (m, key, value) => {
160
+ infoProps.set(key, value);
161
+ });
162
+ }
163
+ }
155
164
 
156
165
  if (infoProps.has('STATUS') && infoProps.has('MSGID')) {
157
166
  return (testAccount.web || ETHEREAL_WEB) + '/message/' + infoProps.get('MSGID');
@@ -4,6 +4,8 @@ const { spawn } = require('child_process');
4
4
  const packageData = require('../../package.json');
5
5
  const shared = require('../shared');
6
6
  const errors = require('../errors');
7
+ const LeWindows = require('../mime-node/le-windows');
8
+ const LeUnix = require('../mime-node/le-unix');
7
9
 
8
10
  /**
9
11
  * Generates a Transport object for Sendmail
@@ -46,6 +48,8 @@ class SendmailTransport {
46
48
  this.args = options.args;
47
49
  }
48
50
  }
51
+
52
+ this.winbreak = ['win', 'windows', 'dos', '\r\n'].includes((options.newline || '').toString().toLowerCase());
49
53
  }
50
54
 
51
55
  /**
@@ -178,7 +182,15 @@ class SendmailTransport {
178
182
  );
179
183
 
180
184
  const sourceStream = mail.message.createReadStream();
181
- sourceStream.once('error', err => {
185
+ let stream = sourceStream;
186
+ if (this.options.newline) {
187
+ // apply the transport-level line ending transform; the message-level
188
+ // `newline` option is handled by MimeNode in createReadStream()
189
+ stream = sourceStream.pipe(this.winbreak ? new LeWindows() : new LeUnix());
190
+ sourceStream.once('error', err => stream.emit('error', err));
191
+ }
192
+
193
+ stream.once('error', err => {
182
194
  this.logger.error(
183
195
  {
184
196
  err,
@@ -193,7 +205,7 @@ class SendmailTransport {
193
205
  callback(err);
194
206
  });
195
207
 
196
- sourceStream.pipe(sendmail.stdin);
208
+ stream.pipe(sendmail.stdin);
197
209
  } else {
198
210
  const err = new Error('sendmail was not found');
199
211
  err.code = errors.ESENDMAIL;
@@ -3,9 +3,22 @@
3
3
  const EventEmitter = require('events');
4
4
  const packageData = require('../../package.json');
5
5
  const shared = require('../shared');
6
+ const errors = require('../errors');
6
7
  const LeWindows = require('../mime-node/le-windows');
7
8
  const MimeNode = require('../mime-node');
8
9
 
10
+ /**
11
+ * Tags AWS SDK rejections that carry no `code` property (SDK v3 errors only
12
+ * have a `name`) with the generic SES transport error code, keeping the
13
+ * original error object intact
14
+ */
15
+ function tagSesError(err) {
16
+ if (err && typeof err === 'object' && !err.code) {
17
+ err.code = errors.ESES;
18
+ }
19
+ return err;
20
+ }
21
+
9
22
  /**
10
23
  * Generates a Transport object for AWS SES
11
24
  *
@@ -157,6 +170,7 @@ class SESTransport extends EventEmitter {
157
170
  });
158
171
  })
159
172
  .catch(err => {
173
+ tagSesError(err);
160
174
  this.logger.error(
161
175
  {
162
176
  err,
@@ -188,7 +202,7 @@ class SESTransport extends EventEmitter {
188
202
 
189
203
  const cb = err => {
190
204
  if (err && !['InvalidParameterValue', 'MessageRejected'].includes(err.code || err.Code || err.name)) {
191
- return callback(err);
205
+ return callback(tagSesError(err));
192
206
  }
193
207
  return callback(null, true);
194
208
  };
@@ -205,15 +219,13 @@ class SESTransport extends EventEmitter {
205
219
  }
206
220
  };
207
221
 
208
- this.getRegion((err, region) => {
209
- if (err || !region) {
210
- region = 'us-east-1';
211
- }
212
-
222
+ // the region value is not used for anything when verifying, but the lookup
223
+ // exercises the client configuration the same way as send() does
224
+ this.getRegion(() => {
213
225
  const command = new this.ses.SendEmailCommand(sesMessage);
214
226
  const sendPromise = this.ses.sesClient.send(command);
215
227
 
216
- sendPromise.then(data => cb(null, data)).catch(err => cb(err));
228
+ sendPromise.then(() => cb(null)).catch(err => cb(err));
217
229
  });
218
230
 
219
231
  return promise;
@@ -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');
@@ -530,6 +530,12 @@ module.exports.resolveContent = (data, key, options, callback) => {
530
530
  });
531
531
  }
532
532
 
533
+ resolveContentValue(data, key, options, callback);
534
+
535
+ return promise;
536
+ };
537
+
538
+ function resolveContentValue(data, key, options, callback) {
533
539
  let content = (data && data[key] && data[key].content) || data[key];
534
540
  const encoding = ((typeof data[key] === 'object' && data[key].encoding) || 'utf8')
535
541
  .toString()
@@ -563,14 +569,11 @@ module.exports.resolveContent = (data, key, options, callback) => {
563
569
  callback(err);
564
570
  });
565
571
  }
566
- return resolveStream(nmfetch(content.path || content.href), callback);
572
+ return resolveStream(nmfetch(content.path || content.href, { headers: content.httpHeaders, tls: content.tls }), callback);
567
573
  } else if (/^data:/i.test(content.path || content.href)) {
568
574
  const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
569
575
 
570
- if (!parsedDataUri || !parsedDataUri.data) {
571
- return callback(null, Buffer.from(0));
572
- }
573
- return callback(null, parsedDataUri.data);
576
+ return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
574
577
  } else if (content.path) {
575
578
  if (options.disableFileAccess) {
576
579
  return setImmediate(() => {
@@ -589,9 +592,7 @@ module.exports.resolveContent = (data, key, options, callback) => {
589
592
 
590
593
  // default action, return as is
591
594
  setImmediate(() => callback(null, content));
592
-
593
- return promise;
594
- };
595
+ }
595
596
 
596
597
  /**
597
598
  * Copies properties from source objects to target objects
@@ -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
  }
@@ -51,9 +51,9 @@ function decodeServerResponse(str) {
51
51
  * * **requireTLS** - forces the client to use STARTTLS
52
52
  * * **name** - the name of the client server
53
53
  * * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
54
- * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 10000)
55
- * * **connectionTimeout** - how many milliseconds to wait for the connection to establish
56
- * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 1 hour)
54
+ * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
55
+ * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
56
+ * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
57
57
  * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
58
58
  * * **lmtp** - if true, uses LMTP instead of SMTP protocol
59
59
  * * **logger** - bunyan compatible logger interface
@@ -838,7 +838,7 @@ class SMTPConnection extends EventEmitter {
838
838
  return;
839
839
  }
840
840
 
841
- let data = (chunk || '').toString('binary');
841
+ let data = chunk.toString('binary');
842
842
  let lines = (this._remainder + data).split(/\r?\n/);
843
843
  let lastline;
844
844
 
@@ -71,13 +71,19 @@ class SMTPTransport extends EventEmitter {
71
71
 
72
72
  getAuth(authOpts) {
73
73
  if (!authOpts) {
74
+ if (this.auth && this.auth.oauth2 && this.mailer) {
75
+ // Transport-level auth is resolved in the constructor, before the Mail wrapper
76
+ // assigns `this.mailer`, so a provision callback registered with
77
+ // `transporter.set('oauth2_provision_cb', ...)` has to be re-checked here
78
+ this.auth.oauth2.provisionCallback = this.mailer.get('oauth2_provision_cb') || this.auth.oauth2.provisionCallback;
79
+ }
74
80
  return this.auth;
75
81
  }
76
82
 
77
83
  const authData = Object.assign(
78
84
  {},
79
85
  this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {},
80
- authOpts && typeof authOpts === 'object' ? authOpts : {}
86
+ typeof authOpts === 'object' ? authOpts : {}
81
87
  );
82
88
 
83
89
  if (Object.keys(authData).length === 0) {
@@ -2,6 +2,8 @@
2
2
 
3
3
  const packageData = require('../../package.json');
4
4
  const shared = require('../shared');
5
+ const LeWindows = require('../mime-node/le-windows');
6
+ const LeUnix = require('../mime-node/le-unix');
5
7
 
6
8
  /**
7
9
  * Generates a Transport object for streaming
@@ -63,6 +65,13 @@ class StreamTransport {
63
65
 
64
66
  try {
65
67
  stream = mail.message.createReadStream();
68
+ if (this.options.newline) {
69
+ // apply the transport-level line ending transform; the message-level
70
+ // `newline` option is handled by MimeNode in createReadStream()
71
+ const sourceStream = stream;
72
+ stream = sourceStream.pipe(this.winbreak ? new LeWindows() : new LeUnix());
73
+ sourceStream.once('error', err => stream.emit('error', err));
74
+ }
66
75
  } catch (E) {
67
76
  this.logger.error(
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.10",
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": {
@@ -27,19 +27,19 @@
27
27
  },
28
28
  "homepage": "https://nodemailer.com/",
29
29
  "devDependencies": {
30
- "@aws-sdk/client-sesv2": "3.1037.0",
30
+ "@aws-sdk/client-sesv2": "3.1065.0",
31
31
  "bunyan": "1.8.15",
32
32
  "c8": "11.0.0",
33
- "eslint": "10.2.1",
33
+ "eslint": "10.4.1",
34
34
  "eslint-config-prettier": "10.1.8",
35
- "globals": "17.5.0",
35
+ "globals": "17.6.0",
36
36
  "libbase64": "1.3.0",
37
37
  "libmime": "5.3.8",
38
38
  "libqp": "2.1.1",
39
- "prettier": "3.8.3",
39
+ "prettier": "3.8.4",
40
40
  "proxy": "1.0.2",
41
41
  "proxy-test-server": "1.0.0",
42
- "smtp-server": "3.18.4"
42
+ "smtp-server": "3.18.5"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=6.0.0"