nodemailer 9.0.4 → 9.0.6

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.
@@ -5,6 +5,7 @@
5
5
  const base64 = require('../base64');
6
6
  const qp = require('../qp');
7
7
  const mimeTypes = require('./mime-types');
8
+ const { isProtoKey } = require('../shared/objects');
8
9
 
9
10
  module.exports = {
10
11
  /**
@@ -13,7 +14,8 @@ module.exports = {
13
14
  * When isParam is set the value is destined for a header parameter, so HT, CR and LF
14
15
  * are not plaintext either: a header parameter has no way to carry them. HT is a valid
15
16
  * 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
17
+ * cannot appear in a header value at all. DEL is neither a token character nor qtext,
18
+ * so it can not be carried bare or quoted. Such values have to go through the rfc2231
17
19
  * parameter continuation encoding instead, the same way a quote already does.
18
20
  *
19
21
  * @param {String} value String to be tested
@@ -21,10 +23,21 @@ module.exports = {
21
23
  * @returns {Boolean} true if it is a plaintext string
22
24
  */
23
25
  isPlainText(value, isParam) {
24
- const re = isParam ? /[\x00-\x1f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
26
+ const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
25
27
  return typeof value === 'string' && !re.test(value);
26
28
  },
27
29
 
30
+ /**
31
+ * Wraps a value into a quoted-string. Inside one a quote would end the string early
32
+ * and a backslash would escape whatever follows it, so both go out as quoted-pairs.
33
+ *
34
+ * @param {String} value String to be quoted
35
+ * @returns {String} The value as a quoted-string, quotes included
36
+ */
37
+ quoteString(value) {
38
+ return '"' + (value || '').toString().replace(/["\\]/g, '\\$&') + '"';
39
+ },
40
+
28
41
  /**
29
42
  * Checks if a multi line string containes lines longer than the selected value.
30
43
  *
@@ -177,10 +190,12 @@ module.exports = {
177
190
  buildHeaderValue(structured) {
178
191
  const paramsArray = [];
179
192
 
180
- Object.keys(structured.params || {}).forEach(param => {
193
+ Object.keys(structured.params || {}).forEach(key => {
181
194
  // filename might include unicode characters so it is a special case
182
195
  // other values probably do not
183
- const value = structured.params[param];
196
+ const value = structured.params[key];
197
+ // a parameter name is a token too and it is emitted without any quoting around it
198
+ const param = key.replace(/[\x00-\x1f\x7f]/g, '');
184
199
  if (!this.isPlainText(value, true) || value.length >= 75) {
185
200
  this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
186
201
  if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
@@ -196,7 +211,11 @@ module.exports = {
196
211
  }
197
212
  });
198
213
 
199
- return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
214
+ // the value ahead of the parameters is a token, it has no way to carry a control
215
+ // char or DEL and there is no quoting construct around it to escape one into
216
+ const value = typeof structured.value === 'string' ? structured.value.replace(/[\x00-\x1f\x7f]/g, '') : structured.value;
217
+
218
+ return value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
200
219
  },
201
220
 
202
221
  /**
@@ -294,8 +313,11 @@ module.exports = {
294
313
  line,
295
314
  encoded
296
315
  });
316
+ // the line we start here holds an encoded char, so it has to be
317
+ // flagged as one. otherwise it gets an unstarred continuation key
318
+ // and a receiver reads the percent escapes as literal text
297
319
  line = '';
298
- startPos = i - 1;
320
+ encoded = true;
299
321
  } else {
300
322
  encoded = true;
301
323
  i = startPos;
@@ -360,6 +382,16 @@ module.exports = {
360
382
  value: false,
361
383
  params: {}
362
384
  };
385
+
386
+ // Parameter names come from a caller supplied contentType/contentDisposition. A
387
+ // "__proto__" name would target the prototype chain of the params object instead of
388
+ // an own property of it, and read back as Object.prototype, so it is dropped.
389
+ const setParam = (name, value) => {
390
+ if (!isProtoKey(name)) {
391
+ response.params[name] = value;
392
+ }
393
+ };
394
+
363
395
  let key = false;
364
396
  let value = '';
365
397
  let type = 'value';
@@ -391,7 +423,7 @@ module.exports = {
391
423
  if (key === false) {
392
424
  response.value = value.trim();
393
425
  } else {
394
- response.params[key] = value.trim();
426
+ setParam(key, value.trim());
395
427
  }
396
428
  type = 'key';
397
429
  value = '';
@@ -406,10 +438,10 @@ module.exports = {
406
438
  if (key === false) {
407
439
  response.value = value.trim();
408
440
  } else {
409
- response.params[key] = value.trim();
441
+ setParam(key, value.trim());
410
442
  }
411
443
  } else if (value.trim()) {
412
- response.params[value.trim().toLowerCase()] = '';
444
+ setParam(value.trim().toLowerCase(), '');
413
445
  }
414
446
 
415
447
  // handle parameter value continuations
@@ -422,6 +454,14 @@ module.exports = {
422
454
  actualKey = key.substr(0, match.index);
423
455
  nr = Number(match[2] || match[3]) || 0;
424
456
 
457
+ if (isProtoKey(actualKey)) {
458
+ // see setParam. Reading it back would yield Object.prototype, which is
459
+ // an object, so the initializer below would be skipped and the write
460
+ // that follows would throw out of a header build the caller can not catch
461
+ delete response.params[key];
462
+ return;
463
+ }
464
+
425
465
  if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
426
466
  response.params[actualKey] = {
427
467
  charset: false,
@@ -548,7 +588,7 @@ module.exports = {
548
588
  */
549
589
  splitMimeEncodedString: (str, maxlen) => {
550
590
  const lines = [];
551
- let curLine, match, chr, done;
591
+ let curLine, fallbackLine, match, chr, done;
552
592
 
553
593
  // require at least 12 symbols to fit possible 4 octet UTF-8 sequences
554
594
  maxlen = Math.max(maxlen || 0, 12);
@@ -561,8 +601,14 @@ module.exports = {
561
601
  curLine = curLine.substr(0, match.index);
562
602
  }
563
603
 
604
+ // Malformed input (a run of stray UTF-8 continuation bytes) has no split point
605
+ // that keeps a character sequence whole, so the loop below walks back to an
606
+ // empty line looking for one. Keep the widest chunk that at least does not cut
607
+ // a "=XX" escape in half, so the part stays a decodable encoded word.
608
+ fallbackLine = curLine.length ? curLine : str.substr(0, maxlen);
609
+
564
610
  done = false;
565
- while (!done) {
611
+ while (!done && curLine.length) {
566
612
  done = true;
567
613
  // check if not middle of a unicode char sequence
568
614
  if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
@@ -575,9 +621,11 @@ module.exports = {
575
621
  }
576
622
  }
577
623
 
578
- if (curLine.length) {
579
- lines.push(curLine);
624
+ if (!curLine.length) {
625
+ curLine = fallbackLine;
580
626
  }
627
+
628
+ lines.push(curLine);
581
629
  str = str.substr(curLine.length);
582
630
  }
583
631
 
@@ -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
@@ -519,11 +533,10 @@ class MimeNode {
519
533
  const formattedHeaders = FORMATTED_HEADERS;
520
534
 
521
535
  if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
522
- Object.keys(value).forEach(key => {
523
- if (key !== 'value') {
524
- options[key] = value[key];
525
- }
526
- });
536
+ // the keys come from a caller supplied header object and `options.prepared`
537
+ // below decides whether the value is emitted raw, so an own "__proto__" key
538
+ // here would turn an unfolded value into header injection
539
+ shared.copyOwnKeys(options, value, optionKey => optionKey === 'value');
527
540
  value = (value.value || '').toString();
528
541
  if (!value.trim()) {
529
542
  return;
@@ -552,6 +565,11 @@ class MimeNode {
552
565
  case 'Content-Type':
553
566
  structured = mimeFuncs.parseHeaderValue(value);
554
567
 
568
+ // the type token decides multipart and charset below, so clean it before
569
+ // those run and not just on the way out, otherwise a control char makes
570
+ // the checks miss and the header ends up claiming a type it is not set up for
571
+ structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, '');
572
+
555
573
  this._handleContentType(structured);
556
574
 
557
575
  if (
@@ -569,9 +587,9 @@ class MimeNode {
569
587
  // we can't build the value with buildHeaderValue as the value is non standard and
570
588
  // would be converted to parameter continuation encoding that we do not want
571
589
  // 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)
590
+ // turn into a space, CR/LF can not appear in a header at all and DEL is not
591
+ // qtext, so force the mime encoded word that a non-ascii filename would get anyway
592
+ param = /[\x00-\x1f\x7f]/.test(this.filename)
575
593
  ? mimeFuncs.encodeWord(this.filename, this._getTextEncoding(this.filename), 52)
576
594
  : this._encodeWords(this.filename);
577
595
 
@@ -602,8 +620,12 @@ class MimeNode {
602
620
 
603
621
  if (typeof this.normalizeHeaderKey === 'function') {
604
622
  const normalized = this.normalizeHeaderKey(key, value);
605
- if (normalized && typeof normalized === 'string' && normalized.length) {
606
- key = normalized;
623
+ // the result replaces the key on the way into the header, so it gets the same
624
+ // treatment the key it replaces already had. a line break here would end the
625
+ // header and start one of the caller's own
626
+ const cleaned = typeof normalized === 'string' ? normalized.replace(/[\x00-\x1f\x7f]/g, '') : '';
627
+ if (cleaned) {
628
+ key = cleaned;
607
629
  }
608
630
  }
609
631
 
@@ -844,7 +866,7 @@ class MimeNode {
844
866
 
845
867
  if (envelope.from) {
846
868
  list = [];
847
- this._convertAddresses(this._parseAddresses(envelope.from), list);
869
+ this._convertAddresses(this._parseEnvelopeAddresses(envelope.from), list);
848
870
  list = list.filter(address => address && address.address);
849
871
  if (list.length && list[0]) {
850
872
  this._envelope.from = list[0].address;
@@ -852,18 +874,14 @@ class MimeNode {
852
874
  }
853
875
  ['to', 'cc', 'bcc'].forEach(key => {
854
876
  if (envelope[key]) {
855
- this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
877
+ this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to);
856
878
  }
857
879
  });
858
880
 
859
881
  this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
860
882
 
861
883
  const standardFields = ['to', 'cc', 'bcc', 'from'];
862
- Object.keys(envelope).forEach(key => {
863
- if (!standardFields.includes(key)) {
864
- this._envelope[key] = envelope[key];
865
- }
866
- });
884
+ shared.copyOwnKeys(this._envelope, envelope, key => standardFields.includes(key));
867
885
 
868
886
  return this;
869
887
  }
@@ -1012,7 +1030,9 @@ class MimeNode {
1012
1030
  });
1013
1031
  return contentStream;
1014
1032
  }
1015
- // fetch URL
1033
+ // fetch URL. nmfetch refuses any scheme that is not http(s), and it decides
1034
+ // that on the parsed URL. Testing the raw string here instead would reject
1035
+ // forms the parser accepts, such as a leading space or a slash-less authority
1016
1036
  return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
1017
1037
  }
1018
1038
 
@@ -1041,15 +1061,69 @@ class MimeNode {
1041
1061
  [],
1042
1062
  [].concat(addresses).map(address => {
1043
1063
  if (address && address.address) {
1044
- address.address = this._normalizeAddress(address.address);
1045
- address.name = address.name || '';
1046
- return [address];
1064
+ const normalized = this._normalizeAddress(address.address);
1065
+ if (normalized === address.address && typeof address.name === 'string') {
1066
+ // there is nothing to rewrite, so there is nothing to keep off the original
1067
+ return [address];
1068
+ }
1069
+
1070
+ // rewriting would land on the object the caller passed in and might
1071
+ // still hold a reference to, so rewrite a copy of it instead. An own
1072
+ // "__proto__" key would make the copy inherit from caller data, and
1073
+ // _convertAddresses reads `group` off it straight into the envelope
1074
+ const copy = shared.copyOwnKeys({}, address);
1075
+ copy.address = normalized;
1076
+ copy.name = address.name || '';
1077
+ return [copy];
1047
1078
  }
1048
- return addressparser(address);
1079
+ return this._normalizeParsedAddresses(addressparser(address));
1049
1080
  })
1050
1081
  );
1051
1082
  }
1052
1083
 
1084
+ /**
1085
+ * Normalizes the addresses of a freshly parsed address list, groups included.
1086
+ *
1087
+ * Everything this method returns carries a normalized address, whether it arrived as an
1088
+ * object or was parsed out of a header value. Without this the two shapes disagree, and
1089
+ * a consumer reading the parsed form back is handed the ambiguous
1090
+ * 'user@evil.com@good.com' that the header and the envelope no longer carry.
1091
+ *
1092
+ * @param {Array} parsed An array of address objects, as returned by addressparser
1093
+ * @return {Array} The same array, with every address normalized
1094
+ */
1095
+ _normalizeParsedAddresses(parsed) {
1096
+ // addressparser builds these objects, so no caller holds a reference to rewrite around
1097
+ parsed.forEach(entry => {
1098
+ if (entry.address) {
1099
+ entry.address = this._normalizeAddress(entry.address);
1100
+ } else if (entry.group) {
1101
+ this._normalizeParsedAddresses(entry.group);
1102
+ }
1103
+ });
1104
+
1105
+ return parsed;
1106
+ }
1107
+
1108
+ /**
1109
+ * Parses the addresses of an explicitly set envelope.
1110
+ *
1111
+ * An envelope value is an addr-spec and never a display name, so a bare local username
1112
+ * such as 'root' is the address here. Header parsing has to read the same value as a
1113
+ * display name, as a value with no '@' in it can not be an addr-spec in a header.
1114
+ *
1115
+ * @param {Mixed} addresses Addresses to be parsed
1116
+ * @return {Array} An array of address objects
1117
+ */
1118
+ _parseEnvelopeAddresses(addresses) {
1119
+ return this._parseAddresses(addresses).map(entry => {
1120
+ if (entry.address || entry.group || !entry.name || /[\s@]/.test(entry.name)) {
1121
+ return entry;
1122
+ }
1123
+ return { address: this._normalizeAddress(entry.name), name: '' };
1124
+ });
1125
+ }
1126
+
1053
1127
  /**
1054
1128
  * Normalizes a header key, uses Camel-Case form, except for uppercase MIME-
1055
1129
  *
@@ -1061,6 +1135,9 @@ class MimeNode {
1061
1135
  .toString()
1062
1136
  // no newlines in keys
1063
1137
  .replace(/\r?\n|\r/g, ' ')
1138
+ // a field name is printable ascii without the colon, so a control char or DEL
1139
+ // can only be dropped, there is no quoting construct around a field name
1140
+ .replace(/[\x00-\x1f\x7f]/g, '')
1064
1141
  .trim()
1065
1142
  .toLowerCase()
1066
1143
  // use uppercase words, except MIME
@@ -1121,7 +1198,13 @@ class MimeNode {
1121
1198
  case 'Message-ID':
1122
1199
  case 'In-Reply-To':
1123
1200
  case 'Content-Id':
1124
- value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1201
+ // a msg-id is structured, so an encoded word inside the angle brackets would
1202
+ // be read as literal text. drop the characters that can not appear in a header
1203
+ // at all, but leave HT alone, it separates the ids of a multi id value
1204
+ value = (value || '')
1205
+ .toString()
1206
+ .replace(/\r?\n|\r/g, ' ')
1207
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
1125
1208
 
1126
1209
  if (value.charAt(0) !== '<') {
1127
1210
  value = '<' + value;
@@ -1141,6 +1224,7 @@ class MimeNode {
1141
1224
  elm = (elm || '')
1142
1225
  .toString()
1143
1226
  .replace(/\r?\n|\r/g, ' ')
1227
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
1144
1228
  .trim();
1145
1229
  return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
1146
1230
  })
@@ -1163,7 +1247,7 @@ class MimeNode {
1163
1247
  }
1164
1248
 
1165
1249
  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1166
- return this._encodeWords(value);
1250
+ return this._encodeHeaderText(value);
1167
1251
 
1168
1252
  case 'Content-Type':
1169
1253
  case 'Content-Disposition':
@@ -1172,8 +1256,7 @@ class MimeNode {
1172
1256
 
1173
1257
  default:
1174
1258
  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);
1259
+ return this._encodeHeaderText(value);
1177
1260
  }
1178
1261
  }
1179
1262
 
@@ -1194,7 +1277,11 @@ class MimeNode {
1194
1277
  address.address = this._normalizeAddress(address.address);
1195
1278
 
1196
1279
  if (!address.name) {
1197
- values.push(address.address.indexOf(' ') >= 0 ? `<${address.address}>` : `${address.address}`);
1280
+ // an address that carries a special, be it a quoted local part or a domain
1281
+ // that could not be normalized, is only unambiguous inside angle brackets.
1282
+ // Without them a ',' or a ';' anywhere in it reads as a recipient separator
1283
+ // and the header would list more recipients than the envelope carries
1284
+ values.push(PLAIN_ADDRESS.test(address.address) ? address.address : `<${address.address}>`);
1198
1285
  } else {
1199
1286
  values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
1200
1287
  }
@@ -1220,19 +1307,26 @@ class MimeNode {
1220
1307
  _normalizeAddress(address) {
1221
1308
  address = (address || '')
1222
1309
  .toString()
1223
- .replace(/[\x00-\x1F<>]+/g, ' ') // remove unallowed characters
1310
+ .replace(/[\x00-\x1F\x7F<>]+/g, ' ') // remove unallowed characters
1224
1311
  .trim();
1225
1312
 
1313
+ if (!address) {
1314
+ // callers use an empty value to detect a missing address
1315
+ return address;
1316
+ }
1317
+
1226
1318
  const lastAt = address.lastIndexOf('@');
1227
1319
  if (lastAt < 0) {
1228
- // Bare username
1229
- return address;
1320
+ // Bare username, there is no domain to split off
1321
+ return this._normalizeLocalPart(address);
1230
1322
  }
1231
1323
 
1232
- let user = address.substr(0, lastAt);
1324
+ const user = address.substr(0, lastAt);
1233
1325
  const domain = address.substr(lastAt + 1);
1234
1326
 
1235
- // Usernames are not touched and are kept as is even if these include unicode.
1327
+ // Unicode in the local part is kept as is, see _normalizeLocalPart for the rest of it.
1328
+ // A domain has no quoting construct to fall back on, so whatever is not a valid domain
1329
+ // is kept as supplied and it is _convertAddresses that keeps such an address unambiguous.
1236
1330
  // Domains are punycoded when the local part is ASCII ('safe@jõgeva.ee' -> 'safe@xn--jgeva-dua.ee').
1237
1331
  // When the local part contains non-ASCII bytes the address already requires SMTPUTF8,
1238
1332
  // so the domain is kept (or decoded back) as UTF-8 for symmetry on both sides of '@'.
@@ -1249,16 +1343,27 @@ class MimeNode {
1249
1343
  // keep domain as supplied
1250
1344
  }
1251
1345
 
1252
- if (user.indexOf(' ') >= 0) {
1253
- if (user.charAt(0) !== '"') {
1254
- user = '"' + user;
1255
- }
1256
- if (user.substr(-1) !== '"') {
1257
- user = user + '"';
1258
- }
1346
+ return `${this._normalizeLocalPart(user)}@${encodedDomain}`;
1347
+ }
1348
+
1349
+ /**
1350
+ * Normalizes the local part of an address into a form that can be emitted as is.
1351
+ *
1352
+ * A local part is either a dot-atom or a quoted-string, anything else is not a valid
1353
+ * addr-spec. The quotes of a quoted local part get lost along the way, and a bare
1354
+ * 'user@evil.com@good.com' leaves it to the receiver which '@' splits the domain off,
1355
+ * while the split here is always at the last one. So whatever is not already one of
1356
+ * the two valid forms goes back out as a quoted-string.
1357
+ *
1358
+ * @param {String} user Local part of an address
1359
+ * @return {String} Local part as a dot-atom or as a quoted-string
1360
+ */
1361
+ _normalizeLocalPart(user) {
1362
+ if (DOT_ATOM.test(user) || QUOTED_STRING.test(user)) {
1363
+ return user;
1259
1364
  }
1260
1365
 
1261
- return `${user}@${encodedDomain}`;
1366
+ return mimeFuncs.quoteString(user);
1262
1367
  }
1263
1368
 
1264
1369
  /**
@@ -1270,7 +1375,7 @@ class MimeNode {
1270
1375
  _encodeAddressName(name) {
1271
1376
  if (!/^[\w ]*$/.test(name)) {
1272
1377
  if (/^[\x20-\x7e]*$/.test(name)) {
1273
- return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
1378
+ return mimeFuncs.quoteString(name);
1274
1379
  } else {
1275
1380
  return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
1276
1381
  }
@@ -1278,6 +1383,21 @@ class MimeNode {
1278
1383
  return name;
1279
1384
  }
1280
1385
 
1386
+ /**
1387
+ * Encodes an unstructured header value. Such a value can only carry VCHAR and WSP, so a
1388
+ * control char or DEL has to be forced into the mime encoded word that a non-ascii value
1389
+ * would get anyway. HT stays as it is, it is valid folding whitespace here.
1390
+ *
1391
+ * @param {String} value Header value to encode
1392
+ * @returns {String} Mime word encoded string if needed
1393
+ */
1394
+ _encodeHeaderText(value) {
1395
+ return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
1396
+ ? mimeFuncs.encodeWord(value, this._getTextEncoding(value), 52)
1397
+ : // encodeWords only encodes if needed, otherwise the original string is returned
1398
+ this._encodeWords(value);
1399
+ }
1400
+
1281
1401
  /**
1282
1402
  * If needed, mime encodes the name part
1283
1403
  *
@@ -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 || []);
@@ -130,7 +130,8 @@ class SESTransport extends EventEmitter {
130
130
  return callback(err);
131
131
  }
132
132
 
133
- const sesMessage = Object.assign(
133
+ // mail.data.ses is caller supplied message data, so copy its own keys only
134
+ const sesMessage = shared.copyOwnKeys(
134
135
  {
135
136
  Content: {
136
137
  Raw: {
@@ -143,7 +144,7 @@ class SESTransport extends EventEmitter {
143
144
  ToAddresses: envelope.to
144
145
  }
145
146
  },
146
- mail.data.ses || {}
147
+ mail.data.ses
147
148
  );
148
149
 
149
150
  this.getRegion((err, region) => {