import * as shared from '../shared/index.js'; import MimeNode from '../mime-node/index.js'; import * as mimeFuncs from '../mime-funcs/index.js'; import * as errors from '../errors.js'; // Only an own key counts as already set. `key in obj` also matches every member of // Object.prototype, which silently drops a transporter default legitimately named // toString or constructor. const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); export default class MailMessage { constructor(mailer, data) { this.mailer = mailer; this.data = {}; this.message = null; data = data || {}; const options = mailer.options || {}; const defaults = mailer._defaults || {}; shared.copyOwnKeys(this.data, data); this.data.headers = this.data.headers || {}; // Apply defaults. `_defaults` is caller supplied too, it is the second argument of // createTransport, so it needs the same treatment as `data` above shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key)); // headers is a special case. Allow setting individual default headers shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key)); // force specific keys from transporter options ['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => { if (key in options) { this.data[key] = options[key]; } }); // The access flags are a sandbox rather than a message field, so `defaults` counts as // transporter configuration for them. For a transporter plugin it is the only channel // there is, createTransport leaves `options` undefined for one, and the defaults copy // above yields to anything the message already set, which let message data switch the // sandbox back off. Closing is one way here, same as in resolveContent below: either // side may switch a flag on, neither can switch off what the other closed. ['disableFileAccess', 'disableUrlAccess'].forEach(key => { if (!(key in options) && hasOwn(defaults, key)) { this.data[key] = this.data[key] || defaults[key]; } }); } resolveContent(data, key, options, callback) { // Most plugins call this with the legacy (data, key, callback) signature, which carries // no access policy. The policy belongs to the message, so apply it here. Explicit // options may only tighten it, never reopen what the transporter closed. if (!callback && typeof options === 'function') { callback = options; options = false; } options = options || {}; const policy = { disableFileAccess: this.data.disableFileAccess || options.disableFileAccess, disableUrlAccess: this.data.disableUrlAccess || options.disableUrlAccess }; return shared.resolveContent(data, key, policy, callback); } resolveAll(callback) { const keys = [ [this.data, 'html'], [this.data, 'text'], [this.data, 'watchHtml'], [this.data, 'amp'], [this.data, 'icalEvent'] ]; if (this.data.alternatives && this.data.alternatives.length) { this.data.alternatives.forEach((alternative, i) => { keys.push([this.data.alternatives, i]); }); } if (this.data.attachments && this.data.attachments.length) { this.data.attachments.forEach((attachment, i) => { if (!attachment.filename) { // a backslash separates as well, so a Windows path does not put the sender's directories in the headers attachment.filename = (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1); if (attachment.filename.indexOf('.') < 0) { attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); } } if (!attachment.contentType) { attachment.contentType = mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin'); } keys.push([this.data.attachments, i]); }); } const mimeNode = new MimeNode(); const addressKeys = ['from', 'to', 'cc', 'bcc', 'sender', 'replyTo']; addressKeys.forEach(address => { let value; if (this.message) { value = [].concat(mimeNode._parseAddresses(this.message.getHeader(address === 'replyTo' ? 'reply-to' : address)) || []); } else if (this.data[address]) { value = [].concat(mimeNode._parseAddresses(this.data[address]) || []); } if (value && value.length) { this.data[address] = value; } else if (address in this.data) { this.data[address] = null; } }); const singleKeys = ['from', 'sender']; singleKeys.forEach(address => { if (this.data[address]) { this.data[address] = this.data[address].shift(); } }); let pos = 0; const resolveNext = () => { if (pos >= keys.length) { return callback(null, this.data); } const args = keys[pos++]; if (!args[0] || !args[0][args[1]]) { return resolveNext(); } shared.resolveContent(...args, { disableFileAccess: this.data.disableFileAccess, disableUrlAccess: this.data.disableUrlAccess }, (err, value) => { if (err) { return callback(err); } const node = { content: value }; if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) { // The keys are the caller's, so copying them takes the same "__proto__" // rule as the constructor. `key in node` stays as the already-set test // here, unlike for the defaults: it also skips the Object.prototype // member names, and letting message data land a `toString` string on a // node only buys a TypeError the first time something stringifies it. shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key)); } args[0][args[1]] = node; resolveNext(); }); }; setImmediate(() => resolveNext()); } /** * Destroys the content streams of the message once sending failed. A stream that was not * read to the end would otherwise keep the file or the socket behind it open */ releaseStreams() { const data = this.data; const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent]; for (const key of ['attachments', 'alternatives']) { if (Array.isArray(data[key])) { // one at a time, spreading a long enough list into the arguments throws for (const entry of data[key]) { values.push(entry); } } } for (const value of values) { if (value && typeof value === 'object') { shared.destroyStream(value); shared.destroyStream(value.content); shared.destroyStream(value.raw); } } } normalize(callback) { const envelope = this.message.getEnvelope(); const messageId = this.message.messageId(); this.resolveAll((err, data) => { if (err) { return callback(err); } data.envelope = envelope; data.messageId = messageId; ['html', 'text', 'watchHtml', 'amp'].forEach(key => { if (data[key] && data[key].content) { if (typeof data[key].content === 'string') { data[key] = data[key].content; } else if (Buffer.isBuffer(data[key].content)) { data[key] = data[key].content.toString(); } } }); if (data.icalEvent && Buffer.isBuffer(data.icalEvent.content)) { data.icalEvent.content = data.icalEvent.content.toString('base64'); data.icalEvent.encoding = 'base64'; } if (data.alternatives && data.alternatives.length) { data.alternatives.forEach((alternative) => { if (alternative && alternative.content && Buffer.isBuffer(alternative.content)) { alternative.content = alternative.content.toString('base64'); alternative.encoding = 'base64'; } }); } if (data.attachments && data.attachments.length) { data.attachments.forEach((attachment) => { if (attachment && attachment.content && Buffer.isBuffer(attachment.content)) { attachment.content = attachment.content.toString('base64'); attachment.encoding = 'base64'; } }); } data.normalizedHeaders = {}; Object.keys(data.headers || {}).forEach(key => { if (shared.isProtoKey(key)) { return; } let value = [].concat(data.headers[key] || []).shift(); value = (value && value.value) || value; if (value) { if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) { value = this.message.encodeHeaderValue(key, value); } data.normalizedHeaders[key] = value; } }); if (data.list && typeof data.list === 'object') { const listHeaders = this._getListHeaders(data.list); listHeaders.forEach(entry => { data.normalizedHeaders[entry.key] = entry.value.map(val => (val && val.value) || val).join(', '); }); } if (data.references) { data.normalizedHeaders.references = this.message.encodeHeaderValue('references', data.references); } if (data.inReplyTo) { data.normalizedHeaders['in-reply-to'] = this.message.encodeHeaderValue('in-reply-to', data.inReplyTo); } return callback(null, data); }); } setMailerHeader() { if (!this.message || !this.data.xMailer) { return; } this.message.setHeader('X-Mailer', this.data.xMailer); } setPriorityHeaders() { if (!this.message || !this.data.priority) { return; } switch ((this.data.priority || '').toString().toLowerCase()) { case 'high': this.message.setHeader('X-Priority', '1 (Highest)'); this.message.setHeader('X-MSMail-Priority', 'High'); this.message.setHeader('Importance', 'High'); break; case 'low': this.message.setHeader('X-Priority', '5 (Lowest)'); this.message.setHeader('X-MSMail-Priority', 'Low'); this.message.setHeader('Importance', 'Low'); break; default: // do not add anything, since all messages are 'Normal' by default } } setListHeaders() { if (!this.message || !this.data.list || typeof this.data.list !== 'object') { return; } // add optional List-* headers this._getListHeaders(this.data.list).forEach(listHeader => { listHeader.value.forEach(value => { this.message.addHeader(listHeader.key, value); }); }); } /** * Sets the OTP-Token header from the otp option. The header is defined by * draft-goto-otp-token-01, an Internet-Draft that expires on 25 March 2027. By then check * for a newer revision of the draft or a published RFC, the header name and its * parameters are still open questions in the draft. */ setOtpHeader() { const otp = this.data.otp; if (!this.message || !otp) { return; } // a structured field String can only hold printable ascii. The code is a secret the recipient // compares against the one in the message body, so it is rejected rather than cleaned up if (typeof otp.code !== 'string' || !/^[\x20-\x7e]+$/.test(otp.code)) { throw otpError('The otp code must be a non-empty string of printable ASCII characters'); } // a structured field String escapes the same two characters as a quoted-string. setHeader // replaces any OTP-Token from the custom headers, the field may appear only once this.message.setHeader('OTP-Token', mimeFuncs.quoteString(otp.code) + '; origin=' + mimeFuncs.quoteString(getOtpOrigin(otp.origin))); } /** @internal */ _getListHeaders(listData) { // make sure an url looks like return Object.keys(listData).map(key => ({ key: 'list-' + key.toLowerCase().trim(), value: [].concat(listData[key] || []).map(value => ({ prepared: true, foldLines: true, value: [] .concat(value || []) .map(value => { if (typeof value === 'string') { value = { url: value }; } if (value && value.url) { // strip CR/LF so a comment can't inject extra header lines. DEL is neither // qtext nor ctext, so it can not be carried literally by either construct // and has to become an encoded word like any other non-plaintext value let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' '); const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment); if (key.toLowerCase().trim() === 'id') { // List-ID: "comment" , where an unescaped quote or a trailing // backslash in the comment would swallow the behind it comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment); // List-ID expects a bare domain-like identifier, so strip the // scheme prefix that _formatListUrl adds or passes through return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<'); } // List-*: (comment) // the ctext specials go out as quoted-pairs, otherwise a ")" closes the // comment early and leaves the rest as junk, an unpaired "(" opens a // nested comment that never closes, and a trailing backslash escapes // the closing ")" so the comment swallows whatever follows it comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&'); return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : ''); } return ''; }) .filter(value => value) .join(', ') })) })); } /** @internal */ _formatListUrl(url) { // a url has no way to carry a control char or DEL, and the angle brackets around it // are not a quoting construct, so anything left here lands in the header raw url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, ''); if (/^(https?|mailto|ftp):/.test(url)) { return '<' + url + '>'; } if (/^[^@]+@[^@]+$/.test(url)) { return ''; } return ''; } } /** * Returns the canonical ascii serialization of the origin the code is bound to. The draft * requires a potentially trustworthy origin, so only https is accepted, plus http on a * loopback host for local development */ function getOtpOrigin(origin) { let url; try { url = new URL(String(origin)); } catch { // handled below } // the WHATWG parser lets characters such as a quote or a brace into a host, which the // origin grammar the draft requires (RFC 6454) does not allow, so a receiver would discard // the header. Only a DNS name, an IPv4 address or a bracketed IPv6 address is accepted. // Userinfo is refused as well, an origin never has it and it turns a value built as // 'https://example.com' + input into the origin of whatever host follows an "@" if (!url || url.username || url.password || !/^([a-z0-9_-]+\.)*[a-z0-9_-]+$|^\[[0-9a-f:.]+\]$/.test(url.hostname)) { throw otpError('The otp origin must be an absolute https URL'); } const isLoopback = /^(localhost|.+\.localhost|127(\.\d+){3}|\[::1\])$/.test(url.hostname); if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) { throw otpError('The otp origin must be an https URL, or an http URL on a loopback host'); } // the URL parser already lowercases the host, converts it to punycode and drops the default port return url.origin; } function otpError(message) { const err = new Error(message); err.code = errors.EOTP; return err; }