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.
- package/CHANGELOG.md +23 -0
- package/README.md +2 -5
- package/lib/addressparser/index.js +140 -1
- package/lib/dkim/index.js +6 -1
- package/lib/dkim/sign.js +8 -3
- package/lib/fetch/index.js +115 -5
- package/lib/json-transport/index.js +1 -1
- package/lib/mail-composer/index.js +5 -3
- package/lib/mailer/mail-message.js +39 -34
- package/lib/mime-funcs/index.js +61 -13
- package/lib/mime-node/index.js +161 -41
- package/lib/punycode/index.js +1 -1
- package/lib/sendmail-transport/index.js +5 -2
- package/lib/ses-transport/index.js +4 -3
- package/lib/shared/index.js +27 -11
- package/lib/shared/objects.js +42 -0
- package/lib/stream-transport/index.js +1 -1
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [9.0.6](https://github.com/nodemailer/nodemailer/compare/v9.0.5...v9.0.6) (2026-08-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **addressparser:** recover the addr-spec from an angle-addr holding whitespace ([e989a22](https://github.com/nodemailer/nodemailer/commit/e989a22ca4f5161929bf37be8fb07de635016fa7))
|
|
9
|
+
* harden copies of user supplied keys and URL fetching ([2f667f4](https://github.com/nodemailer/nodemailer/commit/2f667f4272cb2d7cb479b2e3903ab10600fc0eae))
|
|
10
|
+
|
|
11
|
+
## [9.0.5](https://github.com/nodemailer/nodemailer/compare/v9.0.4...v9.0.5) (2026-08-07)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* **ci:** retrigger the workflows dropped during the Actions outage ([85d16c1](https://github.com/nodemailer/nodemailer/commit/85d16c103ec69e237c7e55c0e3103f439c135ce3))
|
|
17
|
+
* **mailer:** escape specials in List-* header comments ([#1842](https://github.com/nodemailer/nodemailer/issues/1842)) ([75913bb](https://github.com/nodemailer/nodemailer/commit/75913bba032623046dd7fa037b8b880e388d8357))
|
|
18
|
+
* **mime-funcs:** star the continuation key of a restarted parameter line ([36bcf1a](https://github.com/nodemailer/nodemailer/commit/36bcf1a21a92b5a283c780e35aacd3080292d98d))
|
|
19
|
+
* **mime-node:** keep control chars out of header values and msg-id headers ([15cf6d1](https://github.com/nodemailer/nodemailer/commit/15cf6d1c15cdf60f551618fd54fb727ea7aac94c))
|
|
20
|
+
* **mime:** encode DEL in header parameters and List-* comments ([cf69430](https://github.com/nodemailer/nodemailer/commit/cf69430ffac1d246bfbab564daf321f31ed9e1dd))
|
|
21
|
+
* **mime:** keep control chars out of the remaining header positions ([5ed9d26](https://github.com/nodemailer/nodemailer/commit/5ed9d26f85eecb48f4b3ae74ad33ed2abf002040))
|
|
22
|
+
* **mime:** normalize an address parsed out of a string as well ([63685f7](https://github.com/nodemailer/nodemailer/commit/63685f7dd4aefa75cc72a36f983f32f61cd6733e))
|
|
23
|
+
* **mime:** normalize an address so header and envelope agree ([a9343b4](https://github.com/nodemailer/nodemailer/commit/a9343b47e42b9ccb27911ad8e73d4119c6170c85))
|
|
24
|
+
* **mime:** stop a header key callback and the dkim tags from injecting ([b7d772e](https://github.com/nodemailer/nodemailer/commit/b7d772ea4ec12ee82e65a9b919af882bf0f125d9))
|
|
25
|
+
|
|
3
26
|
## [9.0.4](https://github.com/nodemailer/nodemailer/compare/v9.0.3...v9.0.4) (2026-08-04)
|
|
4
27
|
|
|
5
28
|
|
package/README.md
CHANGED
|
@@ -8,10 +8,7 @@ Send emails from Node.js – easy as cake! 🍰✉️
|
|
|
8
8
|
|
|
9
9
|
See [nodemailer.com](https://nodemailer.com/) for documentation and terms.
|
|
10
10
|
|
|
11
|
-
> [
|
|
12
|
-
> Check out **[EmailEngine](https://emailengine.app/?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link)** – a self-hosted email gateway that allows making **REST requests against IMAP and SMTP servers**. EmailEngine also sends webhooks whenever something changes on the registered accounts.\
|
|
13
|
-
> \
|
|
14
|
-
> Using the email accounts registered with EmailEngine, you can receive and [send emails](https://emailengine.app/sending-emails?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link). EmailEngine supports OAuth2, delayed sends, opens and clicks tracking, bounce detection, etc. All on top of regular email accounts without an external MTA service.
|
|
11
|
+
> Nodemailer is developed by the team behind **[EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs)**, a self-hosted email API that turns any Gmail, Microsoft 365, or IMAP account into a REST endpoint, with managed OAuth2, webhooks for incoming mail, and built-in [sending](https://emailengine.app/sending-emails?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs). If you would rather call an HTTP API than maintain IMAP and SMTP connections yourself, that is what it is for.
|
|
15
12
|
|
|
16
13
|
## Having an issue?
|
|
17
14
|
|
|
@@ -25,7 +22,7 @@ You are using an older Node.js version than v6.0. Upgrade Node.js to get support
|
|
|
25
22
|
|
|
26
23
|
#### I'm having issues with Gmail
|
|
27
24
|
|
|
28
|
-
Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/).
|
|
25
|
+
Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/). If the blocker is OAuth2 setup rather than Gmail itself, [EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=faq-gmail) handles the OAuth2 flow and token refresh for you.
|
|
29
26
|
|
|
30
27
|
#### I get ETIMEDOUT errors
|
|
31
28
|
|
|
@@ -1,5 +1,134 @@
|
|
|
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
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reached for every parsed address, so it is built once rather than per call.
|
|
39
|
+
*/
|
|
40
|
+
const HAS_WHITESPACE = /\s/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
|
|
44
|
+
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
|
|
45
|
+
*/
|
|
46
|
+
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
|
50
|
+
*/
|
|
51
|
+
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The looser reading applied once the strict one finds nothing, which tolerates the
|
|
55
|
+
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
56
|
+
*/
|
|
57
|
+
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
|
61
|
+
*
|
|
62
|
+
* A malformed header can put more than a mailbox between the angle brackets, most often
|
|
63
|
+
* because the generator wrote the recipient twice: '<user@example.com user@example.com>'
|
|
64
|
+
* or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
|
|
65
|
+
* never be a mailbox anyone could deliver to, and passing it on as the address loses the
|
|
66
|
+
* recipient that is sitting right there in the header.
|
|
67
|
+
*
|
|
68
|
+
* The run that still reads as an addr-spec is kept and whatever is left over becomes
|
|
69
|
+
* display text rather than being dropped. Candidates are read strictly first and then
|
|
70
|
+
* under the looser grammar, the same two tiers the unquoted-text branch below applies to
|
|
71
|
+
* the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
|
|
72
|
+
* recipient. When several runs qualify the first wins, which is what that branch's looser
|
|
73
|
+
* tier does within a token.
|
|
74
|
+
*
|
|
75
|
+
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
|
|
76
|
+
* '<"user name"@example.com>' is well formed and means exactly what it says.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} data Collected address parts, mutated in place
|
|
79
|
+
*/
|
|
80
|
+
function _recoverAddrSpec(data) {
|
|
81
|
+
if (!HAS_WHITESPACE.test(data.address)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let address;
|
|
86
|
+
let rest;
|
|
87
|
+
|
|
88
|
+
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
|
|
89
|
+
if (quoted) {
|
|
90
|
+
if (!quoted[2]) {
|
|
91
|
+
// the whitespace sits inside the quoted local part, this is a well formed mailbox
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
|
|
96
|
+
// than splitting into the quotes
|
|
97
|
+
address = quoted[1];
|
|
98
|
+
rest = [quoted[2]];
|
|
99
|
+
} else {
|
|
100
|
+
if (data.address.indexOf('"') >= 0) {
|
|
101
|
+
// Splitting on whitespace loses track of where the quoted string starts and ends,
|
|
102
|
+
// and this module does not take addresses out of quoted strings: the run picked out
|
|
103
|
+
// of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
|
|
104
|
+
// quotes were hiding. Every well formed shape was already handled above, so what is
|
|
105
|
+
// left is wreckage either way and the original is the honest answer
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parts = data.address.split(/\s+/);
|
|
110
|
+
|
|
111
|
+
let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
|
|
112
|
+
if (addrIndex < 0) {
|
|
113
|
+
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (addrIndex < 0) {
|
|
117
|
+
// nothing in there reads as an address, there is no better answer than the original
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
address = parts.splice(addrIndex, 1)[0];
|
|
122
|
+
rest = parts;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
data.address = address;
|
|
126
|
+
data.text = [data.text]
|
|
127
|
+
.concat(rest)
|
|
128
|
+
.filter(part => part)
|
|
129
|
+
.join(' ');
|
|
130
|
+
}
|
|
131
|
+
|
|
3
132
|
/**
|
|
4
133
|
* Converts tokens for a single address into an address object
|
|
5
134
|
*
|
|
@@ -103,7 +232,7 @@ function _handleAddress(tokens, depth) {
|
|
|
103
232
|
// Security: Do not extract email addresses from quoted strings.
|
|
104
233
|
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
|
105
234
|
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
|
106
|
-
if (!data.textWasQuoted[i] &&
|
|
235
|
+
if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
|
|
107
236
|
data.address = data.text.splice(i, 1);
|
|
108
237
|
data.textWasQuoted.splice(i, 1);
|
|
109
238
|
break;
|
|
@@ -145,10 +274,16 @@ function _handleAddress(tokens, depth) {
|
|
|
145
274
|
data.text = data.text.concat(data.address.splice(1));
|
|
146
275
|
}
|
|
147
276
|
|
|
277
|
+
// An address is only taken from unquoted text, so anything left in the text at this
|
|
278
|
+
// point that still has to serve as the address carries its quoting in this flag
|
|
279
|
+
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
|
|
280
|
+
|
|
148
281
|
// Join values with spaces
|
|
149
282
|
data.text = data.text.join(' ');
|
|
150
283
|
data.address = data.address.join(' ');
|
|
151
284
|
|
|
285
|
+
_recoverAddrSpec(data);
|
|
286
|
+
|
|
152
287
|
const address = {
|
|
153
288
|
address: data.address || data.text || '',
|
|
154
289
|
name: data.text || data.address || ''
|
|
@@ -162,6 +297,10 @@ function _handleAddress(tokens, depth) {
|
|
|
162
297
|
}
|
|
163
298
|
}
|
|
164
299
|
|
|
300
|
+
if (addressFromQuotedText && address.address) {
|
|
301
|
+
address.address = _quoteLocalPart(address.address);
|
|
302
|
+
}
|
|
303
|
+
|
|
165
304
|
addresses.push(address);
|
|
166
305
|
}
|
|
167
306
|
|
package/lib/dkim/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const { PassThrough } = require('stream');
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const crypto = require('crypto');
|
|
13
|
+
const { copyOwnKeys } = require('../shared/objects');
|
|
13
14
|
|
|
14
15
|
const DKIM_ALGO = 'sha256';
|
|
15
16
|
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
|
|
@@ -225,7 +226,11 @@ class DKIM {
|
|
|
225
226
|
|
|
226
227
|
let options = this.options;
|
|
227
228
|
if (extraOptions && Object.keys(extraOptions).length) {
|
|
228
|
-
|
|
229
|
+
// extraOptions is mail.data._dkim, caller supplied message data. An own
|
|
230
|
+
// "__proto__" key there would let every option this signer reads and the
|
|
231
|
+
// transport did not set, such as skipFields, answer from the caller
|
|
232
|
+
options = copyOwnKeys({}, extraOptions);
|
|
233
|
+
copyOwnKeys(options, this.options);
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
const signer = new DKIMSigner(options, this.keys, inputStream, output);
|
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=';
|
package/lib/fetch/index.js
CHANGED
|
@@ -9,9 +9,67 @@ const Cookies = require('./cookies');
|
|
|
9
9
|
const packageData = require('../../package.json');
|
|
10
10
|
const net = require('net');
|
|
11
11
|
const errors = require('../errors');
|
|
12
|
+
const { isProtoKey } = require('../shared/objects');
|
|
12
13
|
|
|
13
14
|
const MAX_REDIRECTS = 5;
|
|
14
15
|
|
|
16
|
+
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
|
|
17
|
+
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
|
|
18
|
+
// or lookup would otherwise repoint the request at a destination that never went through
|
|
19
|
+
// the URL checks below.
|
|
20
|
+
//
|
|
21
|
+
// The source of truth is the tls.connect() option list in the Node docs. A key missing
|
|
22
|
+
// here is dropped silently, so extend this list rather than working around it.
|
|
23
|
+
const TLS_OPTION_KEYS = [
|
|
24
|
+
'ALPNProtocols',
|
|
25
|
+
'ca',
|
|
26
|
+
'cert',
|
|
27
|
+
'checkServerIdentity',
|
|
28
|
+
'ciphers',
|
|
29
|
+
'crl',
|
|
30
|
+
'dhparam',
|
|
31
|
+
'ecdhCurve',
|
|
32
|
+
'honorCipherOrder',
|
|
33
|
+
'key',
|
|
34
|
+
'maxVersion',
|
|
35
|
+
'minVersion',
|
|
36
|
+
'passphrase',
|
|
37
|
+
'pfx',
|
|
38
|
+
'rejectUnauthorized',
|
|
39
|
+
'secureContext',
|
|
40
|
+
'secureOptions',
|
|
41
|
+
'secureProtocol',
|
|
42
|
+
'servername',
|
|
43
|
+
'sessionIdContext',
|
|
44
|
+
'sigalgs'
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolves a URL only if it is one this module is willing to request.
|
|
49
|
+
*
|
|
50
|
+
* urllib.parse throws for a host that contains forbidden bytes, and it is called for
|
|
51
|
+
* every URL that reaches nmfetch, including ones that arrive from a message attachment
|
|
52
|
+
* or from a redirect Location header. An uncaught throw here takes the process down,
|
|
53
|
+
* so a URL that does not parse is reported the same way as one with a scheme we refuse.
|
|
54
|
+
*
|
|
55
|
+
* @param {String} url URL to parse
|
|
56
|
+
* @returns {Object|Boolean} Parsed URL, or false if it is not a usable http(s) URL
|
|
57
|
+
*/
|
|
58
|
+
function parseFetchUrl(url) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = urllib.parse(url);
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
|
|
15
73
|
module.exports = function (url, options) {
|
|
16
74
|
return nmfetch(url, options);
|
|
17
75
|
};
|
|
@@ -26,6 +84,30 @@ function nmfetch(url, options) {
|
|
|
26
84
|
options.redirects = options.redirects || 0;
|
|
27
85
|
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
|
|
28
86
|
|
|
87
|
+
const fetchRes = options.fetchRes;
|
|
88
|
+
const parsed = parseFetchUrl(url);
|
|
89
|
+
|
|
90
|
+
if (!parsed) {
|
|
91
|
+
// Only http(s) URLs can be fetched. Any other scheme (file:, gopher:, a
|
|
92
|
+
// protocol-relative redirect target etc.) would otherwise be silently served over
|
|
93
|
+
// plain HTTP, possibly against an unintended host. Bail out before the cookie jar
|
|
94
|
+
// is touched so a refused URL can not seed it, and release a caller supplied body:
|
|
95
|
+
// this is the one exit that runs before the error handler below is attached to it,
|
|
96
|
+
// so an error on that stream would have nowhere to go and the fd or socket behind
|
|
97
|
+
// it would never be released.
|
|
98
|
+
if (options.body && typeof options.body.destroy === 'function') {
|
|
99
|
+
options.body.on('error', () => false);
|
|
100
|
+
options.body.destroy();
|
|
101
|
+
}
|
|
102
|
+
setImmediate(() => {
|
|
103
|
+
const err = new Error('Unsupported protocol for URL ' + url);
|
|
104
|
+
err.code = errors.EFETCH;
|
|
105
|
+
err.sourceUrl = url;
|
|
106
|
+
fetchRes.emit('error', err);
|
|
107
|
+
});
|
|
108
|
+
return fetchRes;
|
|
109
|
+
}
|
|
110
|
+
|
|
29
111
|
if (options.cookie) {
|
|
30
112
|
[].concat(options.cookie || []).forEach(cookie => {
|
|
31
113
|
options.cookies.set(cookie, url);
|
|
@@ -33,8 +115,6 @@ function nmfetch(url, options) {
|
|
|
33
115
|
options.cookie = false;
|
|
34
116
|
}
|
|
35
117
|
|
|
36
|
-
const fetchRes = options.fetchRes;
|
|
37
|
-
const parsed = urllib.parse(url);
|
|
38
118
|
let method = (options.method || '').toString().trim().toUpperCase() || 'GET';
|
|
39
119
|
let finished = false;
|
|
40
120
|
let cookies;
|
|
@@ -48,6 +128,10 @@ function nmfetch(url, options) {
|
|
|
48
128
|
};
|
|
49
129
|
|
|
50
130
|
Object.keys(options.headers || {}).forEach(key => {
|
|
131
|
+
// options.headers is the caller's httpHeaders, straight off an attachment
|
|
132
|
+
if (isProtoKey(key.toLowerCase().trim())) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
51
135
|
headers[key.toLowerCase().trim()] = options.headers[key];
|
|
52
136
|
});
|
|
53
137
|
|
|
@@ -131,7 +215,12 @@ function nmfetch(url, options) {
|
|
|
131
215
|
};
|
|
132
216
|
|
|
133
217
|
if (options.tls) {
|
|
134
|
-
|
|
218
|
+
// see TLS_OPTION_KEYS
|
|
219
|
+
Object.keys(options.tls).forEach(key => {
|
|
220
|
+
if (TLS_OPTION_KEYS.includes(key)) {
|
|
221
|
+
reqOptions[key] = options.tls[key];
|
|
222
|
+
}
|
|
223
|
+
});
|
|
135
224
|
}
|
|
136
225
|
|
|
137
226
|
if (
|
|
@@ -216,8 +305,29 @@ function nmfetch(url, options) {
|
|
|
216
305
|
options.method = 'GET';
|
|
217
306
|
options.body = false;
|
|
218
307
|
|
|
219
|
-
|
|
220
|
-
|
|
308
|
+
let redirectUrl;
|
|
309
|
+
try {
|
|
310
|
+
redirectUrl = urllib.resolve(url, res.headers.location);
|
|
311
|
+
} catch (_err) {
|
|
312
|
+
// the legacy resolver throws on a Location the WHATWG parser also refused,
|
|
313
|
+
// so fall through to the check below with what the server actually sent
|
|
314
|
+
redirectUrl = res.headers.location;
|
|
315
|
+
}
|
|
316
|
+
const redirectParsed = parseFetchUrl(redirectUrl);
|
|
317
|
+
|
|
318
|
+
if (!redirectParsed) {
|
|
319
|
+
// Refuse the redirect target here rather than leaving it to the recursive
|
|
320
|
+
// call: that call gets its own `finished` flag and no handle on this
|
|
321
|
+
// request, so this one would stay open and could emit a second error on
|
|
322
|
+
// the shared fetchRes once it times out. Callers listen with req.once().
|
|
323
|
+
finished = true;
|
|
324
|
+
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
|
|
325
|
+
err.code = errors.EFETCH;
|
|
326
|
+
err.sourceUrl = redirectUrl;
|
|
327
|
+
fetchRes.emit('error', err);
|
|
328
|
+
req.abort();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
221
331
|
|
|
222
332
|
// Do not forward credentials when the redirect leaves the original
|
|
223
333
|
// security context: a different host, or a downgrade from https to
|
|
@@ -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 || []);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const MimeNode = require('../mime-node');
|
|
6
6
|
const mimeFuncs = require('../mime-funcs');
|
|
7
|
-
const { parseDataURI } = require('../shared');
|
|
7
|
+
const { parseDataURI, copyOwnKeys } = require('../shared');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Creates the object for composing a MimeNode instance out from the mail options
|
|
@@ -205,7 +205,9 @@ class MailComposer {
|
|
|
205
205
|
typeof this.mail.icalEvent === 'object' &&
|
|
206
206
|
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
|
|
207
207
|
) {
|
|
208
|
-
|
|
208
|
+
// an own "__proto__" key would make the copy inherit path/href from caller
|
|
209
|
+
// data, and the mapping below then replaces the content the caller did set
|
|
210
|
+
icalEvent = copyOwnKeys({}, this.mail.icalEvent);
|
|
209
211
|
} else {
|
|
210
212
|
icalEvent = {
|
|
211
213
|
content: this.mail.icalEvent
|
|
@@ -600,7 +602,7 @@ class MailComposer {
|
|
|
600
602
|
}
|
|
601
603
|
|
|
602
604
|
// Return empty content for excessively long data URLs
|
|
603
|
-
return Object.assign({}, element, {
|
|
605
|
+
return Object.assign(copyOwnKeys({}, element), {
|
|
604
606
|
path: false,
|
|
605
607
|
href: false,
|
|
606
608
|
content: Buffer.alloc(0),
|
|
@@ -4,6 +4,11 @@ const shared = require('../shared');
|
|
|
4
4
|
const MimeNode = require('../mime-node');
|
|
5
5
|
const mimeFuncs = require('../mime-funcs');
|
|
6
6
|
|
|
7
|
+
// Only an own key counts as already set. `key in obj` also matches every member of
|
|
8
|
+
// Object.prototype, which silently drops a transporter default legitimately named
|
|
9
|
+
// toString or constructor.
|
|
10
|
+
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
11
|
+
|
|
7
12
|
class MailMessage {
|
|
8
13
|
constructor(mailer, data) {
|
|
9
14
|
this.mailer = mailer;
|
|
@@ -14,23 +19,16 @@ class MailMessage {
|
|
|
14
19
|
const options = mailer.options || {};
|
|
15
20
|
const defaults = mailer._defaults || {};
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
shared.copyOwnKeys(this.data, data);
|
|
18
23
|
|
|
19
24
|
this.data.headers = this.data.headers || {};
|
|
20
25
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Object.keys(defaults.headers).forEach(key => {
|
|
28
|
-
if (!(key in this.data.headers)) {
|
|
29
|
-
this.data.headers[key] = defaults.headers[key];
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
});
|
|
26
|
+
// Apply defaults. `_defaults` is caller supplied too, it is the second argument of
|
|
27
|
+
// createTransport, so it needs the same treatment as `data` above
|
|
28
|
+
shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
|
|
29
|
+
|
|
30
|
+
// headers is a special case. Allow setting individual default headers
|
|
31
|
+
shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
|
|
34
32
|
|
|
35
33
|
// force specific keys from transporter options
|
|
36
34
|
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => {
|
|
@@ -123,11 +121,12 @@ class MailMessage {
|
|
|
123
121
|
content: value
|
|
124
122
|
};
|
|
125
123
|
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
// The keys are the caller's, so copying them takes the same "__proto__"
|
|
125
|
+
// rule as the constructor. `key in node` stays as the already-set test
|
|
126
|
+
// here, unlike for the defaults: it also skips the Object.prototype
|
|
127
|
+
// member names, and letting message data land a `toString` string on a
|
|
128
|
+
// node only buys a TypeError the first time something stringifies it.
|
|
129
|
+
shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
args[0][args[1]] = node;
|
|
@@ -140,7 +139,7 @@ class MailMessage {
|
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
normalize(callback) {
|
|
143
|
-
const envelope = this.
|
|
142
|
+
const envelope = this.message.getEnvelope();
|
|
144
143
|
const messageId = this.message.messageId();
|
|
145
144
|
|
|
146
145
|
this.resolveAll((err, data) => {
|
|
@@ -186,6 +185,9 @@ class MailMessage {
|
|
|
186
185
|
|
|
187
186
|
data.normalizedHeaders = {};
|
|
188
187
|
Object.keys(data.headers || {}).forEach(key => {
|
|
188
|
+
if (shared.isProtoKey(key)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
189
191
|
let value = [].concat(data.headers[key] || []).shift();
|
|
190
192
|
value = (value && value.value) || value;
|
|
191
193
|
if (value) {
|
|
@@ -271,15 +273,16 @@ class MailMessage {
|
|
|
271
273
|
}
|
|
272
274
|
|
|
273
275
|
if (value && value.url) {
|
|
276
|
+
// strip CR/LF so a comment can't inject extra header lines. DEL is neither
|
|
277
|
+
// qtext nor ctext, so it can not be carried literally by either construct
|
|
278
|
+
// and has to become an encoded word like any other non-plaintext value
|
|
279
|
+
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
|
|
280
|
+
const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment);
|
|
281
|
+
|
|
274
282
|
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
|
-
}
|
|
283
|
+
// List-ID: "comment" <domain>, where an unescaped quote or a trailing
|
|
284
|
+
// backslash in the comment would swallow the <domain> behind it
|
|
285
|
+
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
|
|
283
286
|
|
|
284
287
|
// List-ID expects a bare domain-like identifier, so strip the
|
|
285
288
|
// scheme prefix that _formatListUrl adds or passes through
|
|
@@ -289,11 +292,11 @@ class MailMessage {
|
|
|
289
292
|
}
|
|
290
293
|
|
|
291
294
|
// List-*: <http://domain> (comment)
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
// the ctext specials go out as quoted-pairs, otherwise a ")" closes the
|
|
296
|
+
// comment early and leaves the rest as junk, an unpaired "(" opens a
|
|
297
|
+
// nested comment that never closes, and a trailing backslash escapes
|
|
298
|
+
// the closing ")" so the comment swallows whatever follows it
|
|
299
|
+
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&');
|
|
297
300
|
|
|
298
301
|
return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
|
|
299
302
|
}
|
|
@@ -307,7 +310,9 @@ class MailMessage {
|
|
|
307
310
|
}
|
|
308
311
|
|
|
309
312
|
_formatListUrl(url) {
|
|
310
|
-
url
|
|
313
|
+
// a url has no way to carry a control char or DEL, and the angle brackets around it
|
|
314
|
+
// are not a quoting construct, so anything left here lands in the header raw
|
|
315
|
+
url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, '');
|
|
311
316
|
if (/^(https?|mailto|ftp):/.test(url)) {
|
|
312
317
|
return '<' + url + '>';
|
|
313
318
|
}
|