nodemailer 9.0.3 → 9.0.5

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,31 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [9.0.5](https://github.com/nodemailer/nodemailer/compare/v9.0.4...v9.0.5) (2026-08-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **ci:** retrigger the workflows dropped during the Actions outage ([85d16c1](https://github.com/nodemailer/nodemailer/commit/85d16c103ec69e237c7e55c0e3103f439c135ce3))
9
+ * **mailer:** escape specials in List-* header comments ([#1842](https://github.com/nodemailer/nodemailer/issues/1842)) ([75913bb](https://github.com/nodemailer/nodemailer/commit/75913bba032623046dd7fa037b8b880e388d8357))
10
+ * **mime-funcs:** star the continuation key of a restarted parameter line ([36bcf1a](https://github.com/nodemailer/nodemailer/commit/36bcf1a21a92b5a283c780e35aacd3080292d98d))
11
+ * **mime-node:** keep control chars out of header values and msg-id headers ([15cf6d1](https://github.com/nodemailer/nodemailer/commit/15cf6d1c15cdf60f551618fd54fb727ea7aac94c))
12
+ * **mime:** encode DEL in header parameters and List-* comments ([cf69430](https://github.com/nodemailer/nodemailer/commit/cf69430ffac1d246bfbab564daf321f31ed9e1dd))
13
+ * **mime:** keep control chars out of the remaining header positions ([5ed9d26](https://github.com/nodemailer/nodemailer/commit/5ed9d26f85eecb48f4b3ae74ad33ed2abf002040))
14
+ * **mime:** normalize an address parsed out of a string as well ([63685f7](https://github.com/nodemailer/nodemailer/commit/63685f7dd4aefa75cc72a36f983f32f61cd6733e))
15
+ * **mime:** normalize an address so header and envelope agree ([a9343b4](https://github.com/nodemailer/nodemailer/commit/a9343b47e42b9ccb27911ad8e73d4119c6170c85))
16
+ * **mime:** stop a header key callback and the dkim tags from injecting ([b7d772e](https://github.com/nodemailer/nodemailer/commit/b7d772ea4ec12ee82e65a9b919af882bf0f125d9))
17
+
18
+ ## [9.0.4](https://github.com/nodemailer/nodemailer/compare/v9.0.3...v9.0.4) (2026-08-04)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * **mime-funcs:** do not let an unpaired surrogate consume the next character ([9797f7f](https://github.com/nodemailer/nodemailer/commit/9797f7f57d47b1e27d8ae050550ba93600dbf9f4))
24
+ * **mime-funcs:** keep any surrogate pair intact when chunking base64 mime words ([#1838](https://github.com/nodemailer/nodemailer/issues/1838)) ([5bd3a65](https://github.com/nodemailer/nodemailer/commit/5bd3a657be2d12df1ac2838a1673cb4885e99016))
25
+ * **mime-funcs:** percent encode unpaired surrogates in header parameter values ([78f4aa2](https://github.com/nodemailer/nodemailer/commit/78f4aa253d0ebb9c5ba91301adacef5fb6cf5493))
26
+ * **mime-node:** escape backslash and quote in the Content-Type name parameter ([#1837](https://github.com/nodemailer/nodemailer/issues/1837)) ([adcfc4f](https://github.com/nodemailer/nodemailer/commit/adcfc4f46445edc8a14136b2bf3f775943425c13))
27
+ * **mime:** encode HT/CR/LF in header parameter values instead of quoting them ([#1840](https://github.com/nodemailer/nodemailer/issues/1840)) ([5bc9cab](https://github.com/nodemailer/nodemailer/commit/5bc9cabddcb8d18d16244701ae9facc8fba942a3))
28
+
3
29
  ## [9.0.3](https://github.com/nodemailer/nodemailer/compare/v9.0.2...v9.0.3) (2026-06-30)
4
30
 
5
31
 
@@ -1,5 +1,39 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * Restores the quoting of a local part that was read out of a quoted string.
5
+ *
6
+ * RFC 5321 allows '@' inside a quoted local part, so handing '"user@evil.com"@good.com'
7
+ * on as the bare 'user@evil.com@good.com' leaves it to the consumer which '@' splits the
8
+ * domain off. Getting that wrong is a misrouting vector, so the quotes go back on. The
9
+ * same holds for the other specials: a ',' or a ';' that loses its quotes reads as a
10
+ * recipient separator once the consumer puts the address back into a header.
11
+ *
12
+ * This module has no dependencies so that it can ship on its own, which is why the two
13
+ * grammar tests below are spelled out here instead of shared with lib/mime-node. Keeping
14
+ * only what is ambiguous quoted is deliberate, mime-node applies the stricter RFC 5321
15
+ * dot-atom rule on top of this when it emits an address.
16
+ *
17
+ * @param {String} address Address with an unquoted local part
18
+ * @return {String} Address with the local part as a quoted-string
19
+ */
20
+ function _quoteLocalPart(address) {
21
+ const lastAt = address.lastIndexOf('@');
22
+ if (lastAt < 0) {
23
+ // no domain to split off, nothing can be misrouted
24
+ return address;
25
+ }
26
+
27
+ const user = address.substr(0, lastAt);
28
+ if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) {
29
+ // a local part that carries no special reads the same with or without the quotes,
30
+ // and one that is already a complete quoted-string needs nothing either
31
+ return address;
32
+ }
33
+
34
+ return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
35
+ }
36
+
3
37
  /**
4
38
  * Converts tokens for a single address into an address object
5
39
  *
@@ -145,6 +179,10 @@ function _handleAddress(tokens, depth) {
145
179
  data.text = data.text.concat(data.address.splice(1));
146
180
  }
147
181
 
182
+ // An address is only taken from unquoted text, so anything left in the text at this
183
+ // point that still has to serve as the address carries its quoting in this flag
184
+ const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
185
+
148
186
  // Join values with spaces
149
187
  data.text = data.text.join(' ');
150
188
  data.address = data.address.join(' ');
@@ -162,6 +200,10 @@ function _handleAddress(tokens, depth) {
162
200
  }
163
201
  }
164
202
 
203
+ if (addressFromQuotedText && address.address) {
204
+ address.address = _quoteLocalPart(address.address);
205
+ }
206
+
165
207
  addresses.push(address);
166
208
  }
167
209
 
package/lib/dkim/sign.js CHANGED
@@ -50,15 +50,20 @@ module.exports = (headers, hashAlgo, bodyHash, options) => {
50
50
  module.exports.relaxedHeaders = relaxedHeaders;
51
51
 
52
52
  function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
53
+ // the caller supplied tag values are interpolated straight into the tag list, and none of
54
+ // them has any way to carry a control char, DEL, or one of the delimiters that would close
55
+ // the value and open a tag of its own
56
+ const cleanTagValue = value => (value || '').toString().replace(/[\x00-\x1f\x7f;=]/g, '');
57
+
53
58
  const dkim = [
54
59
  'v=1',
55
60
  'a=rsa-' + hashAlgo,
56
61
  'c=relaxed/relaxed',
57
- 'd=' + punycode.toASCII(domainName),
62
+ 'd=' + punycode.toASCII(cleanTagValue(domainName)),
58
63
  'q=dns/txt',
59
- 's=' + keySelector,
64
+ 's=' + cleanTagValue(keySelector),
60
65
  'bh=' + bodyHash,
61
- 'h=' + fieldNames
66
+ 'h=' + cleanTagValue(fieldNames)
62
67
  ].join('; ');
63
68
 
64
69
  return mimeFuncs.foldLines('DKIM-Signature: ' + dkim, 76) + ';\r\n b=';
@@ -33,7 +33,7 @@ class JSONTransport {
33
33
  // Sendmail strips this header line by itself
34
34
  mail.message.keepBcc = true;
35
35
 
36
- const envelope = mail.data.envelope || mail.message.getEnvelope();
36
+ const envelope = mail.message.getEnvelope();
37
37
  const messageId = mail.message.messageId();
38
38
 
39
39
  const recipients = [].concat(envelope.to || []);
@@ -140,7 +140,7 @@ class MailMessage {
140
140
  }
141
141
 
142
142
  normalize(callback) {
143
- const envelope = this.data.envelope || this.message.getEnvelope();
143
+ const envelope = this.message.getEnvelope();
144
144
  const messageId = this.message.messageId();
145
145
 
146
146
  this.resolveAll((err, data) => {
@@ -271,15 +271,16 @@ class MailMessage {
271
271
  }
272
272
 
273
273
  if (value && value.url) {
274
+ // strip CR/LF so a comment can't inject extra header lines. DEL is neither
275
+ // qtext nor ctext, so it can not be carried literally by either construct
276
+ // and has to become an encoded word like any other non-plaintext value
277
+ let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
278
+ const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment);
279
+
274
280
  if (key.toLowerCase().trim() === 'id') {
275
- // List-ID: "comment" <domain>
276
- // strip CR/LF so a comment can't inject extra header lines
277
- let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
278
- if (mimeFuncs.isPlainText(comment)) {
279
- comment = '"' + comment + '"';
280
- } else {
281
- comment = mimeFuncs.encodeWord(comment);
282
- }
281
+ // List-ID: "comment" <domain>, where an unescaped quote or a trailing
282
+ // backslash in the comment would swallow the <domain> behind it
283
+ comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
283
284
 
284
285
  // List-ID expects a bare domain-like identifier, so strip the
285
286
  // scheme prefix that _formatListUrl adds or passes through
@@ -289,11 +290,11 @@ class MailMessage {
289
290
  }
290
291
 
291
292
  // List-*: <http://domain> (comment)
292
- // strip CR/LF so a comment can't inject extra header lines
293
- let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
294
- if (!mimeFuncs.isPlainText(comment)) {
295
- comment = mimeFuncs.encodeWord(comment);
296
- }
293
+ // the ctext specials go out as quoted-pairs, otherwise a ")" closes the
294
+ // comment early and leaves the rest as junk, an unpaired "(" opens a
295
+ // nested comment that never closes, and a trailing backslash escapes
296
+ // the closing ")" so the comment swallows whatever follows it
297
+ comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&');
297
298
 
298
299
  return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
299
300
  }
@@ -307,7 +308,9 @@ class MailMessage {
307
308
  }
308
309
 
309
310
  _formatListUrl(url) {
310
- url = url.replace(/[\s<]+|[\s>]+/g, '');
311
+ // a url has no way to carry a control char or DEL, and the angle brackets around it
312
+ // are not a quoting construct, so anything left here lands in the header raw
313
+ url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, '');
311
314
  if (/^(https?|mailto|ftp):/.test(url)) {
312
315
  return '<' + url + '>';
313
316
  }
@@ -10,14 +10,33 @@ module.exports = {
10
10
  /**
11
11
  * Checks if a value is plaintext string (uses only printable 7bit chars)
12
12
  *
13
+ * When isParam is set the value is destined for a header parameter, so HT, CR and LF
14
+ * are not plaintext either: a header parameter has no way to carry them. HT is a valid
15
+ * fold point, so folding and unfolding a header would rewrite it as a space, and CR/LF
16
+ * cannot appear in a header value at all. DEL is neither a token character nor qtext,
17
+ * so it can not be carried bare or quoted. Such values have to go through the rfc2231
18
+ * parameter continuation encoding instead, the same way a quote already does.
19
+ *
13
20
  * @param {String} value String to be tested
21
+ * @param {Boolean} [isParam] Set to true if the value is a header parameter value
14
22
  * @returns {Boolean} true if it is a plaintext string
15
23
  */
16
24
  isPlainText(value, isParam) {
17
- const re = isParam ? /[\x00-\x08\x0b\x0c\x0e-\x1f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
25
+ const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
18
26
  return typeof value === 'string' && !re.test(value);
19
27
  },
20
28
 
29
+ /**
30
+ * Wraps a value into a quoted-string. Inside one a quote would end the string early
31
+ * and a backslash would escape whatever follows it, so both go out as quoted-pairs.
32
+ *
33
+ * @param {String} value String to be quoted
34
+ * @returns {String} The value as a quoted-string, quotes included
35
+ */
36
+ quoteString(value) {
37
+ return '"' + (value || '').toString().replace(/["\\]/g, '\\$&') + '"';
38
+ },
39
+
21
40
  /**
22
41
  * Checks if a multi line string containes lines longer than the selected value.
23
42
  *
@@ -80,8 +99,10 @@ module.exports = {
80
99
  for (let i = 0, len = encodedStr.length; i < len; i++) {
81
100
  let chr = encodedStr.charAt(i);
82
101
 
83
- if (/[\ud83c\ud83d\ud83e]/.test(chr) && i < len - 1) {
84
- // composite emoji byte, so add the next byte as well
102
+ if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
103
+ // leading surrogate, so add the trailing surrogate as well
104
+ // an unpaired one must not swallow the next unit, that would destroy
105
+ // a valid pair following it
85
106
  chr += encodedStr.charAt(++i);
86
107
  }
87
108
 
@@ -168,10 +189,12 @@ module.exports = {
168
189
  buildHeaderValue(structured) {
169
190
  const paramsArray = [];
170
191
 
171
- Object.keys(structured.params || {}).forEach(param => {
192
+ Object.keys(structured.params || {}).forEach(key => {
172
193
  // filename might include unicode characters so it is a special case
173
194
  // other values probably do not
174
- const value = structured.params[param];
195
+ const value = structured.params[key];
196
+ // a parameter name is a token too and it is emitted without any quoting around it
197
+ const param = key.replace(/[\x00-\x1f\x7f]/g, '');
175
198
  if (!this.isPlainText(value, true) || value.length >= 75) {
176
199
  this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
177
200
  if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
@@ -187,7 +210,11 @@ module.exports = {
187
210
  }
188
211
  });
189
212
 
190
- return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
213
+ // the value ahead of the parameters is a token, it has no way to carry a control
214
+ // char or DEL and there is no quoting construct around it to escape one into
215
+ const value = typeof structured.value === 'string' ? structured.value.replace(/[\x00-\x1f\x7f]/g, '') : structured.value;
216
+
217
+ return value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
191
218
  },
192
219
 
193
220
  /**
@@ -208,7 +235,7 @@ module.exports = {
208
235
  buildHeaderParam(key, data, maxLength) {
209
236
  const list = [];
210
237
  let encodedStr = typeof data === 'string' ? data : (data || '').toString();
211
- let chr, ord;
238
+ let chr;
212
239
  let line;
213
240
  let startPos = 0;
214
241
  let i, len;
@@ -245,8 +272,9 @@ module.exports = {
245
272
  const encodedStrArr = [];
246
273
  for (i = 0, len = encodedStr.length; i < len; i++) {
247
274
  chr = encodedStr.charAt(i);
248
- ord = chr.charCodeAt(0);
249
- if (ord >= 0xd800 && ord <= 0xdbff && i < len - 1) {
275
+ if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
276
+ // an unpaired leading surrogate must not consume the next unit, that
277
+ // would tear apart a valid pair following it
250
278
  chr += encodedStr.charAt(i + 1);
251
279
  encodedStrArr.push(chr);
252
280
  i++;
@@ -284,8 +312,11 @@ module.exports = {
284
312
  line,
285
313
  encoded
286
314
  });
315
+ // the line we start here holds an encoded char, so it has to be
316
+ // flagged as one. otherwise it gets an unstarred continuation key
317
+ // and a receiver reads the percent escapes as literal text
287
318
  line = '';
288
- startPos = i - 1;
319
+ encoded = true;
289
320
  } else {
290
321
  encoded = true;
291
322
  i = startPos;
@@ -600,8 +631,11 @@ module.exports = {
600
631
  // might throw if we try to encode invalid sequences, eg. partial emoji
601
632
  str = encodeURIComponent(str);
602
633
  } catch (_E) {
603
- // should never run
604
- return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, '');
634
+ // an unpaired surrogate has no utf-8 representation, so run the value through a
635
+ // utf-8 roundtrip to get the same U+FFFD every other encoder here produces and
636
+ // retry. the value must never come back unencoded, it goes into a header parameter
637
+ // where a bare quote or semicolon would break it out into a parameter of its own
638
+ str = encodeURIComponent(Buffer.from(str, 'utf-8').toString('utf-8'));
605
639
  }
606
640
 
607
641
  // ensure chars that are not handled by encodeURICompent are converted as well
@@ -21,6 +21,20 @@ const LeUnix = require('./le-unix');
21
21
 
22
22
  const FORMATTED_HEADERS = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References'];
23
23
 
24
+ // RFC 5321 atext, plus the non-ascii bytes that SMTPUTF8 (RFC 6531) adds to it. A local part
25
+ // built from these, with '.' as a separator, is a dot-atom and can be emitted bare
26
+ const ATEXT = "[A-Za-z0-9!#$%&'*+\\-/=?^_`{|}~\\x80-\\uFFFF]";
27
+ const DOT_ATOM = new RegExp('^' + ATEXT + '+(?:\\.' + ATEXT + '+)*$');
28
+
29
+ // A complete quoted-string: everything between the outer quotes is either a plain char or
30
+ // a quoted-pair. Anchored, so a value that only starts and ends with a quote does not pass
31
+ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
32
+
33
+ // An address that carries no special anywhere can be emitted bare in a header, everything
34
+ // else goes into angle brackets so that the header can not be read as more addresses than
35
+ // the envelope carries
36
+ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
37
+
24
38
  /**
25
39
  * Creates a new mime tree node. Assumes 'multipart/*' as the content type
26
40
  * if it is a branch, anything else counts as leaf. If rootNode is missing from
@@ -552,6 +566,11 @@ class MimeNode {
552
566
  case 'Content-Type':
553
567
  structured = mimeFuncs.parseHeaderValue(value);
554
568
 
569
+ // the type token decides multipart and charset below, so clean it before
570
+ // those run and not just on the way out, otherwise a control char makes
571
+ // the checks miss and the header ends up claiming a type it is not set up for
572
+ structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, '');
573
+
555
574
  this._handleContentType(structured);
556
575
 
557
576
  if (
@@ -568,11 +587,18 @@ class MimeNode {
568
587
  // add support for non-compliant clients like QQ webmail
569
588
  // we can't build the value with buildHeaderValue as the value is non standard and
570
589
  // would be converted to parameter continuation encoding that we do not want
571
- param = this._encodeWords(this.filename);
590
+ // control chars can not be quoted here: HT is a fold point that unfolding would
591
+ // turn into a space, CR/LF can not appear in a header at all and DEL is not
592
+ // qtext, so force the mime encoded word that a non-ascii filename would get anyway
593
+ param = /[\x00-\x1f\x7f]/.test(this.filename)
594
+ ? mimeFuncs.encodeWord(this.filename, this._getTextEncoding(this.filename), 52)
595
+ : this._encodeWords(this.filename);
572
596
 
573
597
  if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) {
574
- // include value in quotes if needed
575
- param = '"' + param + '"';
598
+ // include value in quotes if needed, escaping backslashes and quotes as
599
+ // quoted-pairs exactly like buildHeaderValue does for filename=, otherwise
600
+ // a trailing backslash would escape the closing quote
601
+ param = JSON.stringify(param);
576
602
  }
577
603
  value += '; name=' + param;
578
604
  }
@@ -595,8 +621,12 @@ class MimeNode {
595
621
 
596
622
  if (typeof this.normalizeHeaderKey === 'function') {
597
623
  const normalized = this.normalizeHeaderKey(key, value);
598
- if (normalized && typeof normalized === 'string' && normalized.length) {
599
- key = normalized;
624
+ // the result replaces the key on the way into the header, so it gets the same
625
+ // treatment the key it replaces already had. a line break here would end the
626
+ // header and start one of the caller's own
627
+ const cleaned = typeof normalized === 'string' ? normalized.replace(/[\x00-\x1f\x7f]/g, '') : '';
628
+ if (cleaned) {
629
+ key = cleaned;
600
630
  }
601
631
  }
602
632
 
@@ -837,7 +867,7 @@ class MimeNode {
837
867
 
838
868
  if (envelope.from) {
839
869
  list = [];
840
- this._convertAddresses(this._parseAddresses(envelope.from), list);
870
+ this._convertAddresses(this._parseEnvelopeAddresses(envelope.from), list);
841
871
  list = list.filter(address => address && address.address);
842
872
  if (list.length && list[0]) {
843
873
  this._envelope.from = list[0].address;
@@ -845,7 +875,7 @@ class MimeNode {
845
875
  }
846
876
  ['to', 'cc', 'bcc'].forEach(key => {
847
877
  if (envelope[key]) {
848
- this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
878
+ this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to);
849
879
  }
850
880
  });
851
881
 
@@ -1034,15 +1064,67 @@ class MimeNode {
1034
1064
  [],
1035
1065
  [].concat(addresses).map(address => {
1036
1066
  if (address && address.address) {
1037
- address.address = this._normalizeAddress(address.address);
1038
- address.name = address.name || '';
1039
- return [address];
1067
+ const normalized = this._normalizeAddress(address.address);
1068
+ if (normalized === address.address && typeof address.name === 'string') {
1069
+ // there is nothing to rewrite, so there is nothing to keep off the original
1070
+ return [address];
1071
+ }
1072
+
1073
+ // rewriting would land on the object the caller passed in and might
1074
+ // still hold a reference to, so rewrite a copy of it instead
1075
+ const copy = Object.assign({}, address);
1076
+ copy.address = normalized;
1077
+ copy.name = address.name || '';
1078
+ return [copy];
1040
1079
  }
1041
- return addressparser(address);
1080
+ return this._normalizeParsedAddresses(addressparser(address));
1042
1081
  })
1043
1082
  );
1044
1083
  }
1045
1084
 
1085
+ /**
1086
+ * Normalizes the addresses of a freshly parsed address list, groups included.
1087
+ *
1088
+ * Everything this method returns carries a normalized address, whether it arrived as an
1089
+ * object or was parsed out of a header value. Without this the two shapes disagree, and
1090
+ * a consumer reading the parsed form back is handed the ambiguous
1091
+ * 'user@evil.com@good.com' that the header and the envelope no longer carry.
1092
+ *
1093
+ * @param {Array} parsed An array of address objects, as returned by addressparser
1094
+ * @return {Array} The same array, with every address normalized
1095
+ */
1096
+ _normalizeParsedAddresses(parsed) {
1097
+ // addressparser builds these objects, so no caller holds a reference to rewrite around
1098
+ parsed.forEach(entry => {
1099
+ if (entry.address) {
1100
+ entry.address = this._normalizeAddress(entry.address);
1101
+ } else if (entry.group) {
1102
+ this._normalizeParsedAddresses(entry.group);
1103
+ }
1104
+ });
1105
+
1106
+ return parsed;
1107
+ }
1108
+
1109
+ /**
1110
+ * Parses the addresses of an explicitly set envelope.
1111
+ *
1112
+ * An envelope value is an addr-spec and never a display name, so a bare local username
1113
+ * such as 'root' is the address here. Header parsing has to read the same value as a
1114
+ * display name, as a value with no '@' in it can not be an addr-spec in a header.
1115
+ *
1116
+ * @param {Mixed} addresses Addresses to be parsed
1117
+ * @return {Array} An array of address objects
1118
+ */
1119
+ _parseEnvelopeAddresses(addresses) {
1120
+ return this._parseAddresses(addresses).map(entry => {
1121
+ if (entry.address || entry.group || !entry.name || /[\s@]/.test(entry.name)) {
1122
+ return entry;
1123
+ }
1124
+ return { address: this._normalizeAddress(entry.name), name: '' };
1125
+ });
1126
+ }
1127
+
1046
1128
  /**
1047
1129
  * Normalizes a header key, uses Camel-Case form, except for uppercase MIME-
1048
1130
  *
@@ -1054,6 +1136,9 @@ class MimeNode {
1054
1136
  .toString()
1055
1137
  // no newlines in keys
1056
1138
  .replace(/\r?\n|\r/g, ' ')
1139
+ // a field name is printable ascii without the colon, so a control char or DEL
1140
+ // can only be dropped, there is no quoting construct around a field name
1141
+ .replace(/[\x00-\x1f\x7f]/g, '')
1057
1142
  .trim()
1058
1143
  .toLowerCase()
1059
1144
  // use uppercase words, except MIME
@@ -1114,7 +1199,13 @@ class MimeNode {
1114
1199
  case 'Message-ID':
1115
1200
  case 'In-Reply-To':
1116
1201
  case 'Content-Id':
1117
- value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1202
+ // a msg-id is structured, so an encoded word inside the angle brackets would
1203
+ // be read as literal text. drop the characters that can not appear in a header
1204
+ // at all, but leave HT alone, it separates the ids of a multi id value
1205
+ value = (value || '')
1206
+ .toString()
1207
+ .replace(/\r?\n|\r/g, ' ')
1208
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
1118
1209
 
1119
1210
  if (value.charAt(0) !== '<') {
1120
1211
  value = '<' + value;
@@ -1134,6 +1225,7 @@ class MimeNode {
1134
1225
  elm = (elm || '')
1135
1226
  .toString()
1136
1227
  .replace(/\r?\n|\r/g, ' ')
1228
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
1137
1229
  .trim();
1138
1230
  return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
1139
1231
  })
@@ -1156,7 +1248,7 @@ class MimeNode {
1156
1248
  }
1157
1249
 
1158
1250
  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1159
- return this._encodeWords(value);
1251
+ return this._encodeHeaderText(value);
1160
1252
 
1161
1253
  case 'Content-Type':
1162
1254
  case 'Content-Disposition':
@@ -1165,8 +1257,7 @@ class MimeNode {
1165
1257
 
1166
1258
  default:
1167
1259
  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1168
- // encodeWords only encodes if needed, otherwise the original string is returned
1169
- return this._encodeWords(value);
1260
+ return this._encodeHeaderText(value);
1170
1261
  }
1171
1262
  }
1172
1263
 
@@ -1187,7 +1278,11 @@ class MimeNode {
1187
1278
  address.address = this._normalizeAddress(address.address);
1188
1279
 
1189
1280
  if (!address.name) {
1190
- values.push(address.address.indexOf(' ') >= 0 ? `<${address.address}>` : `${address.address}`);
1281
+ // an address that carries a special, be it a quoted local part or a domain
1282
+ // that could not be normalized, is only unambiguous inside angle brackets.
1283
+ // Without them a ',' or a ';' anywhere in it reads as a recipient separator
1284
+ // and the header would list more recipients than the envelope carries
1285
+ values.push(PLAIN_ADDRESS.test(address.address) ? address.address : `<${address.address}>`);
1191
1286
  } else {
1192
1287
  values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
1193
1288
  }
@@ -1213,19 +1308,26 @@ class MimeNode {
1213
1308
  _normalizeAddress(address) {
1214
1309
  address = (address || '')
1215
1310
  .toString()
1216
- .replace(/[\x00-\x1F<>]+/g, ' ') // remove unallowed characters
1311
+ .replace(/[\x00-\x1F\x7F<>]+/g, ' ') // remove unallowed characters
1217
1312
  .trim();
1218
1313
 
1314
+ if (!address) {
1315
+ // callers use an empty value to detect a missing address
1316
+ return address;
1317
+ }
1318
+
1219
1319
  const lastAt = address.lastIndexOf('@');
1220
1320
  if (lastAt < 0) {
1221
- // Bare username
1222
- return address;
1321
+ // Bare username, there is no domain to split off
1322
+ return this._normalizeLocalPart(address);
1223
1323
  }
1224
1324
 
1225
- let user = address.substr(0, lastAt);
1325
+ const user = address.substr(0, lastAt);
1226
1326
  const domain = address.substr(lastAt + 1);
1227
1327
 
1228
- // Usernames are not touched and are kept as is even if these include unicode.
1328
+ // Unicode in the local part is kept as is, see _normalizeLocalPart for the rest of it.
1329
+ // A domain has no quoting construct to fall back on, so whatever is not a valid domain
1330
+ // is kept as supplied and it is _convertAddresses that keeps such an address unambiguous.
1229
1331
  // Domains are punycoded when the local part is ASCII ('safe@jõgeva.ee' -> 'safe@xn--jgeva-dua.ee').
1230
1332
  // When the local part contains non-ASCII bytes the address already requires SMTPUTF8,
1231
1333
  // so the domain is kept (or decoded back) as UTF-8 for symmetry on both sides of '@'.
@@ -1242,16 +1344,27 @@ class MimeNode {
1242
1344
  // keep domain as supplied
1243
1345
  }
1244
1346
 
1245
- if (user.indexOf(' ') >= 0) {
1246
- if (user.charAt(0) !== '"') {
1247
- user = '"' + user;
1248
- }
1249
- if (user.substr(-1) !== '"') {
1250
- user = user + '"';
1251
- }
1347
+ return `${this._normalizeLocalPart(user)}@${encodedDomain}`;
1348
+ }
1349
+
1350
+ /**
1351
+ * Normalizes the local part of an address into a form that can be emitted as is.
1352
+ *
1353
+ * A local part is either a dot-atom or a quoted-string, anything else is not a valid
1354
+ * addr-spec. The quotes of a quoted local part get lost along the way, and a bare
1355
+ * 'user@evil.com@good.com' leaves it to the receiver which '@' splits the domain off,
1356
+ * while the split here is always at the last one. So whatever is not already one of
1357
+ * the two valid forms goes back out as a quoted-string.
1358
+ *
1359
+ * @param {String} user Local part of an address
1360
+ * @return {String} Local part as a dot-atom or as a quoted-string
1361
+ */
1362
+ _normalizeLocalPart(user) {
1363
+ if (DOT_ATOM.test(user) || QUOTED_STRING.test(user)) {
1364
+ return user;
1252
1365
  }
1253
1366
 
1254
- return `${user}@${encodedDomain}`;
1367
+ return mimeFuncs.quoteString(user);
1255
1368
  }
1256
1369
 
1257
1370
  /**
@@ -1263,7 +1376,7 @@ class MimeNode {
1263
1376
  _encodeAddressName(name) {
1264
1377
  if (!/^[\w ]*$/.test(name)) {
1265
1378
  if (/^[\x20-\x7e]*$/.test(name)) {
1266
- return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
1379
+ return mimeFuncs.quoteString(name);
1267
1380
  } else {
1268
1381
  return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
1269
1382
  }
@@ -1271,6 +1384,21 @@ class MimeNode {
1271
1384
  return name;
1272
1385
  }
1273
1386
 
1387
+ /**
1388
+ * Encodes an unstructured header value. Such a value can only carry VCHAR and WSP, so a
1389
+ * control char or DEL has to be forced into the mime encoded word that a non-ascii value
1390
+ * would get anyway. HT stays as it is, it is valid folding whitespace here.
1391
+ *
1392
+ * @param {String} value Header value to encode
1393
+ * @returns {String} Mime word encoded string if needed
1394
+ */
1395
+ _encodeHeaderText(value) {
1396
+ return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
1397
+ ? mimeFuncs.encodeWord(value, this._getTextEncoding(value), 52)
1398
+ : // encodeWords only encodes if needed, otherwise the original string is returned
1399
+ this._encodeWords(value);
1400
+ }
1401
+
1274
1402
  /**
1275
1403
  * If needed, mime encodes the name part
1276
1404
  *
@@ -250,7 +250,7 @@ const decode = function (input) {
250
250
  // Main decoding loop: start just after the last delimiter if any basic code
251
251
  // points were copied; start at the beginning otherwise.
252
252
 
253
- for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */; ) {
253
+ for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */;) {
254
254
  // `index` is the index of the next character to be consumed.
255
255
  // Decode a generalized variable-length integer into `delta`,
256
256
  // which gets added to `i`. The overflow checking is easier
@@ -62,14 +62,17 @@ class SendmailTransport {
62
62
  // Sendmail strips this header line by itself
63
63
  mail.message.keepBcc = true;
64
64
 
65
- const envelope = mail.data.envelope || mail.message.getEnvelope();
65
+ const envelope = mail.message.getEnvelope();
66
66
  const messageId = mail.message.messageId();
67
67
  let returned;
68
68
 
69
69
  const hasInvalidAddresses = []
70
70
  .concat(envelope.from || [])
71
71
  .concat(envelope.to || [])
72
- .some(addr => /^-/.test(addr));
72
+ // a local part is either a dot-atom or a quoted-string, so a leading dash sits at
73
+ // offset 0 or, behind the opening quote, at offset 1. Only the first shape is read
74
+ // as an option by sendmail, but both are the address this guard keeps out of argv
75
+ .some(addr => /^"?-/.test(addr));
73
76
  if (hasInvalidAddresses) {
74
77
  const err = new Error('Can not send mail. Invalid envelope addresses.');
75
78
  err.code = errors.ESENDMAIL;
@@ -66,7 +66,7 @@ class SESTransport extends EventEmitter {
66
66
  fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value));
67
67
  }
68
68
 
69
- const envelope = mail.data.envelope || mail.message.getEnvelope();
69
+ const envelope = mail.message.getEnvelope();
70
70
  const messageId = mail.message.messageId();
71
71
 
72
72
  const recipients = [].concat(envelope.to || []);
@@ -42,7 +42,7 @@ class StreamTransport {
42
42
  // We probably need this in the output
43
43
  mail.message.keepBcc = true;
44
44
 
45
- const envelope = mail.data.envelope || mail.message.getEnvelope();
45
+ const envelope = mail.message.getEnvelope();
46
46
  const messageId = mail.message.messageId();
47
47
 
48
48
  const recipients = [].concat(envelope.to || []);
@@ -577,6 +577,20 @@
577
577
  "port": 587
578
578
  },
579
579
 
580
+ "TurboSMTP": {
581
+ "description": "TurboSMTP",
582
+ "host": "pro.turbo-smtp.com",
583
+ "port": 465,
584
+ "secure": true
585
+ },
586
+
587
+ "TurboSMTP-EU": {
588
+ "description": "TurboSMTP (EU region)",
589
+ "host": "pro.eu.turbo-smtp.com",
590
+ "port": 465,
591
+ "secure": true
592
+ },
593
+
580
594
  "Tutanota": {
581
595
  "description": "Tutanota (Tuta Mail)",
582
596
  "domains": ["tutanota.com", "tuta.com", "tutanota.de", "tuta.io"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "9.0.3",
3
+ "version": "9.0.5",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "main": "lib/nodemailer.js",
6
6
  "scripts": {
@@ -27,19 +27,19 @@
27
27
  },
28
28
  "homepage": "https://nodemailer.com/",
29
29
  "devDependencies": {
30
- "@aws-sdk/client-sesv2": "3.1068.0",
30
+ "@aws-sdk/client-sesv2": "3.1104.0",
31
31
  "bunyan": "1.8.15",
32
- "c8": "11.0.0",
33
- "eslint": "10.5.0",
32
+ "c8": "12.0.0",
33
+ "eslint": "10.8.0",
34
34
  "eslint-config-prettier": "10.1.8",
35
- "globals": "17.6.0",
35
+ "globals": "17.9.0",
36
36
  "libbase64": "1.3.0",
37
- "libmime": "5.3.8",
37
+ "libmime": "5.4.1",
38
38
  "libqp": "2.1.1",
39
- "prettier": "3.8.4",
39
+ "prettier": "3.9.6",
40
40
  "proxy": "1.0.2",
41
41
  "proxy-test-server": "1.0.0",
42
- "smtp-server": "3.19.0"
42
+ "smtp-server": "3.19.2"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=6.0.0"