├── .gitignore ├── .jscsrc ├── .jshintrc ├── .travis.yml ├── LICENSE ├── README.md ├── gulpfile.js ├── index.js ├── lib ├── emailBuilder.js ├── litmus.js ├── logger.js └── utils.js ├── package.json └── test ├── fixtures ├── input │ ├── conditional_styles.html │ ├── css │ │ ├── embed.css │ │ ├── external.css │ │ └── inline.css │ ├── embedded_styles_ignored.html │ ├── embedded_styles_inlined.html │ ├── encoded_special_characters.html │ ├── external_styles_embedded.html │ ├── external_styles_ignored.html │ ├── external_styles_inlined.html │ ├── remote_url_embedded.html │ └── remote_url_inlined.html └── output │ ├── conditional_styles.html │ ├── embedded_styles_ignored.html │ ├── embedded_styles_inlined.html │ ├── encoded_special_characters.html │ ├── external_styles_embedded.html │ ├── external_styles_ignored.html │ ├── external_styles_inlined.html │ ├── remote_url_embedded.html │ └── remote_url_inlined.html └── specs ├── EmailBuilderSpec.js └── UtilsSpec.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _SpecRunner.html 3 | .grunt -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "grunt" 3 | } 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6.0' 4 | - '4.0' 5 | - '0.12' 6 | - '0.10' 7 | before_install: 8 | - npm install -g gulp-cli 9 | deploy: 10 | provider: npm 11 | email: jeremywpeter@gmail.com 12 | api_key: 13 | secure: COgPwhgviNssx13e2tDxZbacKEz4JNBjzcfh8QEdpcpMJbiZncKe9ssRPMfIrKluqbIB1gmQ4Efus2HYWlc0fH6jDfsN3BWZ2QD/orqIPqolXSdGXyT6ABb/R3Kebdjvjnt/9dCwyN5EYG9Z5EifHQaL4hf0Ku52FX4FGxtw2z4= 14 | on: 15 | tags: true 16 | repo: Email-builder/email-builder-core 17 | branch: master 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jeremy Peter 4 | Copyright (c) 2015 Steve Miller 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | 24 | Source: http://opensource.org/licenses/MIT -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | email-builder-core 2 | ================== 3 | 4 | [![Build Status](https://travis-ci.org/Email-builder/email-builder-core.svg)](https://travis-ci.org/Email-builder/email-builder-core) [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/Email-builder/email-builder-core?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 5 | 6 | Email builder core for export into other projects 7 | 8 | # Constructor 9 | ### `new EmailBuilder(options)` 10 | 11 | Example: 12 | ```javascript 13 | var EmailBuilder = require('email-builder-core'); 14 | var emailBuilder = new EmailBuilder({ encodeSpecialChars: true }); 15 | ``` 16 | 17 | # Options 18 | 19 | The following options may support all available methods of EmailBuilder. However there some that are only needed for a particular method. 20 | 21 | **options.encodeSpecialChars** 22 | Type: `Boolean` 23 | Default: `false` 24 | Supported Method(s): `All` 25 | 26 | Encodes special characters to their HTML numerical form e.g. © --> &#169; 27 | 28 | **options.relativePath** 29 | Type: `String` 30 | Default: `''` 31 | Supported Method(s): `emailBuilder.inlineCss` 32 | 33 | This option must be set when passing a Buffer or a String to the `inlineCss` method. That way it has a relative path to any css files. The path should be whatever directory your src file is in. 34 | 35 | **options.litmus** 36 | Type: `Object` 37 | Default: `{}` 38 | Properties: `username`, `password`, `url`, `applications` 39 | Supported Method(s): `emailBuilder.sendLitmusTest` 40 | 41 | Example: 42 | ```javascript 43 | litmus : { 44 | 45 | // Optional, defaults to title of email or yyyy-mm-dd if and options.subject not set 46 | subject : 'Custom subject line', 47 | 48 | // Litmus username 49 | username : 'username', 50 | 51 | // Litmus password 52 | password : 'password', 53 | 54 | // Url to your Litmus account 55 | url : 'https://yoursite.litmus.com', 56 | 57 | // Email clients to test for. Find them at http://yoursite.litmus.com/emails/clients.xml 58 | // The <application_code> tags contain the name e.g. Gmail Chrome: <application_code> chromegmailnew </application_code> 59 | applications : ['gmailnew', 'hotmail', 'outlookcom', 'ol2000', 'ol2002', 'ol2003', 'ol2007', 'ol2010','ol2011', 'ol2013', 'appmail8', 'iphone5', 'ipad3'] 60 | } 61 | ``` 62 | 63 | **options.emailTest** 64 | Type: `Object` 65 | Default: `{}` 66 | Properties: `to`, `from`, `subject`, `nodemailer` 67 | Supported Method(s): `emailBuilder.sendEmailTest` 68 | 69 | 70 | The optional `nodemailer` property is an object that has `transporter` and `defaults` properties. These get passed to the [nodemailer.createTransport()](https://github.com/nodemailer/nodemailer#setting-up) method. You can use [transport plugins](https://github.com/nodemailer/nodemailer#send-using-a-transport-plugin) or play with the default [SMTP](https://github.com/nodemailer/nodemailer#set-up-smtp) options for the `nodemailer.transporter` property 71 | 72 | Example: 73 | 74 | ```javascript 75 | 76 | emailTest : { 77 | 78 | // Email to send to 79 | to : 'toEmail@email.com', 80 | 81 | // Email sent from 82 | from: 'fromEmail@email.com', 83 | 84 | // Your email Subject 85 | subject : 'Email Subject', 86 | 87 | // Optional 88 | nodemailer: { 89 | transporter: { 90 | service: 'gmail', 91 | auth: { 92 | user: 'gmailuser', 93 | pass: 'gmailpass' 94 | } 95 | }, 96 | defaults: {} 97 | } 98 | } 99 | ``` 100 | 101 | **options.juice** 102 | Type: `Object` 103 | Default: `{}` 104 | Supported Properties: `extraCss`, `applyWidthAttributes`, `applyAttributesTableElements` 105 | Supported Method(s): `emailBuilder.inlineCss` 106 | 107 | View [Juice](https://github.com/Automattic/juice#options) options 108 | 109 | **options.cheerio** 110 | Type: `Object` 111 | Default: `{}` 112 | 113 | View [Cheerio](https://github.com/cheeriojs/cheerio) options. 114 | 115 | 116 | 117 | # Methods 118 | 119 | All methods return a promise, the underlying promise library we use is [Bluebird](https://github.com/petkaantonov/bluebird/blob/master/API.md). Methods can be used seperately, or chained together using the `.then` method. If you're not familiar with promises, instead of using a callback, you chain a `.then` method to get the results. 120 | 121 | ### `emailBuilder.inlineCss(file/html/buffer)` 122 | 123 | Inlines css from embedded or external styles. It'll automatically remove any link or style tags unless one of the data attributes below are used. View [test fixtures](https://github.com/Email-builder/email-builder-core/tree/master/test/fixtures) to see examples. 124 | 125 | **Arguments** 126 | 127 | `file` - String containing path to file 128 | `string` - String of HTML 129 | `buffer` - Buffer of HTML 130 | 131 | **HTML data attributes** 132 | There are two supported data attributes that you can apply to \<style\> or \<link\> tags that have special meaning: 133 | 134 | `data-embed` 135 | - use on \<style\> or \<link\> tags if you want styles to be embedded in the \<head\> of the final output. Does not inline css 136 | 137 | `data-embed-ignore` 138 | - use on \<link\> tags to preserve them in the \<head\>. Does not inline or embed css 139 | 140 | Example: 141 | ```javascript 142 | emailBuilder.inlineCss('path/to/file.html') 143 | .then(function(html){ 144 | console.log(html); 145 | }); 146 | ``` 147 | 148 | ### `emailBuilder.sendLitmusTest(html)` 149 | 150 | Send tests to [Litmus](https://litmus.com/). 151 | 152 | **Arguments** 153 | 154 | `html` - String/Buffer of HTML 155 | 156 | Example: 157 | ```javascript 158 | var fs = require('fs'); 159 | var file = fs.readFileSync('path/to/file.html'); 160 | emailBuilder.sendLitmusTest(file) 161 | .then(function(html){ 162 | console.log(html); 163 | }); 164 | ``` 165 | 166 | ### `emailBuilder.sendEmailTest(html)` 167 | 168 | Send email tests to yourself 169 | 170 | **Arguments** 171 | 172 | `html` - String/Buffer of HTML 173 | 174 | Example: 175 | ```javascript 176 | var fs = require('fs'); 177 | var file = fs.readFileSync('path/to/file.html'); 178 | emailBuilder.sendEmailTest(file) 179 | .then(function(html){ 180 | console.log(html); 181 | }); 182 | ``` 183 | 184 | # Complete Example 185 | 186 | **input.html** 187 | ```html 188 | <!DOCTYPE html> 189 | <html> 190 | <head> 191 | <!-- styles will be inlined --> 192 | <link rel="stylesheet" type="text/css" href="../css/styles.css"> 193 | 194 | <!-- styles will be embedded --> 195 | <link rel="stylesheet" type="text/css" href="../css/otherStyles.css" data-embed> 196 | 197 | <!-- link tag will be preserved and styles will not be inlined or embedded --> 198 | <link href='http://fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css' data-embed-ignore> 199 | 200 | <!-- styles will be inlined --> 201 | <style> 202 | p { color: red; } 203 | </style> 204 | 205 | <!-- styles will be embedded --> 206 | <style data-embed> 207 | h1 { color: black; } 208 | </style> 209 | </head> 210 | <body> 211 | <h1>Heading</h1> 212 | <p>Body</p> 213 | </body> 214 | </html> 215 | ``` 216 | 217 | **main.js** 218 | ```javascript 219 | var fs = require('fs'); 220 | var EmailBuilder = require('email-builder-core'); 221 | var options = { 222 | encodeSpecialChars: true, 223 | litmus: {...}, 224 | emailTest: {...} 225 | }; 226 | var emailBuilder = new EmailBuilder(options); 227 | var src = process.cwd() + '/input.html'; 228 | 229 | emailBuilder.inlineCss(src) 230 | .then(emailBuilder.sendLitmusTest) 231 | .then(emailBuilder.sendEmailTest) 232 | .then(function(html){ 233 | // can write files here 234 | fs.writeFileSync(process.cwd() + '/out.html', html); 235 | }) 236 | catch(function(err){ 237 | console.log(err); 238 | }); 239 | ``` 240 | 241 | **out.html** 242 | ```html 243 | <!DOCTYPE html> 244 | <html> 245 | <head> 246 | <link href='http://fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css'> 247 | <style> 248 | h1 { color: black; } 249 | </style> 250 | </head> 251 | <body> 252 | <h1>Heading</h1> 253 | <p style="color: red">Body</p> 254 | </body> 255 | </html> 256 | ``` 257 | 258 | # Testing 259 | 260 | `gulp test` - Runs **jshint** and **mocha** tests 261 | `gulp inline` - Inlines css from **test/fixtures/input** directory and creates the **test/fixtures/output** directory. Run if you add/update any fixtures in the **test/fixtures/input** directory. 262 | 263 | 264 | # Troubleshooting 265 | 266 | If you're having issues with Litmus taking forever to load a test or the title of the test is showing up as "No Subject", it is most likely an issue with the Litmus API. You can check the [Litmus status](http://status.litmus.com) page to find out if their having any issues. If that's not the case, submit an issue and we'll look into further. 267 | 268 | # Thanks to 269 | [Juice](https://github.com/Automattic/juice) for compiling. 270 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var jscs = require('gulp-jscs'); 3 | var gutil = require('gulp-util'); 4 | var mocha = require('gulp-mocha'); 5 | var jshint = require('gulp-jshint'); 6 | var es = require('event-stream'); 7 | 8 | var options = { 9 | emailTest : { 10 | email : process.env.GMAIL_USER, 11 | subject : 'Email Subject', 12 | nodemailer: { 13 | transporter: { 14 | service: 'gmail', 15 | auth: { 16 | user: process.env.GMAIL_USER, 17 | pass: process.env.GMAIL_PASS 18 | } 19 | } 20 | } 21 | }, 22 | litmus : { 23 | subject : 'Custom subject line', 24 | username : process.env.LIT_USER, 25 | password : process.env.LIT_PASS, 26 | url : process.env.LIT_URL, 27 | applications : ['gmailnew', 'hotmail', 'outlookcom', 'ol2000', 'ol2002', 'ol2003', 'ol2007', 'ol2010','ol2011', 'ol2013', 'appmail8', 'iphone5', 'ipad3'] 28 | }, 29 | encodeSpecialChars: true, 30 | }; 31 | 32 | var EmailBuilder = require('./lib/emailBuilder'); 33 | 34 | 35 | gulp.task('email', function() { 36 | var emailBuilder = new EmailBuilder(options); 37 | return gulp.src(['test/fixtures/input/embedded_styles_inlined.html']) 38 | .pipe(es.map(function(data, cb) { 39 | 40 | emailBuilder.sendEmailTest(data.contents) 41 | .then(function() { 42 | cb(null, data); 43 | }); 44 | 45 | })); 46 | }); 47 | 48 | gulp.task('litmus', function() { 49 | var emailBuilder = new EmailBuilder(options); 50 | return gulp.src(['test/fixtures/input/embedded_styles_inlined.html']) 51 | .pipe(es.map(function(data, cb) { 52 | emailBuilder.sendLitmusTest(data.contents) 53 | .then(function() { 54 | cb(null, data); 55 | }); 56 | 57 | })); 58 | }); 59 | 60 | // Run this task to create the `output` fixtures 61 | // to test against the `input` fixtures 62 | gulp.task('inline', function() { 63 | var emailBuilder = new EmailBuilder(options); 64 | return gulp.src(['test/fixtures/input/*.html']) 65 | .pipe(es.map(function(data, cb) { 66 | 67 | emailBuilder.inlineCss(data.path) 68 | .then(function(html) { 69 | data.contents = new Buffer(html); 70 | cb(null, data); 71 | }); 72 | 73 | })) 74 | .pipe(gulp.dest('./test/fixtures/output')); 75 | }); 76 | 77 | gulp.task('lint', function() { 78 | return gulp.src(['./lib/**/*.js', 'gulpfile.js']) 79 | .pipe(jshint()) 80 | .pipe(jshint.reporter('jshint-stylish')) 81 | .pipe(jscs()); 82 | }); 83 | 84 | gulp.task('test', ['lint'], function() { 85 | return gulp.src(['test/specs/*.js'], {read: false}) 86 | .pipe(mocha({reporter: 'spec'})); 87 | }); 88 | 89 | gulp.task('watch', function() { 90 | return gulp.watch(['./lib/**/*.js', 'gulpfile.js'], ['test']); 91 | }); 92 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/emailBuilder'); 2 | -------------------------------------------------------------------------------- /lib/emailBuilder.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var os = require('os'); 3 | var cheerio = require('cheerio'); 4 | var mailer = require('nodemailer'); 5 | var Litmus = require('./litmus'); 6 | var Promise = require('bluebird'); 7 | var juice = Promise.promisifyAll(require('juice')); 8 | var inliner = Promise.promisifyAll(require('web-resource-inliner')); 9 | var fs = Promise.promisifyAll(require('fs')); 10 | var utils = require('./utils'); 11 | var _ = require('lodash'); 12 | var dateFormat = require('dateformat'); 13 | var logger = require('./logger'); 14 | var es = require('event-stream'); 15 | var isHtml = require('is-html'); 16 | 17 | function EmailBuilder(opts) { 18 | this.options = _.assign(EmailBuilder.Defaults, opts); 19 | } 20 | 21 | EmailBuilder.Defaults = { 22 | encodeSpecialChars: false, 23 | relativePath: '', 24 | cheerio: {} 25 | }; 26 | 27 | /** 28 | * Inlines css using `juice.inlineContent` and keeps 29 | * embedded styles using `web-resource-inliner` 30 | * 31 | * @param {String} src - src file 32 | * 33 | * @returns {Object} a promise which resolves with a new buffer 34 | * of HTML that has css inlined on the elements 35 | * 36 | */ 37 | 38 | EmailBuilder.prototype.inlineCss = function(src) { 39 | 40 | var cssStyles = ''; 41 | var self = this; 42 | 43 | if ((Buffer.isBuffer(src) || isHtml(src)) && !this.options.relativePath) { 44 | throw new Error('Set the `options.relativePath` when passing a string or buffer'); 45 | } 46 | 47 | function addCSS(data, cb) { 48 | data = utils.getData(data.toString(), self.options.cheerio); 49 | cssStyles += data.css; 50 | return cb(null, data); 51 | } 52 | 53 | function embedExternalStyles(data, cb) { 54 | 55 | // web-resource-inliner options 56 | var options = { 57 | fileContent: data.html, 58 | relativeTo: self.options.relativePath || path.dirname(src), 59 | images: false 60 | }; 61 | 62 | inliner.htmlAsync(options) 63 | .then(function(results) { 64 | return cb(null, results); 65 | }) 66 | .catch(function(err) { cb(err); }); 67 | } 68 | 69 | return new Promise(function(resolve, reject) { 70 | 71 | utils.createStreamFromSrc(src) 72 | .pipe(es.wait()) 73 | .pipe(es.map(addCSS)) 74 | .pipe(es.map(embedExternalStyles)) 75 | .pipe(es.map(function(result, cb) { 76 | 77 | var data = utils.getData(result, self.options.cheerio); 78 | var html = utils.removeHTMLAttr(data.html, 'data-embed'); 79 | var css = cssStyles += data.css; 80 | 81 | css += self.options.extraCss; 82 | 83 | // Override juice cheerio xmlMode option if cheerio option specified 84 | self.options.juice = self.options.juice || {xmlMode: false}; 85 | self.options.juice.xmlMode = self.options.cheerio.xmlMode || self.options.juice.xmlMode; 86 | 87 | juicedData = juice.inlineContent(html, css, self.options.juice); 88 | html = (self.options.encodeSpecialChars) ? utils.encode(juicedData) : juicedData; 89 | 90 | cb(); 91 | return resolve(new Buffer(html)); 92 | })); 93 | 94 | }); 95 | 96 | }; 97 | 98 | /** 99 | * Send tests to Litmus App 100 | * 101 | * @param {String/Buffer} html 102 | 103 | * @returns {String/Buffer} html to be passed to next promise 104 | * 105 | */ 106 | 107 | EmailBuilder.prototype.sendLitmusTest = function(html) { 108 | 109 | html = (this.options.encodeSpecialChars) ? utils.encode(html) : html; 110 | 111 | if (this.options.litmus) { 112 | 113 | var litmus = new Litmus(this.options.litmus); 114 | var now = new Date(); 115 | var date = dateFormat(now, 'yyyy-mm-dd'); 116 | var subject = this.options.litmus.subject || ''; 117 | var $ = cheerio.load(html, this.options.cheerio); 118 | var $title = $('title').text().trim(); 119 | 120 | if (!subject) { 121 | subject = $title || date; 122 | } 123 | 124 | return litmus.run(html, subject.trim()); 125 | 126 | } else { 127 | return Promise.resolve(html); 128 | } 129 | 130 | }; 131 | 132 | /** 133 | * Send an email test 134 | * 135 | * @param {String/Buffer} html 136 | * 137 | * @returns {String/Buffer} html to be passed to next promise 138 | * 139 | */ 140 | 141 | EmailBuilder.prototype.sendEmailTest = function(html, text) { 142 | 143 | // Need to turn HTML to a string because some transport plugins 144 | // do not support buffers 145 | html = Buffer.isBuffer(html) ? html.toString() : html; 146 | 147 | html = (this.options.encodeSpecialChars) ? utils.encode(html) : html; 148 | text = text || ''; 149 | 150 | if (this.options.emailTest) { 151 | 152 | var emailTest = this.options.emailTest; 153 | var transporter = emailTest.nodemailer ? emailTest.nodemailer.transporter : false; 154 | var transporterDefaults = emailTest.nodemailer ? emailTest.nodemailer.defaults : false; 155 | var transport = mailer.createTransport(transporter, transporterDefaults); 156 | 157 | if(emailTest.email){ 158 | logger.warn('The `emailTest.email` property will be deprecated in the future. Please use `emailTest.from` and `emailTest.to` properties'); 159 | } 160 | 161 | var mailOptions = { 162 | from: emailTest.from || emailTest.email, 163 | to: emailTest.to || emailTest.email, 164 | subject: emailTest.subject, 165 | text: text, 166 | html: html 167 | }; 168 | 169 | logger.info('Sending test email to ' + emailTest.to || emailTest.email); 170 | 171 | return new Promise(function(resolve, reject) { 172 | 173 | transport.sendMail(mailOptions, function(error, response) { 174 | if (error) { 175 | return reject(error); 176 | } 177 | 178 | if (response.statusHandler) { 179 | // response.statusHandler only applies to 'direct' transport 180 | response.statusHandler.once('failed', function(data) { 181 | logger.error( 182 | 'Permanently failed delivering message to %s with the following response: %s', 183 | data.domain, data.response); 184 | resolve(html); 185 | }); 186 | 187 | response.statusHandler.once('requeue', function(data) { 188 | logger.warn('Temporarily failed delivering message to %s', data.domain); 189 | resolve(html); 190 | }); 191 | 192 | response.statusHandler.once('sent', function(data) { 193 | logger.info('Message was accepted by %s', data.domain); 194 | resolve(html); 195 | }); 196 | 197 | } else { 198 | logger.info(JSON.stringify(response, null, 2)); 199 | logger.info('Message was sent'); 200 | resolve(html); 201 | } 202 | 203 | }); 204 | 205 | }); 206 | 207 | } else { 208 | return Promise.resolve(html); 209 | } 210 | 211 | }; 212 | 213 | module.exports = EmailBuilder; 214 | -------------------------------------------------------------------------------- /lib/litmus.js: -------------------------------------------------------------------------------- 1 | var mailer = require('nodemailer'); 2 | var fs = require('fs'); 3 | var cheerio = require('cheerio'); 4 | var builder = require('xmlbuilder'); 5 | var Table = require('cli-table'); 6 | var LitmusAPI = require('litmus-api'); 7 | var _ = require('lodash'); 8 | var logger = require('./logger'); 9 | var Promise = require('bluebird'); 10 | 11 | function Litmus(options) { 12 | this.options = options; 13 | this.initVars(); 14 | } 15 | 16 | // Initialize variables 17 | Litmus.prototype.initVars = function() { 18 | this.api = new LitmusAPI({ 19 | username: this.options.username, 20 | password: this.options.password, 21 | url: this.options.url 22 | }); 23 | }; 24 | 25 | /** 26 | * Calculate and get the average time for test to complete 27 | * 28 | * @param {String} body - xml body returned from response 29 | * 30 | * @returns {String} average time in seconds/minutes 31 | * 32 | */ 33 | 34 | Litmus.prototype.getAvgTime = function(body) { 35 | var $ = cheerio.load(body, {xmlMode: true}); 36 | var avgTimes = $('average_time_to_process'); 37 | var count = 0; 38 | 39 | avgTimes.each(function(i, el) { 40 | count += +$(this).text(); 41 | }); 42 | 43 | return (count < 60) ? (count + ' secs') : (Math.round((count / avgTimes.length) / 60) + ' mins'); 44 | 45 | }; 46 | 47 | /** 48 | * Get the status of each result in a test 49 | * 50 | * @param {String} body - xml body returned from response 51 | * 52 | * @returns {Object} map of delayed and unavailable clients based on status 53 | * 54 | */ 55 | 56 | Litmus.prototype.getStatus = function(body) { 57 | var $ = cheerio.load(body, {xmlMode: true}); 58 | var statuses = $('status'); 59 | var delayed = []; 60 | var unavailable = []; 61 | var statusCode; 62 | var application; 63 | 64 | statuses.each(function(i, el) { 65 | var $this = $(this); 66 | statusCode = +$this.text(); 67 | application = $this.parent().children('application_long_name').text(); 68 | 69 | if (statusCode === 1) { delayed.push(application); } 70 | 71 | if (statusCode === 2) { unavailable.push(application); } 72 | }); 73 | 74 | return { 75 | delayed: delayed.join('\n'), 76 | unavailable: unavailable.join('\n') 77 | }; 78 | 79 | }; 80 | 81 | /** 82 | * Creates a nice looking table on the command line that logs the 83 | * average time it takes for a test to complete and delayed and unavailable clients 84 | * 85 | * @param {String} body - xml body returned from response 86 | * 87 | */ 88 | 89 | Litmus.prototype.logStatusTable = function(body) { 90 | var table = new Table(); 91 | var delayed = this.getStatus(body).delayed; 92 | var unavailable = this.getStatus(body).unavailable; 93 | var avgTime = this.getAvgTime(body); 94 | var values = []; 95 | 96 | table.options.head = ['Avg. Time to Complete']; 97 | values.push(avgTime); 98 | 99 | if (delayed.length > 0) { 100 | table.options.head.push('Delayed'); 101 | values.push(delayed); 102 | } 103 | 104 | if (unavailable.length > 0) { 105 | table.options.head.push('Unavailable'); 106 | values.push(unavailable); 107 | } 108 | 109 | table.push(values); 110 | 111 | console.log(table.toString()); 112 | }; 113 | 114 | /** 115 | * Logs headers of response once email is sent 116 | * 117 | * @param {Array} data - array of data returned from promise 118 | * 119 | */ 120 | 121 | Litmus.prototype.logHeaders = function(data) { 122 | 123 | var res = data[0]; 124 | var body = data[1]; 125 | var headers = res.headers; 126 | var status = parseFloat(headers.status, 10); 127 | 128 | Object.keys(headers).forEach(function(key) { 129 | console.log(key.toUpperCase() + ': ' + headers[key]); 130 | }); 131 | 132 | console.log('---------------------\n' + body); 133 | 134 | if (status > 199 && status < 300) { 135 | logger.info('Test sent!'); 136 | this.logStatusTable(body); 137 | } else { 138 | throw new Error(headers.status); 139 | } 140 | 141 | }; 142 | 143 | /** 144 | * Mail a new test using the test email Litmus provides in the <url_or_guid> tag 145 | * 146 | * @param {Array} data - array of data returned from promise 147 | * 148 | */ 149 | 150 | Litmus.prototype.mailNewVersion = function(data) { 151 | 152 | var body = data[1]; 153 | var self = this; 154 | var $ = cheerio.load(body); 155 | var guid = $('url_or_guid').text(); 156 | var transport = mailer.createTransport(); 157 | var mailOptions = { 158 | from: 'no-reply@test.com', 159 | to: guid, 160 | subject: this.title, 161 | text: '', 162 | html: this.html 163 | }; 164 | 165 | return new Promise(function(resolve, reject) { 166 | 167 | transport.sendMail(mailOptions, function(error, response) { 168 | if (error) { return reject(error); } 169 | 170 | if (response.statusHandler) { 171 | response.statusHandler.once('sent', function(data) { 172 | logger.info('Message was accepted by %s', data.domain); 173 | resolve(self.html); 174 | }); 175 | } else { 176 | logger.info(response.message); 177 | logger.info('Message was sent'); 178 | resolve(self.html); 179 | } 180 | 181 | }); 182 | 183 | }).then(function() { 184 | logger.info('New version sent!'); 185 | self.logStatusTable(body); 186 | }); 187 | 188 | }; 189 | 190 | /** 191 | * Builds xml body 192 | * 193 | * @param {String} html - final html output 194 | * @param {String} title - title that will be used to name the Litmus test 195 | * 196 | * @returns {Object} xml body for the request 197 | * 198 | */ 199 | 200 | Litmus.prototype.getBuiltXml = function(html, title) { 201 | var xmlApplications = builder.create('applications').att('type', 'array'); 202 | 203 | _.each(this.options.applications, function(app) { 204 | var item = xmlApplications.ele('application'); 205 | 206 | item.ele('code', app); 207 | }); 208 | 209 | //Build Xml to send off, Join with Application XMl 210 | var xml = builder.create('test_set') 211 | .importXMLBuilder(xmlApplications) 212 | .ele('save_defaults', 'false').up() 213 | .ele('use_defaults', 'false').up() 214 | .ele('email_source') 215 | .ele('body').dat(html).up() 216 | .ele('subject', title) 217 | .end({pretty: true}); 218 | 219 | return xml; 220 | }; 221 | 222 | /** 223 | * Grab the name of email and set id if it matches title/subject line 224 | * 225 | * @param {String} body - xml body of all tests 226 | * 227 | * @returns {Object} a map with the id 228 | * 229 | */ 230 | 231 | Litmus.prototype.getId = function(body) { 232 | var xml = body[1]; 233 | 234 | var $ = cheerio.load(xml, {xmlMode: true}); 235 | var $allNameTags = $('name'); 236 | var subjLine = this.title; 237 | var id; 238 | var $matchedName = $allNameTags.filter(function() { 239 | return $(this).text() === subjLine; 240 | }); 241 | 242 | if ($matchedName.length) { 243 | id = $matchedName.eq(0).parent().children('id').text(); 244 | } 245 | 246 | return { 247 | id: id 248 | }; 249 | }; 250 | 251 | /** 252 | * Send a new test 253 | * 254 | * @param {Object} data - object map that contains the id passed 255 | * 256 | * @returns {Object} a promise 257 | * 258 | */ 259 | 260 | Litmus.prototype.sendTest = function(data) { 261 | 262 | var body = this.getBuiltXml(this.html, this.title); 263 | 264 | logger.info('Sending new test: ' + this.title); 265 | return this.api.createEmailTest(body) 266 | .bind(this) 267 | .then(this.logHeaders); 268 | }; 269 | 270 | /** 271 | * Starts the initialization 272 | * 273 | * @param {String} html - final html output 274 | * @param {String} title - title that will be used to name the Litmus test 275 | * 276 | * @returns {Object} a promise 277 | * 278 | */ 279 | 280 | Litmus.prototype.run = function(html, title) { 281 | this.title = this.options.subject; 282 | this.delay = this.options.delay || 3500; 283 | 284 | if ((this.title === undefined) || (this.title.trim().length === 0)) { 285 | this.title = title; 286 | } 287 | 288 | this.html = html; 289 | 290 | return this.api.getTests() 291 | .bind(this) 292 | .then(this.getId) 293 | .then(this.sendTest) 294 | .return(html); 295 | 296 | }; 297 | 298 | module.exports = Litmus; 299 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | 3 | var logger = new (winston.Logger)({ 4 | transports: [ 5 | new (winston.transports.Console)({ 6 | colorize: true 7 | }) 8 | ] 9 | }); 10 | 11 | module.exports = logger; 12 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | var cheerio = require('cheerio'); 2 | var encode = require('special-html'); 3 | var isHtml = require('is-html'); 4 | var Stream = require('stream'); 5 | var fs = require('fs'); 6 | 7 | var utils = { 8 | 9 | /** 10 | * Removes the empty value from an attribute. `cheerio` adds an empty value 11 | * to attributes that have no values so we need to remove them in order 12 | * for `data-inline-ignore` attribute to work for `web-resource-inliner` 13 | * 14 | * @example 15 | * <link data-inline-ignore="" /> ==> <link data-inline-ignore /> 16 | * 17 | * @param {String} html 18 | * @returns {String} html 19 | */ 20 | removeEmptyAttrValue : function(html, attr) { 21 | var reAttr = new RegExp('(\\b' + attr + '\\b)(?:=["\'](?:\\s+)?[\'"])?', 'g'); 22 | return html.replace(reAttr, '$1'); 23 | }, 24 | 25 | /** 26 | * Reverts the `data-embed-ignore` attribute to the default 27 | * `inlineAttribute used by `web-resource-inliner`. This way 28 | * we can keep the attributes consistent on <style> and <link> tags 29 | * 30 | * @param {String} html 31 | * @returns {String} new html 32 | */ 33 | revertEmbedAttr : function(html) { 34 | return html.replace(/\bdata-embed-ignore\b/g, 'data-inline-ignore'); 35 | }, 36 | 37 | /** 38 | * Removes HTML attribute from all elements 39 | * 40 | * @param {String} html 41 | * @param {String} attr - attribute name 42 | * @returns {String} new html that has attribute removed 43 | */ 44 | removeHTMLAttr : function(html, attr) { 45 | var reAttr = new RegExp('\\s+?\\b' + attr + '\\b(?:=["\'].*[\'"](?:\\s+)?)?', 'g'); 46 | return html.replace(reAttr, ''); 47 | }, 48 | 49 | /** 50 | * Searches through each <style> tag, if it doesn't have a 51 | * data attribute, those styles are saved into a string that will 52 | * be passed to `juice.inlineContent`. 53 | * 54 | * @param {String} html 55 | * @param {Object} cheerio options object 56 | * @returns {Object} with css and html 57 | */ 58 | getData : function(html, options) { 59 | 60 | options = options || {}; 61 | options.decodeEntities = options.decodeEntities || false; 62 | 63 | var $ = cheerio.load(utils.revertEmbedAttr(html), options); 64 | var styles = $('style'); 65 | var css = ''; 66 | var attr, $this; 67 | 68 | styles.each(function() { 69 | $this = $(this); 70 | attr = $this.attr('data-embed' || 'data-inline-ignore'); 71 | 72 | if (attr === undefined) { 73 | css += $this.text(); 74 | $this.remove(); 75 | } else { 76 | $this.attr('data-embed', 'true'); 77 | } 78 | }); 79 | 80 | return { 81 | css: css, 82 | html: utils.removeEmptyAttrValue($.html(), 'data-inline-ignore') 83 | }; 84 | }, 85 | 86 | encode : function(html) { 87 | var isBuffer = Buffer.isBuffer(html); 88 | return (isBuffer) ? new Buffer(encode(html.toString())) : encode(html); 89 | }, 90 | 91 | createStreamFromSrc : function(src) { 92 | var stream; 93 | var buffer; 94 | 95 | if (isHtml(src)) { 96 | buffer = new Buffer(src); 97 | } 98 | 99 | if (Buffer.isBuffer(src)) { 100 | buffer = src; 101 | } 102 | 103 | if (isHtml(src) || Buffer.isBuffer(src)) { 104 | stream = Stream.PassThrough(); 105 | stream.end(buffer); 106 | } else { 107 | stream = fs.createReadStream(src); 108 | } 109 | 110 | return stream; 111 | 112 | } 113 | 114 | }; 115 | 116 | module.exports = utils; 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "email-builder-core", 3 | "version": "2.1.2", 4 | "description": "Email builder core. Provides useful methods to inline css, send tests to Litmus and send email tests", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "gulp test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/Email-builder/email-builder-core.git" 12 | }, 13 | "contributors": [ 14 | "Jeremy Peter <jeremywpeter@gmail.com> (https://github.com/jeremypeter)", 15 | "Steve Miller <steven.jmiller@gmail.com> (http://www.stevenjohnmiller.com.au)" 16 | ], 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/Email-builder/email-builder-core/issues" 20 | }, 21 | "homepage": "https://github.com/Email-builder/email-builder-core", 22 | "keywords": [ 23 | "email", 24 | "inline", 25 | "css", 26 | "html", 27 | "litmus", 28 | "test" 29 | ], 30 | "dependencies": { 31 | "bluebird": "^3.4.1", 32 | "chalk": "^1.1.3", 33 | "cheerio": "^0.20.0", 34 | "cli-table": "^0.3.1", 35 | "dateformat": "^1.0.11", 36 | "event-stream": "3.3.4", 37 | "is-html": "^1.0.0", 38 | "juice": "^2.0.0", 39 | "litmus-api": "^0.3.2", 40 | "lodash": "^4.13.1", 41 | "nodemailer": "~2.4.2", 42 | "special-html": "0.0.1", 43 | "web-resource-inliner": "^2.0.0", 44 | "winston": "^2.2.0", 45 | "xmlbuilder": "^8.2.2" 46 | }, 47 | "devDependencies": { 48 | "chai": "^3.5.0", 49 | "chai-as-promised": "^5.3.0", 50 | "gulp": "^3.8.11", 51 | "gulp-jscs": "^4.0.0", 52 | "gulp-jshint": "^2.0.1", 53 | "gulp-mocha": "^2.0.1", 54 | "gulp-util": "^3.0.4", 55 | "isstream": "^0.1.2", 56 | "jshint": "^2.9.2", 57 | "jshint-stylish": "^2.2.0", 58 | "mocha": "^2.2.1", 59 | "sinon": "^1.14.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/fixtures/input/conditional_styles.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html xmlns="http://www.w3.org/1999/xhtml"> 3 | 4 | <head> 5 | <!--[if IEMobile 7]> 6 | <style data-embed> 7 | body { font-size: 12px; } 8 | </style> 9 | <link rel="stylesheet" href="./css/embed.css" data-embed> 10 | <link rel="stylesheet" href="./css/inline.css" data-embed> 11 | <link rel="stylesheet" href="./css/external.css" data-embed-ignore> 12 | <![endif]--> 13 | </head> 14 | 15 | <body> 16 | 17 | <!--[if (gte mso 9)|(IE)]> 18 | <table width="600" align="center" cellpadding="0" cellspacing="0" border="0"><tr><td> 19 | <![endif]--> 20 | <h1>Conditional Styles</h1> 21 | <!--[if (gte mso 9)|(IE)]> 22 | </td></tr></table> 23 | <![endif]--> 24 | 25 | </body> 26 | </html> 27 | 28 | -------------------------------------------------------------------------------- /test/fixtures/input/css/embed.css: -------------------------------------------------------------------------------- 1 | p { margin-bottom: 10px; } -------------------------------------------------------------------------------- /test/fixtures/input/css/external.css: -------------------------------------------------------------------------------- 1 | * { border: 1px solid red;} -------------------------------------------------------------------------------- /test/fixtures/input/css/inline.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | font-size: 16px; 4 | } -------------------------------------------------------------------------------- /test/fixtures/input/embedded_styles_ignored.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <style data-embed> 5 | p { 6 | font-weight: bold; 7 | color: blue; 8 | } 9 | </style> 10 | </head> 11 | <body> 12 | <p>Embedded styles should not be inlined</p> 13 | </body> 14 | </html> -------------------------------------------------------------------------------- /test/fixtures/input/embedded_styles_inlined.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <style> 5 | p { 6 | font-weight: bold; 7 | color: blue; 8 | } 9 | </style> 10 | </head> 11 | <body> 12 | <p>Embedded styles should be inlined</p> 13 | </body> 14 | </html> -------------------------------------------------------------------------------- /test/fixtures/input/encoded_special_characters.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <body> 4 | <p>¡™£¢∞§¶•ªº–åß∂ƒ©˙∆˚¬…Ω≈ç√∫˜µ≤≥÷⁄€‹›fifl‡°·‚—ÅÏ˝ÓÔÒÚÆ◊ı¯˘</p> 5 | </body> 6 | </html> -------------------------------------------------------------------------------- /test/fixtures/input/external_styles_embedded.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <link rel="stylesheet" type="text/css" href="./css/embed.css" data-embed> 5 | </head> 6 | <body> 7 | <p>External styles should be embedded</p> 8 | </body> 9 | </html> -------------------------------------------------------------------------------- /test/fixtures/input/external_styles_ignored.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <link rel="stylesheet" type="text/css" href="./css/embed.css" data-embed-ignore> 5 | </head> 6 | <body> 7 | <p>External styles should NOT be embedded or inlined</p> 8 | </body> 9 | </html> -------------------------------------------------------------------------------- /test/fixtures/input/external_styles_inlined.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <link rel="stylesheet" type="text/css" href="./css/inline.css"> 5 | </head> 6 | <body> 7 | <h1>External styles should be inlined</h1> 8 | </body> 9 | </html> -------------------------------------------------------------------------------- /test/fixtures/input/remote_url_embedded.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Email-builder/email-builder-core/2558a0e84bf5dd3fc0c3361107189ac0d798bf55/test/fixtures/input/remote_url_embedded.html -------------------------------------------------------------------------------- /test/fixtures/input/remote_url_inlined.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html xmlns="http://www.w3.org/1999/xhtml"> 3 | <head> 4 | <link rel="stylesheet" data-test-woop href="http://prod.campaign-monitor.gene.s3.amazonaws.com/inline.css" type="text/css" /> 5 | </head> 6 | 7 | <body> 8 | <p class="p">Styles should NOT be inlined</p> 9 | </body> 10 | </html> 11 | 12 | -------------------------------------------------------------------------------- /test/fixtures/output/conditional_styles.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html xmlns="http://www.w3.org/1999/xhtml"> 3 | 4 | <head> 5 | <!--[if IEMobile 7]> 6 | <style> 7 | body { font-size: 12px; } 8 | </style> 9 | <style> 10 | p { margin-bottom: 10px; } 11 | </style> 12 | <style> 13 | h1 { 14 | color: red; 15 | font-size: 16px; 16 | } 17 | </style> 18 | <link rel="stylesheet" href="./css/external.css"> 19 | <![endif]--> 20 | </head> 21 | 22 | <body> 23 | 24 | <!--[if (gte mso 9)|(IE)]> 25 | <table width="600" align="center" cellpadding="0" cellspacing="0" border="0"><tr><td> 26 | <![endif]--> 27 | <h1>Conditional Styles</h1> 28 | <!--[if (gte mso 9)|(IE)]> 29 | </td></tr></table> 30 | <![endif]--> 31 | 32 | </body> 33 | </html> 34 | 35 | -------------------------------------------------------------------------------- /test/fixtures/output/embedded_styles_ignored.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <style> 5 | p { 6 | font-weight: bold; 7 | color: blue; 8 | } 9 | </style> 10 | </head> 11 | <body> 12 | <p>Embedded styles should not be inlined</p> 13 | </body> 14 | </html> -------------------------------------------------------------------------------- /test/fixtures/output/embedded_styles_inlined.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | 5 | </head> 6 | <body> 7 | <p style="font-weight: bold; color: blue;">Embedded styles should be inlined</p> 8 | </body> 9 | </html> -------------------------------------------------------------------------------- /test/fixtures/output/encoded_special_characters.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <body> 4 | <p>¡™£¢∞§¶•ªº–åß∂ƒ©˙∆˚¬…Ω≈ç√∫˜µ≤≥÷⁄€‹›fifl‡°·‚—ÅÏ˝ÓÔÒÚÆ◊ı¯˘</p> 5 | </body> 6 | </html> -------------------------------------------------------------------------------- /test/fixtures/output/external_styles_embedded.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <style type="text/css"> 5 | p { margin-bottom: 10px; } 6 | </style> 7 | </head> 8 | <body> 9 | <p>External styles should be embedded</p> 10 | </body> 11 | </html> -------------------------------------------------------------------------------- /test/fixtures/output/external_styles_ignored.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <link rel="stylesheet" type="text/css" href="./css/embed.css"> 5 | </head> 6 | <body> 7 | <p>External styles should NOT be embedded or inlined</p> 8 | </body> 9 | </html> -------------------------------------------------------------------------------- /test/fixtures/output/external_styles_inlined.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | 5 | </head> 6 | <body> 7 | <h1 style="color: red; font-size: 16px;">External styles should be inlined</h1> 8 | </body> 9 | </html> -------------------------------------------------------------------------------- /test/fixtures/output/remote_url_embedded.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Email-builder/email-builder-core/2558a0e84bf5dd3fc0c3361107189ac0d798bf55/test/fixtures/output/remote_url_embedded.html -------------------------------------------------------------------------------- /test/fixtures/output/remote_url_inlined.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html xmlns="http://www.w3.org/1999/xhtml" style="padding: 10px;"> 3 | <head style="padding: 10px;"> 4 | 5 | </head> 6 | 7 | <body style="padding: 10px;"> 8 | <p class="p" style="padding: 10px;">Styles should NOT be inlined</p> 9 | </body> 10 | </html> 11 | 12 | -------------------------------------------------------------------------------- /test/specs/EmailBuilderSpec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var expect = require('chai').expect; 6 | var sinon = require('sinon'); 7 | var Promise = require('bluebird'); 8 | var EmailBuilder = require('../../lib/emailBuilder.js'); 9 | var Litmus = require('../../lib/litmus'); 10 | 11 | 12 | function getSrc(file){ 13 | file = file || ''; 14 | return path.join(process.cwd(), 'test', 'fixtures', file); 15 | } 16 | 17 | function read(file){ 18 | return fs.readFileSync(getSrc(file), 'utf8'); 19 | } 20 | 21 | describe("EmailBuilder", function() { 22 | 23 | var emailBuilder; 24 | var src; 25 | var obj; 26 | 27 | beforeEach(function(){ 28 | emailBuilder = new EmailBuilder({}); 29 | }); 30 | 31 | // emailBuilder.inlineCss 32 | describe("#inlineCss", function() { 33 | 34 | it('should inline css from file', function(done){ 35 | 36 | var file = getSrc('input/embedded_styles_inlined.html'); 37 | 38 | emailBuilder.inlineCss(file) 39 | .then(function(html){ 40 | expect(html.toString()).to.eql(read('output/embedded_styles_inlined.html')); 41 | done(); 42 | }).catch(function(err){ done(err); }); 43 | }); 44 | 45 | it('should inline css from a buffer', function(done){ 46 | 47 | var buffer = new Buffer(read('input/embedded_styles_inlined.html')); 48 | emailBuilder.options.relativePath = path.dirname(getSrc('input/embedded_styles_inlined.html')); 49 | 50 | emailBuilder.inlineCss(buffer) 51 | .then(function(html){ 52 | expect(html.toString()).to.eql(read('output/embedded_styles_inlined.html')); 53 | done(); 54 | }).catch(function(err){ done(err); }); 55 | }); 56 | 57 | it('should inline css from a string of HTML', function(done){ 58 | 59 | var html = read('input/embedded_styles_inlined.html'); 60 | emailBuilder.options.relativePath = path.dirname(getSrc('input/embedded_styles_inlined.html')); 61 | 62 | emailBuilder.inlineCss(html) 63 | .then(function(html){ 64 | expect(html.toString()).to.eql(read('output/embedded_styles_inlined.html')); 65 | done(); 66 | }).catch(function(err){ done(err); }); 67 | }); 68 | 69 | it('should throw error if `options.relativePath` property not set when passing a string', function(){ 70 | 71 | var html = read('input/embedded_styles_inlined.html'); 72 | emailBuilder.options.relativePath = null; 73 | 74 | expect(function(){ emailBuilder.inlineCss(html) }).to.throw(Error); 75 | }); 76 | 77 | it('should throw error if `options.relativePath` property not set when passing a buffer', function(){ 78 | 79 | var html = new Buffer(read('input/embedded_styles_inlined.html')); 80 | emailBuilder.options.relativePath = null; 81 | 82 | expect(function(){ emailBuilder.inlineCss(html) }).to.throw(Error); 83 | }); 84 | 85 | describe("conditional styles", function() { 86 | 87 | it('should be embedded', function(done){ 88 | 89 | emailBuilder.inlineCss(getSrc('input/conditional_styles.html')) 90 | .then(function(html){ 91 | expect(html.toString()).to.eql(read('output/conditional_styles.html')); 92 | done(); 93 | }).catch(function(err){ done(err); }); 94 | 95 | }); 96 | }); 97 | 98 | describe("embedded styles", function() { 99 | 100 | it("should be inlined", function(done) { 101 | emailBuilder.inlineCss(getSrc('input/embedded_styles_inlined.html')) 102 | .then(function(html){ 103 | expect(html.toString()).to.eql(read('output/embedded_styles_inlined.html')); 104 | done(); 105 | }).catch(function(err){ done(err); }); 106 | }); 107 | 108 | it("should NOT be inlined if `data-embed` attribute set", function(done) { 109 | emailBuilder.inlineCss(getSrc('input/embedded_styles_ignored.html')) 110 | .then(function(html){ 111 | expect(html.toString()).to.eql(read('output/embedded_styles_ignored.html')); 112 | done(); 113 | }).catch(function(err){ done(err); }); 114 | }); 115 | 116 | }); 117 | 118 | describe("external styles", function() { 119 | 120 | it('should be inlined', function(done){ 121 | emailBuilder.inlineCss(getSrc('input/external_styles_inlined.html')) 122 | .then(function(html){ 123 | expect(html.toString()).to.eql(read('output/external_styles_inlined.html')); 124 | done(); 125 | }).catch(function(err){ done(err); }); 126 | }); 127 | 128 | it("should be embedded if `data-embed` attribute set", function(done) { 129 | emailBuilder.inlineCss(getSrc('input/embedded_styles_ignored.html')) 130 | .then(function(html){ 131 | expect(html.toString()).to.eql(read('output/embedded_styles_ignored.html')); 132 | done(); 133 | }).catch(function(err){ done(err); }); 134 | }); 135 | 136 | it("should NOT be inlined or embedded if `data-embed-ignore` attribute set", function(done) { 137 | emailBuilder.inlineCss(getSrc('input/embedded_styles_ignored.html')) 138 | .then(function(html){ 139 | expect(html.toString()).to.eql(read('output/embedded_styles_ignored.html')); 140 | done(); 141 | }).catch(function(err){ done(err); }); 142 | }); 143 | 144 | }); 145 | 146 | describe("special characters", function() { 147 | it("should be encoded if `options.encodeSpecialChars` is true", function(done) { 148 | emailBuilder.options.encodeSpecialChars = true; 149 | emailBuilder.inlineCss(getSrc('input/encoded_special_characters.html')) 150 | .then(function(html){ 151 | expect(html.toString()).to.eql(read('output/encoded_special_characters.html')); 152 | done(); 153 | }).catch(function(err){ done(err); }) 154 | }); 155 | }); 156 | 157 | }); 158 | 159 | // emailbuilder.sendLitmusTest 160 | describe("#sendLitmusTest", function() { 161 | 162 | var stub; 163 | var options; 164 | var html; 165 | 166 | beforeEach(function(){ 167 | 168 | html = '<title>Test Title'; 169 | 170 | options = emailBuilder.options.litmus = { 171 | username: 'user', 172 | password: 'pass', 173 | url: 'http://testcompany.litmus.com' 174 | }; 175 | 176 | stub = sinon.stub(Litmus.prototype, 'run', function(html, subject){ 177 | return Promise.resolve({ 178 | html: html, 179 | subject: subject 180 | }); 181 | }); 182 | 183 | }); 184 | 185 | afterEach(function(){ 186 | Litmus.prototype.run.restore(); 187 | }); 188 | 189 | 190 | describe("subject", function() { 191 | it("should use optional `options.subject` as the subject if defined", function(done) { 192 | 193 | options.subject = 'Subject Title'; 194 | 195 | emailBuilder.sendLitmusTest(html) 196 | .then(function(obj){ 197 | expect(stub.called).to.be.true; 198 | expect(stub.calledWith(html, 'Subject Title')).to.be.true; 199 | expect(obj.subject).to.equal(options.subject); 200 | done(); 201 | }); 202 | 203 | }); 204 | 205 | it("should use as the subject if `options.subject` not defined", function(done) { 206 | 207 | emailBuilder.sendLitmusTest(html) 208 | .then(function(obj){ 209 | expect(stub.called).to.be.true; 210 | expect(stub.calledWith(html, 'Test Title')).to.be.true; 211 | expect(obj.subject).to.equal('Test Title'); 212 | done(); 213 | }); 214 | 215 | }); 216 | 217 | 218 | it("should use date as the subject if no title or subject is defined", function(done) { 219 | 220 | html = '<title>'; 221 | var dateReg = /\d{4}-\d{2}-\d{2}/; 222 | 223 | emailBuilder.sendLitmusTest(html) 224 | .then(function(obj){ 225 | expect(stub.called).to.be.true; 226 | expect(stub.calledWithMatch(sinon.match(html), sinon.match(dateReg))).to.be.true; 227 | expect(dateReg.test(obj.subject)).to.be.true; 228 | done(); 229 | }); 230 | 231 | }); 232 | }); 233 | 234 | describe("html", function() { 235 | it("should return html if `options.litmus` defined", function(done) { 236 | 237 | emailBuilder.sendLitmusTest(html) 238 | .then(function(data){ 239 | expect(stub.called).to.be.true; 240 | expect(data.html).to.equal(html); 241 | done(); 242 | }); 243 | }); 244 | 245 | it("should return html if `options.litmus` undefined", function(done) { 246 | 247 | delete emailBuilder.options.litmus; 248 | 249 | emailBuilder.sendLitmusTest(html) 250 | .then(function(data){ 251 | expect(stub.called).to.be.false; 252 | expect(data).to.equal(html); 253 | done(); 254 | }); 255 | }); 256 | }); 257 | 258 | describe("special characters", function() { 259 | it("should encode html if `options.encodeSpecialChars` defined", function(done) { 260 | 261 | delete emailBuilder.options.litmus; 262 | emailBuilder.options.encodeSpecialChars = true; 263 | 264 | html = '

©

'; 265 | 266 | emailBuilder.sendLitmusTest(html) 267 | .then(function(data){ 268 | expect(data).to.equal('

©

'); 269 | done(); 270 | }); 271 | }); 272 | }); 273 | 274 | }); 275 | 276 | // emailBuilder.sendEmailTest 277 | describe("#sendEmailTest", function() { 278 | 279 | describe("special characters", function() { 280 | it("should encode html if `options.encodeSpecialChars` defined", function(done) { 281 | 282 | emailBuilder.options.encodeSpecialChars = true; 283 | 284 | var html = '

©

'; 285 | 286 | emailBuilder.sendEmailTest(html) 287 | .then(function(data){ 288 | expect(data).to.equal('

©

'); 289 | done(); 290 | }); 291 | }); 292 | }); 293 | 294 | }); 295 | 296 | }); 297 | -------------------------------------------------------------------------------- /test/specs/UtilsSpec.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | var chai = require('chai'); 4 | var expect = chai.expect; 5 | var utils = require('../../lib/utils'); 6 | var isStream = require('isstream'); 7 | 8 | describe("Utils", function() { 9 | 10 | describe("#removeEmptyAttrValue", function() { 11 | it('should remove empty attribute value from specified attribute', function(){ 12 | var html = ''; 13 | var expected = ''; 14 | 15 | expect(utils.removeEmptyAttrValue(html, 'data-embed-ignore')).to.eql(expected); 16 | }); 17 | }); 18 | 19 | describe("#revertEmbedAttr", function() { 20 | it('should revert the `data-embed-ignore` attribute to `data-inline-ignore` for the `web-resource-inliner` module', function(){ 21 | var html = ''; 22 | var expected = ''; 23 | 24 | expect(utils.revertEmbedAttr(html)).to.eql(expected); 25 | }); 26 | }); 27 | 28 | describe("#removeHTMLAttr", function() { 29 | it('should remove the specified attribute from HTML', function(){ 30 | var html = ''; 31 | var expected = ''; 32 | 33 | expect(utils.removeHTMLAttr(html, 'data-embed')).to.eql(expected); 34 | }); 35 | }); 36 | 37 | describe("#getData", function() { 38 | it('return object with css and html properties', function(){ 39 | var html = ''; 40 | var data = utils.getData(html); 41 | expect(data).to.be.an('object'); 42 | expect(data.css).to.be.eql('td { font-size: 12px; }') 43 | expect(data.html).to.be.eql('') 44 | }); 45 | }); 46 | 47 | describe("#encode", function() { 48 | 49 | it('should encode special characters in a string', function(){ 50 | var html = '

©

'; 51 | var encodedHtml = utils.encode(html); 52 | 53 | expect(encodedHtml).to.be.eql('

©

'); 54 | }); 55 | 56 | it('should encode special characters in a buffer', function(){ 57 | var html = new Buffer('

©

'); 58 | var encodedHtml = utils.encode(html); 59 | 60 | expect(Buffer.isBuffer(encodedHtml)).to.be.true; 61 | expect(encodedHtml.toString()).to.be.eql('

©

'); 62 | }); 63 | 64 | }); 65 | 66 | describe("#createStreamFromSrc", function() { 67 | 68 | var html = ''; 69 | var buffer = new Buffer(html); 70 | var path = process.cwd() + '/test/fixtures/output/embedded_styles_ignored.html'; 71 | 72 | it('should return a stream if the src is an HTML string', function(){ 73 | expect(isStream(utils.createStreamFromSrc(html))).to.be.true; 74 | }); 75 | 76 | it('should return a stream if the src is a Buffer', function(){ 77 | expect(isStream(utils.createStreamFromSrc(buffer))).to.be.true; 78 | }); 79 | 80 | it('should return a stream if the src is a file path', function(){ 81 | expect(isStream(utils.createStreamFromSrc(path))).to.be.true; 82 | }); 83 | }); 84 | 85 | 86 | 87 | }); --------------------------------------------------------------------------------