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 +26 -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 +46 -12
- package/lib/mime-node/index.js +158 -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/lib/well-known/services.json +14 -0
- package/package.json +8 -8
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.
|
|
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
|
@@ -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-\
|
|
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 (/[\
|
|
84
|
-
//
|
|
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(
|
|
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[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
604
|
-
|
|
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
|
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 (
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1038
|
-
address.
|
|
1039
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
+
const user = address.substr(0, lastAt);
|
|
1226
1326
|
const domain = address.substr(lastAt + 1);
|
|
1227
1327
|
|
|
1228
|
-
//
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
|
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
|
|
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
|
*
|
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 || []);
|
|
@@ -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
|
+
"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.
|
|
30
|
+
"@aws-sdk/client-sesv2": "3.1104.0",
|
|
31
31
|
"bunyan": "1.8.15",
|
|
32
|
-
"c8": "
|
|
33
|
-
"eslint": "10.
|
|
32
|
+
"c8": "12.0.0",
|
|
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
|
-
"libmime": "5.
|
|
37
|
+
"libmime": "5.4.1",
|
|
38
38
|
"libqp": "2.1.1",
|
|
39
|
-
"prettier": "3.
|
|
39
|
+
"prettier": "3.9.6",
|
|
40
40
|
"proxy": "1.0.2",
|
|
41
41
|
"proxy-test-server": "1.0.0",
|
|
42
|
-
"smtp-server": "3.19.
|
|
42
|
+
"smtp-server": "3.19.2"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=6.0.0"
|