mirror of
https://github.com/dawidd6/action-send-mail.git
synced 2026-09-09 05:56:37 +07:00
node_modules: update (#314)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
+166
-8
@@ -1,5 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Restores the quoting of a local part that was read out of a quoted string.
|
||||
*
|
||||
* RFC 5321 allows '@' inside a quoted local part, so handing '"user@evil.com"@good.com'
|
||||
* on as the bare 'user@evil.com@good.com' leaves it to the consumer which '@' splits the
|
||||
* domain off. Getting that wrong is a misrouting vector, so the quotes go back on. The
|
||||
* same holds for the other specials: a ',' or a ';' that loses its quotes reads as a
|
||||
* recipient separator once the consumer puts the address back into a header.
|
||||
*
|
||||
* This module has no dependencies so that it can ship on its own, which is why the two
|
||||
* grammar tests below are spelled out here instead of shared with lib/mime-node. Keeping
|
||||
* only what is ambiguous quoted is deliberate, mime-node applies the stricter RFC 5321
|
||||
* dot-atom rule on top of this when it emits an address.
|
||||
*
|
||||
* @param {String} address Address with an unquoted local part
|
||||
* @return {String} Address with the local part as a quoted-string
|
||||
*/
|
||||
function _quoteLocalPart(address) {
|
||||
const lastAt = address.lastIndexOf('@');
|
||||
if (lastAt < 0) {
|
||||
// no domain to split off, nothing can be misrouted
|
||||
return address;
|
||||
}
|
||||
|
||||
const user = address.substr(0, lastAt);
|
||||
if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) {
|
||||
// a local part that carries no special reads the same with or without the quotes,
|
||||
// and one that is already a complete quoted-string needs nothing either
|
||||
return address;
|
||||
}
|
||||
|
||||
return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reached for every parsed address, so it is built once rather than per call.
|
||||
*/
|
||||
const HAS_WHITESPACE = /\s/;
|
||||
|
||||
/**
|
||||
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
|
||||
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
|
||||
*/
|
||||
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
||||
|
||||
/**
|
||||
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
||||
*/
|
||||
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
||||
|
||||
/**
|
||||
* The looser reading applied once the strict one finds nothing, which tolerates the
|
||||
* further '@' that a domain should not have but malformed headers carry anyway.
|
||||
*/
|
||||
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
||||
|
||||
/**
|
||||
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
||||
*
|
||||
* A malformed header can put more than a mailbox between the angle brackets, most often
|
||||
* because the generator wrote the recipient twice: '<user@example.com user@example.com>'
|
||||
* or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
|
||||
* never be a mailbox anyone could deliver to, and passing it on as the address loses the
|
||||
* recipient that is sitting right there in the header.
|
||||
*
|
||||
* The run that still reads as an addr-spec is kept and whatever is left over becomes
|
||||
* display text rather than being dropped. Candidates are read strictly first and then
|
||||
* under the looser grammar, the same two tiers the unquoted-text branch below applies to
|
||||
* the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
|
||||
* recipient. When several runs qualify the first wins, which is what that branch's looser
|
||||
* tier does within a token.
|
||||
*
|
||||
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
|
||||
* '<"user name"@example.com>' is well formed and means exactly what it says.
|
||||
*
|
||||
* @param {Object} data Collected address parts, mutated in place
|
||||
*/
|
||||
function _recoverAddrSpec(data) {
|
||||
if (!HAS_WHITESPACE.test(data.address)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let address;
|
||||
let rest;
|
||||
|
||||
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
|
||||
if (quoted) {
|
||||
if (!quoted[2]) {
|
||||
// the whitespace sits inside the quoted local part, this is a well formed mailbox
|
||||
return;
|
||||
}
|
||||
|
||||
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
|
||||
// than splitting into the quotes
|
||||
address = quoted[1];
|
||||
rest = [quoted[2]];
|
||||
} else {
|
||||
if (data.address.indexOf('"') >= 0) {
|
||||
// Splitting on whitespace loses track of where the quoted string starts and ends,
|
||||
// and this module does not take addresses out of quoted strings: the run picked out
|
||||
// of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
|
||||
// quotes were hiding. Every well formed shape was already handled above, so what is
|
||||
// left is wreckage either way and the original is the honest answer
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = data.address.split(/\s+/);
|
||||
|
||||
let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
|
||||
if (addrIndex < 0) {
|
||||
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
||||
}
|
||||
|
||||
if (addrIndex < 0) {
|
||||
// nothing in there reads as an address, there is no better answer than the original
|
||||
return;
|
||||
}
|
||||
|
||||
address = parts.splice(addrIndex, 1)[0];
|
||||
rest = parts;
|
||||
}
|
||||
|
||||
data.address = address;
|
||||
data.text = [data.text]
|
||||
.concat(rest)
|
||||
.filter(part => part)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
@@ -55,7 +184,18 @@ function _handleAddress(tokens, depth) {
|
||||
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
||||
}
|
||||
|
||||
if (prevToken && prevToken.noBreak && data[state].length) {
|
||||
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
||||
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
||||
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
||||
// deliver to a domain the sender never named.
|
||||
const parts = data[state];
|
||||
const joins =
|
||||
prevToken &&
|
||||
prevToken.noBreak &&
|
||||
parts.length &&
|
||||
(prevToken.value !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');
|
||||
|
||||
if (joins) {
|
||||
data[state][data[state].length - 1] += token.value;
|
||||
if (state === 'text' && insideQuotes) {
|
||||
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
||||
@@ -103,7 +243,7 @@ function _handleAddress(tokens, depth) {
|
||||
// Security: Do not extract email addresses from quoted strings.
|
||||
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
||||
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
||||
if (!data.textWasQuoted[i] && /^[^@\s]+@[^@\s]+$/.test(data.text[i])) {
|
||||
if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
|
||||
data.address = data.text.splice(i, 1);
|
||||
data.textWasQuoted.splice(i, 1);
|
||||
break;
|
||||
@@ -145,10 +285,16 @@ function _handleAddress(tokens, depth) {
|
||||
data.text = data.text.concat(data.address.splice(1));
|
||||
}
|
||||
|
||||
// An address is only taken from unquoted text, so anything left in the text at this
|
||||
// point that still has to serve as the address carries its quoting in this flag
|
||||
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
|
||||
|
||||
// Join values with spaces
|
||||
data.text = data.text.join(' ');
|
||||
data.address = data.address.join(' ');
|
||||
|
||||
_recoverAddrSpec(data);
|
||||
|
||||
const address = {
|
||||
address: data.address || data.text || '',
|
||||
name: data.text || data.address || ''
|
||||
@@ -162,6 +308,10 @@ function _handleAddress(tokens, depth) {
|
||||
}
|
||||
}
|
||||
|
||||
if (addressFromQuotedText && address.address) {
|
||||
address.address = _quoteLocalPart(address.address);
|
||||
}
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
@@ -360,8 +510,10 @@ function addressparser(str, options) {
|
||||
|
||||
addresses.forEach(addr => {
|
||||
const handled = _handleAddress(addr, depth);
|
||||
if (handled.length) {
|
||||
parsedAddresses = parsedAddresses.concat(handled);
|
||||
// Appended in place. Rebuilding the accumulator with concat() would copy every
|
||||
// entry collected so far on each address, making a flat list cost O(n^2).
|
||||
for (let i = 0; i < handled.length; i++) {
|
||||
parsedAddresses.push(handled[i]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -369,14 +521,20 @@ function addressparser(str, options) {
|
||||
// "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
||||
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
|
||||
// Recombine: a name-only entry followed by an entry with both name and address.
|
||||
for (let i = parsedAddresses.length - 2; i >= 0; i--) {
|
||||
// Walked back to front so that a run of fragments folds into one entry in a single
|
||||
// pass. Splicing each fragment out of the list instead would cost O(n^2).
|
||||
const mergedAddresses = [];
|
||||
for (let i = parsedAddresses.length - 1; i >= 0; i--) {
|
||||
const current = parsedAddresses[i];
|
||||
const next = parsedAddresses[i + 1];
|
||||
if (current.address === '' && current.name && !current.group && next.address && next.name) {
|
||||
const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
|
||||
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
||||
next.name = current.name + ', ' + next.name;
|
||||
parsedAddresses.splice(i, 1);
|
||||
} else {
|
||||
mergedAddresses.push(current);
|
||||
}
|
||||
}
|
||||
mergedAddresses.reverse();
|
||||
parsedAddresses = mergedAddresses;
|
||||
|
||||
if (options.flatten) {
|
||||
const flatAddresses = [];
|
||||
|
||||
+6
-1
@@ -10,6 +10,7 @@ const { PassThrough } = require('stream');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { copyOwnKeys } = require('../shared/objects');
|
||||
|
||||
const DKIM_ALGO = 'sha256';
|
||||
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
|
||||
@@ -225,7 +226,11 @@ class DKIM {
|
||||
|
||||
let options = this.options;
|
||||
if (extraOptions && Object.keys(extraOptions).length) {
|
||||
options = Object.assign({}, extraOptions, this.options);
|
||||
// extraOptions is mail.data._dkim, caller supplied message data. An own
|
||||
// "__proto__" key there would let every option this signer reads and the
|
||||
// transport did not set, such as skipFields, answer from the caller
|
||||
options = copyOwnKeys({}, extraOptions);
|
||||
copyOwnKeys(options, this.options);
|
||||
}
|
||||
|
||||
const signer = new DKIMSigner(options, this.keys, inputStream, output);
|
||||
|
||||
+8
-3
@@ -50,15 +50,20 @@ module.exports = (headers, hashAlgo, bodyHash, options) => {
|
||||
module.exports.relaxedHeaders = relaxedHeaders;
|
||||
|
||||
function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
|
||||
// the caller supplied tag values are interpolated straight into the tag list, and none of
|
||||
// them has any way to carry a control char, DEL, or one of the delimiters that would close
|
||||
// the value and open a tag of its own
|
||||
const cleanTagValue = value => (value || '').toString().replace(/[\x00-\x1f\x7f;=]/g, '');
|
||||
|
||||
const dkim = [
|
||||
'v=1',
|
||||
'a=rsa-' + hashAlgo,
|
||||
'c=relaxed/relaxed',
|
||||
'd=' + punycode.toASCII(domainName),
|
||||
'd=' + punycode.toASCII(cleanTagValue(domainName)),
|
||||
'q=dns/txt',
|
||||
's=' + keySelector,
|
||||
's=' + cleanTagValue(keySelector),
|
||||
'bh=' + bodyHash,
|
||||
'h=' + fieldNames
|
||||
'h=' + cleanTagValue(fieldNames)
|
||||
].join('; ');
|
||||
|
||||
return mimeFuncs.foldLines('DKIM-Signature: ' + dkim, 76) + ';\r\n b=';
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ const ERROR_CODES = {
|
||||
|
||||
// Resource errors
|
||||
EMAXLIMIT: 'Pool resource limit reached (max messages per connection)',
|
||||
EMAXRECIPIENTS: 'Recipient count exceeds maxRecipients',
|
||||
|
||||
// Transport-specific errors
|
||||
ESENDMAIL: 'Sendmail command error',
|
||||
|
||||
+115
-5
@@ -9,9 +9,67 @@ const Cookies = require('./cookies');
|
||||
const packageData = require('../../package.json');
|
||||
const net = require('net');
|
||||
const errors = require('../errors');
|
||||
const { isProtoKey } = require('../shared/objects');
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
|
||||
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
|
||||
// or lookup would otherwise repoint the request at a destination that never went through
|
||||
// the URL checks below.
|
||||
//
|
||||
// The source of truth is the tls.connect() option list in the Node docs. A key missing
|
||||
// here is dropped silently, so extend this list rather than working around it.
|
||||
const TLS_OPTION_KEYS = [
|
||||
'ALPNProtocols',
|
||||
'ca',
|
||||
'cert',
|
||||
'checkServerIdentity',
|
||||
'ciphers',
|
||||
'crl',
|
||||
'dhparam',
|
||||
'ecdhCurve',
|
||||
'honorCipherOrder',
|
||||
'key',
|
||||
'maxVersion',
|
||||
'minVersion',
|
||||
'passphrase',
|
||||
'pfx',
|
||||
'rejectUnauthorized',
|
||||
'secureContext',
|
||||
'secureOptions',
|
||||
'secureProtocol',
|
||||
'servername',
|
||||
'sessionIdContext',
|
||||
'sigalgs'
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves a URL only if it is one this module is willing to request.
|
||||
*
|
||||
* urllib.parse throws for a host that contains forbidden bytes, and it is called for
|
||||
* every URL that reaches nmfetch, including ones that arrive from a message attachment
|
||||
* or from a redirect Location header. An uncaught throw here takes the process down,
|
||||
* so a URL that does not parse is reported the same way as one with a scheme we refuse.
|
||||
*
|
||||
* @param {String} url URL to parse
|
||||
* @returns {Object|Boolean} Parsed URL, or false if it is not a usable http(s) URL
|
||||
*/
|
||||
function parseFetchUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = urllib.parse(url);
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
module.exports = function (url, options) {
|
||||
return nmfetch(url, options);
|
||||
};
|
||||
@@ -26,6 +84,30 @@ function nmfetch(url, options) {
|
||||
options.redirects = options.redirects || 0;
|
||||
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
|
||||
|
||||
const fetchRes = options.fetchRes;
|
||||
const parsed = parseFetchUrl(url);
|
||||
|
||||
if (!parsed) {
|
||||
// Only http(s) URLs can be fetched. Any other scheme (file:, gopher:, a
|
||||
// protocol-relative redirect target etc.) would otherwise be silently served over
|
||||
// plain HTTP, possibly against an unintended host. Bail out before the cookie jar
|
||||
// is touched so a refused URL can not seed it, and release a caller supplied body:
|
||||
// this is the one exit that runs before the error handler below is attached to it,
|
||||
// so an error on that stream would have nowhere to go and the fd or socket behind
|
||||
// it would never be released.
|
||||
if (options.body && typeof options.body.destroy === 'function') {
|
||||
options.body.on('error', () => false);
|
||||
options.body.destroy();
|
||||
}
|
||||
setImmediate(() => {
|
||||
const err = new Error('Unsupported protocol for URL ' + url);
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
});
|
||||
return fetchRes;
|
||||
}
|
||||
|
||||
if (options.cookie) {
|
||||
[].concat(options.cookie || []).forEach(cookie => {
|
||||
options.cookies.set(cookie, url);
|
||||
@@ -33,8 +115,6 @@ function nmfetch(url, options) {
|
||||
options.cookie = false;
|
||||
}
|
||||
|
||||
const fetchRes = options.fetchRes;
|
||||
const parsed = urllib.parse(url);
|
||||
let method = (options.method || '').toString().trim().toUpperCase() || 'GET';
|
||||
let finished = false;
|
||||
let cookies;
|
||||
@@ -48,6 +128,10 @@ function nmfetch(url, options) {
|
||||
};
|
||||
|
||||
Object.keys(options.headers || {}).forEach(key => {
|
||||
// options.headers is the caller's httpHeaders, straight off an attachment
|
||||
if (isProtoKey(key.toLowerCase().trim())) {
|
||||
return;
|
||||
}
|
||||
headers[key.toLowerCase().trim()] = options.headers[key];
|
||||
});
|
||||
|
||||
@@ -131,7 +215,12 @@ function nmfetch(url, options) {
|
||||
};
|
||||
|
||||
if (options.tls) {
|
||||
Object.assign(reqOptions, options.tls);
|
||||
// see TLS_OPTION_KEYS
|
||||
Object.keys(options.tls).forEach(key => {
|
||||
if (TLS_OPTION_KEYS.includes(key)) {
|
||||
reqOptions[key] = options.tls[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -216,8 +305,29 @@ function nmfetch(url, options) {
|
||||
options.method = 'GET';
|
||||
options.body = false;
|
||||
|
||||
const redirectUrl = urllib.resolve(url, res.headers.location);
|
||||
const redirectParsed = urllib.parse(redirectUrl);
|
||||
let redirectUrl;
|
||||
try {
|
||||
redirectUrl = urllib.resolve(url, res.headers.location);
|
||||
} catch (_err) {
|
||||
// the legacy resolver throws on a Location the WHATWG parser also refused,
|
||||
// so fall through to the check below with what the server actually sent
|
||||
redirectUrl = res.headers.location;
|
||||
}
|
||||
const redirectParsed = parseFetchUrl(redirectUrl);
|
||||
|
||||
if (!redirectParsed) {
|
||||
// Refuse the redirect target here rather than leaving it to the recursive
|
||||
// call: that call gets its own `finished` flag and no handle on this
|
||||
// request, so this one would stay open and could emit a second error on
|
||||
// the shared fetchRes once it times out. Callers listen with req.once().
|
||||
finished = true;
|
||||
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = redirectUrl;
|
||||
fetchRes.emit('error', err);
|
||||
req.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not forward credentials when the redirect leaves the original
|
||||
// security context: a different host, or a downgrade from https to
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class JSONTransport {
|
||||
// Sendmail strips this header line by itself
|
||||
mail.message.keepBcc = true;
|
||||
|
||||
const envelope = mail.data.envelope || mail.message.getEnvelope();
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
const MimeNode = require('../mime-node');
|
||||
const mimeFuncs = require('../mime-funcs');
|
||||
const { parseDataURI } = require('../shared');
|
||||
const { parseDataURI, copyOwnKeys } = require('../shared');
|
||||
|
||||
/**
|
||||
* Creates the object for composing a MimeNode instance out from the mail options
|
||||
@@ -205,7 +205,9 @@ class MailComposer {
|
||||
typeof this.mail.icalEvent === 'object' &&
|
||||
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
|
||||
) {
|
||||
icalEvent = Object.assign({}, this.mail.icalEvent);
|
||||
// an own "__proto__" key would make the copy inherit path/href from caller
|
||||
// data, and the mapping below then replaces the content the caller did set
|
||||
icalEvent = copyOwnKeys({}, this.mail.icalEvent);
|
||||
} else {
|
||||
icalEvent = {
|
||||
content: this.mail.icalEvent
|
||||
@@ -600,7 +602,7 @@ class MailComposer {
|
||||
}
|
||||
|
||||
// Return empty content for excessively long data URLs
|
||||
return Object.assign({}, element, {
|
||||
return Object.assign(copyOwnKeys({}, element), {
|
||||
path: false,
|
||||
href: false,
|
||||
content: Buffer.alloc(0),
|
||||
|
||||
+27
@@ -15,6 +15,13 @@ const net = require('net');
|
||||
const dns = require('dns');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Recipients allowed on one message unless the caller sets its own maxRecipients. A backstop
|
||||
* against a runaway or hostile recipient list rather than a delivery policy: RFC 5321 only
|
||||
* asks a server to accept 100, so a real send is bounded far below this.
|
||||
*/
|
||||
const DEFAULT_MAX_RECIPIENTS = 100000;
|
||||
|
||||
/**
|
||||
* Creates an object for exposing the Mail API
|
||||
*
|
||||
@@ -191,6 +198,26 @@ class Mail extends EventEmitter {
|
||||
mail.setPriorityHeaders();
|
||||
mail.setListHeaders();
|
||||
|
||||
const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients;
|
||||
const recipientCount = mail.message.getEnvelope().to.length;
|
||||
|
||||
if (maxRecipients && recipientCount > maxRecipients) {
|
||||
const err = new Error(
|
||||
`Message has ${recipientCount} recipients, which is over the ${maxRecipients} allowed by maxRecipients`
|
||||
);
|
||||
err.code = errors.EMAXRECIPIENTS;
|
||||
this.logger.error(
|
||||
{
|
||||
err,
|
||||
tnx: 'transport',
|
||||
action: 'send'
|
||||
},
|
||||
'Send Error: %s',
|
||||
err.message
|
||||
);
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
this._processPlugins('stream', mail, err => {
|
||||
if (err) {
|
||||
this.logger.error(
|
||||
|
||||
+68
-37
@@ -4,6 +4,11 @@ const shared = require('../shared');
|
||||
const MimeNode = require('../mime-node');
|
||||
const mimeFuncs = require('../mime-funcs');
|
||||
|
||||
// Only an own key counts as already set. `key in obj` also matches every member of
|
||||
// Object.prototype, which silently drops a transporter default legitimately named
|
||||
// toString or constructor.
|
||||
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
class MailMessage {
|
||||
constructor(mailer, data) {
|
||||
this.mailer = mailer;
|
||||
@@ -14,34 +19,53 @@ class MailMessage {
|
||||
const options = mailer.options || {};
|
||||
const defaults = mailer._defaults || {};
|
||||
|
||||
Object.assign(this.data, data);
|
||||
shared.copyOwnKeys(this.data, data);
|
||||
|
||||
this.data.headers = this.data.headers || {};
|
||||
|
||||
// apply defaults
|
||||
Object.keys(defaults).forEach(key => {
|
||||
if (!(key in this.data)) {
|
||||
this.data[key] = defaults[key];
|
||||
} else if (key === 'headers') {
|
||||
// headers is a special case. Allow setting individual default headers
|
||||
Object.keys(defaults.headers).forEach(key => {
|
||||
if (!(key in this.data.headers)) {
|
||||
this.data.headers[key] = defaults.headers[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Apply defaults. `_defaults` is caller supplied too, it is the second argument of
|
||||
// createTransport, so it needs the same treatment as `data` above
|
||||
shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
|
||||
|
||||
// headers is a special case. Allow setting individual default headers
|
||||
shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
|
||||
|
||||
// force specific keys from transporter options
|
||||
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => {
|
||||
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
|
||||
if (key in options) {
|
||||
this.data[key] = options[key];
|
||||
}
|
||||
});
|
||||
|
||||
// The access flags are a sandbox rather than a message field, so `defaults` counts as
|
||||
// transporter configuration for them. For a transporter plugin it is the only channel
|
||||
// there is, createTransport leaves `options` undefined for one, and the defaults copy
|
||||
// above yields to anything the message already set, which let message data switch the
|
||||
// sandbox back off. Closing is one way here, same as in resolveContent below: either
|
||||
// side may switch a flag on, neither can switch off what the other closed.
|
||||
['disableFileAccess', 'disableUrlAccess'].forEach(key => {
|
||||
if (!(key in options) && hasOwn(defaults, key)) {
|
||||
this.data[key] = this.data[key] || defaults[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveContent(...args) {
|
||||
return shared.resolveContent(...args);
|
||||
resolveContent(data, key, options, callback) {
|
||||
// Most plugins call this with the legacy (data, key, callback) signature, which carries
|
||||
// no access policy. The policy belongs to the message, so apply it here. Explicit
|
||||
// options may only tighten it, never reopen what the transporter closed.
|
||||
if (!callback && typeof options === 'function') {
|
||||
callback = options;
|
||||
options = false;
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
const policy = {
|
||||
disableFileAccess: this.data.disableFileAccess || options.disableFileAccess,
|
||||
disableUrlAccess: this.data.disableUrlAccess || options.disableUrlAccess
|
||||
};
|
||||
|
||||
return shared.resolveContent(data, key, policy, callback);
|
||||
}
|
||||
|
||||
resolveAll(callback) {
|
||||
@@ -123,11 +147,12 @@ class MailMessage {
|
||||
content: value
|
||||
};
|
||||
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
|
||||
Object.keys(args[0][args[1]]).forEach(key => {
|
||||
if (!(key in node) && !['content', 'path', 'href', 'raw'].includes(key)) {
|
||||
node[key] = args[0][args[1]][key];
|
||||
}
|
||||
});
|
||||
// The keys are the caller's, so copying them takes the same "__proto__"
|
||||
// rule as the constructor. `key in node` stays as the already-set test
|
||||
// here, unlike for the defaults: it also skips the Object.prototype
|
||||
// member names, and letting message data land a `toString` string on a
|
||||
// node only buys a TypeError the first time something stringifies it.
|
||||
shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
|
||||
}
|
||||
|
||||
args[0][args[1]] = node;
|
||||
@@ -140,7 +165,7 @@ class MailMessage {
|
||||
}
|
||||
|
||||
normalize(callback) {
|
||||
const envelope = this.data.envelope || this.message.getEnvelope();
|
||||
const envelope = this.message.getEnvelope();
|
||||
const messageId = this.message.messageId();
|
||||
|
||||
this.resolveAll((err, data) => {
|
||||
@@ -186,6 +211,9 @@ class MailMessage {
|
||||
|
||||
data.normalizedHeaders = {};
|
||||
Object.keys(data.headers || {}).forEach(key => {
|
||||
if (shared.isProtoKey(key)) {
|
||||
return;
|
||||
}
|
||||
let value = [].concat(data.headers[key] || []).shift();
|
||||
value = (value && value.value) || value;
|
||||
if (value) {
|
||||
@@ -271,15 +299,16 @@ class MailMessage {
|
||||
}
|
||||
|
||||
if (value && value.url) {
|
||||
// strip CR/LF so a comment can't inject extra header lines. DEL is neither
|
||||
// qtext nor ctext, so it can not be carried literally by either construct
|
||||
// and has to become an encoded word like any other non-plaintext value
|
||||
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
|
||||
const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment);
|
||||
|
||||
if (key.toLowerCase().trim() === 'id') {
|
||||
// List-ID: "comment" <domain>
|
||||
// strip CR/LF so a comment can't inject extra header lines
|
||||
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
|
||||
if (mimeFuncs.isPlainText(comment)) {
|
||||
comment = '"' + comment + '"';
|
||||
} else {
|
||||
comment = mimeFuncs.encodeWord(comment);
|
||||
}
|
||||
// List-ID: "comment" <domain>, where an unescaped quote or a trailing
|
||||
// backslash in the comment would swallow the <domain> behind it
|
||||
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
|
||||
|
||||
// List-ID expects a bare domain-like identifier, so strip the
|
||||
// scheme prefix that _formatListUrl adds or passes through
|
||||
@@ -289,11 +318,11 @@ class MailMessage {
|
||||
}
|
||||
|
||||
// List-*: <http://domain> (comment)
|
||||
// strip CR/LF so a comment can't inject extra header lines
|
||||
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
|
||||
if (!mimeFuncs.isPlainText(comment)) {
|
||||
comment = mimeFuncs.encodeWord(comment);
|
||||
}
|
||||
// the ctext specials go out as quoted-pairs, otherwise a ")" closes the
|
||||
// comment early and leaves the rest as junk, an unpaired "(" opens a
|
||||
// nested comment that never closes, and a trailing backslash escapes
|
||||
// the closing ")" so the comment swallows whatever follows it
|
||||
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&');
|
||||
|
||||
return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
|
||||
}
|
||||
@@ -307,7 +336,9 @@ class MailMessage {
|
||||
}
|
||||
|
||||
_formatListUrl(url) {
|
||||
url = url.replace(/[\s<]+|[\s>]+/g, '');
|
||||
// a url has no way to carry a control char or DEL, and the angle brackets around it
|
||||
// are not a quoting construct, so anything left here lands in the header raw
|
||||
url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, '');
|
||||
if (/^(https?|mailto|ftp):/.test(url)) {
|
||||
return '<' + url + '>';
|
||||
}
|
||||
|
||||
+80
-19
@@ -5,19 +5,39 @@
|
||||
const base64 = require('../base64');
|
||||
const qp = require('../qp');
|
||||
const mimeTypes = require('./mime-types');
|
||||
const { isProtoKey } = require('../shared/objects');
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Checks if a value is plaintext string (uses only printable 7bit chars)
|
||||
*
|
||||
* When isParam is set the value is destined for a header parameter, so HT, CR and LF
|
||||
* are not plaintext either: a header parameter has no way to carry them. HT is a valid
|
||||
* fold point, so folding and unfolding a header would rewrite it as a space, and CR/LF
|
||||
* cannot appear in a header value at all. DEL is neither a token character nor qtext,
|
||||
* so it can not be carried bare or quoted. Such values have to go through the rfc2231
|
||||
* parameter continuation encoding instead, the same way a quote already does.
|
||||
*
|
||||
* @param {String} value String to be tested
|
||||
* @param {Boolean} [isParam] Set to true if the value is a header parameter value
|
||||
* @returns {Boolean} true if it is a plaintext string
|
||||
*/
|
||||
isPlainText(value, isParam) {
|
||||
const re = isParam ? /[\x00-\x08\x0b\x0c\x0e-\x1f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
|
||||
const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
|
||||
return typeof value === 'string' && !re.test(value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Wraps a value into a quoted-string. Inside one a quote would end the string early
|
||||
* and a backslash would escape whatever follows it, so both go out as quoted-pairs.
|
||||
*
|
||||
* @param {String} value String to be quoted
|
||||
* @returns {String} The value as a quoted-string, quotes included
|
||||
*/
|
||||
quoteString(value) {
|
||||
return '"' + (value || '').toString().replace(/["\\]/g, '\\$&') + '"';
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if a multi line string containes lines longer than the selected value.
|
||||
*
|
||||
@@ -80,8 +100,10 @@ module.exports = {
|
||||
for (let i = 0, len = encodedStr.length; i < len; i++) {
|
||||
let chr = encodedStr.charAt(i);
|
||||
|
||||
if (/[\ud83c\ud83d\ud83e]/.test(chr) && i < len - 1) {
|
||||
// composite emoji byte, so add the next byte as well
|
||||
if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
|
||||
// leading surrogate, so add the trailing surrogate as well
|
||||
// an unpaired one must not swallow the next unit, that would destroy
|
||||
// a valid pair following it
|
||||
chr += encodedStr.charAt(++i);
|
||||
}
|
||||
|
||||
@@ -168,10 +190,12 @@ module.exports = {
|
||||
buildHeaderValue(structured) {
|
||||
const paramsArray = [];
|
||||
|
||||
Object.keys(structured.params || {}).forEach(param => {
|
||||
Object.keys(structured.params || {}).forEach(key => {
|
||||
// filename might include unicode characters so it is a special case
|
||||
// other values probably do not
|
||||
const value = structured.params[param];
|
||||
const value = structured.params[key];
|
||||
// a parameter name is a token too and it is emitted without any quoting around it
|
||||
const param = key.replace(/[\x00-\x1f\x7f]/g, '');
|
||||
if (!this.isPlainText(value, true) || value.length >= 75) {
|
||||
this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
|
||||
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
|
||||
@@ -187,7 +211,11 @@ module.exports = {
|
||||
}
|
||||
});
|
||||
|
||||
return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
|
||||
// the value ahead of the parameters is a token, it has no way to carry a control
|
||||
// char or DEL and there is no quoting construct around it to escape one into
|
||||
const value = typeof structured.value === 'string' ? structured.value.replace(/[\x00-\x1f\x7f]/g, '') : structured.value;
|
||||
|
||||
return value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -208,7 +236,7 @@ module.exports = {
|
||||
buildHeaderParam(key, data, maxLength) {
|
||||
const list = [];
|
||||
let encodedStr = typeof data === 'string' ? data : (data || '').toString();
|
||||
let chr, ord;
|
||||
let chr;
|
||||
let line;
|
||||
let startPos = 0;
|
||||
let i, len;
|
||||
@@ -245,8 +273,9 @@ module.exports = {
|
||||
const encodedStrArr = [];
|
||||
for (i = 0, len = encodedStr.length; i < len; i++) {
|
||||
chr = encodedStr.charAt(i);
|
||||
ord = chr.charCodeAt(0);
|
||||
if (ord >= 0xd800 && ord <= 0xdbff && i < len - 1) {
|
||||
if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
|
||||
// an unpaired leading surrogate must not consume the next unit, that
|
||||
// would tear apart a valid pair following it
|
||||
chr += encodedStr.charAt(i + 1);
|
||||
encodedStrArr.push(chr);
|
||||
i++;
|
||||
@@ -284,8 +313,11 @@ module.exports = {
|
||||
line,
|
||||
encoded
|
||||
});
|
||||
// the line we start here holds an encoded char, so it has to be
|
||||
// flagged as one. otherwise it gets an unstarred continuation key
|
||||
// and a receiver reads the percent escapes as literal text
|
||||
line = '';
|
||||
startPos = i - 1;
|
||||
encoded = true;
|
||||
} else {
|
||||
encoded = true;
|
||||
i = startPos;
|
||||
@@ -350,6 +382,16 @@ module.exports = {
|
||||
value: false,
|
||||
params: {}
|
||||
};
|
||||
|
||||
// Parameter names come from a caller supplied contentType/contentDisposition. A
|
||||
// "__proto__" name would target the prototype chain of the params object instead of
|
||||
// an own property of it, and read back as Object.prototype, so it is dropped.
|
||||
const setParam = (name, value) => {
|
||||
if (!isProtoKey(name)) {
|
||||
response.params[name] = value;
|
||||
}
|
||||
};
|
||||
|
||||
let key = false;
|
||||
let value = '';
|
||||
let type = 'value';
|
||||
@@ -381,7 +423,7 @@ module.exports = {
|
||||
if (key === false) {
|
||||
response.value = value.trim();
|
||||
} else {
|
||||
response.params[key] = value.trim();
|
||||
setParam(key, value.trim());
|
||||
}
|
||||
type = 'key';
|
||||
value = '';
|
||||
@@ -396,10 +438,10 @@ module.exports = {
|
||||
if (key === false) {
|
||||
response.value = value.trim();
|
||||
} else {
|
||||
response.params[key] = value.trim();
|
||||
setParam(key, value.trim());
|
||||
}
|
||||
} else if (value.trim()) {
|
||||
response.params[value.trim().toLowerCase()] = '';
|
||||
setParam(value.trim().toLowerCase(), '');
|
||||
}
|
||||
|
||||
// handle parameter value continuations
|
||||
@@ -412,6 +454,14 @@ module.exports = {
|
||||
actualKey = key.substr(0, match.index);
|
||||
nr = Number(match[2] || match[3]) || 0;
|
||||
|
||||
if (isProtoKey(actualKey)) {
|
||||
// see setParam. Reading it back would yield Object.prototype, which is
|
||||
// an object, so the initializer below would be skipped and the write
|
||||
// that follows would throw out of a header build the caller can not catch
|
||||
delete response.params[key];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
|
||||
response.params[actualKey] = {
|
||||
charset: false,
|
||||
@@ -538,7 +588,7 @@ module.exports = {
|
||||
*/
|
||||
splitMimeEncodedString: (str, maxlen) => {
|
||||
const lines = [];
|
||||
let curLine, match, chr, done;
|
||||
let curLine, fallbackLine, match, chr, done;
|
||||
|
||||
// require at least 12 symbols to fit possible 4 octet UTF-8 sequences
|
||||
maxlen = Math.max(maxlen || 0, 12);
|
||||
@@ -551,8 +601,14 @@ module.exports = {
|
||||
curLine = curLine.substr(0, match.index);
|
||||
}
|
||||
|
||||
// Malformed input (a run of stray UTF-8 continuation bytes) has no split point
|
||||
// that keeps a character sequence whole, so the loop below walks back to an
|
||||
// empty line looking for one. Keep the widest chunk that at least does not cut
|
||||
// a "=XX" escape in half, so the part stays a decodable encoded word.
|
||||
fallbackLine = curLine.length ? curLine : str.substr(0, maxlen);
|
||||
|
||||
done = false;
|
||||
while (!done) {
|
||||
while (!done && curLine.length) {
|
||||
done = true;
|
||||
// check if not middle of a unicode char sequence
|
||||
if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
|
||||
@@ -565,9 +621,11 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
if (curLine.length) {
|
||||
lines.push(curLine);
|
||||
if (!curLine.length) {
|
||||
curLine = fallbackLine;
|
||||
}
|
||||
|
||||
lines.push(curLine);
|
||||
str = str.substr(curLine.length);
|
||||
}
|
||||
|
||||
@@ -600,8 +658,11 @@ module.exports = {
|
||||
// might throw if we try to encode invalid sequences, eg. partial emoji
|
||||
str = encodeURIComponent(str);
|
||||
} catch (_E) {
|
||||
// should never run
|
||||
return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, '');
|
||||
// an unpaired surrogate has no utf-8 representation, so run the value through a
|
||||
// utf-8 roundtrip to get the same U+FFFD every other encoder here produces and
|
||||
// retry. the value must never come back unencoded, it goes into a header parameter
|
||||
// where a bare quote or semicolon would break it out into a parameter of its own
|
||||
str = encodeURIComponent(Buffer.from(str, 'utf-8').toString('utf-8'));
|
||||
}
|
||||
|
||||
// ensure chars that are not handled by encodeURICompent are converted as well
|
||||
|
||||
+288
-60
@@ -7,6 +7,7 @@ const fs = require('fs');
|
||||
const punycode = require('../punycode');
|
||||
const { PassThrough } = require('stream');
|
||||
const shared = require('../shared');
|
||||
const urlModule = require('url');
|
||||
|
||||
const mimeFuncs = require('../mime-funcs');
|
||||
const qp = require('../qp');
|
||||
@@ -21,6 +22,59 @@ const LeUnix = require('./le-unix');
|
||||
|
||||
const FORMATTED_HEADERS = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References'];
|
||||
|
||||
// RFC 5321 atext, plus the non-ascii bytes that SMTPUTF8 (RFC 6531) adds to it. A local part
|
||||
// built from these, with '.' as a separator, is a dot-atom and can be emitted bare
|
||||
const ATEXT = "[A-Za-z0-9!#$%&'*+\\-/=?^_`{|}~\\x80-\\uFFFF]";
|
||||
const DOT_ATOM = new RegExp('^' + ATEXT + '+(?:\\.' + ATEXT + '+)*$');
|
||||
|
||||
// A complete quoted-string: everything between the outer quotes is either a plain char or
|
||||
// a quoted-pair. Anchored, so a value that only starts and ends with a quote does not pass
|
||||
const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
|
||||
|
||||
// An address that carries no special anywhere can be emitted bare in a header, everything
|
||||
// else goes into angle brackets so that the header can not be read as more addresses than
|
||||
// the envelope carries
|
||||
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
|
||||
|
||||
// domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
|
||||
// mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
|
||||
// controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
|
||||
// deliverable 'evil.example', which would turn a value the bundled codec leaves as
|
||||
// unroutable garbage into mail for a domain the sender never named. None of these
|
||||
// characters are legal in a domain, so keep them away from the mapper.
|
||||
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
|
||||
|
||||
/**
|
||||
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
|
||||
* which is with UTS-46 mapping applied before the Punycode step.
|
||||
*
|
||||
* The bundled codec is plain RFC 3492 and maps nothing, so it disagrees with every
|
||||
* conformant parser on any domain holding a mapped or ignored code point. An invisible
|
||||
* U+00AD in 'compa\u00ADny.com' encoded to 'xn--company-pka.com' where a validator reads
|
||||
* 'company.com', which let an allow-listed domain be checked and a different one mailed.
|
||||
*
|
||||
* Anything the URL parser does not accept as a hostname, an address literal such as
|
||||
* '[127.0.0.1]' included, comes back empty and falls through to the bundled codec, which
|
||||
* leaves those as they were supplied.
|
||||
*
|
||||
* @param {String} domain Domain to encode, already lowercased by the caller
|
||||
* @param {Boolean} toUnicode Return the U-label form instead of the A-label form
|
||||
* @return {String} Encoded domain
|
||||
*/
|
||||
function normalizeDomain(domain, toUnicode) {
|
||||
// domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6
|
||||
const mapper = toUnicode ? urlModule.domainToUnicode : urlModule.domainToASCII;
|
||||
|
||||
if (typeof mapper === 'function' && !URL_PARSER_UNSAFE.test(domain)) {
|
||||
const mapped = mapper(domain);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new mime tree node. Assumes 'multipart/*' as the content type
|
||||
* if it is a branch, anything else counts as leaf. If rootNode is missing from
|
||||
@@ -190,6 +244,14 @@ class MimeNode {
|
||||
* @return {Object} Appended node object
|
||||
*/
|
||||
appendChild(childNode) {
|
||||
// Take the node out of the tree it is in first. Leaving it there keeps it in that
|
||||
// parent's childNodes, so it still streams as part of the old tree while parentNode
|
||||
// already points at the new one, and anything read off the parent chain answers for
|
||||
// the wrong tree.
|
||||
if (childNode.parentNode && childNode.parentNode !== this) {
|
||||
childNode.remove();
|
||||
}
|
||||
|
||||
if (childNode.rootNode !== this.rootNode) {
|
||||
childNode.rootNode = this.rootNode;
|
||||
childNode._nodeId = ++this.rootNode.nodeCounter;
|
||||
@@ -519,11 +581,10 @@ class MimeNode {
|
||||
const formattedHeaders = FORMATTED_HEADERS;
|
||||
|
||||
if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
|
||||
Object.keys(value).forEach(key => {
|
||||
if (key !== 'value') {
|
||||
options[key] = value[key];
|
||||
}
|
||||
});
|
||||
// the keys come from a caller supplied header object and `options.prepared`
|
||||
// below decides whether the value is emitted raw, so an own "__proto__" key
|
||||
// here would turn an unfolded value into header injection
|
||||
shared.copyOwnKeys(options, value, optionKey => optionKey === 'value');
|
||||
value = (value.value || '').toString();
|
||||
if (!value.trim()) {
|
||||
return;
|
||||
@@ -552,6 +613,11 @@ class MimeNode {
|
||||
case 'Content-Type':
|
||||
structured = mimeFuncs.parseHeaderValue(value);
|
||||
|
||||
// the type token decides multipart and charset below, so clean it before
|
||||
// those run and not just on the way out, otherwise a control char makes
|
||||
// the checks miss and the header ends up claiming a type it is not set up for
|
||||
structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, '');
|
||||
|
||||
this._handleContentType(structured);
|
||||
|
||||
if (
|
||||
@@ -568,11 +634,18 @@ class MimeNode {
|
||||
// add support for non-compliant clients like QQ webmail
|
||||
// we can't build the value with buildHeaderValue as the value is non standard and
|
||||
// would be converted to parameter continuation encoding that we do not want
|
||||
param = this._encodeWords(this.filename);
|
||||
// control chars can not be quoted here: HT is a fold point that unfolding would
|
||||
// turn into a space, CR/LF can not appear in a header at all and DEL is not
|
||||
// qtext, so force the mime encoded word that a non-ascii filename would get anyway
|
||||
param = /[\x00-\x1f\x7f]/.test(this.filename)
|
||||
? mimeFuncs.encodeWord(this.filename, this._getTextEncoding(this.filename), 52)
|
||||
: this._encodeWords(this.filename);
|
||||
|
||||
if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) {
|
||||
// include value in quotes if needed
|
||||
param = '"' + param + '"';
|
||||
// include value in quotes if needed, escaping backslashes and quotes as
|
||||
// quoted-pairs exactly like buildHeaderValue does for filename=, otherwise
|
||||
// a trailing backslash would escape the closing quote
|
||||
param = JSON.stringify(param);
|
||||
}
|
||||
value += '; name=' + param;
|
||||
}
|
||||
@@ -595,8 +668,12 @@ class MimeNode {
|
||||
|
||||
if (typeof this.normalizeHeaderKey === 'function') {
|
||||
const normalized = this.normalizeHeaderKey(key, value);
|
||||
if (normalized && typeof normalized === 'string' && normalized.length) {
|
||||
key = normalized;
|
||||
// the result replaces the key on the way into the header, so it gets the same
|
||||
// treatment the key it replaces already had. a line break here would end the
|
||||
// header and start one of the caller's own
|
||||
const cleaned = typeof normalized === 'string' ? normalized.replace(/[\x00-\x1f\x7f]/g, '') : '';
|
||||
if (cleaned) {
|
||||
key = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,26 +914,23 @@ class MimeNode {
|
||||
|
||||
if (envelope.from) {
|
||||
list = [];
|
||||
this._convertAddresses(this._parseAddresses(envelope.from), list);
|
||||
this._convertAddresses(this._parseEnvelopeAddresses(envelope.from), list);
|
||||
list = list.filter(address => address && address.address);
|
||||
if (list.length && list[0]) {
|
||||
this._envelope.from = list[0].address;
|
||||
}
|
||||
}
|
||||
const seenRecipients = new Set();
|
||||
['to', 'cc', 'bcc'].forEach(key => {
|
||||
if (envelope[key]) {
|
||||
this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
|
||||
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to, seenRecipients);
|
||||
}
|
||||
});
|
||||
|
||||
this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
|
||||
|
||||
const standardFields = ['to', 'cc', 'bcc', 'from'];
|
||||
Object.keys(envelope).forEach(key => {
|
||||
if (!standardFields.includes(key)) {
|
||||
this._envelope[key] = envelope[key];
|
||||
}
|
||||
});
|
||||
shared.copyOwnKeys(this._envelope, envelope, key => standardFields.includes(key));
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -868,15 +942,17 @@ class MimeNode {
|
||||
*/
|
||||
getAddresses() {
|
||||
const addresses = {};
|
||||
const seenByKey = new Map();
|
||||
|
||||
this._headers.forEach(header => {
|
||||
const key = header.key.toLowerCase();
|
||||
if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
|
||||
if (!Array.isArray(addresses[key])) {
|
||||
addresses[key] = [];
|
||||
seenByKey.set(key, new Set());
|
||||
}
|
||||
|
||||
this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
|
||||
this._convertAddresses(this._parseAddresses(header.value), addresses[key], seenByKey.get(key));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -897,6 +973,12 @@ class MimeNode {
|
||||
from: false,
|
||||
to: []
|
||||
};
|
||||
|
||||
// Built once and carried across the headers. Letting _convertAddresses seed it per
|
||||
// call would cost O(headers x recipients), and a message can carry many address
|
||||
// headers: `headers: { to: [...] }` emits one To per entry.
|
||||
const seenRecipients = new Set();
|
||||
|
||||
this._headers.forEach(header => {
|
||||
const list = [];
|
||||
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
|
||||
@@ -905,7 +987,7 @@ class MimeNode {
|
||||
envelope.from = list[0].address;
|
||||
}
|
||||
} else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
|
||||
this._convertAddresses(this._parseAddresses(header.value), envelope.to);
|
||||
this._convertAddresses(this._parseAddresses(header.value), envelope.to, seenRecipients);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -952,6 +1034,26 @@ class MimeNode {
|
||||
|
||||
/////// PRIVATE METHODS
|
||||
|
||||
/**
|
||||
* Checks an access policy flag for this node and every node above it. The flags are set
|
||||
* from the options the node was built with, and createChild only ever sees the options
|
||||
* the caller passed, so a child of a closed tree starts out open. Reading the answer off
|
||||
* the parent chain keeps it right whatever order the tree was assembled in.
|
||||
*
|
||||
* @param {String} flag Either 'disableFileAccess' or 'disableUrlAccess'
|
||||
* @return {Boolean} true if this node or an ancestor closed that access
|
||||
*/
|
||||
_accessDisabled(flag) {
|
||||
let node = this;
|
||||
while (node) {
|
||||
if (node[flag]) {
|
||||
return true;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and returns handle to a stream related with the content.
|
||||
*
|
||||
@@ -982,7 +1084,7 @@ class MimeNode {
|
||||
}
|
||||
|
||||
if (content && typeof content.path === 'string' && !content.href) {
|
||||
if (this.disableFileAccess) {
|
||||
if (this._accessDisabled('disableFileAccess')) {
|
||||
contentStream = new PassThrough();
|
||||
setImmediate(() => {
|
||||
const err = new Error('File access rejected for ' + content.path);
|
||||
@@ -996,7 +1098,7 @@ class MimeNode {
|
||||
}
|
||||
|
||||
if (content && typeof content.href === 'string') {
|
||||
if (this.disableUrlAccess) {
|
||||
if (this._accessDisabled('disableUrlAccess')) {
|
||||
contentStream = new PassThrough();
|
||||
setImmediate(() => {
|
||||
const err = new Error('Url access rejected for ' + content.href);
|
||||
@@ -1005,7 +1107,9 @@ class MimeNode {
|
||||
});
|
||||
return contentStream;
|
||||
}
|
||||
// fetch URL
|
||||
// fetch URL. nmfetch refuses any scheme that is not http(s), and it decides
|
||||
// that on the parsed URL. Testing the raw string here instead would reject
|
||||
// forms the parser accepts, such as a leading space or a slash-less authority
|
||||
return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
|
||||
}
|
||||
|
||||
@@ -1030,17 +1134,81 @@ class MimeNode {
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
_parseAddresses(addresses) {
|
||||
return [].concat.apply(
|
||||
[],
|
||||
[].concat(addresses).map(address => {
|
||||
if (address && address.address) {
|
||||
address.address = this._normalizeAddress(address.address);
|
||||
address.name = address.name || '';
|
||||
return [address];
|
||||
// Collected into one list as we go. concat.apply spreads the entries into arguments
|
||||
// and throws a RangeError once a recipient array is long enough to pass the
|
||||
// argument limit, which a large Bcc list reaches on its own.
|
||||
const flattened = [];
|
||||
|
||||
[].concat(addresses).forEach(address => {
|
||||
if (address && address.address) {
|
||||
const normalized = this._normalizeAddress(address.address);
|
||||
if (normalized === address.address && typeof address.name === 'string') {
|
||||
// there is nothing to rewrite, so there is nothing to keep off the original
|
||||
flattened.push(address);
|
||||
return;
|
||||
}
|
||||
return addressparser(address);
|
||||
})
|
||||
);
|
||||
|
||||
// rewriting would land on the object the caller passed in and might
|
||||
// still hold a reference to, so rewrite a copy of it instead. An own
|
||||
// "__proto__" key would make the copy inherit from caller data, and
|
||||
// _convertAddresses reads `group` off it straight into the envelope
|
||||
const copy = shared.copyOwnKeys({}, address);
|
||||
copy.address = normalized;
|
||||
copy.name = address.name || '';
|
||||
flattened.push(copy);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = this._normalizeParsedAddresses(addressparser(address));
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
flattened.push(parsed[i]);
|
||||
}
|
||||
});
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the addresses of a freshly parsed address list, groups included.
|
||||
*
|
||||
* Everything this method returns carries a normalized address, whether it arrived as an
|
||||
* object or was parsed out of a header value. Without this the two shapes disagree, and
|
||||
* a consumer reading the parsed form back is handed the ambiguous
|
||||
* 'user@evil.com@good.com' that the header and the envelope no longer carry.
|
||||
*
|
||||
* @param {Array} parsed An array of address objects, as returned by addressparser
|
||||
* @return {Array} The same array, with every address normalized
|
||||
*/
|
||||
_normalizeParsedAddresses(parsed) {
|
||||
// addressparser builds these objects, so no caller holds a reference to rewrite around
|
||||
parsed.forEach(entry => {
|
||||
if (entry.address) {
|
||||
entry.address = this._normalizeAddress(entry.address);
|
||||
} else if (entry.group) {
|
||||
this._normalizeParsedAddresses(entry.group);
|
||||
}
|
||||
});
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the addresses of an explicitly set envelope.
|
||||
*
|
||||
* An envelope value is an addr-spec and never a display name, so a bare local username
|
||||
* such as 'root' is the address here. Header parsing has to read the same value as a
|
||||
* display name, as a value with no '@' in it can not be an addr-spec in a header.
|
||||
*
|
||||
* @param {Mixed} addresses Addresses to be parsed
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
_parseEnvelopeAddresses(addresses) {
|
||||
return this._parseAddresses(addresses).map(entry => {
|
||||
if (entry.address || entry.group || !entry.name || /[\s@]/.test(entry.name)) {
|
||||
return entry;
|
||||
}
|
||||
return { address: this._normalizeAddress(entry.name), name: '' };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1054,6 +1222,9 @@ class MimeNode {
|
||||
.toString()
|
||||
// no newlines in keys
|
||||
.replace(/\r?\n|\r/g, ' ')
|
||||
// a field name is printable ascii without the colon, so a control char or DEL
|
||||
// can only be dropped, there is no quoting construct around a field name
|
||||
.replace(/[\x00-\x1f\x7f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
// use uppercase words, except MIME
|
||||
@@ -1114,7 +1285,13 @@ class MimeNode {
|
||||
case 'Message-ID':
|
||||
case 'In-Reply-To':
|
||||
case 'Content-Id':
|
||||
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
|
||||
// a msg-id is structured, so an encoded word inside the angle brackets would
|
||||
// be read as literal text. drop the characters that can not appear in a header
|
||||
// at all, but leave HT alone, it separates the ids of a multi id value
|
||||
value = (value || '')
|
||||
.toString()
|
||||
.replace(/\r?\n|\r/g, ' ')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
||||
|
||||
if (value.charAt(0) !== '<') {
|
||||
value = '<' + value;
|
||||
@@ -1134,6 +1311,7 @@ class MimeNode {
|
||||
elm = (elm || '')
|
||||
.toString()
|
||||
.replace(/\r?\n|\r/g, ' ')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.trim();
|
||||
return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
|
||||
})
|
||||
@@ -1156,7 +1334,7 @@ class MimeNode {
|
||||
}
|
||||
|
||||
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
|
||||
return this._encodeWords(value);
|
||||
return this._encodeHeaderText(value);
|
||||
|
||||
case 'Content-Type':
|
||||
case 'Content-Disposition':
|
||||
@@ -1165,8 +1343,7 @@ class MimeNode {
|
||||
|
||||
default:
|
||||
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
|
||||
// encodeWords only encodes if needed, otherwise the original string is returned
|
||||
return this._encodeWords(value);
|
||||
return this._encodeHeaderText(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1177,26 +1354,44 @@ class MimeNode {
|
||||
* @param {Array} [uniqueList] An array to be populated with addresses
|
||||
* @return {String} address string
|
||||
*/
|
||||
_convertAddresses(addresses, uniqueList) {
|
||||
_convertAddresses(addresses, uniqueList, seenAddresses) {
|
||||
const values = [];
|
||||
|
||||
uniqueList = uniqueList || [];
|
||||
|
||||
// Membership is checked once per address, so scanning uniqueList itself would make
|
||||
// a recipient list cost O(n^2). Groups recurse with the same set so that a nested
|
||||
// group still dedupes against the addresses collected around it, and a caller that
|
||||
// passes a partly filled list (To, then Cc, then Bcc) keeps deduping across headers.
|
||||
if (!seenAddresses) {
|
||||
seenAddresses = new Set();
|
||||
for (let i = 0; i < uniqueList.length; i++) {
|
||||
seenAddresses.add(uniqueList[i].address);
|
||||
}
|
||||
}
|
||||
|
||||
[].concat(addresses || []).forEach(address => {
|
||||
if (address.address) {
|
||||
address.address = this._normalizeAddress(address.address);
|
||||
|
||||
if (!address.name) {
|
||||
values.push(address.address.indexOf(' ') >= 0 ? `<${address.address}>` : `${address.address}`);
|
||||
// an address that carries a special, be it a quoted local part or a domain
|
||||
// that could not be normalized, is only unambiguous inside angle brackets.
|
||||
// Without them a ',' or a ';' anywhere in it reads as a recipient separator
|
||||
// and the header would list more recipients than the envelope carries
|
||||
values.push(PLAIN_ADDRESS.test(address.address) ? address.address : `<${address.address}>`);
|
||||
} else {
|
||||
values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
|
||||
}
|
||||
|
||||
if (!uniqueList.some(a => a.address === address.address)) {
|
||||
if (!seenAddresses.has(address.address)) {
|
||||
seenAddresses.add(address.address);
|
||||
uniqueList.push(address);
|
||||
}
|
||||
} else if (address.group) {
|
||||
const groupListAddresses = (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim();
|
||||
const groupListAddresses = (
|
||||
address.group.length ? this._convertAddresses(address.group, uniqueList, seenAddresses) : ''
|
||||
).trim();
|
||||
values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
|
||||
}
|
||||
});
|
||||
@@ -1213,45 +1408,63 @@ class MimeNode {
|
||||
_normalizeAddress(address) {
|
||||
address = (address || '')
|
||||
.toString()
|
||||
.replace(/[\x00-\x1F<>]+/g, ' ') // remove unallowed characters
|
||||
.replace(/[\x00-\x1F\x7F<>]+/g, ' ') // remove unallowed characters
|
||||
.trim();
|
||||
|
||||
const lastAt = address.lastIndexOf('@');
|
||||
if (lastAt < 0) {
|
||||
// Bare username
|
||||
if (!address) {
|
||||
// callers use an empty value to detect a missing address
|
||||
return address;
|
||||
}
|
||||
|
||||
let user = address.substr(0, lastAt);
|
||||
const lastAt = address.lastIndexOf('@');
|
||||
if (lastAt < 0) {
|
||||
// Bare username, there is no domain to split off
|
||||
return this._normalizeLocalPart(address);
|
||||
}
|
||||
|
||||
const user = address.substr(0, lastAt);
|
||||
const domain = address.substr(lastAt + 1);
|
||||
|
||||
// Usernames are not touched and are kept as is even if these include unicode.
|
||||
// Unicode in the local part is kept as is, see _normalizeLocalPart for the rest of it.
|
||||
// A domain has no quoting construct to fall back on, so whatever is not a valid domain
|
||||
// is kept as supplied and it is _convertAddresses that keeps such an address unambiguous.
|
||||
// Domains are punycoded when the local part is ASCII ('safe@jõgeva.ee' -> 'safe@xn--jgeva-dua.ee').
|
||||
// When the local part contains non-ASCII bytes the address already requires SMTPUTF8,
|
||||
// so the domain is kept (or decoded back) as UTF-8 for symmetry on both sides of '@'.
|
||||
|
||||
let encodedDomain = domain;
|
||||
|
||||
// A non-ASCII local part already requires SMTPUTF8, so the domain stays UTF-8 for
|
||||
// symmetry on both sides of the '@' rather than being encoded to an A-label
|
||||
const smtputf8 = /[\x80-\uFFFF]/.test(user);
|
||||
|
||||
try {
|
||||
if (/[\x80-\uFFFF]/.test(user)) {
|
||||
encodedDomain = punycode.toUnicode(domain.toLowerCase());
|
||||
} else {
|
||||
encodedDomain = punycode.toASCII(domain.toLowerCase());
|
||||
}
|
||||
encodedDomain = normalizeDomain(domain.toLowerCase(), smtputf8);
|
||||
} catch (_err) {
|
||||
// keep domain as supplied
|
||||
}
|
||||
|
||||
if (user.indexOf(' ') >= 0) {
|
||||
if (user.charAt(0) !== '"') {
|
||||
user = '"' + user;
|
||||
}
|
||||
if (user.substr(-1) !== '"') {
|
||||
user = user + '"';
|
||||
}
|
||||
return `${this._normalizeLocalPart(user)}@${encodedDomain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the local part of an address into a form that can be emitted as is.
|
||||
*
|
||||
* A local part is either a dot-atom or a quoted-string, anything else is not a valid
|
||||
* addr-spec. The quotes of a quoted local part get lost along the way, and a bare
|
||||
* 'user@evil.com@good.com' leaves it to the receiver which '@' splits the domain off,
|
||||
* while the split here is always at the last one. So whatever is not already one of
|
||||
* the two valid forms goes back out as a quoted-string.
|
||||
*
|
||||
* @param {String} user Local part of an address
|
||||
* @return {String} Local part as a dot-atom or as a quoted-string
|
||||
*/
|
||||
_normalizeLocalPart(user) {
|
||||
if (DOT_ATOM.test(user) || QUOTED_STRING.test(user)) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return `${user}@${encodedDomain}`;
|
||||
return mimeFuncs.quoteString(user);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1263,7 +1476,7 @@ class MimeNode {
|
||||
_encodeAddressName(name) {
|
||||
if (!/^[\w ]*$/.test(name)) {
|
||||
if (/^[\x20-\x7e]*$/.test(name)) {
|
||||
return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
|
||||
return mimeFuncs.quoteString(name);
|
||||
} else {
|
||||
return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
|
||||
}
|
||||
@@ -1271,6 +1484,21 @@ class MimeNode {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an unstructured header value. Such a value can only carry VCHAR and WSP, so a
|
||||
* control char or DEL has to be forced into the mime encoded word that a non-ascii value
|
||||
* would get anyway. HT stays as it is, it is valid folding whitespace here.
|
||||
*
|
||||
* @param {String} value Header value to encode
|
||||
* @returns {String} Mime word encoded string if needed
|
||||
*/
|
||||
_encodeHeaderText(value) {
|
||||
return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
|
||||
? mimeFuncs.encodeWord(value, this._getTextEncoding(value), 52)
|
||||
: // encodeWords only encodes if needed, otherwise the original string is returned
|
||||
this._encodeWords(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* If needed, mime encodes the name part
|
||||
*
|
||||
|
||||
+1
-1
@@ -250,7 +250,7 @@ const decode = function (input) {
|
||||
// Main decoding loop: start just after the last delimiter if any basic code
|
||||
// points were copied; start at the beginning otherwise.
|
||||
|
||||
for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */; ) {
|
||||
for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */;) {
|
||||
// `index` is the index of the next character to be consumed.
|
||||
// Decode a generalized variable-length integer into `delta`,
|
||||
// which gets added to `i`. The overflow checking is easier
|
||||
|
||||
+5
-2
@@ -62,14 +62,17 @@ class SendmailTransport {
|
||||
// Sendmail strips this header line by itself
|
||||
mail.message.keepBcc = true;
|
||||
|
||||
const envelope = mail.data.envelope || mail.message.getEnvelope();
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
let returned;
|
||||
|
||||
const hasInvalidAddresses = []
|
||||
.concat(envelope.from || [])
|
||||
.concat(envelope.to || [])
|
||||
.some(addr => /^-/.test(addr));
|
||||
// a local part is either a dot-atom or a quoted-string, so a leading dash sits at
|
||||
// offset 0 or, behind the opening quote, at offset 1. Only the first shape is read
|
||||
// as an option by sendmail, but both are the address this guard keeps out of argv
|
||||
.some(addr => /^"?-/.test(addr));
|
||||
if (hasInvalidAddresses) {
|
||||
const err = new Error('Can not send mail. Invalid envelope addresses.');
|
||||
err.code = errors.ESENDMAIL;
|
||||
|
||||
+4
-3
@@ -66,7 +66,7 @@ class SESTransport extends EventEmitter {
|
||||
fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value));
|
||||
}
|
||||
|
||||
const envelope = mail.data.envelope || mail.message.getEnvelope();
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
@@ -130,7 +130,8 @@ class SESTransport extends EventEmitter {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const sesMessage = Object.assign(
|
||||
// mail.data.ses is caller supplied message data, so copy its own keys only
|
||||
const sesMessage = shared.copyOwnKeys(
|
||||
{
|
||||
Content: {
|
||||
Raw: {
|
||||
@@ -143,7 +144,7 @@ class SESTransport extends EventEmitter {
|
||||
ToAddresses: envelope.to
|
||||
}
|
||||
},
|
||||
mail.data.ses || {}
|
||||
mail.data.ses
|
||||
);
|
||||
|
||||
this.getRegion((err, region) => {
|
||||
|
||||
+29
-13
@@ -7,10 +7,15 @@ const util = require('util');
|
||||
const fs = require('fs');
|
||||
const nmfetch = require('../fetch');
|
||||
const errors = require('../errors');
|
||||
const objects = require('./objects');
|
||||
const dns = require('dns');
|
||||
const net = require('net');
|
||||
const os = require('os');
|
||||
|
||||
// re-exported for the callers that already depend on this module, see ./objects
|
||||
const isProtoKey = (module.exports.isProtoKey = objects.isProtoKey);
|
||||
module.exports.copyOwnKeys = objects.copyOwnKeys;
|
||||
|
||||
const DNS_TTL = 5 * 60 * 1000;
|
||||
const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
|
||||
const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
|
||||
@@ -355,7 +360,9 @@ module.exports.parseConnectionUrl = str => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(lKey in obj)) {
|
||||
// `in` already keeps "__proto__" out, but only as a side effect of it being an
|
||||
// Object.prototype member. Say it, so the protection survives a change to the check
|
||||
if (!isProtoKey(lKey) && !(lKey in obj)) {
|
||||
obj[lKey] = value;
|
||||
}
|
||||
});
|
||||
@@ -470,7 +477,7 @@ module.exports.parseDataURI = uri => {
|
||||
// Ensure there's a key before the '='
|
||||
const key = entry.substring(0, sepPos).trim();
|
||||
const value = entry.substring(sepPos + 1).trim();
|
||||
if (key) {
|
||||
if (key && !isProtoKey(key)) {
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -561,19 +568,24 @@ function resolveContentValue(data, key, options, callback) {
|
||||
}
|
||||
callback(null, value);
|
||||
});
|
||||
} else if (/^https?:\/\//i.test(content.path || content.href)) {
|
||||
if (options.disableUrlAccess) {
|
||||
return setImmediate(() => {
|
||||
const err = new Error('Url access rejected for ' + (content.path || content.href));
|
||||
err.code = errors.EURLACCESS;
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
return resolveStream(nmfetch(content.path || content.href, { headers: content.httpHeaders, tls: content.tls }), callback);
|
||||
} else if (/^data:/i.test(content.path || content.href)) {
|
||||
const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
|
||||
|
||||
return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
|
||||
} else if (content.href || /^https?:\/\//i.test(content.path)) {
|
||||
// An href is always a URL, and so is a path that looks like one. Let nmfetch
|
||||
// decide whether it is fetchable, it validates the parsed URL. Testing the raw
|
||||
// string here instead would let a file: href fall through to the "return as is"
|
||||
// default below and travel on inside the resolved message.
|
||||
const url = content.href || content.path;
|
||||
if (options.disableUrlAccess) {
|
||||
return setImmediate(() => {
|
||||
const err = new Error('Url access rejected for ' + url);
|
||||
err.code = errors.EURLACCESS;
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
return resolveStream(nmfetch(url, { headers: content.httpHeaders, tls: content.tls }), callback);
|
||||
} else if (content.path) {
|
||||
if (options.disableFileAccess) {
|
||||
return setImmediate(() => {
|
||||
@@ -603,10 +615,14 @@ module.exports.assign = function (/* target, ... sources */) {
|
||||
|
||||
args.forEach(source => {
|
||||
Object.keys(source || {}).forEach(key => {
|
||||
if (isProtoKey(key)) {
|
||||
return;
|
||||
}
|
||||
if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
|
||||
// tls and auth are special keys that need to be enumerated separately
|
||||
// other objects are passed as is
|
||||
target[key] = Object.assign(target[key] || {}, source[key]);
|
||||
// other objects are passed as is. Enumerating is a copy of user supplied
|
||||
// keys just like the loop above, so it gets the same treatment
|
||||
target[key] = module.exports.copyOwnKeys(target[key] || {}, source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
// Safe copying of objects whose keys come from the caller.
|
||||
//
|
||||
// This lives in its own leaf module, like ./url.js, so that every layer can reach it.
|
||||
// lib/shared/index.js requires lib/fetch, so lib/fetch can not require lib/shared back,
|
||||
// and lib/mime-funcs is a leaf that would otherwise pull in dns/net/os/fs for a string
|
||||
// comparison. lib/shared/index.js re-exports both functions for the callers that already
|
||||
// depend on it.
|
||||
|
||||
/**
|
||||
* Detects a key that can not be copied onto a plain object with `target[key] = value`.
|
||||
*
|
||||
* "__proto__" is the only one: assigning it runs the inherited setter and replaces the
|
||||
* prototype of the target instead of adding a property to it, so a caller can smuggle
|
||||
* values past validation that only inspects own keys. JSON.parse produces such a key
|
||||
* where an object literal can not. "constructor" and "prototype" have no such setter and
|
||||
* become ordinary own properties, so dropping them would only discard legitimate values.
|
||||
*
|
||||
* @param {String} key Key to check
|
||||
* @returns {Boolean} true if the key must not be copied
|
||||
*/
|
||||
module.exports.isProtoKey = key => key === '__proto__';
|
||||
|
||||
/**
|
||||
* Copies own enumerable keys from a source object to a target object. Every copy that
|
||||
* walks the keys of user supplied data goes through here, see isProtoKey.
|
||||
*
|
||||
* @param {Object} target Object to copy the keys to
|
||||
* @param {Object} source Object to copy the keys from
|
||||
* @param {Function} [skip] Optional predicate, return true to leave a key out
|
||||
* @returns {Object} The target object
|
||||
*/
|
||||
module.exports.copyOwnKeys = (target, source, skip) => {
|
||||
Object.keys(source || {}).forEach(key => {
|
||||
if (module.exports.isProtoKey(key) || (skip && skip(key))) {
|
||||
return;
|
||||
}
|
||||
target[key] = source[key];
|
||||
});
|
||||
return target;
|
||||
};
|
||||
+1
-1
@@ -42,7 +42,7 @@ class StreamTransport {
|
||||
// We probably need this in the output
|
||||
mail.message.keepBcc = true;
|
||||
|
||||
const envelope = mail.data.envelope || mail.message.getEnvelope();
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
|
||||
+14
@@ -577,6 +577,20 @@
|
||||
"port": 587
|
||||
},
|
||||
|
||||
"TurboSMTP": {
|
||||
"description": "TurboSMTP",
|
||||
"host": "pro.turbo-smtp.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"TurboSMTP-EU": {
|
||||
"description": "TurboSMTP (EU region)",
|
||||
"host": "pro.eu.turbo-smtp.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Tutanota": {
|
||||
"description": "Tutanota (Tuta Mail)",
|
||||
"domains": ["tutanota.com", "tuta.com", "tutanota.de", "tuta.io"],
|
||||
|
||||
Reference in New Issue
Block a user