mailbuild
Version:
mailbuild is a low level rfc2822 message composer. Define your own mime tree, no magic included.
730 lines (641 loc) • 26.3 kB
JavaScript
// Copyright (c) 2013 Andris Reinman
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
(function(root, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
define(['mimefuncs', 'mimetypes', 'punycode', 'addressparser'], factory);
} else if (typeof exports === 'object') {
module.exports = factory(require('mimefuncs'), require('mimetypes'), require('punycode'), require('wo-addressparser'));
} else {
root.mailbuild = factory(mimefuncs, mimetypes, punycode, addressparser);
}
}(this, function(mimefuncs, mimetypes, punycode, addressparser) {
'use strict';
/**
* 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
*/
function MimeNode(contentType, options) {
this.nodeCounter = 0;
options = options || {};
/**
* shared part of the unique multipart boundary
*/
this.baseBoundary = options.baseBoundary || Date.now().toString() + Math.random();
/**
* 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 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 = mimetypes.detectMimeType(this.filename.split('.').pop());
}
}
/**
* Immediate parent for this node (or undefined if not set)
*/
this.parentNode = options.parentNode;
/**
* Used for generating unique boundaries (prepended to the shared base)
*/
this._nodeId = ++this.rootNode.nodeCounter;
/**
* An array for possible child nodes
*/
this._childNodes = [];
/**
* A list of header values for this node in the form of [{key:'', value:''}]
*/
this._headers = [];
/**
* 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
*/
MimeNode.prototype.createChild = function(contentType, options) {
if (!options && typeof contentType === 'object') {
options = contentType;
contentType = undefined;
}
var 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
*/
MimeNode.prototype.appendChild = function(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
*/
MimeNode.prototype.replace = function(node) {
if (node === this) {
return this;
}
this.parentNode._childNodes.forEach(function(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;
}
}.bind(this));
return node;
};
/**
* Removes current node from the mime tree
*
* @return {Object} removed node
*/
MimeNode.prototype.remove = function() {
if (!this.parentNode) {
return this;
}
for (var 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
*/
MimeNode.prototype.setHeader = function(key, value) {
var 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 && key.value) {
this.setHeader(key.key, key.value);
}
// allow [{key:'content-type', value: 'text/plain'}]
else if (Array.isArray(key)) {
key.forEach(function(i) {
this.setHeader(i.key, i.value);
}.bind(this));
}
// allow {'content-type': 'text/plain'}
else {
Object.keys(key).forEach(function(i) {
this.setHeader(i, key[i]);
}.bind(this));
}
return this;
}
key = this._normalizeHeaderKey(key);
headerValue = {
key: key,
value: value
};
// Check if the value exists and overwrite
for (var 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
*/
MimeNode.prototype.addHeader = function(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);
}
// allow [{key:'content-type', value: 'text/plain'}]
else if (Array.isArray(key)) {
key.forEach(function(i) {
this.addHeader(i.key, i.value);
}.bind(this));
}
// allow {'content-type': 'text/plain'}
else {
Object.keys(key).forEach(function(i) {
this.addHeader(i, key[i]);
}.bind(this));
}
return this;
}
this._headers.push({
key: this._normalizeHeaderKey(key),
value: 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
*/
MimeNode.prototype.getHeader = function(key) {
key = this._normalizeHeaderKey(key);
for (var 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 Typed Array, you need to specify
* the charset yourself
*
* @param (String|Uint8Array) content Body content
* @return {Object} current node
*/
MimeNode.prototype.setContent = function(content) {
this.content = content;
return this;
};
/**
* Builds 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
*/
MimeNode.prototype.build = function() {
var lines = [],
contentType = (this.getHeader('Content-Type') || '').toString().toLowerCase().trim(),
transferEncoding, flowed;
if (this.content) {
transferEncoding = (this.getHeader('Content-Transfer-Encoding') || '').toString().toLowerCase().trim();
if (!transferEncoding || ['base64', 'quoted-printable'].indexOf(transferEncoding) < 0) {
if (/^text\//i.test(contentType)) {
// If there are no special symbols, no need to modify the text
if (this._isPlainText(this.content)) {
// If there are lines longer than 76 symbols/bytes, make the text 'flowed'
if (/^.{77,}/m.test(this.content)) {
flowed = true;
}
transferEncoding = '7bit';
} else {
transferEncoding = 'quoted-printable';
}
} else if (!/^multipart\//i.test(contentType)) {
transferEncoding = transferEncoding || 'base64';
}
}
if (transferEncoding) {
this.setHeader('Content-Transfer-Encoding', transferEncoding);
}
}
if (this.filename && !this.getHeader('Content-Disposition')) {
this.setHeader('Content-Disposition', 'attachment');
}
this._headers.forEach(function(header) {
var key = header.key,
value = header.value,
structured;
switch (header.key) {
case 'Content-Disposition':
structured = mimefuncs.parseHeaderValue(value);
if (this.filename) {
structured.params.filename = this.filename;
}
value = this._buildHeaderValue(structured);
break;
case 'Content-Type':
structured = mimefuncs.parseHeaderValue(value);
this._handleContentType(structured);
if (flowed) {
structured.params.format = 'flowed';
}
if (String(structured.params.format).toLowerCase().trim() === 'flowed') {
flowed = true;
}
if (structured.value.match(/^text\//) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
structured.params.charset = 'utf-8';
}
value = this._buildHeaderValue(structured);
break;
case 'Bcc':
// skip BCC values
return;
}
// skip empty lines
value = this._encodeHeaderValue(key, value);
if (!(value || '').toString().trim()) {
return;
}
lines.push(mimefuncs.foldLines(key + ': ' + value, 76));
}.bind(this));
// Ensure mandatory header fields
if (this.rootNode === this) {
if (!this.getHeader('Date')) {
lines.push('Date: ' + this.date.toUTCString().replace(/GMT/, '+0000'));
}
// You really should define your own Message-Id field
if (!this.getHeader('Message-Id')) {
lines.push('Message-Id: <' +
// crux to generate random strings like this:
// "1401391905590-58aa8c32-d32a065c-c1a2aad2"
[0, 0, 0].reduce(function(prev) {
return prev + '-' + Math.floor((1 + Math.random()) * 0x100000000).
toString(16).
substring(1);
}, Date.now()) +
'@' +
// try to use the domain of the FROM address or fallback localhost
(this.getEnvelope().from || 'localhost').split('@').pop() +
'>');
}
if (!this.getHeader('MIME-Version')) {
lines.push('MIME-Version: 1.0');
}
}
lines.push('');
if (this.content) {
switch (transferEncoding) {
case 'quoted-printable':
lines.push(mimefuncs.quotedPrintableEncode(this.content));
break;
case 'base64':
lines.push(mimefuncs.base64Encode(this.content, typeof this.content === 'object' && 'binary' || false));
break;
default:
if (flowed) {
lines.push(mimefuncs.foldLines(this.content.replace(/\r?\n/g, '\r\n').
// space stuffing http://tools.ietf.org/html/rfc3676#section-4.2
replace(/^( |From|>)/igm, ' $1'),
76, true));
} else {
lines.push(this.content.replace(/\r?\n/g, '\r\n'));
}
}
if (this.multipart) {
lines.push('');
}
}
if (this.multipart) {
this._childNodes.forEach(function(node) {
lines.push('--' + this.boundary);
lines.push(node.build());
}.bind(this));
lines.push('--' + this.boundary + '--');
lines.push('');
}
return lines.join('\r\n');
};
/**
* 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']}
*/
MimeNode.prototype.getEnvelope = function() {
var envelope = {
from: false,
to: []
};
this._headers.forEach(function(header) {
var list = [];
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].indexOf(header.key) >= 0)) {
this._convertAddresses(this._parseAddresses(header.value), list);
if (list.length && list[0]) {
envelope.from = list[0];
}
} else if (['To', 'Cc', 'Bcc'].indexOf(header.key) >= 0) {
this._convertAddresses(this._parseAddresses(header.value), envelope.to);
}
}.bind(this));
return envelope;
};
/////// PRIVATE METHODS
/**
* 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
*/
MimeNode.prototype._parseAddresses = function(addresses) {
return [].concat.apply([], [].concat(addresses).map(function(address) {
if (address && address.address) {
address = this._convertAddresses(address);
}
return addressparser.parse(address);
}.bind(this)));
};
/**
* 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
*/
MimeNode.prototype._normalizeHeaderKey = function(key) {
return (key || '').toString().
// no newlines in keys
replace(/\r?\n|\r/g, ' ').
trim().toLowerCase().
// use uppercase words, except MIME
replace(/^MIME\b|^[a-z]|\-[a-z]/ig, function(c) {
return c.toUpperCase();
});
};
/**
* Joins parsed header value together as 'value; param1=value1; param2=value2'
*
* @param {Object} structured Parsed header value
* @return {String} joined header value
*/
MimeNode.prototype._buildHeaderValue = function(structured) {
var paramsArray = [];
Object.keys(structured.params || {}).forEach(function(param) {
// filename might include unicode characters so it is a special case
if (param === 'filename') {
mimefuncs.continuationEncode(param, structured.params[param], 50).forEach(function(encodedParam) {
// continuation encoded strings are always escaped, so no need to use enclosing quotes
// in fact using quotes might end up with invalid filenames in some clients
paramsArray.push(encodedParam.key + '=' + encodedParam.value);
});
} else {
paramsArray.push(param + '=' + this._escapeHeaderArgument(structured.params[param]));
}
}.bind(this));
return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
};
/**
* Escapes a header argument value (eg. boundary value for content type),
* adds surrounding quotes if needed
*
* @param {String} value Header argument value
* @return {String} escaped and quoted (if needed) argument value
*/
MimeNode.prototype._escapeHeaderArgument = function(value) {
if (value.match(/[\s'"\\;\/=]|^\-/g)) {
return '"' + value.replace(/(["\\])/g, "\\$1") + '"';
} else {
return value;
}
};
/**
* 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
*/
MimeNode.prototype._handleContentType = function(structured) {
this.contentType = structured.value.trim().toLowerCase();
this.multipart = this.contentType.split('/').reduce(function(prev, value) {
return 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
*/
MimeNode.prototype._generateBoundary = function() {
return '----sinikael-?=_' + this._nodeId + '-' + this.rootNode.baseBoundary;
};
/**
* Encodes a header value for use in the generated rfc2822 email.
*
* @param {String} key Header key
* @param {String} value Header value
*/
MimeNode.prototype._encodeHeaderValue = function(key, value) {
key = this._normalizeHeaderKey(key);
switch (key) {
case 'From':
case 'Sender':
case 'To':
case 'Cc':
case 'Bcc':
case 'Reply-To':
return this._convertAddresses(this._parseAddresses(value));
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;
case 'References':
value = [].concat.apply([], [].concat(value || '').map(function(elm) {
elm = (elm || '').toString().replace(/\r?\n|\r/g, ' ').trim();
return elm.replace(/<[^>]*>/g, function(str) {
return str.replace(/\s/g, '');
}).split(/\s+/);
})).map(function(elm) {
if (elm.charAt(0) !== '<') {
elm = '<' + elm;
}
if (elm.charAt(elm.length - 1) !== '>') {
elm = elm + '>';
}
return elm;
});
return value.join(' ').trim();
default:
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
// mimeWordsEncode only encodes if needed, otherwise the original string is returned
return mimefuncs.mimeWordsEncode(value, 'Q', 52);
}
};
/**
* 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
*/
MimeNode.prototype._convertAddresses = function(addresses, uniqueList) {
var values = [];
uniqueList = uniqueList || [];
[].concat(addresses || []).forEach(function(address) {
if (address.address) {
address.address = address.address.replace(/^.*?(?=\@)/, function(user) {
return mimefuncs.mimeWordsEncode(user, 'Q', 52);
}).replace(/@.+$/, function(domain) {
return '@' + punycode.toASCII(domain.substr(1));
});
if (!address.name) {
values.push(address.address);
} else if (address.name) {
values.push(this._encodeAddressName(address.name) + ' <' + address.address + '>');
}
if (uniqueList.indexOf(address.address) < 0) {
uniqueList.push(address.address);
}
} else if (address.group) {
values.push(this._encodeAddressName(address.name) + ':' + (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim() + ';');
}
}.bind(this));
return values.join(', ');
};
/**
* If needed, mime encodes the name part
*
* @param {String} name Name part of an address
* @returns {String} Mime word encoded string if needed
*/
MimeNode.prototype._encodeAddressName = function(name) {
if (!/^[\w ']*$/.test(name)) {
if (/^[\x20-\x7e]*$/.test(name)) {
return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
} else {
return mimefuncs.mimeWordEncode(name, 'Q', 52);
}
}
return name;
};
/**
* Checks if a value is plaintext string (uses only printable 7bit chars)
*
* @param {String} value String to be tested
* @returns {Boolean} true if it is a plaintext string
*/
MimeNode.prototype._isPlainText = function(value) {
if (typeof value !== 'string' || /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/.test(value)) {
return false;
} else {
return true;
}
};
return MimeNode;
}));