/* eslint no-undefined: 0 */ 'use strict'; const MimeNode = require('../mime-node'); const mimeFuncs = require('../mime-funcs'); /** * Creates the object for composing a MimeNode instance out from the mail options * * @constructor * @param {Object} mail Mail options */ class MailComposer { constructor(mail) { this.mail = mail || {}; this.message = false; } /** * Builds MimeNode instance */ compile() { this._alternatives = this.getAlternatives(); this._htmlNode = this._alternatives.filter(alternative => /^text\/html\b/i.test(alternative.contentType)).pop(); this._attachments = this.getAttachments(!!this._htmlNode); this._useRelated = !!(this._htmlNode && this._attachments.related.length); this._useAlternative = this._alternatives.length > 1; this._useMixed = this._attachments.attached.length > 1 || (this._alternatives.length && this._attachments.attached.length === 1); // Compose MIME tree if (this.mail.raw) { this.message = new MimeNode('message/rfc822', { newline: this.mail.newline }).setRaw(this.mail.raw); } else if (this._useMixed) { this.message = this._createMixed(); } else if (this._useAlternative) { this.message = this._createAlternative(); } else if (this._useRelated) { this.message = this._createRelated(); } else { this.message = this._createContentNode( false, [] .concat(this._alternatives || []) .concat(this._attachments.attached || []) .shift() || { contentType: 'text/plain', content: '' } ); } // Add custom headers if (this.mail.headers) { this.message.addHeader(this.mail.headers); } // Add headers to the root node, always overrides custom headers ['from', 'sender', 'to', 'cc', 'bcc', 'reply-to', 'in-reply-to', 'references', 'subject', 'message-id', 'date'].forEach(header => { let key = header.replace(/-(\w)/g, (o, c) => c.toUpperCase()); if (this.mail[key]) { this.message.setHeader(header, this.mail[key]); } }); // Sets custom envelope if (this.mail.envelope) { this.message.setEnvelope(this.mail.envelope); } // ensure Message-Id value this.message.messageId(); return this.message; } /** * List all attachments. Resulting attachment objects can be used as input for MimeNode nodes * * @param {Boolean} findRelated If true separate related attachments from attached ones * @returns {Object} An object of arrays (`related` and `attached`) */ getAttachments(findRelated) { let icalEvent, eventObject; let attachments = [].concat(this.mail.attachments || []).map((attachment, i) => { let data; let isMessageNode = /^message\//i.test(attachment.contentType); if (/^data:/i.test(attachment.path || attachment.href)) { attachment = this._processDataUrl(attachment); } data = { contentType: attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin'), contentDisposition: attachment.contentDisposition || (isMessageNode ? 'inline' : 'attachment'), contentTransferEncoding: 'contentTransferEncoding' in attachment ? attachment.contentTransferEncoding : 'base64' }; if (attachment.filename) { data.filename = attachment.filename; } else if (!isMessageNode && attachment.filename !== false) { data.filename = (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1); if (data.filename.indexOf('.') < 0) { data.filename += '.' + mimeFuncs.detectExtension(data.contentType); } } if (/^https?:\/\//i.test(attachment.path)) { attachment.href = attachment.path; attachment.path = undefined; } if (attachment.cid) { data.cid = attachment.cid; } if (attachment.raw) { data.raw = attachment.raw; } else if (attachment.path) { data.content = { path: attachment.path }; } else if (attachment.href) { data.content = { href: attachment.href, httpHeaders: attachment.httpHeaders }; } else { data.content = attachment.content || ''; } if (attachment.encoding) { data.encoding = attachment.encoding; } if (attachment.headers) { data.headers = attachment.headers; } return data; }); if (this.mail.icalEvent) { if ( typeof this.mail.icalEvent === 'object' && (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw) ) { icalEvent = this.mail.icalEvent; } else { icalEvent = { content: this.mail.icalEvent }; } eventObject = {}; Object.keys(icalEvent).forEach(key => { eventObject[key] = icalEvent[key]; }); eventObject.contentType = 'application/ics'; if (!eventObject.headers) { eventObject.headers = {}; } eventObject.filename = eventObject.filename || 'invite.ics'; eventObject.headers['Content-Disposition'] = 'attachment'; eventObject.headers['Content-Transfer-Encoding'] = 'base64'; } if (!findRelated) { return { attached: attachments.concat(eventObject || []), related: [] }; } else { return { attached: attachments.filter(attachment => !attachment.cid).concat(eventObject || []), related: attachments.filter(attachment => !!attachment.cid) }; } } /** * List alternatives. Resulting objects can be used as input for MimeNode nodes * * @returns {Array} An array of alternative elements. Includes the `text` and `html` values as well */ getAlternatives() { let alternatives = [], text, html, watchHtml, amp, icalEvent, eventObject; if (this.mail.text) { if (typeof this.mail.text === 'object' && (this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)) { text = this.mail.text; } else { text = { content: this.mail.text }; } text.contentType = 'text/plain; charset=utf-8'; } if (this.mail.watchHtml) { if ( typeof this.mail.watchHtml === 'object' && (this.mail.watchHtml.content || this.mail.watchHtml.path || this.mail.watchHtml.href || this.mail.watchHtml.raw) ) { watchHtml = this.mail.watchHtml; } else { watchHtml = { content: this.mail.watchHtml }; } watchHtml.contentType = 'text/watch-html; charset=utf-8'; } if (this.mail.amp) { if (typeof this.mail.amp === 'object' && (this.mail.amp.content || this.mail.amp.path || this.mail.amp.href || this.mail.amp.raw)) { amp = this.mail.amp; } else { amp = { content: this.mail.amp }; } amp.contentType = 'text/x-amp-html; charset=utf-8'; } // NB! when including attachments with a calendar alternative you might end up in a blank screen on some clients if (this.mail.icalEvent) { if ( typeof this.mail.icalEvent === 'object' && (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw) ) { icalEvent = this.mail.icalEvent; } else { icalEvent = { content: this.mail.icalEvent }; } eventObject = {}; Object.keys(icalEvent).forEach(key => { eventObject[key] = icalEvent[key]; }); if (eventObject.content && typeof eventObject.content === 'object') { // we are going to have the same attachment twice, so mark this to be // resolved just once eventObject.content._resolve = true; } eventObject.filename = false; eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase(); if (!eventObject.headers) { eventObject.headers = {}; } } if (this.mail.html) { if (typeof this.mail.html === 'object' && (this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)) { html = this.mail.html; } else { html = { content: this.mail.html }; } html.contentType = 'text/html; charset=utf-8'; } [] .concat(text || []) .concat(watchHtml || []) .concat(amp || []) .concat(html || []) .concat(eventObject || []) .concat(this.mail.alternatives || []) .forEach(alternative => { let data; if (/^data:/i.test(alternative.path || alternative.href)) { alternative = this._processDataUrl(alternative); } data = { contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'), contentTransferEncoding: alternative.contentTransferEncoding }; if (alternative.filename) { data.filename = alternative.filename; } if (/^https?:\/\//i.test(alternative.path)) { alternative.href = alternative.path; alternative.path = undefined; } if (alternative.raw) { data.raw = alternative.raw; } else if (alternative.path) { data.content = { path: alternative.path }; } else if (alternative.href) { data.content = { href: alternative.href }; } else { data.content = alternative.content || ''; } if (alternative.encoding) { data.encoding = alternative.encoding; } if (alternative.headers) { data.headers = alternative.headers; } alternatives.push(data); }); return alternatives; } /** * Builds multipart/mixed node. It should always contain different type of elements on the same level * eg. text + attachments * * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created * @returns {Object} MimeNode node element */ _createMixed(parentNode) { let node; if (!parentNode) { node = new MimeNode('multipart/mixed', { baseBoundary: this.mail.baseBoundary, textEncoding: this.mail.textEncoding, boundaryPrefix: this.mail.boundaryPrefix, disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } else { node = parentNode.createChild('multipart/mixed', { disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } if (this._useAlternative) { this._createAlternative(node); } else if (this._useRelated) { this._createRelated(node); } [] .concat((!this._useAlternative && this._alternatives) || []) .concat(this._attachments.attached || []) .forEach(element => { // if the element is a html node from related subpart then ignore it if (!this._useRelated || element !== this._htmlNode) { this._createContentNode(node, element); } }); return node; } /** * Builds multipart/alternative node. It should always contain same type of elements on the same level * eg. text + html view of the same data * * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created * @returns {Object} MimeNode node element */ _createAlternative(parentNode) { let node; if (!parentNode) { node = new MimeNode('multipart/alternative', { baseBoundary: this.mail.baseBoundary, textEncoding: this.mail.textEncoding, boundaryPrefix: this.mail.boundaryPrefix, disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } else { node = parentNode.createChild('multipart/alternative', { disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } this._alternatives.forEach(alternative => { if (this._useRelated && this._htmlNode === alternative) { this._createRelated(node); } else { this._createContentNode(node, alternative); } }); return node; } /** * Builds multipart/related node. It should always contain html node with related attachments * * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created * @returns {Object} MimeNode node element */ _createRelated(parentNode) { let node; if (!parentNode) { node = new MimeNode('multipart/related; type="text/html"', { baseBoundary: this.mail.baseBoundary, textEncoding: this.mail.textEncoding, boundaryPrefix: this.mail.boundaryPrefix, disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } else { node = parentNode.createChild('multipart/related; type="text/html"', { disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } this._createContentNode(node, this._htmlNode); this._attachments.related.forEach(alternative => this._createContentNode(node, alternative)); return node; } /** * Creates a regular node with contents * * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created * @param {Object} element Node data * @returns {Object} MimeNode node element */ _createContentNode(parentNode, element) { element = element || {}; element.content = element.content || ''; let node; let encoding = (element.encoding || 'utf8') .toString() .toLowerCase() .replace(/[-_\s]/g, ''); if (!parentNode) { node = new MimeNode(element.contentType, { filename: element.filename, baseBoundary: this.mail.baseBoundary, textEncoding: this.mail.textEncoding, boundaryPrefix: this.mail.boundaryPrefix, disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } else { node = parentNode.createChild(element.contentType, { filename: element.filename, textEncoding: this.mail.textEncoding, disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess, normalizeHeaderKey: this.mail.normalizeHeaderKey, newline: this.mail.newline }); } // add custom headers if (element.headers) { node.addHeader(element.headers); } if (element.cid) { node.setHeader('Content-Id', '<' + element.cid.replace(/[<>]/g, '') + '>'); } if (element.contentTransferEncoding) { node.setHeader('Content-Transfer-Encoding', element.contentTransferEncoding); } else if (this.mail.encoding && /^text\//i.test(element.contentType)) { node.setHeader('Content-Transfer-Encoding', this.mail.encoding); } if (!/^text\//i.test(element.contentType) || element.contentDisposition) { node.setHeader('Content-Disposition', element.contentDisposition || (element.cid ? 'inline' : 'attachment')); } if (typeof element.content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) { element.content = Buffer.from(element.content, encoding); } // prefer pregenerated raw content if (element.raw) { node.setRaw(element.raw); } else { node.setContent(element.content); } return node; } /** * Parses data uri and converts it to a Buffer * * @param {Object} element Content element * @return {Object} Parsed element */ _processDataUrl(element) { let parts = (element.path || element.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i); if (!parts) { return element; } element.content = /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2])); if ('path' in element) { element.path = false; } if ('href' in element) { element.href = false; } parts[1].split(';').forEach(item => { if (/^\w+\/[^/]+$/i.test(item)) { element.contentType = element.contentType || item.toLowerCase(); } }); return element; } } module.exports = MailComposer;