nodemailer 10.1.1 → 10.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [10.1.3](https://github.com/nodemailer/nodemailer/compare/v10.1.2...v10.1.3) (2026-10-18)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * format References and strip URLs in linear time, release any number of streams ([2f18641](https://github.com/nodemailer/nodemailer/commit/2f186418cf6c8fff18f235d4318892fd42f810fa))
9
+
10
+ ## [10.1.2](https://github.com/nodemailer/nodemailer/compare/v10.1.1...v10.1.2) (2026-10-16)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * 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))
16
+
3
17
  ## [10.1.1](https://github.com/nodemailer/nodemailer/compare/v10.1.0...v10.1.1) (2026-10-14)
4
18
 
5
19
 
@@ -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
@@ -126,6 +130,12 @@ function nmfetch(url, options) {
126
130
  options.cookies = options.cookies || new cookies_js_1.default();
127
131
  options.redirects = options.redirects || 0;
128
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
+ }
129
139
  const fetchRes = options.fetchRes;
130
140
  const parsed = parseFetchUrl(url);
131
141
  if (!parsed) {
@@ -271,12 +281,22 @@ function nmfetch(url, options) {
271
281
  });
272
282
  return fetchRes;
273
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
+ };
274
293
  // reports the first failure of this request on fetchRes and releases the request
275
294
  const fail = (err, sourceUrl = url) => {
276
295
  if (finished) {
277
296
  return;
278
297
  }
279
298
  finished = true;
299
+ clearDeadline();
280
300
  setFetchCode(err);
281
301
  err.sourceUrl = sourceUrl;
282
302
  fetchRes.emit('error', err);
@@ -286,10 +306,16 @@ function nmfetch(url, options) {
286
306
  if (timeout) {
287
307
  req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
288
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
+ }
289
314
  req.on('error', (err) => fail(err));
290
315
  // a consumer that destroys the response stream before it ended does not want the rest of
291
316
  // the body, release the request now instead of when the timeout fires
292
317
  fetchRes.once('close', () => {
318
+ clearDeadline();
293
319
  if (finished || fetchRes.readableEnded) {
294
320
  return;
295
321
  }
@@ -358,6 +384,7 @@ function nmfetch(url, options) {
358
384
  // this request is done with, release it so its socket and timeout do not
359
385
  // outlive it and report a late error on the shared fetchRes
360
386
  finished = true;
387
+ clearDeadline();
361
388
  res.resume();
362
389
  req.abort();
363
390
  return nmfetch(redirectUrl, options);
@@ -368,6 +395,8 @@ function nmfetch(url, options) {
368
395
  return fail(new Error('Invalid status code ' + res.statusCode));
369
396
  }
370
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());
371
400
  const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
372
401
  const source = inflate || res;
373
402
  let received = 0;
@@ -405,6 +434,7 @@ function nmfetch(url, options) {
405
434
  }
406
435
  nmfetch.Cookies = cookies_js_1.default;
407
436
  nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
437
+ nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
408
438
  exports.default = nmfetch;
409
439
  module.exports = exports.default;
410
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;
@@ -183,7 +183,10 @@ class MailMessage {
183
183
  const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent];
184
184
  for (const key of ['attachments', 'alternatives']) {
185
185
  if (Array.isArray(data[key])) {
186
- values.push(...data[key]);
186
+ // one at a time, spreading a long enough list into the arguments throws
187
+ for (const entry of data[key]) {
188
+ values.push(entry);
189
+ }
187
190
  }
188
191
  }
189
192
  for (const value of values) {
@@ -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) : ''));
@@ -64,6 +64,31 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
64
64
  // else goes into angle brackets so that the header can not be read as more addresses than
65
65
  // the envelope carries
66
66
  const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
67
+ /**
68
+ * Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
69
+ * it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
70
+ * from every such "<"
71
+ *
72
+ * @param value References header value
73
+ * @returns The value with the msg-ids closed up
74
+ */
75
+ function stripWhitespaceInAngleBrackets(value) {
76
+ let result = '';
77
+ let pos = 0;
78
+ for (;;) {
79
+ const open = value.indexOf('<', pos);
80
+ if (open < 0) {
81
+ break;
82
+ }
83
+ const close = value.indexOf('>', open + 1);
84
+ if (close < 0) {
85
+ break;
86
+ }
87
+ result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, '');
88
+ pos = close + 1;
89
+ }
90
+ return result + value.slice(pos);
91
+ }
67
92
  // domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
68
93
  // mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
69
94
  // controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
@@ -71,6 +96,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
71
96
  // unroutable garbage into mail for a domain the sender never named. None of these
72
97
  // characters are legal in a domain, so keep them away from the mapper.
73
98
  const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
99
+ // RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
100
+ // the code points it encodes, so anything past these bounds could not have become a valid
101
+ // domain whichever way it is encoded. The bundled codec is super-linear in the length of a
102
+ // label, a 60 KB label of distinct code points took seconds to encode, several times per
103
+ // address, so it is only handed what could be a domain
104
+ const MAX_DOMAIN_LENGTH = 253;
105
+ const MAX_LABEL_LENGTH = 63;
74
106
  // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
75
107
  const PIPELINE_NOOP = () => false;
76
108
  // The error a node streaming into an output that was destroyed stops with
@@ -79,6 +111,27 @@ function abortedError() {
79
111
  err.code = errors.ESTREAM;
80
112
  return err;
81
113
  }
114
+ /**
115
+ * Checks a domain against the DNS length limits, counted in code points per label and in
116
+ * total. The label separators are the ones the bundled codec splits on.
117
+ *
118
+ * @param domain Domain to check
119
+ * @return true if a label or the domain as a whole is too long to be a valid domain
120
+ */
121
+ function exceedsDomainLimits(domain) {
122
+ let total = 0;
123
+ let label = 0;
124
+ for (const char of domain) {
125
+ total++;
126
+ if (char === '.' || char === '。' || char === '.' || char === '。') {
127
+ label = 0;
128
+ }
129
+ else if (++label > MAX_LABEL_LENGTH) {
130
+ return true;
131
+ }
132
+ }
133
+ return total > MAX_DOMAIN_LENGTH;
134
+ }
82
135
  /**
83
136
  * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
84
137
  * which is with UTS-46 mapping applied before the Punycode step.
@@ -110,6 +163,10 @@ function normalizeDomain(domain, toUnicode) {
110
163
  return mapped;
111
164
  }
112
165
  }
166
+ if (exceedsDomainLimits(domain)) {
167
+ // too long to be a domain whichever way it is encoded, keep it as supplied
168
+ return domain;
169
+ }
113
170
  return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
114
171
  }
115
172
  /**
@@ -614,6 +671,17 @@ class MimeNode {
614
671
  /[\u0080-\uFFFF]/.test(this.content)) {
615
672
  structured.params.charset = 'utf-8';
616
673
  }
674
+ if (this.filename) {
675
+ // the name parameter is derived from the filename below, so a name that
676
+ // arrived inside the content type is dropped first (a plain parameter or
677
+ // rfc2231 continuation sections), otherwise the header carries two and a
678
+ // receiver that reads the first sees a name the filename does not match
679
+ for (const paramKey of Object.keys(structured.params)) {
680
+ if (/^name(\*|$)/i.test(paramKey)) {
681
+ delete structured.params[paramKey];
682
+ }
683
+ }
684
+ }
617
685
  value = mimeFuncs.buildHeaderValue(structured);
618
686
  if (this.filename) {
619
687
  // add support for non-compliant clients like QQ webmail
@@ -1223,12 +1291,12 @@ class MimeNode {
1223
1291
  _normalizeHeaderKey(key) {
1224
1292
  key = (key || '')
1225
1293
  .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()
1294
+ // a field name is printable ascii without the colon, so a control char, DEL,
1295
+ // whitespace or a colon can only be dropped, there is no quoting construct
1296
+ // around a field name. A colon or a space left in place ends the name early on
1297
+ // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
1298
+ // second From header that neither setHeader() nor the Bcc filter could match
1299
+ .replace(/[\s\x00-\x20\x7f:]/g, '')
1232
1300
  .toLowerCase()
1233
1301
  // use uppercase words, except MIME
1234
1302
  .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
@@ -1314,26 +1382,26 @@ class MimeNode {
1314
1382
  }
1315
1383
  return value;
1316
1384
  // space separated list of values enclosed in <>
1317
- case 'References':
1318
- value = [].concat
1319
- .apply([], [].concat(value || '').map(elm => {
1320
- elm = (elm || '')
1385
+ case 'References': {
1386
+ const ids = [];
1387
+ for (const entry of [].concat(value || '')) {
1388
+ const elm = (entry || '')
1321
1389
  .toString()
1322
1390
  .replace(/\r?\n|\r/g, ' ')
1323
1391
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
1324
1392
  .trim();
1325
- return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
1326
- }))
1327
- .map(elm => {
1328
- if (elm.charAt(0) !== '<') {
1329
- elm = '<' + elm;
1330
- }
1331
- if (elm.charAt(elm.length - 1) !== '>') {
1332
- elm = elm + '>';
1393
+ for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) {
1394
+ if (id.charAt(0) !== '<') {
1395
+ id = '<' + id;
1396
+ }
1397
+ if (id.charAt(id.length - 1) !== '>') {
1398
+ id = id + '>';
1399
+ }
1400
+ ids.push(id);
1333
1401
  }
1334
- return elm;
1335
- });
1336
- return value.join(' ').trim();
1402
+ }
1403
+ return ids.join(' ').trim();
1404
+ }
1337
1405
  case 'Date':
1338
1406
  if (Object.prototype.toString.call(value) === '[object Date]') {
1339
1407
  return value.toUTCString().replace(/GMT/, '+0000');
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.1.1";
2
+ export declare const version = "10.1.3";
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.1';
6
+ exports.version = '10.1.3';
7
7
  exports.homepage = 'https://nodemailer.com/';
@@ -58,10 +58,27 @@ const punycode = __importStar(require("../punycode/index.js"));
58
58
  // Matches a "scheme:" not followed by "//" (and with something after it), used
59
59
  // to re-insert the authority separator the legacy parser did not require.
60
60
  const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/;
61
- // Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it
62
- // looks at the input. Stripped up front so that the slash-less form is recognized in a
63
- // value read from a file with a trailing newline as well.
64
- const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g;
61
+ /**
62
+ * Drops the leading and trailing C0 controls and spaces, which the WHATWG parser strips
63
+ * before it looks at the input. Stripped up front so that the slash-less form is recognized
64
+ * in a value read from a file with a trailing newline as well. An index scan, the regex
65
+ * /^[\x00-\x20]+|[\x00-\x20]+$/g retried a run of spaces inside the value from every
66
+ * position in it
67
+ *
68
+ * @param input URL string
69
+ * @returns The string without the surrounding controls and spaces
70
+ */
71
+ function stripSurroundingWhitespace(input) {
72
+ let start = 0;
73
+ let end = input.length;
74
+ while (start < end && input.charCodeAt(start) <= 0x20) {
75
+ start++;
76
+ }
77
+ while (end > start && input.charCodeAt(end - 1) <= 0x20) {
78
+ end--;
79
+ }
80
+ return input.slice(start, end);
81
+ }
65
82
  // Leading characters legacy url.parse() skips before it reads the scheme
66
83
  const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/;
67
84
  // The authority of a "scheme://authority/..." or a scheme-relative "//authority/..."
@@ -156,7 +173,7 @@ function normalizeHostname(raw, href) {
156
173
  return mapped;
157
174
  }
158
175
  const parse = (input, parseQueryString) => {
159
- input = (input || '').replace(SURROUNDING_WHITESPACE, '');
176
+ input = stripSurroundingWhitespace(input || '');
160
177
  // Legacy url.parse() parses a "user:pass@host:port" authority that follows
161
178
  // the scheme even without the "//" separator, for schemes outside its
162
179
  // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG
@@ -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
@@ -88,6 +92,12 @@ function nmfetch(url, options) {
88
92
  options.cookies = options.cookies || new Cookies();
89
93
  options.redirects = options.redirects || 0;
90
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
+ }
91
101
  const fetchRes = options.fetchRes;
92
102
  const parsed = parseFetchUrl(url);
93
103
  if (!parsed) {
@@ -233,12 +243,22 @@ function nmfetch(url, options) {
233
243
  });
234
244
  return fetchRes;
235
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
+ };
236
255
  // reports the first failure of this request on fetchRes and releases the request
237
256
  const fail = (err, sourceUrl = url) => {
238
257
  if (finished) {
239
258
  return;
240
259
  }
241
260
  finished = true;
261
+ clearDeadline();
242
262
  setFetchCode(err);
243
263
  err.sourceUrl = sourceUrl;
244
264
  fetchRes.emit('error', err);
@@ -248,10 +268,16 @@ function nmfetch(url, options) {
248
268
  if (timeout) {
249
269
  req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
250
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
+ }
251
276
  req.on('error', (err) => fail(err));
252
277
  // a consumer that destroys the response stream before it ended does not want the rest of
253
278
  // the body, release the request now instead of when the timeout fires
254
279
  fetchRes.once('close', () => {
280
+ clearDeadline();
255
281
  if (finished || fetchRes.readableEnded) {
256
282
  return;
257
283
  }
@@ -320,6 +346,7 @@ function nmfetch(url, options) {
320
346
  // this request is done with, release it so its socket and timeout do not
321
347
  // outlive it and report a late error on the shared fetchRes
322
348
  finished = true;
349
+ clearDeadline();
323
350
  res.resume();
324
351
  req.abort();
325
352
  return nmfetch(redirectUrl, options);
@@ -330,6 +357,8 @@ function nmfetch(url, options) {
330
357
  return fail(new Error('Invalid status code ' + res.statusCode));
331
358
  }
332
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());
333
362
  const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
334
363
  const source = inflate || res;
335
364
  let received = 0;
@@ -367,4 +396,5 @@ function nmfetch(url, options) {
367
396
  }
368
397
  nmfetch.Cookies = Cookies;
369
398
  nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
399
+ nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
370
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;
@@ -145,7 +145,10 @@ export default class MailMessage {
145
145
  const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent];
146
146
  for (const key of ['attachments', 'alternatives']) {
147
147
  if (Array.isArray(data[key])) {
148
- values.push(...data[key]);
148
+ // one at a time, spreading a long enough list into the arguments throws
149
+ for (const entry of data[key]) {
150
+ values.push(entry);
151
+ }
149
152
  }
150
153
  }
151
154
  for (const value of values) {
@@ -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) : ''));
@@ -26,6 +26,31 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
26
26
  // else goes into angle brackets so that the header can not be read as more addresses than
27
27
  // the envelope carries
28
28
  const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
29
+ /**
30
+ * Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
31
+ * it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
32
+ * from every such "<"
33
+ *
34
+ * @param value References header value
35
+ * @returns The value with the msg-ids closed up
36
+ */
37
+ function stripWhitespaceInAngleBrackets(value) {
38
+ let result = '';
39
+ let pos = 0;
40
+ for (;;) {
41
+ const open = value.indexOf('<', pos);
42
+ if (open < 0) {
43
+ break;
44
+ }
45
+ const close = value.indexOf('>', open + 1);
46
+ if (close < 0) {
47
+ break;
48
+ }
49
+ result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, '');
50
+ pos = close + 1;
51
+ }
52
+ return result + value.slice(pos);
53
+ }
29
54
  // domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
30
55
  // mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
31
56
  // controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
@@ -33,6 +58,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
33
58
  // unroutable garbage into mail for a domain the sender never named. None of these
34
59
  // characters are legal in a domain, so keep them away from the mapper.
35
60
  const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
61
+ // RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
62
+ // the code points it encodes, so anything past these bounds could not have become a valid
63
+ // domain whichever way it is encoded. The bundled codec is super-linear in the length of a
64
+ // label, a 60 KB label of distinct code points took seconds to encode, several times per
65
+ // address, so it is only handed what could be a domain
66
+ const MAX_DOMAIN_LENGTH = 253;
67
+ const MAX_LABEL_LENGTH = 63;
36
68
  // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
37
69
  const PIPELINE_NOOP = () => false;
38
70
  // The error a node streaming into an output that was destroyed stops with
@@ -41,6 +73,27 @@ function abortedError() {
41
73
  err.code = errors.ESTREAM;
42
74
  return err;
43
75
  }
76
+ /**
77
+ * Checks a domain against the DNS length limits, counted in code points per label and in
78
+ * total. The label separators are the ones the bundled codec splits on.
79
+ *
80
+ * @param domain Domain to check
81
+ * @return true if a label or the domain as a whole is too long to be a valid domain
82
+ */
83
+ function exceedsDomainLimits(domain) {
84
+ let total = 0;
85
+ let label = 0;
86
+ for (const char of domain) {
87
+ total++;
88
+ if (char === '.' || char === '。' || char === '.' || char === '。') {
89
+ label = 0;
90
+ }
91
+ else if (++label > MAX_LABEL_LENGTH) {
92
+ return true;
93
+ }
94
+ }
95
+ return total > MAX_DOMAIN_LENGTH;
96
+ }
44
97
  /**
45
98
  * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
46
99
  * which is with UTS-46 mapping applied before the Punycode step.
@@ -72,6 +125,10 @@ function normalizeDomain(domain, toUnicode) {
72
125
  return mapped;
73
126
  }
74
127
  }
128
+ if (exceedsDomainLimits(domain)) {
129
+ // too long to be a domain whichever way it is encoded, keep it as supplied
130
+ return domain;
131
+ }
75
132
  return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
76
133
  }
77
134
  /**
@@ -576,6 +633,17 @@ class MimeNode {
576
633
  /[\u0080-\uFFFF]/.test(this.content)) {
577
634
  structured.params.charset = 'utf-8';
578
635
  }
636
+ if (this.filename) {
637
+ // the name parameter is derived from the filename below, so a name that
638
+ // arrived inside the content type is dropped first (a plain parameter or
639
+ // rfc2231 continuation sections), otherwise the header carries two and a
640
+ // receiver that reads the first sees a name the filename does not match
641
+ for (const paramKey of Object.keys(structured.params)) {
642
+ if (/^name(\*|$)/i.test(paramKey)) {
643
+ delete structured.params[paramKey];
644
+ }
645
+ }
646
+ }
579
647
  value = mimeFuncs.buildHeaderValue(structured);
580
648
  if (this.filename) {
581
649
  // add support for non-compliant clients like QQ webmail
@@ -1185,12 +1253,12 @@ class MimeNode {
1185
1253
  _normalizeHeaderKey(key) {
1186
1254
  key = (key || '')
1187
1255
  .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()
1256
+ // a field name is printable ascii without the colon, so a control char, DEL,
1257
+ // whitespace or a colon can only be dropped, there is no quoting construct
1258
+ // around a field name. A colon or a space left in place ends the name early on
1259
+ // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
1260
+ // second From header that neither setHeader() nor the Bcc filter could match
1261
+ .replace(/[\s\x00-\x20\x7f:]/g, '')
1194
1262
  .toLowerCase()
1195
1263
  // use uppercase words, except MIME
1196
1264
  .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
@@ -1276,26 +1344,26 @@ class MimeNode {
1276
1344
  }
1277
1345
  return value;
1278
1346
  // space separated list of values enclosed in <>
1279
- case 'References':
1280
- value = [].concat
1281
- .apply([], [].concat(value || '').map(elm => {
1282
- elm = (elm || '')
1347
+ case 'References': {
1348
+ const ids = [];
1349
+ for (const entry of [].concat(value || '')) {
1350
+ const elm = (entry || '')
1283
1351
  .toString()
1284
1352
  .replace(/\r?\n|\r/g, ' ')
1285
1353
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
1286
1354
  .trim();
1287
- return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
1288
- }))
1289
- .map(elm => {
1290
- if (elm.charAt(0) !== '<') {
1291
- elm = '<' + elm;
1292
- }
1293
- if (elm.charAt(elm.length - 1) !== '>') {
1294
- elm = elm + '>';
1355
+ for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) {
1356
+ if (id.charAt(0) !== '<') {
1357
+ id = '<' + id;
1358
+ }
1359
+ if (id.charAt(id.length - 1) !== '>') {
1360
+ id = id + '>';
1361
+ }
1362
+ ids.push(id);
1295
1363
  }
1296
- return elm;
1297
- });
1298
- return value.join(' ').trim();
1364
+ }
1365
+ return ids.join(' ').trim();
1366
+ }
1299
1367
  case 'Date':
1300
1368
  if (Object.prototype.toString.call(value) === '[object Date]') {
1301
1369
  return value.toUTCString().replace(/GMT/, '+0000');
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.1.1";
2
+ export declare const version = "10.1.3";
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.1';
3
+ export const version = '10.1.3';
4
4
  export const homepage = 'https://nodemailer.com/';
@@ -19,10 +19,27 @@ import * as punycode from '../punycode/index.js';
19
19
  // Matches a "scheme:" not followed by "//" (and with something after it), used
20
20
  // to re-insert the authority separator the legacy parser did not require.
21
21
  const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/;
22
- // Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it
23
- // looks at the input. Stripped up front so that the slash-less form is recognized in a
24
- // value read from a file with a trailing newline as well.
25
- const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g;
22
+ /**
23
+ * Drops the leading and trailing C0 controls and spaces, which the WHATWG parser strips
24
+ * before it looks at the input. Stripped up front so that the slash-less form is recognized
25
+ * in a value read from a file with a trailing newline as well. An index scan, the regex
26
+ * /^[\x00-\x20]+|[\x00-\x20]+$/g retried a run of spaces inside the value from every
27
+ * position in it
28
+ *
29
+ * @param input URL string
30
+ * @returns The string without the surrounding controls and spaces
31
+ */
32
+ function stripSurroundingWhitespace(input) {
33
+ let start = 0;
34
+ let end = input.length;
35
+ while (start < end && input.charCodeAt(start) <= 0x20) {
36
+ start++;
37
+ }
38
+ while (end > start && input.charCodeAt(end - 1) <= 0x20) {
39
+ end--;
40
+ }
41
+ return input.slice(start, end);
42
+ }
26
43
  // Leading characters legacy url.parse() skips before it reads the scheme
27
44
  const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/;
28
45
  // The authority of a "scheme://authority/..." or a scheme-relative "//authority/..."
@@ -117,7 +134,7 @@ function normalizeHostname(raw, href) {
117
134
  return mapped;
118
135
  }
119
136
  export const parse = (input, parseQueryString) => {
120
- input = (input || '').replace(SURROUNDING_WHITESPACE, '');
137
+ input = stripSurroundingWhitespace(input || '');
121
138
  // Legacy url.parse() parses a "user:pass@host:port" authority that follows
122
139
  // the scheme even without the "//" separator, for schemes outside its
123
140
  // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG
@@ -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.1",
3
+ "version": "10.1.3",
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",