UNPKG

mailjs

Version:

JavaScript HTML and text email renderer/formatter.

757 lines (607 loc) 23.2 kB
var _ = require('lodash'), juice = require('juice'); /** * @license * mail.js * Copyright 2014 Mark Bradley, Ted Halmrast * Available under MIT license <http://mailjs.org/license> */ ;(function( window ) { /* * Start of Builtin Bindings and Templates * ======================================= */ /* * Many of these builtin templates are borrowed from various email boilerplate projects on the web ... * * heavily from: http://htmlemailboilerplate.com/ * also from: http://www.emailology.org/#1 */ /* * Filters are not needed afaik ... !{ height | pixels } can be done as [pixels=!height] var builtinFilters = { pixels: function( pixels ) { return parseInt( pixels, 10 ); } }; */ var builtinBinds = { title: '', headStyle: '' }; var builtinTemplates = { /* * Utility Templates * ================= */ /* * [pixels=300px] yields "300" */ pixels: { binds: { pixels: '' }, src: function( scope ) { return '' + scope.pixels( scope.binding( 'pixels' ) ); } }, borderRadius: { html: '-moz-border-radius:!borderRadius;-webkit-border-radius:!borderRadius;!:borderRadius;' }, /* * Component Templates * =================== */ /* * Borders, cellspacing, and cellpadding should be cleared out since outlook doesn't handle tables properly. * http://www.emailonacid.com/blog/details/C13/removing_unwanted_spacing_or_gaps_between_tables_in_outlook_2007_2010 */ table: { html: '<table border="0" cellpadding="0" cellspacing="0" !=align !=class !=style>', htmlClose: '</table>', binds: { align: '', class: '', style: '' } }, /* * Portable button that deals with padding correctly. Also supports optional corner radius in Outlook. * * inspired by http://www.industrydive.com/blog/how-to-make-html-email-buttons-that-rock/#outlook */ button: { binds: { href: 'http://example.com', label: 'Example', color: '#ffffff', backgroundColor: '#d62828', fontSize: '16px', fontFamily: 'sans-serif', height: '40px', width: '300px', borderRadius: '', // set to '6px' for example margin: '' }, html: function( scope ) { var borderRadius = scope.binding( 'borderRadius' ); if ( borderRadius ) { return ( '<div>' + '<!--\\[if mso\\]>' + '<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" !=href style="!:height;v-text-anchor:middle;!:width;" arcsize="10%" stroke="f" fillcolor="!backgroundColor">' + '<w:anchorlock/>' + '<center style="!:color;!:fontFamily;!:fontSize;font-weight:bold;">!label</center>' + '</v:roundrect>' + '<!\\[endif\\]-->' + '<!\\[if \\!mso\\]>' + '[table style="!:margin"]' + '<tr>' + '<td align="center" width="[pixels=!width]" height="[pixels=!height]" bgcolor="!backgroundColor" style="[borderRadius=!borderRadius];!:color;display:block;">' + '<a href="!href" style="color:!color;font-size:!fontSize;font-weight:bold;!:fontFamily;text-decoration:none;line-height:!height;width:100%;display:inline-block">!label</a>' + '</td>' + '</tr>' + '[/table]' + '<!\\[endif\\]>' + '</div>' ); } else { return ( '[table style="!:margin"]' + '<tr>' + '<td align="center" width="[pixels=!width]" height="[pixels=!height]" bgcolor="!backgroundColor" style="!:color;display:block;">' + '<a !=href style="!:color;!:fontSize;font-weight:bold;!:fontFamily;text-decoration:none;line-height:!height;width:100%;display:inline-block">!label</a>' + '</td>' + '</tr>' + '[/table]' ); } }, text: '!label: !href' }, /* * Outlook doesn't support margins or padding on block-level elements, so it is often better to use a [tdiv] instead of a <div>. */ tdiv: { binds: { align: '', class: '', style: '' }, html: '[table align="!align" class="!class" style="width:100%;"]' + '<tr>' + '<td !=style>', htmlClose: '</td>' + '</tr>' + '[/table]' }, p: { binds: { style: '' }, htmlOpen: '<br><br><p style="margin:0;!style">', htmlClose: '</p>', textOpen: '\n', textClose: '' }, imglink: { binds: { }, html: '<a !=href style="border:none;"><img style="!:height;" !=src></a>', text: '!href' }, /* * Boilerplate Templates * ===================== */ doctype: { src: '<\\!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">' }, wrapper: { html: '[html]' + '[head/]' + '<body>', htmlClose: '</body>' + '</html>' }, html: { html: '<html xmlns="http://www.w3.org/1999/xhtml">', htmlClose: '</html>' }, head: { html: '<head>' + '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />' + '<meta name="viewport" content="width=device-width, initial-scale=1.0"/>' + '<title>!title</title>' + '<style>!headStyle</style>', htmlClose: '</head>' }, headStyles: { html: '#outlook a {padding:0;}\n' + // Force Outlook to provide a "view in browser" menu link. // Prevent Webkit and Windows Mobile platforms from changing default font sizes, while not breaking desktop design. 'body{width:100% \\!important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;}\n' + // Force Hotmail to display emails at full width '.ExternalClass {width:100%;}\n' + // Force Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */ '.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}\n' + '#backgroundTable {margin:0; padding:0; width:100% !important; line-height: 100% !important;}\n' + /* Some sensible defaults for images * 1. "-ms-interpolation-mode: bicubic" works to help ie properly resize images in IE. (if you are resizing them using the width and height attributes) * 2. "border:none" removes border when linking images. * 3. Updated the common Gmail/Hotmail image display fix: Gmail and Hotmail unwantedly adds in an extra space below images when using non IE browsers. * You may not always want all of your images to be block elements. Apply the "image_fix" class to any image you need to fix. */ 'img {outline:none; text-decoration:none; -ms-interpolation-mode: bicubic;}\n' + 'a img {border:none;}\n' + '.image_fix {display:block;}\n' + /* Yahoo paragraph fix: removes the proper spacing or the paragraph (p) tag. To correct we set the top/bottom margin to 1em in the head of the document. * Simple fix with little effect on other styling. NOTE: It is also common to use two breaks instead of the paragraph tag but I think this way is cleaner * and more semantic. NOTE: This example recommends 1em. More info on setting web defaults: http://www.w3.org/TR/CSS21/sample.html or * http://meiert.com/en/blog/20070922/user-agent-style-sheets/ * ... but the above doesn't deal with * http://www.emailonacid.com/blog/details/C13/7_tips_and_tricks_regarding_margins_and_padding_in_html_emails * use [p] element instead which does the double <br> */ 'p {margin:0;margin-bottom:0;}\n' + /* Hotmail header color reset: Hotmail replaces your header color styles with a green color on H2, H3, H4, H5, and H6 tags. In this example, the color is * reset to black for a non-linked header, blue for a linked header, red for an active header (limited support), and purple for a visited header (limited * support). Replace with your choice of color. The !important is really what is overriding Hotmail's styling. Hotmail also sets the H1 and H2 tags to * the same size. */ 'h1, h2, h3, h4, h5, h6 {color: black !important;}\n' + 'h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {color: blue !important;}\n' + 'h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {\n' + 'color: red !important;\n' + // Preferably not the same color as the normal header link color. There is limited support for psuedo classes in email clients, this was added just for good measure. '}\n' + 'h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {\n' + 'color: purple !important;\n' + // Preferably not the same color as the normal header link color. There is limited support for psuedo classes in email clients, this was added just for good measure. '}\n' + /* Outlook 07, 10 Padding issue: These "newer" versions of Outlook add some padding around table cells potentially throwing off your perfectly pixeled * table. The issue can cause added space and also throw off borders completely. Use this fix in your header or inline to safely fix your table woes. * * More info: http://www.ianhoar.com/2008/04/29/outlook-2007-borders-and-1px-padding-on-table-cells/ * http://www.campaignmonitor.com/blog/post/3392/1px-borders-padding-on-table-cells-in-outlook-07/ * * H/T @edmelly */ 'table td {border-collapse: collapse;}\n' + /* Styling your links has become much simpler with the new Yahoo. In fact, it falls in line with the main credo of styling in email, bring your * styles inline. Your link colors will be uniform across clients when brought inline. */ 'a {color: orange;}\n' + /* Or to go the gold star route... a:link { color: orange; } a:visited { color: blue; } a:hover { color: green; } */ /*** MOBILE TARGETING * * Use @media queries with care. You should not bring these styles inline -- so it's recommended to apply them AFTER you bring the other styling * inline. * * Note: test carefully with Yahoo. * Note 2: Don't bring anything below this line inline. * * NOTE: To properly use @media queries and play nice with yahoo mail, use attribute selectors in place of class, id declarations. * table[class=classname] * Read more: http://www.campaignmonitor.com/blog/post/3457/media-query-issues-in-yahoo-mail-mobile-email/ */ '@media only screen and (max-device-width: 480px) {\n' + /* A nice and clean way to target phone numbers you want clickable and avoid a mobile phone from linking other numbers that look like, but are * not phone numbers. Use these two blocks of code to "unstyle" any numbers that may be linked. The second block gives you a class to apply * with a span tag to the numbers you would like linked and styled. * * Inspired by Campaign Monitor's article on using phone numbers in email: http://www.campaignmonitor.com/blog/post/3571/using-phone-numbers-in-html-email/. * * Step 1 (Step 2: line 224) */ 'a[href^="tel"], a[href^="sms"] {\n' + 'text-decoration: none;\n' + 'color: black;\n' + // or whatever your want 'pointer-events: none;\n' + 'cursor: default;\n' + '}\n' + '.mobile_link a[href^="tel"], .mobile_link a[href^="sms"] {\n' + 'text-decoration: default;\n' + 'color: orange !important;\n' + // or whatever your want 'pointer-events: auto;\n' + 'cursor: default;\n' + '}\n' + '}\n' + /* More Specific Targeting */ '@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) {\n' + // ipad (tablets, smaller screens, etc) /* Step 1a: Repeating for the iPad */ 'a[href^="tel"], a[href^="sms"] {\n' + 'text-decoration: none;\n' + 'color: blue;\n' + // or whatever your want 'pointer-events: none;\n' + 'cursor: default;\n' + '}\n' + '.mobile_link a[href^="tel"], .mobile_link a[href^="sms"] {\n' + 'text-decoration: default;\n' + 'color: orange !important;\n' + 'pointer-events: auto;\n' + 'cursor: default;\n' + '}\n' + '}\n' + //@media only screen and (-webkit-min-device-pixel-ratio: 2) { // Put your iPhone 4g styles in here //} /* Following Android targeting from: * http://developer.android.com/guide/webapps/targeting.html * http://pugetworks.com/2011/04/css-media-queries-for-targeting-different-mobile-devices/ ; */ //@media only screen and (-webkit-device-pixel-ratio:.75){ // Put CSS for low density (ldpi) Android layouts in here //} //@media only screen and (-webkit-device-pixel-ratio:1){ // Put CSS for medium density (mdpi) Android layouts in here //} //@media only screen and (-webkit-device-pixel-ratio:1.5){ // Put CSS for high density (hdpi) Android layouts in here //} '!styleBlock' + '</style>\n' }, p: { } }; /* * End of Builtin Bindings and Templates * ===================================== */ /* * mail.js * ======= */ var whitespaceChar = /\s/, attrNameChar = /[^\t\n\f \/>"'=]/; /* * Scope * ----- */ function Scope( el, binds, template ) { this.el = el; this.binds = binds; this.template = template; } Scope.prototype.binding = function( name ) { return mailjs.resolveBind( name, this.el, this.binds, this.template ); }; Scope.prototype.if = function( name, template ) { var v = mailjs.resolveBind( name, this.el, this.binds, this.template ); return v != null ? template : ''; }; // undocumented, not sure if this is a good idea Scope.prototype.pixels = function( pixels ) { return parseInt( pixels, 10 ); }; var mailjs = { opts: {}, config: function( opts ) { mailjs.opts = opts; }, resolveTemplate: function( name, templates ) { return ( ( templates && templates[ name ] ) || ( this.opts.templates && this.opts.templates[ name ] ) || builtinTemplates[ name ] ); }, // returns a bind ... null means no bind, '' means a bind was found and it was intentionally blank resolveBind: function( name, el, binds, template ) { var rslt = ( el && el.attrs[ name ] ) || binds[ name ] || ( template && template.binds && template.binds[ name ] ) || ( this.opts.binds && this.opts.binds[ name ] ) || builtinBinds[ name ]; if ( rslt ) return rslt; else return ( // "string != null" means "string !== null && string !== undefined", intentional ( el && el.attrs[ name ] != null ) || binds[ name ] != null || ( template && template.binds && template.binds[ name ] != null ) || ( this.opts.binds && this.opts.binds[ name ] != null ) || builtinBinds[ name ] != null ) ? '' : null; }, /* resolveFilter: function( name, filters, template ) { var rslt = filters[ name ] || ( template && template.filters && template.filters[ name ] ) || ( this.opts.filters && this.opts.filters[ name ] ) || builtinFilters[ name ]; return rslt; }, */ parseAttrs: function( s ) { var attrs = {}; s.replace( /([^\s=,]+)\s*=\s*(?:'([^']*)'|"([^"]*)"|(\S+))/g, function( m, m1, m2, m3, m4 /*, offset, str */ ) { var v = m2 || m3 || m4; // three level priority ... !!v > '' > undefined attrs[ m1 ] = !v && ( m2 != null || m3 != null || m4 != null ) ? '' : v; } ); return attrs; }, parseElement: function( s ) { var matches = s.match( // note: this not only grabs the tag but also verifies that the entire element can be parsed, including the attributes /^\[(\/?)([^\s\]/]+)+(?:\s*(?:[^\s\]=,]+)\s*=\s*(?:'(?:[^']*)'|"(?:[^"]*)"|(?:[^'"\[\]\s]+)))*(\/?)\]/ ); if ( !matches ) throw new Error( 'Unable to parse element at: ' + s ); var el = matches[ 0 ], closed = matches[ 1 ], tagDef = matches[ 2 ], selfClosed = matches[ 3 ]; if ( closed && selfClosed ) throw new Error( 'An element cannot be both closed and self-closed.' ); var tag, tagVal; if ( tagDef.indexOf( '=' ) != -1 ) { var tmatches = tagDef.match( /([^\s=,]+)\s*=\s*(?:'([^']+)'|"([^"]+)"|(\S+))/ ); if ( !tmatches ) throw new Error( 'Unable to parse tag-value at: ' + tagDef ); tag = tmatches[ 1 ]; tagVal = tmatches[ 2 ] || tmatches[ 3 ] || tmatches[ 4 ]; } else { tag = tagDef; } var obj = { el: el, tag: tag, attrs: mailjs.parseAttrs( el.substring( tagDef.length, el.length - 1 ) ) }; if ( tagVal != null ) obj.attrs[ tag ] = tagVal; if ( closed ) { obj.close = true; if ( Object.keys( obj.attrs ).length ) throw new Error( 'Closed elements should not contain attributes.' ); } else if ( selfClosed ) { obj.selfClose = true; } return obj; }, render: function( opts ) { var binds = opts.binds || {}, templates = opts.templates || {}, html = opts.html, el = opts.el, template = opts.template, src = opts.src, dest = '', close = opts.close || ( el && el.close ); if ( src == null && template ) { if ( html ) src = close ? template.htmlClose : template.html; else src = close ? template.textClose : template.text; if ( src == null ) src = close ? template.srcClose : template.src; if ( close && src == null && !template.htmlClose && !template.textClose ) throw new Error( 'Closing elements not supported for template: ' + ( el ? el.el : '' ) ); } src = src || ''; if ( typeof src === 'function' ) src = src( new Scope( el, binds, template ) ); if ( !opts.inside ) src = '[wrapper]' + src + '[/wrapper]'; for ( var si=0, slen=src.length; si < slen; ) { ch = src[ si ]; switch ( ch ) { case '\\': si++; if ( si<slen ) dest += src[ si++ ]; break; case '[': var cEl = mailjs.parseElement( src.substring( si ) ); var cTemplate = mailjs.resolveTemplate( cEl.tag, templates ); if ( cTemplate ) { // evaluate/render attributes before invoking the template var attrs = cEl.attrs; for ( var key in attrs ) { if ( attrs.hasOwnProperty( key ) ) { attrs[ key ] = mailjs.render({ el: el, template: template, src: attrs[ key ], binds: binds, html: html, templates: templates, inside: true }); } } dest += mailjs.render({ template: cTemplate, el: cEl, binds: binds, html: html, templates: templates, inside: true }); si += cEl.el.length; } else { dest += '['; si++; } break; case '!': si++; var assignmentBind = undefined, delim = src.substring( si, si + 1 ); if ( delim === '=' || delim === ':' ) { assignmentBind = delim; si++; } //var matches = src.substring( si ).match( /^([a-zA-Z0-9_]+)|^\{\s*([a-zA-Z0-9_]+)\s*(\|\s*[a-zA-Z0-9_|\s]+)?\}/ ); var matches = src.substring( si ).match( /^([a-zA-Z0-9_]+)|^\{\s*([a-zA-Z0-9_]+)\s*\}/ ); if ( !matches || matches.length < 3 ) { // not a binding, skip past it ... i.e. might be something like <!-- ... dest += '!'; break; } var name = matches[ 1 ] || matches[ 2 ]; si += matches[ 0 ].length; // remove the trailing semicolon so that we don't output it if the binding is blank if ( assignmentBind === ':' && src.substring( si, si + 1 ) === ';' ) { si++; } if ( name.match( /^(important|doctype)$/i ) ) { dest += '!' + name; break; } var bind = this.resolveBind( name, el, binds, template ); if ( bind == null ) throw new Error( 'No bind definition for !' + name + ' when processing' + ( el ? ' template ' + el.tag : '' ) + ' (mode=' + ( html ? 'HTML' : 'text' ) + '):\n\n' + src ); var nestedSrc; if ( assignmentBind ) { if ( !bind ) { var dlast = dest.length - 1; if ( dlast >= 0 && dest.substring( dlast ) === ' ' ) { dest = dest.substring( 0, dlast ); dlast--; } break; } switch ( assignmentBind ) { case '=': nestedSrc = name + '="' + bind + '"'; break; case ':': nestedSrc = _.kebabCase( name ) + ':' + bind + ';'; break; } } else { nestedSrc = bind; } bind = mailjs.render({ src: nestedSrc, binds: binds, html: html, templates: templates, inside: true }); dest += bind; break; default: dest += src[ si++ ]; } } if ( el && !opts.close && el.selfClose ) { var savedInside = opts.inside; opts.close = true; opts.inside = true; dest += mailjs.render( opts ); delete opts.close; opts.inside = savedInside; } if ( !opts.inside && opts.html ) { var css = (mailjs.opts.css || '') + (opts.css || ''); if (css) { dest = juice( dest, { extraCss: css } ); } } return dest; }, send: function( opts ) { if ( !this.opts.transport ) throw new Error( "Nodemailer transport not configured." ); var body = opts.body; body.html = true; opts.html = mailjs.render( body ); delete body.html; opts.text = mailjs.render( body ); this.opts.transport.sendMail( opts ); } }; if ( typeof define == 'function' && typeof define.amd == 'object' && define.amd ) { define(function() { return mailjs; }); } else if ( typeof exports === 'object' ) { module.exports = mailjs; } else { window.mailjs = mailjs; } }(this));