nodemailer 10.1.0 → 10.1.2

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,19 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [10.1.2](https://github.com/nodemailer/nodemailer/compare/v10.1.1...v10.1.2) (2026-10-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * bound the work a message can make the mailer do and keep per-message auth off the transport's token endpoint ([e347fa2](https://github.com/nodemailer/nodemailer/commit/e347fa2260a7e342e772a7e187937aa87ac96364))
9
+
10
+ ## [10.1.1](https://github.com/nodemailer/nodemailer/compare/v10.1.0...v10.1.1) (2026-10-14)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **fetch:** strip credentials on any cross-origin redirect ([a15360c](https://github.com/nodemailer/nodemailer/commit/a15360cd2cd12e4670e96ab0b7290e8c862391cf))
16
+
3
17
  ## [10.1.0](https://github.com/nodemailer/nodemailer/compare/v10.0.16...v10.1.0) (2026-10-10)
4
18
 
5
19
 
package/SECURITY.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # Security Policy
2
2
 
3
- Nodemailer is a widely deployed, zero-dependency e-mail library. We take security
4
- reports seriously and aim to respond quickly.
3
+ Nodemailer is a widely deployed, zero-dependency email library. We welcome
4
+ responsible reports of vulnerabilities that present a credible security risk to
5
+ users. Please distinguish security vulnerabilities from ordinary bugs, unusual
6
+ edge cases, and hardening suggestions.
5
7
 
6
8
  ## Supported Versions
7
9
 
@@ -17,45 +19,119 @@ the supported way to receive security updates.
17
19
  If you are on an older major, please upgrade. See the migration notes at
18
20
  <https://nodemailer.com/> before updating.
19
21
 
20
- ## Reporting a Vulnerability
22
+ ## What Qualifies as a Security Vulnerability
21
23
 
22
- **Please do not report security vulnerabilities through public GitHub issues,
23
- pull requests, or discussions.**
24
+ A security report should identify a realistic way for an attacker to cross a
25
+ security boundary in a supported version of Nodemailer. Unexpected behavior,
26
+ a failing test, or a technically correct proof of concept is not sufficient on
27
+ its own to establish security impact.
24
28
 
25
- Report privately through one of the following channels:
29
+ In particular, explain:
26
30
 
27
- 1. **GitHub Security Advisories (preferred).** Open a private report at
28
- <https://github.com/nodemailer/nodemailer/security/advisories/new>. This keeps
29
- the discussion private until a fix is published and lets us credit you.
30
- 2. **Email.** Send details to **andris@reinman.eu** (the contact listed in
31
- [`SECURITY.txt`](SECURITY.txt)). Encrypt sensitive details if possible.
31
+ - **Attacker capabilities:** Who is the attacker, and what inputs, systems, or
32
+ responses can they control _without_ already having the privileges or secrets
33
+ they are trying to gain?
34
+ - **Attack path:** How does attacker-controlled input reach the affected code
35
+ through a supported Nodemailer API or a realistic application integration?
36
+ - **Prerequisites:** What configuration and user actions are required? If the
37
+ attack depends on unusual options or deliberately malformed input, explain
38
+ why those conditions could arise in a real application.
39
+ - **Impact:** What confidentiality, integrity, or availability boundary is
40
+ crossed, and what can the attacker achieve as a result?
32
41
 
33
- When reporting, please include as much of the following as you can:
42
+ A vulnerability involving an optional or less common feature can still qualify
43
+ if these conditions are met. Conversely, the existence of a defensive check in
44
+ the code does not, by itself, establish that every bypass of that check has a
45
+ realistic security impact.
34
46
 
35
- - The affected version(s) and environment (Node.js version, OS).
36
- - The component involved (e.g. SMTP connection, address parsing, MIME/header
37
- generation, DKIM).
38
- - A clear description of the issue and its impact (e.g. header/SMTP command
39
- injection, information disclosure, DoS).
40
- - A minimal proof of concept or reproduction steps.
41
- - Any suggested remediation, if you have one.
47
+ ## Reporting a Vulnerability
42
48
 
43
- Nodemailer is maintained by a single person, so there is no guaranteed response
44
- time: sometimes reports are handled within hours, sometimes they take longer.
45
- Accepted issues are fixed in a new release and coordinated through a GitHub
46
- Security Advisory, and reporters who wish to be named are credited.
49
+ **Do not disclose suspected exploitable vulnerabilities through public GitHub
50
+ issues, pull requests, or discussions before coordinating with the maintainer.**
47
51
 
48
- ## CVEs
52
+ Report privately through one of the following channels:
49
53
 
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.
54
+ 1. **GitHub Security Advisories (preferred):**
55
+ <https://github.com/nodemailer/nodemailer/security/advisories/new>.
56
+ 2. **Email:** **andris@reinman.eu**, as listed in
57
+ [`SECURITY.txt`](SECURITY.txt). Encrypt sensitive details if possible.
58
+
59
+ ### Required Information
60
+
61
+ Please provide enough information to evaluate both the defect and its security
62
+ impact. Reports should include:
63
+
64
+ 1. The affected Nodemailer version or commit, Node.js version, and relevant
65
+ configuration.
66
+ 2. A concise description of the issue and the affected public API or code path.
67
+ 3. A minimal, tested reproduction, including steps to run it, expected behavior,
68
+ and observed behavior.
69
+ 4. A concrete threat model: attacker control, prerequisites, trust boundary, and
70
+ resulting impact. Clearly distinguish what your reproduction proves from what
71
+ you believe might be possible.
72
+ 5. An explanation of why the issue affects applications beyond a deliberately
73
+ constructed test setup, where this is not obvious.
74
+
75
+ A suggested fix is welcome but does not replace an explanation of exploitability.
76
+ A complete real-world exploit is not always necessary, but the report must make
77
+ a credible security impact clear.
78
+
79
+ ### Reports That May Be Treated as Ordinary Bugs
80
+
81
+ The following generally do **not** qualify for private security handling unless
82
+ you can demonstrate an additional, meaningful security boundary crossing:
83
+
84
+ - Issues that require the attacker to control trusted application code,
85
+ administrator-only configuration, or the secret they supposedly extract.
86
+ - Problems caused solely by application-level misuse, exposed credentials, or
87
+ insecure deployment choices outside Nodemailer's control.
88
+ - Contrived proof-of-concept setups where the tester controls both sides of a
89
+ request, without explaining how a separate attacker could trigger the issue.
90
+ - Parser quirks, unexpected behavior, and bypasses of internal checks with no
91
+ demonstrated security impact.
92
+ - Speculative findings based only on static analysis or an automated tool's
93
+ assessment, without a verified reproduction and credible attack scenario.
94
+
95
+ These may still be valid bugs and may be considered through the normal issue
96
+ tracker. Please coordinate before making any potentially exploitable details
97
+ public. Classification as a bug does not guarantee a fix or a particular
98
+ priority.
99
+
100
+ ### Automated and AI-Assisted Reports
101
+
102
+ Automated analysis and AI tools are welcome as research aids, but the person
103
+ submitting a report is responsible for verifying its claims. **Do not submit
104
+ bulk, unverified, or speculative AI-generated findings.** Test each finding
105
+ against a supported release and establish the actual security impact. Reports
106
+ that do not meet the requirements above may be closed without further
107
+ investigation or individual feedback; they can be resubmitted with stronger
108
+ evidence.
109
+
110
+ ## Triage and Disclosure
111
+
112
+ Nodemailer is maintained by a single person, and there is **no guaranteed
113
+ response or resolution time**. Reports are prioritized based on demonstrated
114
+ impact, exploitability, affected usage, and available maintenance resources.
115
+
116
+ A private report does not automatically become a security advisory. Confirmed
117
+ vulnerabilities may be fixed and disclosed through GitHub Security Advisories;
118
+ other findings may be closed, deferred, or handled as ordinary bugs. Please do
119
+ not disclose details of an unresolved, potentially exploitable issue publicly
120
+ without coordinating with the maintainer.
121
+
122
+ ## CVEs and Credit
123
+
124
+ We do not request or manage CVE identifiers. A report being acknowledged, a bug
125
+ being fixed, or a pull request being merged does **not** mean that Nodemailer
126
+ has confirmed a security vulnerability or endorsed a CVE assignment. We do not
127
+ guarantee advisories, CVE identifiers, or public credit for reports. For
128
+ confirmed vulnerabilities disclosed through an advisory, substantive reporters
129
+ may be credited if they wish.
54
130
 
55
131
  ## Scope
56
132
 
57
133
  In scope: the `nodemailer` package source in this repository: message and MIME
58
- generation, SMTP/LMTP client behaviour, address parsing, header handling, DKIM
134
+ generation, SMTP/LMTP client behavior, address parsing, header handling, DKIM
59
135
  signing, and the bundled transports.
60
136
 
61
137
  Out of scope: vulnerabilities in your own application code, misconfiguration of
@@ -133,8 +133,9 @@ class DKIMSigner {
133
133
  }
134
134
  createWriteCache() {
135
135
  this.output.usingCache = true;
136
- // pipe remainings to cache file
137
- this.cache = node_fs_1.default.createWriteStream(this.cachePath);
136
+ // pipe remainings to cache file. It holds the message body and cacheDir is often a
137
+ // shared directory such as /tmp, so nobody but the owner gets to read it
138
+ this.cache = node_fs_1.default.createWriteStream(this.cachePath, { mode: 0o600 });
138
139
  this.cache.once('error', err => {
139
140
  // drain input
140
141
  this.relaxedBody.unpipe(this.cache);
@@ -27,6 +27,8 @@ export interface FetchOptions {
27
27
  } | undefined;
28
28
  /** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
29
29
  timeout?: number | undefined;
30
+ /** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */
31
+ maxTime?: number | undefined;
30
32
  /** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
31
33
  maxBytes?: number | undefined;
32
34
  /** Maximum number of redirects to follow (default 5) */
@@ -37,6 +39,8 @@ export interface FetchOptions {
37
39
  redirects?: number | undefined;
38
40
  /** Response stream shared across redirects, set by nmfetch itself */
39
41
  fetchRes?: FetchResponse | undefined;
42
+ /** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */
43
+ deadline?: number | undefined;
40
44
  }
41
45
  /**
42
46
  * The stream nmfetch returns. The response body is piped into it, the status code and
@@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
50
54
  declare namespace nmfetch {
51
55
  var Cookies: typeof import("./cookies.js").default;
52
56
  var DEFAULT_TIMEOUT: number;
57
+ var DEFAULT_MAX_TIME: number;
53
58
  }
54
59
  type CookiesJar = Cookies;
55
60
  /**
@@ -49,6 +49,10 @@ const objects_js_1 = require("../shared/objects.js");
49
49
  const MAX_REDIRECTS = 5;
50
50
  // a stalled server would otherwise hold the request, and whatever waits on it, open forever
51
51
  const DEFAULT_TIMEOUT = 60 * 1000;
52
+ // the inactivity timeout above is reset by every byte, so a server dripping one within each
53
+ // window could still hold the request, and the message being built from it, open for as
54
+ // long as it liked. The whole request, redirects included, has to be done within this
55
+ const DEFAULT_MAX_TIME = 2 * 60 * 1000;
52
56
  // the body is usually buffered in memory by the caller, so an unbounded download is refused
53
57
  const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
54
58
  // Only genuine TLS settings are taken from options.tls. That object reaches us straight
@@ -116,12 +120,22 @@ function parseFetchUrl(url) {
116
120
  }
117
121
  return parsed;
118
122
  }
123
+ // the port a request to the URL goes to, explicit or the default of its scheme
124
+ function fetchPort(parsed) {
125
+ return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
126
+ }
119
127
  function nmfetch(url, options) {
120
128
  options = options || {};
121
129
  options.fetchRes = options.fetchRes || new node_stream_1.PassThrough();
122
130
  options.cookies = options.cookies || new cookies_js_1.default();
123
131
  options.redirects = options.redirects || 0;
124
132
  options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
133
+ if (!options.deadline) {
134
+ options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME;
135
+ // the one deadline is shared with the requests the redirects lead to, every hop
136
+ // gets the time that is left rather than starting over
137
+ options.deadline = options.maxTime ? Date.now() + options.maxTime : 0;
138
+ }
125
139
  const fetchRes = options.fetchRes;
126
140
  const parsed = parseFetchUrl(url);
127
141
  if (!parsed) {
@@ -232,7 +246,7 @@ function nmfetch(url, options) {
232
246
  method,
233
247
  host: parsed.hostname,
234
248
  path: parsed.path,
235
- port: parsed.port ? parsed.port : parsed.protocol === 'https:' ? 443 : 80,
249
+ port: fetchPort(parsed),
236
250
  headers,
237
251
  // Validate TLS certificates by default. Callers that genuinely need to
238
252
  // reach a self-signed/internal host opt out explicitly with
@@ -267,12 +281,22 @@ function nmfetch(url, options) {
267
281
  });
268
282
  return fetchRes;
269
283
  }
284
+ // the whole request has to be done by options.deadline, see maxTime. The timer is
285
+ // released on every way out of the request, so it never outlives it
286
+ let deadlineTimer;
287
+ const clearDeadline = () => {
288
+ if (deadlineTimer) {
289
+ clearTimeout(deadlineTimer);
290
+ deadlineTimer = undefined;
291
+ }
292
+ };
270
293
  // reports the first failure of this request on fetchRes and releases the request
271
294
  const fail = (err, sourceUrl = url) => {
272
295
  if (finished) {
273
296
  return;
274
297
  }
275
298
  finished = true;
299
+ clearDeadline();
276
300
  setFetchCode(err);
277
301
  err.sourceUrl = sourceUrl;
278
302
  fetchRes.emit('error', err);
@@ -282,10 +306,16 @@ function nmfetch(url, options) {
282
306
  if (timeout) {
283
307
  req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
284
308
  }
309
+ if (options.deadline) {
310
+ deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1));
311
+ // a pending deadline is no reason to keep the process alive
312
+ deadlineTimer.unref();
313
+ }
285
314
  req.on('error', (err) => fail(err));
286
315
  // a consumer that destroys the response stream before it ended does not want the rest of
287
316
  // the body, release the request now instead of when the timeout fires
288
317
  fetchRes.once('close', () => {
318
+ clearDeadline();
289
319
  if (finished || fetchRes.readableEnded) {
290
320
  return;
291
321
  }
@@ -334,17 +364,19 @@ function nmfetch(url, options) {
334
364
  // the shared fetchRes once it times out. Callers listen with req.once().
335
365
  return fail(new Error('Unsupported protocol for URL ' + redirectUrl), redirectUrl);
336
366
  }
337
- // Do not forward credentials when the redirect leaves the original
338
- // security context: a different host, or a downgrade from https to
339
- // http (which would otherwise put them on the wire in cleartext).
340
- // Strip sensitive request headers so an attacker who controls the
341
- // redirect target cannot harvest them.
342
- const crossHost = redirectParsed.hostname !== parsed.hostname;
343
- const downgrade = parsed.protocol === 'https:' && redirectParsed.protocol === 'http:';
344
- if (options.headers && (crossHost || downgrade)) {
367
+ // Do not forward credentials when the redirect leaves the origin: a different
368
+ // scheme, host or port is a different security context (an https to http
369
+ // downgrade would also put them on the wire in cleartext). Strip sensitive
370
+ // request headers so an attacker who controls the redirect target cannot
371
+ // harvest them.
372
+ const crossOrigin = redirectParsed.protocol !== parsed.protocol ||
373
+ redirectParsed.hostname !== parsed.hostname ||
374
+ fetchPort(redirectParsed) !== fetchPort(parsed);
375
+ if (options.headers && crossOrigin) {
345
376
  const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
346
377
  Object.keys(options.headers).forEach(key => {
347
- if (sensitive.includes(key.toLowerCase())) {
378
+ // normalized the way the request headers are built above, a padded name is sent too
379
+ if (sensitive.includes(key.toLowerCase().trim())) {
348
380
  delete options.headers[key];
349
381
  }
350
382
  });
@@ -352,6 +384,7 @@ function nmfetch(url, options) {
352
384
  // this request is done with, release it so its socket and timeout do not
353
385
  // outlive it and report a late error on the shared fetchRes
354
386
  finished = true;
387
+ clearDeadline();
355
388
  res.resume();
356
389
  req.abort();
357
390
  return nmfetch(redirectUrl, options);
@@ -362,6 +395,8 @@ function nmfetch(url, options) {
362
395
  return fail(new Error('Invalid status code ' + res.statusCode));
363
396
  }
364
397
  res.on('error', (err) => fail(err));
398
+ // the response is in, whatever is left of it is in memory and no longer on the network
399
+ res.once('end', () => clearDeadline());
365
400
  const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
366
401
  const source = inflate || res;
367
402
  let received = 0;
@@ -399,6 +434,7 @@ function nmfetch(url, options) {
399
434
  }
400
435
  nmfetch.Cookies = cookies_js_1.default;
401
436
  nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
437
+ nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
402
438
  exports.default = nmfetch;
403
439
  module.exports = exports.default;
404
440
  Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
@@ -398,9 +398,12 @@ class Mail extends node_events_1.EventEmitter {
398
398
  return callback(err);
399
399
  }
400
400
  let cidCounter = 0;
401
+ // the media type is a bounded token: a quote, whitespace, '>' or '<' ends the
402
+ // attribute value and with it the type, so one img tag never scans ahead to a
403
+ // ';' that belongs to a later tag, which was quadratic
401
404
  html = (html || '')
402
405
  .toString()
403
- .replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
406
+ .replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
404
407
  const cid = node_crypto_1.default.randomBytes(10).toString('hex') + '@localhost';
405
408
  if (!mail.data.attachments) {
406
409
  mail.data.attachments = [];
@@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions {
23
23
  dsn?: SMTPEnvelopeDsn | undefined;
24
24
  /** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */
25
25
  requireTLSExtensionEnabled?: boolean | undefined;
26
- /** SMTP transports: per-message authentication settings, used instead of the transport level auth */
26
+ /**
27
+ * SMTP transports: per-message authentication settings, used instead of the transport level auth.
28
+ * Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires),
29
+ * the token endpoint and client settings stay those of the transport
30
+ */
27
31
  auth?: SMTPTransportAuthOptions | undefined;
28
32
  /** SES transport: extra SendEmailCommand parameters merged into the API call */
29
33
  ses?: SESSendEmailRequest | undefined;
@@ -186,26 +186,38 @@ function encodeWord(data, mimeWordEncoding, maxLength) {
186
186
  */
187
187
  function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
188
188
  maxLength = maxLength || 0;
189
- // find first word with a non-printable ascii or special symbol in it
190
- const firstMatch = value.match(/(?:^|\s)([^\s]*["\u0080-\uFFFF])/);
191
- if (!firstMatch) {
189
+ // find the first and the last character that needs encoding, a quote or anything outside
190
+ // printable ascii. A scan rather than a regex: matching the last such word with a
191
+ // backtracking pattern took quadratic time on a long word of non-ascii characters
192
+ let firstSpecial = -1;
193
+ let lastSpecial = -1;
194
+ for (let i = 0; i < value.length; i++) {
195
+ const code = value.charCodeAt(i);
196
+ if (code === 0x22 || code >= 0x80) {
197
+ if (firstSpecial < 0) {
198
+ firstSpecial = i;
199
+ }
200
+ lastSpecial = i;
201
+ }
202
+ }
203
+ if (firstSpecial < 0) {
192
204
  return value;
193
205
  }
194
206
  if (encodeAll) {
195
207
  // if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
196
208
  return encodeWord(value, mimeWordEncoding, maxLength);
197
209
  }
198
- // find the last word with a non-printable ascii in it
199
- const lastMatch = value.match(/(["\u0080-\uFFFF][^\s]*)[^"\u0080-\uFFFF]*$/);
200
- if (!lastMatch) {
201
- // should not happen
202
- return value;
210
+ // encode from the start of the word holding the first such character to the end of the
211
+ // word holding the last one, so the encoded words stay separated from the plain text by
212
+ // the whitespace that was already there
213
+ let startIndex = firstSpecial;
214
+ while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) {
215
+ startIndex--;
216
+ }
217
+ let endIndex = lastSpecial + 1;
218
+ while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) {
219
+ endIndex++;
203
220
  }
204
- const startIndex = firstMatch.index +
205
- (firstMatch[0].match(/[^\s]/) || {
206
- index: 0
207
- }).index;
208
- const endIndex = lastMatch.index + (lastMatch[1] || '').length;
209
221
  return ((startIndex ? value.substr(0, startIndex) : '') +
210
222
  encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
211
223
  (endIndex < value.length ? value.substr(endIndex) : ''));
@@ -71,6 +71,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
71
71
  // unroutable garbage into mail for a domain the sender never named. None of these
72
72
  // characters are legal in a domain, so keep them away from the mapper.
73
73
  const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
74
+ // RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
75
+ // the code points it encodes, so anything past these bounds could not have become a valid
76
+ // domain whichever way it is encoded. The bundled codec is super-linear in the length of a
77
+ // label, a 60 KB label of distinct code points took seconds to encode, several times per
78
+ // address, so it is only handed what could be a domain
79
+ const MAX_DOMAIN_LENGTH = 253;
80
+ const MAX_LABEL_LENGTH = 63;
74
81
  // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
75
82
  const PIPELINE_NOOP = () => false;
76
83
  // The error a node streaming into an output that was destroyed stops with
@@ -79,6 +86,27 @@ function abortedError() {
79
86
  err.code = errors.ESTREAM;
80
87
  return err;
81
88
  }
89
+ /**
90
+ * Checks a domain against the DNS length limits, counted in code points per label and in
91
+ * total. The label separators are the ones the bundled codec splits on.
92
+ *
93
+ * @param domain Domain to check
94
+ * @return true if a label or the domain as a whole is too long to be a valid domain
95
+ */
96
+ function exceedsDomainLimits(domain) {
97
+ let total = 0;
98
+ let label = 0;
99
+ for (const char of domain) {
100
+ total++;
101
+ if (char === '.' || char === '。' || char === '.' || char === '。') {
102
+ label = 0;
103
+ }
104
+ else if (++label > MAX_LABEL_LENGTH) {
105
+ return true;
106
+ }
107
+ }
108
+ return total > MAX_DOMAIN_LENGTH;
109
+ }
82
110
  /**
83
111
  * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
84
112
  * which is with UTS-46 mapping applied before the Punycode step.
@@ -110,6 +138,10 @@ function normalizeDomain(domain, toUnicode) {
110
138
  return mapped;
111
139
  }
112
140
  }
141
+ if (exceedsDomainLimits(domain)) {
142
+ // too long to be a domain whichever way it is encoded, keep it as supplied
143
+ return domain;
144
+ }
113
145
  return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
114
146
  }
115
147
  /**
@@ -614,6 +646,17 @@ class MimeNode {
614
646
  /[\u0080-\uFFFF]/.test(this.content)) {
615
647
  structured.params.charset = 'utf-8';
616
648
  }
649
+ if (this.filename) {
650
+ // the name parameter is derived from the filename below, so a name that
651
+ // arrived inside the content type is dropped first (a plain parameter or
652
+ // rfc2231 continuation sections), otherwise the header carries two and a
653
+ // receiver that reads the first sees a name the filename does not match
654
+ for (const paramKey of Object.keys(structured.params)) {
655
+ if (/^name(\*|$)/i.test(paramKey)) {
656
+ delete structured.params[paramKey];
657
+ }
658
+ }
659
+ }
617
660
  value = mimeFuncs.buildHeaderValue(structured);
618
661
  if (this.filename) {
619
662
  // add support for non-compliant clients like QQ webmail
@@ -1223,12 +1266,12 @@ class MimeNode {
1223
1266
  _normalizeHeaderKey(key) {
1224
1267
  key = (key || '')
1225
1268
  .toString()
1226
- // no newlines in keys
1227
- .replace(/\r?\n|\r/g, ' ')
1228
- // a field name is printable ascii without the colon, so a control char or DEL
1229
- // can only be dropped, there is no quoting construct around a field name
1230
- .replace(/[\x00-\x1f\x7f]/g, '')
1231
- .trim()
1269
+ // a field name is printable ascii without the colon, so a control char, DEL,
1270
+ // whitespace or a colon can only be dropped, there is no quoting construct
1271
+ // around a field name. A colon or a space left in place ends the name early on
1272
+ // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
1273
+ // second From header that neither setHeader() nor the Bcc filter could match
1274
+ .replace(/[\s\x00-\x20\x7f:]/g, '')
1232
1275
  .toLowerCase()
1233
1276
  // use uppercase words, except MIME
1234
1277
  .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.1.0";
2
+ export declare const version = "10.1.2";
3
3
  export declare const homepage = "https://nodemailer.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = 'nodemailer';
6
- exports.version = '10.1.0';
6
+ exports.version = '10.1.2';
7
7
  exports.homepage = 'https://nodemailer.com/';
@@ -114,6 +114,21 @@ function isPartialResponse(str) {
114
114
  function isPartialLine(line) {
115
115
  return /^\d+-/.test(line);
116
116
  }
117
+ /**
118
+ * The log form of a command a custom authentication handler sends. Such a command carries
119
+ * the credentials in whatever shape its mechanism uses, so only an AUTH verb with its
120
+ * mechanism name is kept, the way the built-in mechanisms hide their own secrets.
121
+ *
122
+ * @param cmd Command the handler sends
123
+ * @return What the transaction log gets instead
124
+ */
125
+ function maskAuthCommand(cmd) {
126
+ const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i);
127
+ if (!match) {
128
+ return '/* secret */';
129
+ }
130
+ return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */';
131
+ }
117
132
  /**
118
133
  * Generates a SMTP connection object
119
134
  *
@@ -601,7 +616,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
601
616
  }
602
617
  done(null, data);
603
618
  });
604
- setImmediate(() => this._sendCommand(cmd));
619
+ setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd)));
605
620
  return promise;
606
621
  };
607
622
  const handlerResponse = handler({
@@ -43,6 +43,13 @@ const shared = __importStar(require("../shared/index.js"));
43
43
  const index_js_3 = __importDefault(require("../xoauth2/index.js"));
44
44
  const errors = __importStar(require("../errors.js"));
45
45
  const packageData = __importStar(require("../package-info.js"));
46
+ // The keys a message's `auth` may set on top of the transport's auth settings: the ones that
47
+ // say who logs in and with what. Everything else in the auth block (accessUrl, clientId,
48
+ // clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest)
49
+ // names the endpoint the credentials go to or is a secret of the application itself, and
50
+ // a message that could set `auth.accessUrl` and force a refresh would have the transport's
51
+ // client secret and refresh token posted to a URL of its choosing
52
+ const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires'];
46
53
  /**
47
54
  * Creates a SMTP transport object for Nodemailer
48
55
  *
@@ -102,7 +109,11 @@ class SMTPTransport extends node_events_1.EventEmitter {
102
109
  }
103
110
  return this.auth;
104
111
  }
105
- const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}, typeof authOpts === 'object' ? authOpts : {});
112
+ const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {});
113
+ if (typeof authOpts === 'object') {
114
+ // see MESSAGE_AUTH_KEYS
115
+ shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key));
116
+ }
106
117
  if (Object.keys(authData).length === 0) {
107
118
  return false;
108
119
  }
@@ -363,6 +363,13 @@ exports.services = {
363
363
  "port": 587,
364
364
  "secure": false
365
365
  },
366
+ "Oveyon": {
367
+ "description": "Oveyon",
368
+ "host": "smtp.oveyon.com",
369
+ "port": 587,
370
+ "secure": false,
371
+ "requireTLS": true
372
+ },
366
373
  "Postmark": {
367
374
  "description": "Postmark",
368
375
  "aliases": [
@@ -264,8 +264,14 @@ class XOAuth2 extends node_stream_1.Stream {
264
264
  grant_type: 'refresh_token'
265
265
  };
266
266
  }
267
- Object.assign(urlOptions, this.options.customParams);
268
- Object.assign(loggedUrlOptions, this.options.customParams);
267
+ const customParams = this.options.customParams || {};
268
+ Object.assign(urlOptions, customParams);
269
+ // a custom param is as likely to be a client assertion or an API key as a tenant
270
+ // name, so the log gets the same glimpse of it that it gets of the client secret
271
+ for (const key of Object.keys(customParams)) {
272
+ const param = customParams[key];
273
+ loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...';
274
+ }
269
275
  this.logger.debug({
270
276
  tnx: 'OAUTH2',
271
277
  user: this.options.user,
@@ -128,8 +128,9 @@ class DKIMSigner {
128
128
  }
129
129
  createWriteCache() {
130
130
  this.output.usingCache = true;
131
- // pipe remainings to cache file
132
- this.cache = fs.createWriteStream(this.cachePath);
131
+ // pipe remainings to cache file. It holds the message body and cacheDir is often a
132
+ // shared directory such as /tmp, so nobody but the owner gets to read it
133
+ this.cache = fs.createWriteStream(this.cachePath, { mode: 0o600 });
133
134
  this.cache.once('error', err => {
134
135
  // drain input
135
136
  this.relaxedBody.unpipe(this.cache);
@@ -27,6 +27,8 @@ export interface FetchOptions {
27
27
  } | undefined;
28
28
  /** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
29
29
  timeout?: number | undefined;
30
+ /** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */
31
+ maxTime?: number | undefined;
30
32
  /** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
31
33
  maxBytes?: number | undefined;
32
34
  /** Maximum number of redirects to follow (default 5) */
@@ -37,6 +39,8 @@ export interface FetchOptions {
37
39
  redirects?: number | undefined;
38
40
  /** Response stream shared across redirects, set by nmfetch itself */
39
41
  fetchRes?: FetchResponse | undefined;
42
+ /** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */
43
+ deadline?: number | undefined;
40
44
  }
41
45
  /**
42
46
  * The stream nmfetch returns. The response body is piped into it, the status code and
@@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
50
54
  declare namespace nmfetch {
51
55
  var Cookies: typeof import("./cookies.js").default;
52
56
  var DEFAULT_TIMEOUT: number;
57
+ var DEFAULT_MAX_TIME: number;
53
58
  }
54
59
  type CookiesJar = Cookies;
55
60
  /**
@@ -11,6 +11,10 @@ import { isProtoKey } from '../shared/objects.js';
11
11
  const MAX_REDIRECTS = 5;
12
12
  // a stalled server would otherwise hold the request, and whatever waits on it, open forever
13
13
  const DEFAULT_TIMEOUT = 60 * 1000;
14
+ // the inactivity timeout above is reset by every byte, so a server dripping one within each
15
+ // window could still hold the request, and the message being built from it, open for as
16
+ // long as it liked. The whole request, redirects included, has to be done within this
17
+ const DEFAULT_MAX_TIME = 2 * 60 * 1000;
14
18
  // the body is usually buffered in memory by the caller, so an unbounded download is refused
15
19
  const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
16
20
  // Only genuine TLS settings are taken from options.tls. That object reaches us straight
@@ -78,12 +82,22 @@ function parseFetchUrl(url) {
78
82
  }
79
83
  return parsed;
80
84
  }
85
+ // the port a request to the URL goes to, explicit or the default of its scheme
86
+ function fetchPort(parsed) {
87
+ return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
88
+ }
81
89
  function nmfetch(url, options) {
82
90
  options = options || {};
83
91
  options.fetchRes = options.fetchRes || new PassThrough();
84
92
  options.cookies = options.cookies || new Cookies();
85
93
  options.redirects = options.redirects || 0;
86
94
  options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
95
+ if (!options.deadline) {
96
+ options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME;
97
+ // the one deadline is shared with the requests the redirects lead to, every hop
98
+ // gets the time that is left rather than starting over
99
+ options.deadline = options.maxTime ? Date.now() + options.maxTime : 0;
100
+ }
87
101
  const fetchRes = options.fetchRes;
88
102
  const parsed = parseFetchUrl(url);
89
103
  if (!parsed) {
@@ -194,7 +208,7 @@ function nmfetch(url, options) {
194
208
  method,
195
209
  host: parsed.hostname,
196
210
  path: parsed.path,
197
- port: parsed.port ? parsed.port : parsed.protocol === 'https:' ? 443 : 80,
211
+ port: fetchPort(parsed),
198
212
  headers,
199
213
  // Validate TLS certificates by default. Callers that genuinely need to
200
214
  // reach a self-signed/internal host opt out explicitly with
@@ -229,12 +243,22 @@ function nmfetch(url, options) {
229
243
  });
230
244
  return fetchRes;
231
245
  }
246
+ // the whole request has to be done by options.deadline, see maxTime. The timer is
247
+ // released on every way out of the request, so it never outlives it
248
+ let deadlineTimer;
249
+ const clearDeadline = () => {
250
+ if (deadlineTimer) {
251
+ clearTimeout(deadlineTimer);
252
+ deadlineTimer = undefined;
253
+ }
254
+ };
232
255
  // reports the first failure of this request on fetchRes and releases the request
233
256
  const fail = (err, sourceUrl = url) => {
234
257
  if (finished) {
235
258
  return;
236
259
  }
237
260
  finished = true;
261
+ clearDeadline();
238
262
  setFetchCode(err);
239
263
  err.sourceUrl = sourceUrl;
240
264
  fetchRes.emit('error', err);
@@ -244,10 +268,16 @@ function nmfetch(url, options) {
244
268
  if (timeout) {
245
269
  req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
246
270
  }
271
+ if (options.deadline) {
272
+ deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1));
273
+ // a pending deadline is no reason to keep the process alive
274
+ deadlineTimer.unref();
275
+ }
247
276
  req.on('error', (err) => fail(err));
248
277
  // a consumer that destroys the response stream before it ended does not want the rest of
249
278
  // the body, release the request now instead of when the timeout fires
250
279
  fetchRes.once('close', () => {
280
+ clearDeadline();
251
281
  if (finished || fetchRes.readableEnded) {
252
282
  return;
253
283
  }
@@ -296,17 +326,19 @@ function nmfetch(url, options) {
296
326
  // the shared fetchRes once it times out. Callers listen with req.once().
297
327
  return fail(new Error('Unsupported protocol for URL ' + redirectUrl), redirectUrl);
298
328
  }
299
- // Do not forward credentials when the redirect leaves the original
300
- // security context: a different host, or a downgrade from https to
301
- // http (which would otherwise put them on the wire in cleartext).
302
- // Strip sensitive request headers so an attacker who controls the
303
- // redirect target cannot harvest them.
304
- const crossHost = redirectParsed.hostname !== parsed.hostname;
305
- const downgrade = parsed.protocol === 'https:' && redirectParsed.protocol === 'http:';
306
- if (options.headers && (crossHost || downgrade)) {
329
+ // Do not forward credentials when the redirect leaves the origin: a different
330
+ // scheme, host or port is a different security context (an https to http
331
+ // downgrade would also put them on the wire in cleartext). Strip sensitive
332
+ // request headers so an attacker who controls the redirect target cannot
333
+ // harvest them.
334
+ const crossOrigin = redirectParsed.protocol !== parsed.protocol ||
335
+ redirectParsed.hostname !== parsed.hostname ||
336
+ fetchPort(redirectParsed) !== fetchPort(parsed);
337
+ if (options.headers && crossOrigin) {
307
338
  const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
308
339
  Object.keys(options.headers).forEach(key => {
309
- if (sensitive.includes(key.toLowerCase())) {
340
+ // normalized the way the request headers are built above, a padded name is sent too
341
+ if (sensitive.includes(key.toLowerCase().trim())) {
310
342
  delete options.headers[key];
311
343
  }
312
344
  });
@@ -314,6 +346,7 @@ function nmfetch(url, options) {
314
346
  // this request is done with, release it so its socket and timeout do not
315
347
  // outlive it and report a late error on the shared fetchRes
316
348
  finished = true;
349
+ clearDeadline();
317
350
  res.resume();
318
351
  req.abort();
319
352
  return nmfetch(redirectUrl, options);
@@ -324,6 +357,8 @@ function nmfetch(url, options) {
324
357
  return fail(new Error('Invalid status code ' + res.statusCode));
325
358
  }
326
359
  res.on('error', (err) => fail(err));
360
+ // the response is in, whatever is left of it is in memory and no longer on the network
361
+ res.once('end', () => clearDeadline());
327
362
  const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
328
363
  const source = inflate || res;
329
364
  let received = 0;
@@ -361,4 +396,5 @@ function nmfetch(url, options) {
361
396
  }
362
397
  nmfetch.Cookies = Cookies;
363
398
  nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
399
+ nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
364
400
  export default nmfetch;
@@ -360,9 +360,12 @@ class Mail extends EventEmitter {
360
360
  return callback(err);
361
361
  }
362
362
  let cidCounter = 0;
363
+ // the media type is a bounded token: a quote, whitespace, '>' or '<' ends the
364
+ // attribute value and with it the type, so one img tag never scans ahead to a
365
+ // ';' that belongs to a later tag, which was quadratic
363
366
  html = (html || '')
364
367
  .toString()
365
- .replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
368
+ .replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
366
369
  const cid = crypto.randomBytes(10).toString('hex') + '@localhost';
367
370
  if (!mail.data.attachments) {
368
371
  mail.data.attachments = [];
@@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions {
23
23
  dsn?: SMTPEnvelopeDsn | undefined;
24
24
  /** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */
25
25
  requireTLSExtensionEnabled?: boolean | undefined;
26
- /** SMTP transports: per-message authentication settings, used instead of the transport level auth */
26
+ /**
27
+ * SMTP transports: per-message authentication settings, used instead of the transport level auth.
28
+ * Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires),
29
+ * the token endpoint and client settings stay those of the transport
30
+ */
27
31
  auth?: SMTPTransportAuthOptions | undefined;
28
32
  /** SES transport: extra SendEmailCommand parameters merged into the API call */
29
33
  ses?: SESSendEmailRequest | undefined;
@@ -137,26 +137,38 @@ export function encodeWord(data, mimeWordEncoding, maxLength) {
137
137
  */
138
138
  export function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
139
139
  maxLength = maxLength || 0;
140
- // find first word with a non-printable ascii or special symbol in it
141
- const firstMatch = value.match(/(?:^|\s)([^\s]*["\u0080-\uFFFF])/);
142
- if (!firstMatch) {
140
+ // find the first and the last character that needs encoding, a quote or anything outside
141
+ // printable ascii. A scan rather than a regex: matching the last such word with a
142
+ // backtracking pattern took quadratic time on a long word of non-ascii characters
143
+ let firstSpecial = -1;
144
+ let lastSpecial = -1;
145
+ for (let i = 0; i < value.length; i++) {
146
+ const code = value.charCodeAt(i);
147
+ if (code === 0x22 || code >= 0x80) {
148
+ if (firstSpecial < 0) {
149
+ firstSpecial = i;
150
+ }
151
+ lastSpecial = i;
152
+ }
153
+ }
154
+ if (firstSpecial < 0) {
143
155
  return value;
144
156
  }
145
157
  if (encodeAll) {
146
158
  // if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
147
159
  return encodeWord(value, mimeWordEncoding, maxLength);
148
160
  }
149
- // find the last word with a non-printable ascii in it
150
- const lastMatch = value.match(/(["\u0080-\uFFFF][^\s]*)[^"\u0080-\uFFFF]*$/);
151
- if (!lastMatch) {
152
- // should not happen
153
- return value;
161
+ // encode from the start of the word holding the first such character to the end of the
162
+ // word holding the last one, so the encoded words stay separated from the plain text by
163
+ // the whitespace that was already there
164
+ let startIndex = firstSpecial;
165
+ while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) {
166
+ startIndex--;
167
+ }
168
+ let endIndex = lastSpecial + 1;
169
+ while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) {
170
+ endIndex++;
154
171
  }
155
- const startIndex = firstMatch.index +
156
- (firstMatch[0].match(/[^\s]/) || {
157
- index: 0
158
- }).index;
159
- const endIndex = lastMatch.index + (lastMatch[1] || '').length;
160
172
  return ((startIndex ? value.substr(0, startIndex) : '') +
161
173
  encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
162
174
  (endIndex < value.length ? value.substr(endIndex) : ''));
@@ -33,6 +33,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
33
33
  // unroutable garbage into mail for a domain the sender never named. None of these
34
34
  // characters are legal in a domain, so keep them away from the mapper.
35
35
  const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
36
+ // RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
37
+ // the code points it encodes, so anything past these bounds could not have become a valid
38
+ // domain whichever way it is encoded. The bundled codec is super-linear in the length of a
39
+ // label, a 60 KB label of distinct code points took seconds to encode, several times per
40
+ // address, so it is only handed what could be a domain
41
+ const MAX_DOMAIN_LENGTH = 253;
42
+ const MAX_LABEL_LENGTH = 63;
36
43
  // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
37
44
  const PIPELINE_NOOP = () => false;
38
45
  // The error a node streaming into an output that was destroyed stops with
@@ -41,6 +48,27 @@ function abortedError() {
41
48
  err.code = errors.ESTREAM;
42
49
  return err;
43
50
  }
51
+ /**
52
+ * Checks a domain against the DNS length limits, counted in code points per label and in
53
+ * total. The label separators are the ones the bundled codec splits on.
54
+ *
55
+ * @param domain Domain to check
56
+ * @return true if a label or the domain as a whole is too long to be a valid domain
57
+ */
58
+ function exceedsDomainLimits(domain) {
59
+ let total = 0;
60
+ let label = 0;
61
+ for (const char of domain) {
62
+ total++;
63
+ if (char === '.' || char === '。' || char === '.' || char === '。') {
64
+ label = 0;
65
+ }
66
+ else if (++label > MAX_LABEL_LENGTH) {
67
+ return true;
68
+ }
69
+ }
70
+ return total > MAX_DOMAIN_LENGTH;
71
+ }
44
72
  /**
45
73
  * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
46
74
  * which is with UTS-46 mapping applied before the Punycode step.
@@ -72,6 +100,10 @@ function normalizeDomain(domain, toUnicode) {
72
100
  return mapped;
73
101
  }
74
102
  }
103
+ if (exceedsDomainLimits(domain)) {
104
+ // too long to be a domain whichever way it is encoded, keep it as supplied
105
+ return domain;
106
+ }
75
107
  return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
76
108
  }
77
109
  /**
@@ -576,6 +608,17 @@ class MimeNode {
576
608
  /[\u0080-\uFFFF]/.test(this.content)) {
577
609
  structured.params.charset = 'utf-8';
578
610
  }
611
+ if (this.filename) {
612
+ // the name parameter is derived from the filename below, so a name that
613
+ // arrived inside the content type is dropped first (a plain parameter or
614
+ // rfc2231 continuation sections), otherwise the header carries two and a
615
+ // receiver that reads the first sees a name the filename does not match
616
+ for (const paramKey of Object.keys(structured.params)) {
617
+ if (/^name(\*|$)/i.test(paramKey)) {
618
+ delete structured.params[paramKey];
619
+ }
620
+ }
621
+ }
579
622
  value = mimeFuncs.buildHeaderValue(structured);
580
623
  if (this.filename) {
581
624
  // add support for non-compliant clients like QQ webmail
@@ -1185,12 +1228,12 @@ class MimeNode {
1185
1228
  _normalizeHeaderKey(key) {
1186
1229
  key = (key || '')
1187
1230
  .toString()
1188
- // no newlines in keys
1189
- .replace(/\r?\n|\r/g, ' ')
1190
- // a field name is printable ascii without the colon, so a control char or DEL
1191
- // can only be dropped, there is no quoting construct around a field name
1192
- .replace(/[\x00-\x1f\x7f]/g, '')
1193
- .trim()
1231
+ // a field name is printable ascii without the colon, so a control char, DEL,
1232
+ // whitespace or a colon can only be dropped, there is no quoting construct
1233
+ // around a field name. A colon or a space left in place ends the name early on
1234
+ // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
1235
+ // second From header that neither setHeader() nor the Bcc filter could match
1236
+ .replace(/[\s\x00-\x20\x7f:]/g, '')
1194
1237
  .toLowerCase()
1195
1238
  // use uppercase words, except MIME
1196
1239
  .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.1.0";
2
+ export declare const version = "10.1.2";
3
3
  export declare const homepage = "https://nodemailer.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = 'nodemailer';
3
- export const version = '10.1.0';
3
+ export const version = '10.1.2';
4
4
  export const homepage = 'https://nodemailer.com/';
@@ -76,6 +76,21 @@ function isPartialResponse(str) {
76
76
  function isPartialLine(line) {
77
77
  return /^\d+-/.test(line);
78
78
  }
79
+ /**
80
+ * The log form of a command a custom authentication handler sends. Such a command carries
81
+ * the credentials in whatever shape its mechanism uses, so only an AUTH verb with its
82
+ * mechanism name is kept, the way the built-in mechanisms hide their own secrets.
83
+ *
84
+ * @param cmd Command the handler sends
85
+ * @return What the transaction log gets instead
86
+ */
87
+ function maskAuthCommand(cmd) {
88
+ const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i);
89
+ if (!match) {
90
+ return '/* secret */';
91
+ }
92
+ return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */';
93
+ }
79
94
  /**
80
95
  * Generates a SMTP connection object
81
96
  *
@@ -563,7 +578,7 @@ class SMTPConnection extends EventEmitter {
563
578
  }
564
579
  done(null, data);
565
580
  });
566
- setImmediate(() => this._sendCommand(cmd));
581
+ setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd)));
567
582
  return promise;
568
583
  };
569
584
  const handlerResponse = handler({
@@ -5,6 +5,13 @@ import * as shared from '../shared/index.js';
5
5
  import XOAuth2 from '../xoauth2/index.js';
6
6
  import * as errors from '../errors.js';
7
7
  import * as packageData from '../package-info.js';
8
+ // The keys a message's `auth` may set on top of the transport's auth settings: the ones that
9
+ // say who logs in and with what. Everything else in the auth block (accessUrl, clientId,
10
+ // clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest)
11
+ // names the endpoint the credentials go to or is a secret of the application itself, and
12
+ // a message that could set `auth.accessUrl` and force a refresh would have the transport's
13
+ // client secret and refresh token posted to a URL of its choosing
14
+ const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires'];
8
15
  /**
9
16
  * Creates a SMTP transport object for Nodemailer
10
17
  *
@@ -64,7 +71,11 @@ class SMTPTransport extends EventEmitter {
64
71
  }
65
72
  return this.auth;
66
73
  }
67
- const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}, typeof authOpts === 'object' ? authOpts : {});
74
+ const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {});
75
+ if (typeof authOpts === 'object') {
76
+ // see MESSAGE_AUTH_KEYS
77
+ shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key));
78
+ }
68
79
  if (Object.keys(authData).length === 0) {
69
80
  return false;
70
81
  }
@@ -360,6 +360,13 @@ export const services = {
360
360
  "port": 587,
361
361
  "secure": false
362
362
  },
363
+ "Oveyon": {
364
+ "description": "Oveyon",
365
+ "host": "smtp.oveyon.com",
366
+ "port": 587,
367
+ "secure": false,
368
+ "requireTLS": true
369
+ },
363
370
  "Postmark": {
364
371
  "description": "Postmark",
365
372
  "aliases": [
@@ -226,8 +226,14 @@ class XOAuth2 extends Stream {
226
226
  grant_type: 'refresh_token'
227
227
  };
228
228
  }
229
- Object.assign(urlOptions, this.options.customParams);
230
- Object.assign(loggedUrlOptions, this.options.customParams);
229
+ const customParams = this.options.customParams || {};
230
+ Object.assign(urlOptions, customParams);
231
+ // a custom param is as likely to be a client assertion or an API key as a tenant
232
+ // name, so the log gets the same glimpse of it that it gets of the client secret
233
+ for (const key of Object.keys(customParams)) {
234
+ const param = customParams[key];
235
+ loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...';
236
+ }
231
237
  this.logger.debug({
232
238
  tnx: 'OAUTH2',
233
239
  user: this.options.user,
@@ -333,6 +333,14 @@
333
333
  "secure": false
334
334
  },
335
335
 
336
+ "Oveyon": {
337
+ "description": "Oveyon",
338
+ "host": "smtp.oveyon.com",
339
+ "port": 587,
340
+ "secure": false,
341
+ "requireTLS": true
342
+ },
343
+
336
344
  "Postmark": {
337
345
  "description": "Postmark",
338
346
  "aliases": ["PostmarkApp"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "10.1.0",
3
+ "version": "10.1.2",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/nodemailer.js",
@@ -151,7 +151,7 @@
151
151
  },
152
152
  "homepage": "https://nodemailer.com/",
153
153
  "devDependencies": {
154
- "@aws-sdk/client-sesv2": "3.1146.0",
154
+ "@aws-sdk/client-sesv2": "3.1151.0",
155
155
  "@types/node": "20.19.43",
156
156
  "bunyan": "1.8.15",
157
157
  "c8": "12.0.0",
@@ -161,14 +161,14 @@
161
161
  "libbase64": "1.3.2",
162
162
  "libmime": "5.4.7",
163
163
  "libqp": "2.1.2",
164
- "mailauth": "7.1.1",
165
- "prettier": "3.9.9",
164
+ "mailauth": "7.3.0",
165
+ "prettier": "3.9.10",
166
166
  "proxy": "1.0.2",
167
167
  "proxy-test-server": "1.0.0",
168
- "smtp-server": "3.19.17",
168
+ "smtp-server": "3.19.21",
169
169
  "tsx": "4.23.15",
170
170
  "typescript": "6.0.3",
171
- "typescript-eslint": "8.71.1"
171
+ "typescript-eslint": "8.71.2"
172
172
  },
173
173
  "engines": {
174
174
  "node": ">=20.0.0"