mirror of
https://github.com/dawidd6/action-send-mail.git
synced 2026-10-19 14:04:32 +07:00
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
1589 lines
66 KiB
JavaScript
1589 lines
66 KiB
JavaScript
/* eslint no-undefined: 0, prefer-spread: 0, no-control-regex: 0 */
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import * as punycode from '../punycode/index.js';
|
||
import { PassThrough, finished, pipeline } from 'node:stream';
|
||
import * as shared from '../shared/index.js';
|
||
import urlModule from 'node:url';
|
||
import * as mimeFuncs from '../mime-funcs/index.js';
|
||
import * as qp from '../qp/index.js';
|
||
import * as base64 from '../base64/index.js';
|
||
import addressparser from '../addressparser/index.js';
|
||
import nmfetch from '../fetch/index.js';
|
||
import * as errors from '../errors.js';
|
||
import LastNewline from './last-newline.js';
|
||
import LeWindows from './le-windows.js';
|
||
import LeUnix from './le-unix.js';
|
||
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"(),:;<>@[\\\]]+$/;
|
||
/**
|
||
* A domain literal that ends an address, see _normalizeAddress
|
||
*/
|
||
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
|
||
/**
|
||
* Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
|
||
* it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
|
||
* from every such "<"
|
||
*
|
||
* @param value References header value
|
||
* @returns The value with the msg-ids closed up
|
||
*/
|
||
function stripWhitespaceInAngleBrackets(value) {
|
||
let result = '';
|
||
let pos = 0;
|
||
for (;;) {
|
||
const open = value.indexOf('<', pos);
|
||
if (open < 0) {
|
||
break;
|
||
}
|
||
const close = value.indexOf('>', open + 1);
|
||
if (close < 0) {
|
||
break;
|
||
}
|
||
result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, '');
|
||
pos = close + 1;
|
||
}
|
||
return result + value.slice(pos);
|
||
}
|
||
// 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]/;
|
||
// RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
|
||
// the code points it encodes, so anything past these bounds could not have become a valid
|
||
// domain whichever way it is encoded. The bundled codec is super-linear in the length of a
|
||
// label, a 60 KB label of distinct code points took seconds to encode, several times per
|
||
// address, so it is only handed what could be a domain
|
||
const MAX_DOMAIN_LENGTH = 253;
|
||
const MAX_LABEL_LENGTH = 63;
|
||
// pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
|
||
const PIPELINE_NOOP = () => false;
|
||
// The error a node streaming into an output that was destroyed stops with
|
||
function abortedError() {
|
||
const err = new Error('Message stream was closed before the message was generated');
|
||
err.code = errors.ESTREAM;
|
||
return err;
|
||
}
|
||
/**
|
||
* Checks a domain against the DNS length limits, counted in code points per label and in
|
||
* total. The label separators are the ones the bundled codec splits on.
|
||
*
|
||
* @param domain Domain to check
|
||
* @return true if a label or the domain as a whole is too long to be a valid domain
|
||
*/
|
||
function exceedsDomainLimits(domain) {
|
||
let total = 0;
|
||
let label = 0;
|
||
for (const char of domain) {
|
||
total++;
|
||
if (char === '.' || char === '。' || char === '.' || char === '。') {
|
||
label = 0;
|
||
}
|
||
else if (++label > MAX_LABEL_LENGTH) {
|
||
return true;
|
||
}
|
||
}
|
||
return total > MAX_DOMAIN_LENGTH;
|
||
}
|
||
/**
|
||
* 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 domain Domain to encode, already lowercased by the caller
|
||
* @param toUnicode Return the U-label form instead of the A-label form
|
||
* @return Encoded domain
|
||
*/
|
||
function normalizeDomain(domain, toUnicode) {
|
||
// domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6
|
||
if (typeof urlModule.domainToASCII === 'function' &&
|
||
typeof urlModule.domainToUnicode === 'function' &&
|
||
!URL_PARSER_UNSAFE.test(domain)) {
|
||
// The U-label form is decoded from the A-label form rather than from the input:
|
||
// Deno's domainToASCII returns an empty string for a label it cannot decode, while
|
||
// its domainToUnicode returns the label with U+FFFD in place of the bad part
|
||
const ascii = urlModule.domainToASCII(domain);
|
||
const mapped = ascii && toUnicode ? urlModule.domainToUnicode(ascii) : ascii;
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
}
|
||
if (exceedsDomainLimits(domain)) {
|
||
// too long to be a domain whichever way it is encoded, keep it as supplied
|
||
return domain;
|
||
}
|
||
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
|
||
}
|
||
/**
|
||
* Removes the characters that must never reach a multipart delimiter line.
|
||
*
|
||
* A line break splits the delimiter, so the boundary declared in the header can never
|
||
* match it again and the parts go out as body lines instead. The other C0 controls and
|
||
* DEL do not split anything but must not be written either: RFC 5321 does not allow a
|
||
* NUL in DATA at all, and an MTA or a scanner that stops at one reads a different
|
||
* message than a client that does not, which is the same parser disagreement a split
|
||
* delimiter creates. Both sides are cleaned together, since the declared value and the
|
||
* delimiters come from this one result.
|
||
*
|
||
* @param value Value to clean
|
||
* @return Value with every control character removed
|
||
*/
|
||
function _stripBoundaryControls(value) {
|
||
return value.replace(/[\x00-\x1f\x7f]+/g, '');
|
||
}
|
||
/**
|
||
* 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
|
||
* the options, assumes this is the root.
|
||
*
|
||
* @param contentType Define the content type for the node. Can be left blank for attachments (derived from filename)
|
||
* @param [options] optional options
|
||
* @param [options.rootNode] root node for this tree
|
||
* @param [options.parentNode] immediate parent for this node
|
||
* @param [options.filename] filename for an attachment node
|
||
* @param [options.baseBoundary] shared part of the unique multipart boundary
|
||
* @param [options.keepBcc] If true, do not exclude Bcc from the generated headers
|
||
* @param [options.normalizeHeaderKey] method to normalize header keys for custom caseing
|
||
* @param [options.textEncoding] either 'Q' (the default) or 'B'
|
||
*/
|
||
class MimeNode {
|
||
constructor(contentType, options) {
|
||
this.nodeCounter = 0;
|
||
options = options || {};
|
||
/**
|
||
* shared part of the unique multipart boundary. Control characters are dropped
|
||
* here rather than at the delimiter, see _stripBoundaryControls
|
||
*/
|
||
this.baseBoundary = _stripBoundaryControls(options.baseBoundary || crypto.randomBytes(8).toString('hex'));
|
||
this.boundaryPrefix = _stripBoundaryControls(options.boundaryPrefix || '--_NmP');
|
||
this.disableFileAccess = !!options.disableFileAccess;
|
||
this.disableUrlAccess = !!options.disableUrlAccess;
|
||
this.normalizeHeaderKey = options.normalizeHeaderKey;
|
||
/**
|
||
* If date headers is missing and current node is the root, this value is used instead
|
||
*/
|
||
this.date = options.parentNode ? null : new Date();
|
||
/**
|
||
* Root node for current mime tree
|
||
*/
|
||
this.rootNode = options.rootNode || this;
|
||
/**
|
||
* If true include Bcc in generated headers (if available)
|
||
*/
|
||
this.keepBcc = !!options.keepBcc;
|
||
/**
|
||
* If filename is specified but contentType is not (probably an attachment)
|
||
* detect the content type from filename extension
|
||
*/
|
||
if (options.filename) {
|
||
/**
|
||
* Filename for this node. Useful with attachments
|
||
*/
|
||
this.filename = options.filename;
|
||
if (!contentType) {
|
||
contentType = mimeFuncs.detectMimeType(this.filename.split('.').pop());
|
||
}
|
||
}
|
||
/**
|
||
* Indicates which encoding should be used for header strings: "Q" or "B"
|
||
*/
|
||
this.textEncoding = (options.textEncoding || '').toString().trim().charAt(0).toUpperCase();
|
||
/**
|
||
* Immediate parent for this node (or undefined if not set)
|
||
*/
|
||
this.parentNode = options.parentNode;
|
||
/**
|
||
* Hostname for default message-id values
|
||
*/
|
||
this.hostname = options.hostname;
|
||
/**
|
||
* If set to 'win' then uses \r\n, if 'linux' then \n. If not set (or `raw` is used) then newlines are kept as is.
|
||
*/
|
||
this.newline = options.newline;
|
||
/**
|
||
* An array for possible child nodes
|
||
*/
|
||
this.childNodes = [];
|
||
/**
|
||
* Used for generating unique boundaries (prepended to the shared base)
|
||
*/
|
||
this._nodeId = ++this.rootNode.nodeCounter;
|
||
/**
|
||
* A list of header values for this node in the form of [{key:'', value:''}]
|
||
*/
|
||
this._headers = [];
|
||
/**
|
||
* True if the content only uses ASCII printable characters
|
||
* @type {Boolean}
|
||
*/
|
||
this._isPlainText = false;
|
||
/**
|
||
* True if the content is plain text but has longer lines than allowed
|
||
* @type {Boolean}
|
||
*/
|
||
this._hasLongLines = false;
|
||
/**
|
||
* If set, use instead this value for envelopes instead of generating one
|
||
* @type {Boolean}
|
||
*/
|
||
this._envelope = false;
|
||
/**
|
||
* If set then use this value as the stream content instead of building it
|
||
* @type {String|Buffer|Stream}
|
||
*/
|
||
this._raw = false;
|
||
/**
|
||
* Additional transform streams that the message will be piped before
|
||
* exposing by createReadStream
|
||
* @type {Array}
|
||
*/
|
||
this._transforms = [];
|
||
/**
|
||
* Additional process functions that the message will be piped through before
|
||
* exposing by createReadStream. These functions are run after transforms
|
||
* @type {Array}
|
||
*/
|
||
this._processFuncs = [];
|
||
/**
|
||
* If content type is set (or derived from the filename) add it to headers
|
||
*/
|
||
if (contentType) {
|
||
this.setHeader('Content-Type', contentType);
|
||
}
|
||
}
|
||
/////// PUBLIC METHODS
|
||
/**
|
||
* Creates and appends a child node.Arguments provided are passed to MimeNode constructor
|
||
*
|
||
* @param [contentType] Optional content type
|
||
* @param [options] Optional options object
|
||
* @return Created node object
|
||
*/
|
||
createChild(contentType, options) {
|
||
if (!options && typeof contentType === 'object') {
|
||
options = contentType;
|
||
contentType = undefined;
|
||
}
|
||
const node = new MimeNode(contentType, options);
|
||
this.appendChild(node);
|
||
return node;
|
||
}
|
||
/**
|
||
* Appends an existing node to the mime tree. Removes the node from an existing
|
||
* tree if needed
|
||
*
|
||
* @param childNode node to be appended
|
||
* @return 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;
|
||
}
|
||
childNode.parentNode = this;
|
||
this.childNodes.push(childNode);
|
||
return childNode;
|
||
}
|
||
/**
|
||
* Replaces current node with another node
|
||
*
|
||
* @param node Replacement node
|
||
* @return Replacement node
|
||
*/
|
||
replace(node) {
|
||
if (node === this) {
|
||
return this;
|
||
}
|
||
this.parentNode.childNodes.forEach((childNode, i) => {
|
||
if (childNode === this) {
|
||
node.rootNode = this.rootNode;
|
||
node.parentNode = this.parentNode;
|
||
node._nodeId = this._nodeId;
|
||
this.rootNode = this;
|
||
this.parentNode = undefined;
|
||
node.parentNode.childNodes[i] = node;
|
||
}
|
||
});
|
||
return node;
|
||
}
|
||
/**
|
||
* Removes current node from the mime tree
|
||
*
|
||
* @return removed node
|
||
*/
|
||
remove() {
|
||
if (!this.parentNode) {
|
||
return this;
|
||
}
|
||
for (let i = this.parentNode.childNodes.length - 1; i >= 0; i--) {
|
||
if (this.parentNode.childNodes[i] === this) {
|
||
this.parentNode.childNodes.splice(i, 1);
|
||
this.parentNode = undefined;
|
||
this.rootNode = this;
|
||
return this;
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* Sets a header value. If the value for selected key exists, it is overwritten.
|
||
* You can set multiple values as well by using [{key:'', value:''}] or
|
||
* {key: 'value'} as the first argument.
|
||
*
|
||
* @param key Header key or a list of key value pairs
|
||
* @param value Header value
|
||
* @return current node
|
||
*/
|
||
setHeader(key, value) {
|
||
let added = false;
|
||
// Allow setting multiple headers at once
|
||
if (!value && key && typeof key === 'object') {
|
||
// allow {key:'content-type', value: 'text/plain'}
|
||
if (key.key && 'value' in key) {
|
||
this.setHeader(key.key, key.value);
|
||
}
|
||
else if (Array.isArray(key)) {
|
||
// allow [{key:'content-type', value: 'text/plain'}]
|
||
key.forEach(i => {
|
||
this.setHeader(i.key, i.value);
|
||
});
|
||
}
|
||
else {
|
||
// allow {'content-type': 'text/plain'}
|
||
Object.keys(key).forEach(i => {
|
||
this.setHeader(i, key[i]);
|
||
});
|
||
}
|
||
return this;
|
||
}
|
||
key = this._normalizeHeaderKey(key);
|
||
const headerValue = {
|
||
key,
|
||
value
|
||
};
|
||
// Check if the value exists and overwrite
|
||
for (let i = 0, len = this._headers.length; i < len; i++) {
|
||
if (this._headers[i].key === key) {
|
||
if (!added) {
|
||
// replace the first match
|
||
this._headers[i] = headerValue;
|
||
added = true;
|
||
}
|
||
else {
|
||
// remove following matches
|
||
this._headers.splice(i, 1);
|
||
i--;
|
||
len--;
|
||
}
|
||
}
|
||
}
|
||
// match not found, append the value
|
||
if (!added) {
|
||
this._headers.push(headerValue);
|
||
}
|
||
return this;
|
||
}
|
||
/**
|
||
* Adds a header value. If the value for selected key exists, the value is appended
|
||
* as a new field and old one is not touched.
|
||
* You can set multiple values as well by using [{key:'', value:''}] or
|
||
* {key: 'value'} as the first argument.
|
||
*
|
||
* @param key Header key or a list of key value pairs
|
||
* @param value Header value
|
||
* @return current node
|
||
*/
|
||
addHeader(key, value) {
|
||
// Allow setting multiple headers at once
|
||
if (!value && key && typeof key === 'object') {
|
||
// allow {key:'content-type', value: 'text/plain'}
|
||
if (key.key && key.value) {
|
||
this.addHeader(key.key, key.value);
|
||
}
|
||
else if (Array.isArray(key)) {
|
||
// allow [{key:'content-type', value: 'text/plain'}]
|
||
key.forEach(i => {
|
||
this.addHeader(i.key, i.value);
|
||
});
|
||
}
|
||
else {
|
||
// allow {'content-type': 'text/plain'}
|
||
Object.keys(key).forEach(i => {
|
||
this.addHeader(i, key[i]);
|
||
});
|
||
}
|
||
return this;
|
||
}
|
||
else if (Array.isArray(value)) {
|
||
value.forEach(val => {
|
||
this.addHeader(key, val);
|
||
});
|
||
return this;
|
||
}
|
||
this._headers.push({
|
||
key: this._normalizeHeaderKey(key),
|
||
value
|
||
});
|
||
return this;
|
||
}
|
||
/**
|
||
* Retrieves the first mathcing value of a selected key
|
||
*
|
||
* @param key Key to search for
|
||
* @retun Value for the key
|
||
*/
|
||
getHeader(key) {
|
||
key = this._normalizeHeaderKey(key);
|
||
for (let i = 0, len = this._headers.length; i < len; i++) {
|
||
if (this._headers[i].key === key) {
|
||
return this._headers[i].value;
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* Sets body content for current node. If the value is a string, charset is added automatically
|
||
* to Content-Type (if it is text/*). If the value is a Buffer, you need to specify
|
||
* the charset yourself
|
||
*
|
||
* @param content Body content
|
||
* @return current node
|
||
*/
|
||
setContent(content) {
|
||
this.content = content;
|
||
if (typeof this.content.pipe === 'function') {
|
||
// pre-stream handler. might be triggered if a stream is set as content
|
||
// and 'error' fires before anything is done with this stream
|
||
shared.recordStreamErrors(this.content);
|
||
}
|
||
else if (typeof this.content === 'string') {
|
||
this._isPlainText = mimeFuncs.isPlainText(this.content);
|
||
if (this._isPlainText && mimeFuncs.hasLongerLines(this.content, 76)) {
|
||
// If there are lines longer than 76 symbols/bytes do not use 7bit
|
||
this._hasLongLines = true;
|
||
}
|
||
}
|
||
return this;
|
||
}
|
||
build(callback) {
|
||
let promise;
|
||
if (!callback) {
|
||
promise = new Promise((resolve, reject) => {
|
||
callback = shared.callbackPromise(resolve, reject);
|
||
});
|
||
}
|
||
const done = callback;
|
||
const stream = this.createReadStream();
|
||
const buf = [];
|
||
let buflen = 0;
|
||
let returned = false;
|
||
stream.on('readable', () => {
|
||
let chunk;
|
||
while ((chunk = stream.read()) !== null) {
|
||
buf.push(chunk);
|
||
buflen += chunk.length;
|
||
}
|
||
});
|
||
stream.once('error', err => {
|
||
if (returned) {
|
||
return;
|
||
}
|
||
returned = true;
|
||
return done(err);
|
||
});
|
||
stream.once('end', (chunk) => {
|
||
if (returned) {
|
||
return;
|
||
}
|
||
returned = true;
|
||
if (chunk && chunk.length) {
|
||
buf.push(chunk);
|
||
buflen += chunk.length;
|
||
}
|
||
return done(null, Buffer.concat(buf, buflen));
|
||
});
|
||
return promise;
|
||
}
|
||
getTransferEncoding() {
|
||
let transferEncoding = false;
|
||
const contentType = (this.getHeader('Content-Type') || '').toString().toLowerCase().trim();
|
||
if (this.content) {
|
||
transferEncoding = (this.getHeader('Content-Transfer-Encoding') || '').toString().toLowerCase().trim();
|
||
if (!transferEncoding || !['base64', 'quoted-printable'].includes(transferEncoding)) {
|
||
if (/^text\//i.test(contentType)) {
|
||
// If there are no special symbols, no need to modify the text
|
||
if (this._isPlainText && !this._hasLongLines) {
|
||
transferEncoding = '7bit';
|
||
}
|
||
else if (typeof this.content === 'string' || this.content instanceof Buffer) {
|
||
// detect preferred encoding for string value
|
||
transferEncoding = this._getTextEncoding(this.content) === 'Q' ? 'quoted-printable' : 'base64';
|
||
}
|
||
else {
|
||
// we can not check content for a stream, so either use preferred encoding or fallback to QP
|
||
transferEncoding = this.textEncoding === 'B' ? 'base64' : 'quoted-printable';
|
||
}
|
||
}
|
||
else if (!/^(multipart|message)\//i.test(contentType)) {
|
||
transferEncoding = transferEncoding || 'base64';
|
||
}
|
||
}
|
||
}
|
||
return transferEncoding;
|
||
}
|
||
/**
|
||
* Builds the header block for the mime node. Append \r\n\r\n before writing the content
|
||
*
|
||
* @returns Headers
|
||
*/
|
||
buildHeaders() {
|
||
const transferEncoding = this.getTransferEncoding();
|
||
const headers = [];
|
||
if (transferEncoding) {
|
||
this.setHeader('Content-Transfer-Encoding', transferEncoding);
|
||
}
|
||
if (this.filename && !this.getHeader('Content-Disposition')) {
|
||
this.setHeader('Content-Disposition', 'attachment');
|
||
}
|
||
// Ensure mandatory header fields
|
||
if (this.rootNode === this) {
|
||
if (!this.getHeader('Date')) {
|
||
this.setHeader('Date', this.date.toUTCString().replace(/GMT/, '+0000'));
|
||
}
|
||
// ensure that Message-Id is present
|
||
this.messageId();
|
||
if (!this.getHeader('MIME-Version')) {
|
||
this.setHeader('MIME-Version', '1.0');
|
||
}
|
||
// Ensure that Content-Type is the last header for the root node
|
||
for (let i = this._headers.length - 2; i >= 0; i--) {
|
||
const header = this._headers[i];
|
||
if (header.key === 'Content-Type') {
|
||
this._headers.splice(i, 1);
|
||
this._headers.push(header);
|
||
}
|
||
}
|
||
}
|
||
this._headers.forEach(header => {
|
||
let key = header.key;
|
||
let value = header.value;
|
||
let structured;
|
||
let param;
|
||
const options = {};
|
||
const formattedHeaders = FORMATTED_HEADERS;
|
||
if (value && typeof value === 'object' && !formattedHeaders.includes(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;
|
||
}
|
||
}
|
||
if (options.prepared) {
|
||
// header value is
|
||
if (options.foldLines) {
|
||
headers.push(mimeFuncs.foldLines(key + ': ' + value));
|
||
}
|
||
else {
|
||
headers.push(key + ': ' + value);
|
||
}
|
||
return;
|
||
}
|
||
switch (header.key) {
|
||
case 'Content-Disposition':
|
||
structured = mimeFuncs.parseHeaderValue(value);
|
||
if (this.filename) {
|
||
structured.params.filename = this.filename;
|
||
}
|
||
value = mimeFuncs.buildHeaderValue(structured);
|
||
break;
|
||
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 (structured.value.match(/^text\/plain\b/) &&
|
||
typeof this.content === 'string' &&
|
||
/[\u0080-\uFFFF]/.test(this.content)) {
|
||
structured.params.charset = 'utf-8';
|
||
}
|
||
if (this.filename) {
|
||
// the name parameter is derived from the filename below, so a name that
|
||
// arrived inside the content type is dropped first (a plain parameter or
|
||
// rfc2231 continuation sections), otherwise the header carries two and a
|
||
// receiver that reads the first sees a name the filename does not match
|
||
for (const paramKey of Object.keys(structured.params)) {
|
||
if (/^name(\*|$)/i.test(paramKey)) {
|
||
delete structured.params[paramKey];
|
||
}
|
||
}
|
||
}
|
||
value = mimeFuncs.buildHeaderValue(structured);
|
||
if (this.filename) {
|
||
// 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
|
||
// 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, 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;
|
||
}
|
||
break;
|
||
case 'Bcc':
|
||
if (!this.keepBcc) {
|
||
// skip BCC values
|
||
return;
|
||
}
|
||
break;
|
||
}
|
||
value = this._encodeHeaderValue(key, value);
|
||
// skip empty lines
|
||
if (!(value || '').toString().trim()) {
|
||
return;
|
||
}
|
||
if (typeof this.normalizeHeaderKey === 'function') {
|
||
const normalized = this.normalizeHeaderKey(key, value);
|
||
// 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;
|
||
}
|
||
}
|
||
headers.push(mimeFuncs.foldLines(key + ': ' + value, 76));
|
||
});
|
||
return headers.join('\r\n');
|
||
}
|
||
/**
|
||
* Streams the rfc2822 message from the current node. If this is a root node,
|
||
* mandatory header fields are set if missing (Date, Message-Id, MIME-Version)
|
||
*
|
||
* @return Compiled message
|
||
*/
|
||
createReadStream(options) {
|
||
options = options || {};
|
||
const stream = new PassThrough(options);
|
||
this.stream(stream, options, err => {
|
||
if (err) {
|
||
stream.destroy(err);
|
||
return;
|
||
}
|
||
stream.end();
|
||
});
|
||
// the content streams of the nodes the message did not get to are not going to be read
|
||
stream.once('close', () => {
|
||
if (!stream.writableFinished) {
|
||
this._destroyContentStreams();
|
||
}
|
||
});
|
||
// The stages are joined with pipeline, which destroys all of them when any one fails
|
||
// or is destroyed. An error anywhere reaches the returned stream exactly once, and a
|
||
// consumer that destroys the returned stream stops the tree from reading its sources
|
||
const stages = [stream];
|
||
for (const transform of this._transforms) {
|
||
stages.push(typeof transform === 'function' ? transform() : transform);
|
||
}
|
||
// ensure terminating newline after possible user transforms
|
||
stages.push(new LastNewline());
|
||
let outputStream = pipeline(stages, PIPELINE_NOOP);
|
||
// dkim and stuff. A process function reads its input itself and reports the errors of
|
||
// that input on its output, so only a consumer abort has to be carried back upstream
|
||
for (const processFunc of this._processFuncs) {
|
||
const input = outputStream;
|
||
outputStream = processFunc(input);
|
||
if (outputStream !== input) {
|
||
finished(outputStream, err => {
|
||
if (err) {
|
||
input.destroy();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
if (this.newline) {
|
||
const winbreak = ['win', 'windows', 'dos', '\r\n'].includes(this.newline.toString().toLowerCase());
|
||
outputStream = pipeline(outputStream, winbreak ? new LeWindows() : new LeUnix(), PIPELINE_NOOP);
|
||
}
|
||
return outputStream;
|
||
}
|
||
/**
|
||
* Appends a transform stream object to the transforms list. Final output
|
||
* is passed through this stream before exposing
|
||
*
|
||
* @param transform Read-Write stream
|
||
*/
|
||
transform(transform) {
|
||
this._transforms.push(transform);
|
||
}
|
||
/**
|
||
* Appends a post process function. The functon is run after transforms and
|
||
* uses the following syntax
|
||
*
|
||
* processFunc(input) -> outputStream
|
||
*
|
||
* @param processFunc Read-Write stream
|
||
*/
|
||
processFunc(processFunc) {
|
||
this._processFuncs.push(processFunc);
|
||
}
|
||
stream(outputStream, options, done) {
|
||
const transferEncoding = this.getTransferEncoding();
|
||
// the streams this node is reading from. A consumer that goes away mid-message destroys
|
||
// the output, and these are released with it instead of staying paused with a file or a
|
||
// socket open behind them
|
||
let activeStreams = [];
|
||
// protect actual callback against multiple triggering
|
||
let returned = false;
|
||
const callback = (err) => {
|
||
if (returned) {
|
||
return;
|
||
}
|
||
returned = true;
|
||
outputStream.removeListener('close', onOutputClose);
|
||
done(err);
|
||
};
|
||
function onOutputClose() {
|
||
for (const stream of activeStreams) {
|
||
stream.destroy();
|
||
}
|
||
return callback(abortedError());
|
||
}
|
||
// reads the streams into the output. The listener is only attached while the node reads
|
||
// something, a deeply nested tree would otherwise stack one per level on the output
|
||
const readInto = (...streams) => {
|
||
if (!activeStreams.length) {
|
||
outputStream.once('close', onOutputClose);
|
||
}
|
||
activeStreams = streams;
|
||
};
|
||
// stops here if the output was destroyed in the meantime
|
||
const aborted = () => {
|
||
if (outputStream.destroyed) {
|
||
callback(abortedError());
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
// for multipart nodes, push child nodes
|
||
// for content nodes end the stream
|
||
const finalize = () => {
|
||
let childId = 0;
|
||
const processChildNode = () => {
|
||
if (aborted()) {
|
||
return;
|
||
}
|
||
if (childId >= this.childNodes.length) {
|
||
outputStream.write('\r\n--' + this.boundary + '--\r\n');
|
||
return callback();
|
||
}
|
||
const child = this.childNodes[childId++];
|
||
outputStream.write((childId > 1 ? '\r\n' : '') + '--' + this.boundary + '\r\n');
|
||
child.stream(outputStream, options, err => {
|
||
if (err) {
|
||
return callback(err);
|
||
}
|
||
setImmediate(processChildNode);
|
||
});
|
||
};
|
||
if (this.multipart) {
|
||
setImmediate(processChildNode);
|
||
}
|
||
else {
|
||
return callback();
|
||
}
|
||
};
|
||
// pushes node content
|
||
const sendContent = () => {
|
||
if (aborted()) {
|
||
return;
|
||
}
|
||
if (!this.content) {
|
||
return setImmediate(finalize);
|
||
}
|
||
const createStream = () => {
|
||
if (aborted()) {
|
||
return;
|
||
}
|
||
const contentError = this._takeStreamContent(this.content);
|
||
if (contentError) {
|
||
return callback(contentError);
|
||
}
|
||
const localStream = this._getStream(this.content);
|
||
if (['quoted-printable', 'base64'].includes(transferEncoding)) {
|
||
const contentStream = transferEncoding === 'base64'
|
||
? new base64.Encoder(options)
|
||
: // outside of text a lone CR or LF is data, encoding it keeps it from being
|
||
// turned into a line break by a newline transform or the receiving side
|
||
new qp.Encoder(Object.assign({}, options, { binary: !/^text\//i.test(this.contentType || '') }));
|
||
readInto(localStream, contentStream);
|
||
contentStream.pipe(outputStream, {
|
||
end: false
|
||
});
|
||
// pipeline reports errors and tears both streams down. It calls back once the
|
||
// encoder took all of its input, the encoded output may still be waiting to
|
||
// be read, so the node is only done once the encoder's readable side ended
|
||
pipeline(localStream, contentStream, err => err && callback(err));
|
||
finished(contentStream, { writable: false }, err => (err ? callback(err) : finalize()));
|
||
}
|
||
else {
|
||
// anything that is not QP or Base54 passes as-is
|
||
readInto(localStream);
|
||
localStream.pipe(outputStream, {
|
||
end: false
|
||
});
|
||
finished(localStream, { writable: false }, err => (err ? callback(err) : finalize()));
|
||
}
|
||
};
|
||
if (this.content._resolve) {
|
||
const sourceStream = this._getStream(this.content);
|
||
readInto(sourceStream);
|
||
shared.resolveStream(sourceStream, (err, value) => {
|
||
if (err) {
|
||
return callback(err);
|
||
}
|
||
if (returned) {
|
||
return;
|
||
}
|
||
this.content._resolve = false;
|
||
this.content._resolvedValue = value;
|
||
setImmediate(createStream);
|
||
});
|
||
}
|
||
else {
|
||
setImmediate(createStream);
|
||
}
|
||
};
|
||
if (this._raw) {
|
||
setImmediate(() => {
|
||
if (aborted()) {
|
||
return;
|
||
}
|
||
const rawError = this._takeStreamContent(this._raw);
|
||
if (rawError) {
|
||
return callback(rawError);
|
||
}
|
||
const raw = this._getStream(this._raw);
|
||
readInto(raw);
|
||
raw.pipe(outputStream, {
|
||
end: false
|
||
});
|
||
finished(raw, { writable: false }, err => (err ? callback(err) : finalize()));
|
||
});
|
||
}
|
||
else {
|
||
outputStream.write(this.buildHeaders() + '\r\n\r\n');
|
||
setImmediate(sendContent);
|
||
}
|
||
}
|
||
/**
|
||
* Sets envelope to be used instead of the generated one
|
||
*
|
||
* @return SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
|
||
*/
|
||
setEnvelope(envelope) {
|
||
let list;
|
||
this._envelope = {
|
||
from: false,
|
||
to: []
|
||
};
|
||
if (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();
|
||
const recipients = [];
|
||
['to', 'cc', 'bcc'].forEach(key => {
|
||
if (envelope[key]) {
|
||
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), recipients, seenRecipients);
|
||
}
|
||
});
|
||
this._envelope.to = recipients.map(to => to.address).filter(address => address);
|
||
const standardFields = ['to', 'cc', 'bcc', 'from'];
|
||
shared.copyOwnKeys(this._envelope, envelope, key => standardFields.includes(key));
|
||
return this;
|
||
}
|
||
/**
|
||
* Generates and returns an object with parsed address fields
|
||
*
|
||
* @return Address object
|
||
*/
|
||
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], seenByKey.get(key));
|
||
}
|
||
});
|
||
return addresses;
|
||
}
|
||
/**
|
||
* Generates and returns SMTP envelope with the sender address and a list of recipients addresses
|
||
*
|
||
* @return SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
|
||
*/
|
||
getEnvelope() {
|
||
if (this._envelope) {
|
||
return this._envelope;
|
||
}
|
||
const envelope = {
|
||
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();
|
||
const recipients = [];
|
||
this._headers.forEach(header => {
|
||
const list = [];
|
||
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
|
||
this._convertAddresses(this._parseAddresses(header.value), list);
|
||
if (list.length && list[0]) {
|
||
envelope.from = list[0].address;
|
||
}
|
||
}
|
||
else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
|
||
this._convertAddresses(this._parseAddresses(header.value), recipients, seenRecipients);
|
||
}
|
||
});
|
||
envelope.to = recipients.map(to => to.address);
|
||
return envelope;
|
||
}
|
||
/**
|
||
* Returns Message-Id value. If it does not exist, then creates one
|
||
*
|
||
* @return Message-Id value
|
||
*/
|
||
messageId() {
|
||
let messageId = this.getHeader('Message-ID');
|
||
// You really should define your own Message-Id field!
|
||
if (!messageId) {
|
||
messageId = this._generateMessageId();
|
||
this.setHeader('Message-ID', messageId);
|
||
}
|
||
return messageId;
|
||
}
|
||
/**
|
||
* Sets pregenerated content that will be used as the output of this node
|
||
*
|
||
* @param raw Raw MIME contents
|
||
*/
|
||
setRaw(raw) {
|
||
this._raw = raw;
|
||
if (this._raw && typeof this._raw.pipe === 'function') {
|
||
// pre-stream handler. might be triggered if a stream is set as content
|
||
// and 'error' fires before anything is done with this stream
|
||
shared.recordStreamErrors(this._raw);
|
||
}
|
||
return this;
|
||
}
|
||
/////// 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 flag Either 'disableFileAccess' or 'disableUrlAccess'
|
||
* @return true if this node or an ancestor closed that access
|
||
* @internal
|
||
*/
|
||
_accessDisabled(flag) {
|
||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||
let node = this;
|
||
while (node) {
|
||
if (node[flag]) {
|
||
return true;
|
||
}
|
||
node = node.parentNode;
|
||
}
|
||
return false;
|
||
}
|
||
/**
|
||
* Destroys the content streams of this node and of every node below it
|
||
*
|
||
* @internal
|
||
*/
|
||
_destroyContentStreams() {
|
||
shared.destroyStream(this.content);
|
||
shared.destroyStream(this._raw);
|
||
for (const child of this.childNodes) {
|
||
child._destroyContentStreams();
|
||
}
|
||
}
|
||
/**
|
||
* Checks that a content value can still be read before the node streams it. A stream is
|
||
* refused once it errored, ended or was destroyed: piping it would either never finish or
|
||
* produce an empty body without any error
|
||
*
|
||
* @param content Node content or raw value
|
||
* @returns The error to stop with, or null when the content can be read
|
||
* @internal
|
||
*/
|
||
_takeStreamContent(content) {
|
||
if (Object.prototype.toString.call(content) === '[object Error]') {
|
||
return content;
|
||
}
|
||
if (!content || typeof content.pipe !== 'function' || content._resolvedValue) {
|
||
// the value of a resolved stream is read from the buffered copy
|
||
return null;
|
||
}
|
||
return shared.unreadableStreamError(content);
|
||
}
|
||
/**
|
||
* Detects and returns handle to a stream related with the content.
|
||
*
|
||
* @param content Node content
|
||
* @returns Stream object
|
||
* @internal
|
||
*/
|
||
_getStream(content) {
|
||
let contentStream;
|
||
if (content._resolvedValue) {
|
||
// pass string or buffer content as a stream
|
||
contentStream = new PassThrough();
|
||
setImmediate(() => {
|
||
try {
|
||
contentStream.end(content._resolvedValue);
|
||
}
|
||
catch (_err) {
|
||
contentStream.emit('error', _err);
|
||
}
|
||
});
|
||
return contentStream;
|
||
}
|
||
if (typeof content.pipe === 'function') {
|
||
// assume as stream
|
||
return content;
|
||
}
|
||
if (content && typeof content.path === 'string' && !content.href) {
|
||
if (this._accessDisabled('disableFileAccess')) {
|
||
contentStream = new PassThrough();
|
||
setImmediate(() => {
|
||
const err = new Error('File access rejected for ' + content.path);
|
||
err.code = errors.EFILEACCESS;
|
||
contentStream.emit('error', err);
|
||
});
|
||
return contentStream;
|
||
}
|
||
// read file
|
||
return fs.createReadStream(content.path);
|
||
}
|
||
if (content && typeof content.href === 'string') {
|
||
if (this._accessDisabled('disableUrlAccess')) {
|
||
contentStream = new PassThrough();
|
||
setImmediate(() => {
|
||
const err = new Error('Url access rejected for ' + content.href);
|
||
err.code = errors.EURLACCESS;
|
||
contentStream.emit('error', err);
|
||
});
|
||
return contentStream;
|
||
}
|
||
// 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 });
|
||
}
|
||
// pass string or buffer content as a stream
|
||
contentStream = new PassThrough();
|
||
setImmediate(() => {
|
||
try {
|
||
contentStream.end(content || '');
|
||
}
|
||
catch (_err) {
|
||
contentStream.emit('error', _err);
|
||
}
|
||
});
|
||
return contentStream;
|
||
}
|
||
/**
|
||
* Parses addresses. Takes in a single address or an array or an
|
||
* array of address arrays (eg. To: [[first group], [second group],...])
|
||
*
|
||
* @param addresses Addresses to be parsed
|
||
* @return An array of address objects
|
||
* @internal
|
||
*/
|
||
_parseAddresses(addresses) {
|
||
const flattened = [];
|
||
// Nested arrays are flattened iteratively. concat.apply throws once a recipient list
|
||
// is long enough to pass the argument limit, and an array handed to addressparser is
|
||
// stringified, which recurses once per nesting level and exhausts the call stack on
|
||
// a deep enough value. Every array is walked once, which also ends a self-referential
|
||
// input
|
||
const seen = new WeakSet();
|
||
const stack = [];
|
||
const enter = (list) => {
|
||
if (!seen.has(list)) {
|
||
seen.add(list);
|
||
stack.push({ list, pos: 0 });
|
||
}
|
||
};
|
||
enter(Array.isArray(addresses) ? addresses : [addresses]);
|
||
while (stack.length) {
|
||
const frame = stack[stack.length - 1];
|
||
if (frame.pos >= frame.list.length) {
|
||
stack.pop();
|
||
continue;
|
||
}
|
||
const address = frame.list[frame.pos++];
|
||
if (Array.isArray(address)) {
|
||
enter(address);
|
||
continue;
|
||
}
|
||
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);
|
||
continue;
|
||
}
|
||
// 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);
|
||
continue;
|
||
}
|
||
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 parsed An array of address objects, as returned by addressparser
|
||
* @return The same array, with every address normalized
|
||
* @internal
|
||
*/
|
||
_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 addresses Addresses to be parsed
|
||
* @return An array of address objects
|
||
* @internal
|
||
*/
|
||
_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: '' };
|
||
});
|
||
}
|
||
/**
|
||
* Normalizes a header key, uses Camel-Case form, except for uppercase MIME-
|
||
*
|
||
* @param key Key to be normalized
|
||
* @return key in Camel-Case form
|
||
* @internal
|
||
*/
|
||
_normalizeHeaderKey(key) {
|
||
key = (key || '')
|
||
.toString()
|
||
// a field name is printable ascii without the colon, so a control char, DEL,
|
||
// whitespace or a colon can only be dropped, there is no quoting construct
|
||
// around a field name. A colon or a space left in place ends the name early on
|
||
// the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
|
||
// second From header that neither setHeader() nor the Bcc filter could match
|
||
.replace(/[\s\x00-\x20\x7f:]/g, '')
|
||
.toLowerCase()
|
||
// use uppercase words, except MIME
|
||
.replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
|
||
// special case
|
||
.replace(/^Content-Features$/i, 'Content-features');
|
||
return key;
|
||
}
|
||
/**
|
||
* Checks if the content type is multipart and defines boundary if needed.
|
||
* Doesn't return anything, modifies object argument instead.
|
||
*
|
||
* @param structured Parsed header value for 'Content-Type' key
|
||
* @internal
|
||
*/
|
||
_handleContentType(structured) {
|
||
this.contentType = structured.value.trim().toLowerCase();
|
||
this.multipart = /^multipart\//i.test(this.contentType) ? this.contentType.substr(this.contentType.indexOf('/') + 1) : false;
|
||
if (this.multipart) {
|
||
// The declared value and the delimiters are assigned from the same expression
|
||
// here, so whatever the header emitter then does with it, the two sides cannot
|
||
// disagree about which characters the boundary is made of.
|
||
//
|
||
// Stripping runs before the fallback rather than over the whole chain: a
|
||
// boundary made only of control characters would otherwise strip to '' and
|
||
// leave the node declaring no boundary and streaming bare '--' delimiters.
|
||
// _generateBoundary cleans what it builds, but this is the one place the boundary
|
||
// is written, so it cleans the value it is about to write rather than trusting
|
||
// where it came from. MimeNode is exported and subclassable, so the generator is
|
||
// not necessarily the one below. Stripping twice costs nothing, it is idempotent.
|
||
const declared = _stripBoundaryControls(structured.params.boundary || this.boundary || '');
|
||
this.boundary = structured.params.boundary =
|
||
declared || _stripBoundaryControls(this._generateBoundary());
|
||
}
|
||
else {
|
||
this.boundary = false;
|
||
}
|
||
}
|
||
/**
|
||
* Generates a multipart boundary value
|
||
*
|
||
* @return boundary value
|
||
* @internal
|
||
*/
|
||
_generateBoundary() {
|
||
// baseBoundary and boundaryPrefix are public, so they may hold something the
|
||
// constructor never cleaned, and the -Part_ suffix keeps the result non empty
|
||
return _stripBoundaryControls(this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary) + '-Part_' + this._nodeId;
|
||
}
|
||
/**
|
||
* Encodes a header value for use in the generated rfc2822 email.
|
||
*
|
||
* @param key Header key
|
||
* @param value Header value
|
||
* @internal
|
||
*/
|
||
_encodeHeaderValue(key, value) {
|
||
key = this._normalizeHeaderKey(key);
|
||
switch (key) {
|
||
// Structured headers
|
||
case 'From':
|
||
case 'Sender':
|
||
case 'To':
|
||
case 'Cc':
|
||
case 'Bcc':
|
||
case 'Reply-To':
|
||
return this._convertAddresses(this._parseAddresses(value));
|
||
// values enclosed in <>
|
||
case 'Message-ID':
|
||
case 'In-Reply-To':
|
||
case 'Content-Id':
|
||
// 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;
|
||
}
|
||
if (value.charAt(value.length - 1) !== '>') {
|
||
value = value + '>';
|
||
}
|
||
return value;
|
||
// space separated list of values enclosed in <>
|
||
case 'References': {
|
||
const ids = [];
|
||
for (const entry of [].concat(value || '')) {
|
||
const elm = (entry || '')
|
||
.toString()
|
||
.replace(/\r?\n|\r/g, ' ')
|
||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||
.trim();
|
||
for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) {
|
||
if (id.charAt(0) !== '<') {
|
||
id = '<' + id;
|
||
}
|
||
if (id.charAt(id.length - 1) !== '>') {
|
||
id = id + '>';
|
||
}
|
||
ids.push(id);
|
||
}
|
||
}
|
||
return ids.join(' ').trim();
|
||
}
|
||
case 'Date':
|
||
if (Object.prototype.toString.call(value) === '[object Date]') {
|
||
return value.toUTCString().replace(/GMT/, '+0000');
|
||
}
|
||
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
|
||
return this._encodeHeaderText(value);
|
||
case 'Content-Type':
|
||
case 'Content-Disposition':
|
||
// if it includes a filename then it is already encoded
|
||
return (value || '').toString().replace(/\r?\n|\r/g, ' ');
|
||
default:
|
||
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
|
||
return this._encodeHeaderText(value);
|
||
}
|
||
}
|
||
/**
|
||
* Rebuilds address object using punycode and other adjustments
|
||
*
|
||
* @param addresses An array of address objects
|
||
* @param [uniqueList] An array to be populated with addresses
|
||
* @param [seenAddresses] Addresses already added to uniqueList, shared with recursive calls to keep deduplication linear
|
||
* @return address string
|
||
* @internal
|
||
*/
|
||
_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) {
|
||
// 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 (!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, seenAddresses) : '').trim();
|
||
values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
|
||
}
|
||
});
|
||
return values.join(', ');
|
||
}
|
||
/**
|
||
* Normalizes an email address
|
||
*
|
||
* @param address An array of address objects
|
||
* @return address string
|
||
* @internal
|
||
*/
|
||
_normalizeAddress(address) {
|
||
address = (address || '')
|
||
.toString()
|
||
.replace(/[\x00-\x1F\x7F<>]+/g, ' ') // remove unallowed characters
|
||
.trim();
|
||
if (!address) {
|
||
// callers use an empty value to detect a missing address
|
||
return address;
|
||
}
|
||
// dtext includes '@', so a domain literal is split off at the '@' in front of it and
|
||
// not at the last one, which delivered 'a@[x@evil.com]' to evil.com
|
||
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
|
||
const lastAt = literal ? address.length - literal[1].length - 1 : 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);
|
||
// 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 {
|
||
encodedDomain = normalizeDomain(domain.toLowerCase(), smtputf8);
|
||
}
|
||
catch (_err) {
|
||
// keep domain as supplied
|
||
}
|
||
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 user Local part of an address
|
||
* @return Local part as a dot-atom or as a quoted-string
|
||
* @internal
|
||
*/
|
||
_normalizeLocalPart(user) {
|
||
if (DOT_ATOM.test(user) || QUOTED_STRING.test(user)) {
|
||
return user;
|
||
}
|
||
return mimeFuncs.quoteString(user);
|
||
}
|
||
/**
|
||
* If needed, mime encodes the name part
|
||
*
|
||
* @param name Name part of an address
|
||
* @returns Mime word encoded string if needed
|
||
* @internal
|
||
*/
|
||
_encodeAddressName(name) {
|
||
if (!/^[\w ]*$/.test(name)) {
|
||
if (/^[\x20-\x7e]*$/.test(name)) {
|
||
return mimeFuncs.quoteString(name);
|
||
}
|
||
else {
|
||
return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
|
||
}
|
||
}
|
||
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 value Header value to encode
|
||
* @returns Mime word encoded string if needed
|
||
* @internal
|
||
*/
|
||
_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
|
||
*
|
||
* @param value Name part of an address
|
||
* @returns Mime word encoded string if needed
|
||
* @internal
|
||
*/
|
||
_encodeWords(value) {
|
||
// set encodeAll parameter to true even though it is against the recommendation of RFC2047,
|
||
// by default only words that include non-ascii should be converted into encoded words
|
||
// but some clients (eg. Zimbra) do not handle it properly and remove surrounding whitespace
|
||
return mimeFuncs.encodeWords(value, this._getTextEncoding(value), 52, true);
|
||
}
|
||
/**
|
||
* Detects best mime encoding for a text value
|
||
*
|
||
* @param value Value to check for
|
||
* @return either 'Q' or 'B'
|
||
* @internal
|
||
*/
|
||
_getTextEncoding(value) {
|
||
value = (value || '').toString();
|
||
if (this.textEncoding) {
|
||
return this.textEncoding;
|
||
}
|
||
// count latin alphabet symbols and 8-bit range symbols + control symbols
|
||
// if there are more latin characters, then use quoted-printable
|
||
// encoding, otherwise use base64
|
||
let nonLatinLen = 0;
|
||
let latinLen = 0;
|
||
for (let i = 0, len = value.length; i < len; i++) {
|
||
const code = value.charCodeAt(i);
|
||
if ((code >= 0x00 && code <= 0x08) || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f) || code >= 0x80) {
|
||
nonLatinLen++;
|
||
}
|
||
else if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) {
|
||
latinLen++;
|
||
}
|
||
}
|
||
// if there are more latin symbols than binary/unicode, then prefer Q, otherwise B
|
||
return nonLatinLen < latinLen ? 'Q' : 'B';
|
||
}
|
||
/**
|
||
* Generates a message id
|
||
*
|
||
* @return Random Message-ID value
|
||
* @internal
|
||
*/
|
||
_generateMessageId() {
|
||
return ('<' +
|
||
[2, 2, 2, 6].reduce(
|
||
// crux to generate UUID-like random strings
|
||
(prev, len) => prev + '-' + crypto.randomBytes(len).toString('hex'), crypto.randomBytes(4).toString('hex')) +
|
||
'@' +
|
||
// try to use the domain of the FROM address or fallback to server hostname
|
||
(this.getEnvelope().from || this.hostname || 'localhost').split('@').pop() +
|
||
'>');
|
||
}
|
||
}
|
||
export default MimeNode;
|