123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266 |
- /* eslint no-undefined: 0, prefer-spread: 0, no-control-regex: 0 */
- 'use strict';
- const crypto = require('crypto');
- const os = require('os');
- const fs = require('fs');
- const punycode = require('punycode');
- const PassThrough = require('stream').PassThrough;
- const shared = require('../shared');
- const mimeFuncs = require('../mime-funcs');
- const qp = require('../qp');
- const base64 = require('../base64');
- const addressparser = require('../addressparser');
- const fetch = require('../fetch');
- const LastNewline = require('./last-newline');
- /**
- * 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 {String} contentType Define the content type for the node. Can be left blank for attachments (derived from filename)
- * @param {Object} [options] optional options
- * @param {Object} [options.rootNode] root node for this tree
- * @param {Object} [options.parentNode] immediate parent for this node
- * @param {Object} [options.filename] filename for an attachment node
- * @param {String} [options.baseBoundary] shared part of the unique multipart boundary
- * @param {Boolean} [options.keepBcc] If true, do not exclude Bcc from the generated headers
- * @param {Function} [options.normalizeHeaderKey] method to normalize header keys for custom caseing
- * @param {String} [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
- */
- this.baseBoundary = options.baseBoundary || crypto.randomBytes(8).toString('hex');
- this.boundaryPrefix = 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 = 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;
- /**
- * 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 {String} [contentType] Optional content type
- * @param {Object} [options] Optional options object
- * @return {Object} Created node object
- */
- createChild(contentType, options) {
- if (!options && typeof contentType === 'object') {
- options = contentType;
- contentType = undefined;
- }
- let 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 {Object} childNode node to be appended
- * @return {Object} Appended node object
- */
- appendChild(childNode) {
- 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 {Object} node Replacement node
- * @return {Object} 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 {Object} 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 {String|Array|Object} key Header key or a list of key value pairs
- * @param {String} value Header value
- * @return {Object} current node
- */
- setHeader(key, value) {
- let added = false,
- headerValue;
- // 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);
- 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 {String|Array|Object} key Header key or a list of key value pairs
- * @param {String} value Header value
- * @return {Object} 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 {String} key Key to search for
- * @retun {String} 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 (String|Buffer) content Body content
- * @return {Object} 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
- this._contentErrorHandler = err => {
- this.content.removeListener('error', this._contentErrorHandler);
- this.content = err;
- };
- this.content.once('error', this._contentErrorHandler);
- } 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);
- });
- }
- let stream = this.createReadStream();
- let 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 callback(err);
- });
- stream.once('end', chunk => {
- if (returned) {
- return;
- }
- returned = true;
- if (chunk && chunk.length) {
- buf.push(chunk);
- buflen += chunk.length;
- }
- return callback(null, Buffer.concat(buf, buflen));
- });
- return promise;
- }
- getTransferEncoding() {
- let transferEncoding = false;
- let 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.transferEncoding === '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 {String} Headers
- */
- buildHeaders() {
- let transferEncoding = this.getTransferEncoding();
- let 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');
- }
- }
- this._headers.forEach(header => {
- let key = header.key;
- let value = header.value;
- let structured;
- let param;
- let options = {};
- let formattedHeaders = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References'];
- if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
- Object.keys(value).forEach(key => {
- if (key !== 'value') {
- options[key] = value[key];
- }
- });
- 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);
- this._handleContentType(structured);
- if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
- structured.params.charset = 'utf-8';
- }
- 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
- param = this._encodeWords(this.filename);
- if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) {
- // include value in quotes if needed
- param = '"' + 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') {
- let normalized = this.normalizeHeaderKey(key, value);
- if (normalized && typeof normalized === 'string' && normalized.length) {
- key = normalized;
- }
- }
- 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 {String} Compiled message
- */
- createReadStream(options) {
- options = options || {};
- let stream = new PassThrough(options);
- let outputStream = stream;
- let transform;
- this.stream(stream, options, err => {
- if (err) {
- outputStream.emit('error', err);
- return;
- }
- stream.end();
- });
- for (let i = 0, len = this._transforms.length; i < len; i++) {
- transform = typeof this._transforms[i] === 'function' ? this._transforms[i]() : this._transforms[i];
- outputStream.once('error', err => {
- transform.emit('error', err);
- });
- outputStream = outputStream.pipe(transform);
- }
- // ensure terminating newline after possible user transforms
- transform = new LastNewline();
- outputStream.once('error', err => {
- transform.emit('error', err);
- });
- outputStream = outputStream.pipe(transform);
- // dkim and stuff
- for (let i = 0, len = this._processFuncs.length; i < len; i++) {
- transform = this._processFuncs[i];
- outputStream = transform(outputStream);
- }
- return outputStream;
- }
- /**
- * Appends a transform stream object to the transforms list. Final output
- * is passed through this stream before exposing
- *
- * @param {Object} 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 {Object} processFunc Read-Write stream
- */
- processFunc(processFunc) {
- this._processFuncs.push(processFunc);
- }
- stream(outputStream, options, done) {
- let transferEncoding = this.getTransferEncoding();
- let contentStream;
- let localStream;
- // protect actual callback against multiple triggering
- let returned = false;
- let callback = err => {
- if (returned) {
- return;
- }
- returned = true;
- done(err);
- };
- // for multipart nodes, push child nodes
- // for content nodes end the stream
- let finalize = () => {
- let childId = 0;
- let processChildNode = () => {
- if (childId >= this.childNodes.length) {
- outputStream.write('\r\n--' + this.boundary + '--\r\n');
- return callback();
- }
- let 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
- let sendContent = () => {
- if (this.content) {
- if (Object.prototype.toString.call(this.content) === '[object Error]') {
- // content is already errored
- return callback(this.content);
- }
- if (typeof this.content.pipe === 'function') {
- this.content.removeListener('error', this._contentErrorHandler);
- this._contentErrorHandler = err => callback(err);
- this.content.once('error', this._contentErrorHandler);
- }
- let createStream = () => {
- if (['quoted-printable', 'base64'].includes(transferEncoding)) {
- contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options);
- contentStream.pipe(
- outputStream,
- {
- end: false
- }
- );
- contentStream.once('end', finalize);
- contentStream.once('error', err => callback(err));
- localStream = this._getStream(this.content);
- localStream.pipe(contentStream);
- } else {
- // anything that is not QP or Base54 passes as-is
- localStream = this._getStream(this.content);
- localStream.pipe(
- outputStream,
- {
- end: false
- }
- );
- localStream.once('end', finalize);
- }
- localStream.once('error', err => callback(err));
- };
- if (this.content._resolve) {
- let chunks = [];
- let chunklen = 0;
- let returned = false;
- let sourceStream = this._getStream(this.content);
- sourceStream.on('error', err => {
- if (returned) {
- return;
- }
- returned = true;
- callback(err);
- });
- sourceStream.on('readable', () => {
- let chunk;
- while ((chunk = sourceStream.read()) !== null) {
- chunks.push(chunk);
- chunklen += chunk.length;
- }
- });
- sourceStream.on('end', () => {
- if (returned) {
- return;
- }
- returned = true;
- this.content._resolve = false;
- this.content._resolvedValue = Buffer.concat(chunks, chunklen);
- setImmediate(createStream);
- });
- } else {
- setImmediate(createStream);
- }
- return;
- } else {
- return setImmediate(finalize);
- }
- };
- if (this._raw) {
- setImmediate(() => {
- if (Object.prototype.toString.call(this._raw) === '[object Error]') {
- // content is already errored
- return callback(this._raw);
- }
- // remove default error handler (if set)
- if (typeof this._raw.pipe === 'function') {
- this._raw.removeListener('error', this._contentErrorHandler);
- }
- let raw = this._getStream(this._raw);
- raw.pipe(
- outputStream,
- {
- end: false
- }
- );
- raw.on('error', err => outputStream.emit('error', err));
- raw.on('end', finalize);
- });
- } else {
- outputStream.write(this.buildHeaders() + '\r\n\r\n');
- setImmediate(sendContent);
- }
- }
- /**
- * Sets envelope to be used instead of the generated one
- *
- * @return {Object} 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._parseAddresses(envelope.from), list);
- list = list.filter(address => address && address.address);
- if (list.length && list[0]) {
- this._envelope.from = list[0].address;
- }
- }
- ['to', 'cc', 'bcc'].forEach(key => {
- if (envelope[key]) {
- this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
- }
- });
- this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
- let standardFields = ['to', 'cc', 'bcc', 'from'];
- Object.keys(envelope).forEach(key => {
- if (!standardFields.includes(key)) {
- this._envelope[key] = envelope[key];
- }
- });
- return this;
- }
- /**
- * Generates and returns an object with parsed address fields
- *
- * @return {Object} Address object
- */
- getAddresses() {
- let addresses = {};
- this._headers.forEach(header => {
- let key = header.key.toLowerCase();
- if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
- if (!Array.isArray(addresses[key])) {
- addresses[key] = [];
- }
- this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
- }
- });
- return addresses;
- }
- /**
- * Generates and returns SMTP envelope with the sender address and a list of recipients addresses
- *
- * @return {Object} SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
- */
- getEnvelope() {
- if (this._envelope) {
- return this._envelope;
- }
- let envelope = {
- from: false,
- to: []
- };
- this._headers.forEach(header => {
- let 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), envelope.to);
- }
- });
- envelope.to = envelope.to.map(to => to.address);
- return envelope;
- }
- /**
- * Returns Message-Id value. If it does not exist, then creates one
- *
- * @return {String} 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 {String|Buffer|Stream} 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
- this._contentErrorHandler = err => {
- this._raw.removeListener('error', this._contentErrorHandler);
- this._raw = err;
- };
- this._raw.once('error', this._contentErrorHandler);
- }
- return this;
- }
- /////// PRIVATE METHODS
- /**
- * Detects and returns handle to a stream related with the content.
- *
- * @param {Mixed} content Node content
- * @returns {Object} Stream object
- */
- _getStream(content) {
- let contentStream;
- if (content._resolvedValue) {
- // pass string or buffer content as a stream
- contentStream = new PassThrough();
- setImmediate(() => contentStream.end(content._resolvedValue));
- return contentStream;
- } else if (typeof content.pipe === 'function') {
- // assume as stream
- return content;
- } else if (content && typeof content.path === 'string' && !content.href) {
- if (this.disableFileAccess) {
- contentStream = new PassThrough();
- setImmediate(() => contentStream.emit('error', new Error('File access rejected for ' + content.path)));
- return contentStream;
- }
- // read file
- return fs.createReadStream(content.path);
- } else if (content && typeof content.href === 'string') {
- if (this.disableUrlAccess) {
- contentStream = new PassThrough();
- setImmediate(() => contentStream.emit('error', new Error('Url access rejected for ' + content.href)));
- return contentStream;
- }
- // fetch URL
- return fetch(content.href);
- } else {
- // pass string or buffer content as a stream
- contentStream = new PassThrough();
- setImmediate(() => contentStream.end(content || ''));
- 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 {Mixed} addresses Addresses to be parsed
- * @return {Array} An array of address objects
- */
- _parseAddresses(addresses) {
- return [].concat.apply(
- [],
- [].concat(addresses).map(address => {
- // eslint-disable-line prefer-spread
- if (address && address.address) {
- address.address = this._normalizeAddress(address.address);
- address.name = address.name || '';
- return [address];
- }
- return addressparser(address);
- })
- );
- }
- /**
- * Normalizes a header key, uses Camel-Case form, except for uppercase MIME-
- *
- * @param {String} key Key to be normalized
- * @return {String} key in Camel-Case form
- */
- _normalizeHeaderKey(key) {
- key = (key || '')
- .toString()
- // no newlines in keys
- .replace(/\r?\n|\r/g, ' ')
- .trim()
- .toLowerCase()
- // use uppercase words, except MIME
- .replace(/^X-SMTPAPI$|^(MIME|DKIM)\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 {Object} structured Parsed header value for 'Content-Type' key
- */
- _handleContentType(structured) {
- this.contentType = structured.value.trim().toLowerCase();
- this.multipart = this.contentType.split('/').reduce((prev, value) => (prev === 'multipart' ? value : false));
- if (this.multipart) {
- this.boundary = structured.params.boundary = structured.params.boundary || this.boundary || this._generateBoundary();
- } else {
- this.boundary = false;
- }
- }
- /**
- * Generates a multipart boundary value
- *
- * @return {String} boundary value
- */
- _generateBoundary() {
- return this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary + '-Part_' + this._nodeId;
- }
- /**
- * Encodes a header value for use in the generated rfc2822 email.
- *
- * @param {String} key Header key
- * @param {String} value Header value
- */
- _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':
- value = (value || '').toString().replace(/\r?\n|\r/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':
- value = [].concat
- .apply(
- [],
- [].concat(value || '').map(elm => {
- // eslint-disable-line prefer-spread
- elm = (elm || '')
- .toString()
- .replace(/\r?\n|\r/g, ' ')
- .trim();
- return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
- })
- )
- .map(elm => {
- if (elm.charAt(0) !== '<') {
- elm = '<' + elm;
- }
- if (elm.charAt(elm.length - 1) !== '>') {
- elm = elm + '>';
- }
- return elm;
- });
- return value.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._encodeWords(value);
- default:
- value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
- // encodeWords only encodes if needed, otherwise the original string is returned
- return this._encodeWords(value);
- }
- }
- /**
- * Rebuilds address object using punycode and other adjustments
- *
- * @param {Array} addresses An array of address objects
- * @param {Array} [uniqueList] An array to be populated with addresses
- * @return {String} address string
- */
- _convertAddresses(addresses, uniqueList) {
- let values = [];
- uniqueList = uniqueList || [];
- [].concat(addresses || []).forEach(address => {
- if (address.address) {
- address.address = this._normalizeAddress(address.address);
- if (!address.name) {
- values.push(address.address);
- } else if (address.name) {
- values.push(this._encodeAddressName(address.name) + ' <' + address.address + '>');
- }
- if (address.address) {
- if (!uniqueList.filter(a => a.address === address.address).length) {
- uniqueList.push(address);
- }
- }
- } else if (address.group) {
- values.push(
- this._encodeAddressName(address.name) + ':' + (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim() + ';'
- );
- }
- });
- return values.join(', ');
- }
- /**
- * Normalizes an email address
- *
- * @param {Array} address An array of address objects
- * @return {String} address string
- */
- _normalizeAddress(address) {
- address = (address || '').toString().trim();
- let lastAt = address.lastIndexOf('@');
- if (lastAt < 0) {
- // Bare username
- return address;
- }
- let user = address.substr(0, lastAt);
- let domain = address.substr(lastAt + 1);
- // Usernames are not touched and are kept as is even if these include unicode
- // Domains are punycoded by default
- // 'jõgeva.ee' will be converted to 'xn--jgeva-dua.ee'
- // non-unicode domains are left as is
- return user + '@' + punycode.toASCII(domain.toLowerCase());
- }
- /**
- * If needed, mime encodes the name part
- *
- * @param {String} name Name part of an address
- * @returns {String} Mime word encoded string if needed
- */
- _encodeAddressName(name) {
- if (!/^[\w ']*$/.test(name)) {
- if (/^[\x20-\x7e]*$/.test(name)) {
- return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
- } else {
- return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
- }
- }
- return name;
- }
- /**
- * If needed, mime encodes the name part
- *
- * @param {String} name Name part of an address
- * @returns {String} Mime word encoded string if needed
- */
- _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 {String} value Value to check for
- * @return {String} either 'Q' or 'B'
- */
- _getTextEncoding(value) {
- value = (value || '').toString();
- let encoding = this.textEncoding;
- let latinLen;
- let nonLatinLen;
- if (!encoding) {
- // 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
- nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length; // eslint-disable-line no-control-regex
- latinLen = (value.match(/[a-z]/gi) || []).length;
- // if there are more latin symbols than binary/unicode, then prefer Q, otherwise B
- encoding = nonLatinLen < latinLen ? 'Q' : 'B';
- }
- return encoding;
- }
- /**
- * Generates a message id
- *
- * @return {String} Random Message-ID value
- */
- _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 || os.hostname() || 'localhost').split('@').pop() +
- '>'
- );
- }
- }
- module.exports = MimeNode;
|