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 +15 -0
- package/lib/addressparser/index.js +42 -0
- package/lib/dkim/sign.js +8 -3
- package/lib/json-transport/index.js +1 -1
- package/lib/mailer/mail-message.js +18 -15
- package/lib/mime-funcs/index.js +27 -6
- package/lib/mime-node/index.js +151 -30
- package/lib/punycode/index.js +1 -1
- package/lib/sendmail-transport/index.js +5 -2
- package/lib/ses-transport/index.js +1 -1
- package/lib/stream-transport/index.js +1 -1
- package/package.json +3 -3
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.
|
|
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.
|
|
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
|
-
//
|
|
277
|
-
|
|
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
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
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
|
}
|
package/lib/mime-funcs/index.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
319
|
+
encoded = true;
|
|
299
320
|
} else {
|
|
300
321
|
encoded = true;
|
|
301
322
|
i = startPos;
|
package/lib/mime-node/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
606
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1045
|
-
address.
|
|
1046
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
+
const user = address.substr(0, lastAt);
|
|
1233
1326
|
const domain = address.substr(lastAt + 1);
|
|
1234
1327
|
|
|
1235
|
-
//
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
|
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
|
|
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
|
*
|
package/lib/punycode/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
35
|
+
"globals": "17.9.0",
|
|
36
36
|
"libbase64": "1.3.0",
|
|
37
37
|
"libmime": "5.4.1",
|
|
38
38
|
"libqp": "2.1.1",
|