nodemailer 9.0.4 → 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,20 @@
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
+
3
18
  ## [9.0.4](https://github.com/nodemailer/nodemailer/compare/v9.0.3...v9.0.4) (2026-08-04)
4
19
 
5
20
 
@@ -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
  }
@@ -13,7 +13,8 @@ module.exports = {
13
13
  * When isParam is set the value is destined for a header parameter, so HT, CR and LF
14
14
  * are not plaintext either: a header parameter has no way to carry them. HT is a valid
15
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. Such values have to go through the rfc2231
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
17
18
  * parameter continuation encoding instead, the same way a quote already does.
18
19
  *
19
20
  * @param {String} value String to be tested
@@ -21,10 +22,21 @@ module.exports = {
21
22
  * @returns {Boolean} true if it is a plaintext string
22
23
  */
23
24
  isPlainText(value, isParam) {
24
- const re = isParam ? /[\x00-\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]/;
25
26
  return typeof value === 'string' && !re.test(value);
26
27
  },
27
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
+
28
40
  /**
29
41
  * Checks if a multi line string containes lines longer than the selected value.
30
42
  *
@@ -177,10 +189,12 @@ module.exports = {
177
189
  buildHeaderValue(structured) {
178
190
  const paramsArray = [];
179
191
 
180
- Object.keys(structured.params || {}).forEach(param => {
192
+ Object.keys(structured.params || {}).forEach(key => {
181
193
  // filename might include unicode characters so it is a special case
182
194
  // other values probably do not
183
- 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, '');
184
198
  if (!this.isPlainText(value, true) || value.length >= 75) {
185
199
  this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
186
200
  if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
@@ -196,7 +210,11 @@ module.exports = {
196
210
  }
197
211
  });
198
212
 
199
- 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('; ') : '');
200
218
  },
201
219
 
202
220
  /**
@@ -294,8 +312,11 @@ module.exports = {
294
312
  line,
295
313
  encoded
296
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
297
318
  line = '';
298
- startPos = i - 1;
319
+ encoded = true;
299
320
  } else {
300
321
  encoded = true;
301
322
  i = startPos;
@@ -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 (
@@ -569,9 +588,9 @@ class MimeNode {
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
590
  // control chars can not be quoted here: HT is a fold point that unfolding would
572
- // turn into a space and CR/LF can not appear in a header at all, so force the
573
- // mime encoded word that a non-ascii filename would get anyway
574
- param = /[\x00-\x1f]/.test(this.filename)
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)
575
594
  ? mimeFuncs.encodeWord(this.filename, this._getTextEncoding(this.filename), 52)
576
595
  : this._encodeWords(this.filename);
577
596
 
@@ -602,8 +621,12 @@ class MimeNode {
602
621
 
603
622
  if (typeof this.normalizeHeaderKey === 'function') {
604
623
  const normalized = this.normalizeHeaderKey(key, value);
605
- if (normalized && typeof normalized === 'string' && normalized.length) {
606
- 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;
607
630
  }
608
631
  }
609
632
 
@@ -844,7 +867,7 @@ class MimeNode {
844
867
 
845
868
  if (envelope.from) {
846
869
  list = [];
847
- this._convertAddresses(this._parseAddresses(envelope.from), list);
870
+ this._convertAddresses(this._parseEnvelopeAddresses(envelope.from), list);
848
871
  list = list.filter(address => address && address.address);
849
872
  if (list.length && list[0]) {
850
873
  this._envelope.from = list[0].address;
@@ -852,7 +875,7 @@ class MimeNode {
852
875
  }
853
876
  ['to', 'cc', 'bcc'].forEach(key => {
854
877
  if (envelope[key]) {
855
- this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
878
+ this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to);
856
879
  }
857
880
  });
858
881
 
@@ -1041,15 +1064,67 @@ class MimeNode {
1041
1064
  [],
1042
1065
  [].concat(addresses).map(address => {
1043
1066
  if (address && address.address) {
1044
- address.address = this._normalizeAddress(address.address);
1045
- address.name = address.name || '';
1046
- 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];
1047
1079
  }
1048
- return addressparser(address);
1080
+ return this._normalizeParsedAddresses(addressparser(address));
1049
1081
  })
1050
1082
  );
1051
1083
  }
1052
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
+
1053
1128
  /**
1054
1129
  * Normalizes a header key, uses Camel-Case form, except for uppercase MIME-
1055
1130
  *
@@ -1061,6 +1136,9 @@ class MimeNode {
1061
1136
  .toString()
1062
1137
  // no newlines in keys
1063
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, '')
1064
1142
  .trim()
1065
1143
  .toLowerCase()
1066
1144
  // use uppercase words, except MIME
@@ -1121,7 +1199,13 @@ class MimeNode {
1121
1199
  case 'Message-ID':
1122
1200
  case 'In-Reply-To':
1123
1201
  case 'Content-Id':
1124
- 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, '');
1125
1209
 
1126
1210
  if (value.charAt(0) !== '<') {
1127
1211
  value = '<' + value;
@@ -1141,6 +1225,7 @@ class MimeNode {
1141
1225
  elm = (elm || '')
1142
1226
  .toString()
1143
1227
  .replace(/\r?\n|\r/g, ' ')
1228
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
1144
1229
  .trim();
1145
1230
  return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
1146
1231
  })
@@ -1163,7 +1248,7 @@ class MimeNode {
1163
1248
  }
1164
1249
 
1165
1250
  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1166
- return this._encodeWords(value);
1251
+ return this._encodeHeaderText(value);
1167
1252
 
1168
1253
  case 'Content-Type':
1169
1254
  case 'Content-Disposition':
@@ -1172,8 +1257,7 @@ class MimeNode {
1172
1257
 
1173
1258
  default:
1174
1259
  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1175
- // encodeWords only encodes if needed, otherwise the original string is returned
1176
- return this._encodeWords(value);
1260
+ return this._encodeHeaderText(value);
1177
1261
  }
1178
1262
  }
1179
1263
 
@@ -1194,7 +1278,11 @@ class MimeNode {
1194
1278
  address.address = this._normalizeAddress(address.address);
1195
1279
 
1196
1280
  if (!address.name) {
1197
- 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}>`);
1198
1286
  } else {
1199
1287
  values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
1200
1288
  }
@@ -1220,19 +1308,26 @@ class MimeNode {
1220
1308
  _normalizeAddress(address) {
1221
1309
  address = (address || '')
1222
1310
  .toString()
1223
- .replace(/[\x00-\x1F<>]+/g, ' ') // remove unallowed characters
1311
+ .replace(/[\x00-\x1F\x7F<>]+/g, ' ') // remove unallowed characters
1224
1312
  .trim();
1225
1313
 
1314
+ if (!address) {
1315
+ // callers use an empty value to detect a missing address
1316
+ return address;
1317
+ }
1318
+
1226
1319
  const lastAt = address.lastIndexOf('@');
1227
1320
  if (lastAt < 0) {
1228
- // Bare username
1229
- return address;
1321
+ // Bare username, there is no domain to split off
1322
+ return this._normalizeLocalPart(address);
1230
1323
  }
1231
1324
 
1232
- let user = address.substr(0, lastAt);
1325
+ const user = address.substr(0, lastAt);
1233
1326
  const domain = address.substr(lastAt + 1);
1234
1327
 
1235
- // 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.
1236
1331
  // Domains are punycoded when the local part is ASCII ('safe@jõgeva.ee' -> 'safe@xn--jgeva-dua.ee').
1237
1332
  // When the local part contains non-ASCII bytes the address already requires SMTPUTF8,
1238
1333
  // so the domain is kept (or decoded back) as UTF-8 for symmetry on both sides of '@'.
@@ -1249,16 +1344,27 @@ class MimeNode {
1249
1344
  // keep domain as supplied
1250
1345
  }
1251
1346
 
1252
- if (user.indexOf(' ') >= 0) {
1253
- if (user.charAt(0) !== '"') {
1254
- user = '"' + user;
1255
- }
1256
- if (user.substr(-1) !== '"') {
1257
- user = user + '"';
1258
- }
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;
1259
1365
  }
1260
1366
 
1261
- return `${user}@${encodedDomain}`;
1367
+ return mimeFuncs.quoteString(user);
1262
1368
  }
1263
1369
 
1264
1370
  /**
@@ -1270,7 +1376,7 @@ class MimeNode {
1270
1376
  _encodeAddressName(name) {
1271
1377
  if (!/^[\w ]*$/.test(name)) {
1272
1378
  if (/^[\x20-\x7e]*$/.test(name)) {
1273
- return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
1379
+ return mimeFuncs.quoteString(name);
1274
1380
  } else {
1275
1381
  return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
1276
1382
  }
@@ -1278,6 +1384,21 @@ class MimeNode {
1278
1384
  return name;
1279
1385
  }
1280
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
+
1281
1402
  /**
1282
1403
  * If needed, mime encodes the name part
1283
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 || []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "9.0.4",
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,12 +27,12 @@
27
27
  },
28
28
  "homepage": "https://nodemailer.com/",
29
29
  "devDependencies": {
30
- "@aws-sdk/client-sesv2": "3.1096.0",
30
+ "@aws-sdk/client-sesv2": "3.1104.0",
31
31
  "bunyan": "1.8.15",
32
32
  "c8": "12.0.0",
33
33
  "eslint": "10.8.0",
34
34
  "eslint-config-prettier": "10.1.8",
35
- "globals": "17.8.0",
35
+ "globals": "17.9.0",
36
36
  "libbase64": "1.3.0",
37
37
  "libmime": "5.4.1",
38
38
  "libqp": "2.1.1",