| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225 |
- /* eslint no-undefined: 0, prefer-spread: 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 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 {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;
- /**
- * 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 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));
- });
- }
- 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
- 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;
- }
- 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) {
- return (
- (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')
- );
- }
- /**
- * 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('@');
- 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) {
- return mimeFuncs.encodeWords(value, this._getTextEncoding(value), 52);
- }
- /**
- * 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;
|