├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── demo ├── output │ └── body.html └── templates │ ├── body.mjml │ ├── footer.mjml │ └── header.mjml ├── package.json └── src ├── builder └── index.js ├── inject └── index.js ├── mjml-utils.js ├── sender ├── answers.js ├── index.js ├── promptToSend.js ├── questions.js └── send.js ├── sendmail └── index.js └── watcher └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.{diff,md}] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/** -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | logs 3 | tmp 4 | .DS_Store 5 | .DS_Store? 6 | ._* 7 | .*swo 8 | .*swp 9 | .Spotlight-V100 10 | .Trashes 11 | ehthumbs.db 12 | Thumbs.db 13 | node_modules 14 | lib 15 | package-lock.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintignore 2 | .eslintrc 3 | npm_debug.log -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Fork it! 4 | 2. Create your feature branch: `git checkout -b my-new-feature` 5 | 3. Commit your changes: `git commit -m 'Add some feature'` 6 | 4. Push to the branch: `git push origin my-new-feature` 7 | 5. Submit a pull request 8 | 9 | **After your pull request is merged** 10 | 11 | After your pull request is merged, you can safely delete your branch. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Justin Sisley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | mjml-utils 3 |

4 | 5 |

6 | mjml-utils 7 |

8 | 9 |

10 | The utility belt for MJML developers 11 |

12 | 13 |

14 | 15 | 16 | 17 |

18 | 19 | [Changelog](https://github.com/justinsisley/mjml-utils/releases) 20 | 21 | ## Installation 22 | 23 | Installing globally is the easiest way to get started, since you won't need any project-specific setup: 24 | 25 | ```bash 26 | npm install -g mjml-utils 27 | ``` 28 | 29 | Installing as a local dev-dependency gives you more flexibility: 30 | 31 | ```bash 32 | npm install -D mjml-utils 33 | ``` 34 | 35 | If you install __mjml-utils__ locally, you'll probably want to configure it to run via your `package.json` scripts. This method is encouraged, and an example of local usage via `package.json` scripts is provided [below](#npm-script-usage). 36 | 37 | ## Global Usage 38 | 39 | #### --build 40 | 41 | The `mju --build` command compiles all MJML templates into HTML templates. 42 | 43 | ```bash 44 | mju --build -i ./templates -o ./build 45 | ``` 46 | 47 | The `--build` command requires input (`-i`) and output (`-o`) arguments. `-i` is the directory in which your raw MJML templates are located, and `-o` is the directory you would like the compiled HTML files written to. 48 | With the optional extension (`-e`) argument you can specify the output file extension (default: `.html`) to your liking. 49 | 50 | > Note: only files with the `.mjml` file extension will be compiled. 51 | 52 | ```bash 53 | mju --build -i ./templates -o ./build -e .handlebars 54 | ``` 55 | 56 | #### --watch 57 | 58 | The `mju --watch` command will monitor all MJML templates in a specified directory and compile them to HTML every time they're modified. 59 | 60 | > Note: only files with the `.mjml` file extension will be compiled. 61 | 62 | ```bash 63 | mju --watch -i ./templates -o ./build 64 | ``` 65 | 66 | Like the `--build` command, the `--watch` command requires both input (`-i`) and output (`-o`) arguments. 67 | 68 | #### --send 69 | 70 | The `mju --send` command sends compiled MJML templates as HTML emails to a recipient of your choosing using your Gmail credentials. 71 | 72 | ```bash 73 | mju --send -o ./build 74 | ``` 75 | 76 | The `--send` command will prompt you to provide all of the information needed to send test emails. 77 | 78 | ## NPM Script Usage 79 | 80 | If you'd prefer to install __mjml-utils__ locally, you can easily tailor its commands specifically for your project. 81 | 82 | For example, if your project contains MJML email templates in the `./templates/email` directory, and you'd like to compile them to the `./build/templates/email` directory, you might configure your `package.json` file like this: 83 | 84 | ```json 85 | { 86 | "name": "my-project", 87 | "version": "1.0.0", 88 | "scripts": { 89 | "email-build": "mju --build -i ./templates/email -o ./build/templates/email", 90 | "email-watch": "mju --watch -i ./templates/email -o ./build/templates/email", 91 | "email-send": "mju --send -o ./build/templates/email" 92 | }, 93 | "dependencies": { 94 | "mjml": "*", 95 | "mjml-utils": "*" 96 | } 97 | } 98 | ``` 99 | 100 | The above configuration would allow you to run the following commands from the command line: 101 | 102 | ```bash 103 | npm run email-build 104 | npm run email-watch 105 | npm run email-send 106 | ``` 107 | 108 | This is the preferred way of using __mjml-utils__, since you can configure it on a per-project basis, and you won't have to remember any command line arguments other than the simple NPM script alias. 109 | 110 | ## Module Usage 111 | 112 | __mjml-utils__ also has a few built-in helper functions. 113 | 114 | #### inject() 115 | 116 | Inject variables into your email templates. 117 | 118 | Usage: 119 | 120 | ```javascript 121 | const mjmlUtils = require('mjml-utils'); 122 | const pathToHtmlEmailTemplate = path.join(__dirname, '../emails/welcome.html'); 123 | 124 | mjmlUtils.inject(pathToHtmlEmailTemplate, { 125 | name: 'bob', 126 | profileURL: 'https://app.com/bob', 127 | }) 128 | .then(finalTemplate => { 129 | // finalTemplate is an HTML string containing the template with all occurrences 130 | // of `{name}` replaced with "bob", and all occurrences of `{profileURL}` 131 | // replaced with "https://app.com/bob". 132 | }); 133 | ``` 134 | 135 | The above JavaScript assumes a template called `welcome.html` exists at the specified path, and that it's contents are something like the following example: 136 | 137 | ```html 138 | 139 | 140 |

Welcome {name}

141 | 142 |

Click here to view your profile.

143 | 144 | 145 | ``` 146 | 147 | This means your raw MJML template should contain the necessary template strings that you intend to replace with dynamic values. 148 | 149 | #### sendmail() 150 | 151 | Inject variables, compose, and send an email in one step. Caches templates in memory. Uses [nodemailer](https://github.com/nodemailer/nodemailer) to send email. 152 | 153 | Usage (using nodemailer SES transport): 154 | 155 | ```javascript 156 | const mjmlUtils = require('mjml-utils'); 157 | const pathToHtmlEmailTemplate = path.join(__dirname, '../emails/welcome.html'); 158 | const accessKeyId = 'AWS_IAM_ACCESS_KEY_ID'; 159 | const secretAccessKey = 'AWS_IAM_SECRET_ACCESS_KEY'; 160 | const region = 'AWS_SES_REGION'; 161 | 162 | mjmlUtils.sendmail.config({ 163 | fromAddress: 'you@domain.com', 164 | transport: nodemailer.createTransport(sesTransport({ 165 | accessKeyId, 166 | secretAccessKey, 167 | region, 168 | })), 169 | }); 170 | 171 | mjmlUtils.sendmail({ 172 | to: 'someone@domain.com', 173 | subject: 'Custom transactional email made easy!', 174 | text: 'If the HTML email doesn\'t show up, this text should help you out.', 175 | template: pathToHtmlEmailTemplate, 176 | // The same data you would pass to #inject() 177 | data: { confirmationURL: '...' } 178 | }) 179 | .then(() => { 180 | console.log('Email sent!'); 181 | }) 182 | .catch((error) => { 183 | console.warn('mjmlUtils.sendmail error', error); 184 | }); 185 | ``` 186 | 187 | ## Versioning 188 | 189 | To keep better organization of releases this project follows the [Semantic Versioning 2.0.0](http://semver.org/) guidelines. 190 | 191 | ## Contributing 192 | Want to contribute? [Follow these recommendations](https://github.com/justinsisley/mjml-utils/blob/master/CONTRIBUTING.md). 193 | 194 | ## License 195 | [MIT License](https://github.com/justinsisley/mjml-utils/blob/master/LICENSE.md) © [Justin Sisley](http://justinsisley.com/) 196 | 197 | ## Credits 198 | Icon by [Flaticon](http://www.flaticon.com/) 199 | -------------------------------------------------------------------------------- /demo/output/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 20 | 21 | 27 | 28 | 36 | 43 | 44 | 45 | 46 | 51 | 56 | 57 | 58 | 59 |
66 |
Thanks for signing up.
79 |
92 | 93 | -------------------------------------------------------------------------------- /demo/templates/body.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | .link-nostyle { 11 | color: inherit; 12 | } 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 34 | Thanks for signing up. 35 | 36 | 37 | 38 | 39 | 46 | 47 | 55 | Magic Login Button 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /demo/templates/footer.mjml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 13 | © 2017 Company 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/templates/header.mjml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | Hello from Company! 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mjml-utils", 3 | "version": "2.2.1", 4 | "author": "Justin Sisley", 5 | "description": "The utility belt for MJML developers", 6 | "repository": "https://github.com/justinsisley/mjml-utils", 7 | "license": "MIT", 8 | "main": "./src/mjml-utils.js", 9 | "bin": { 10 | "mjml-utils": "./src/mjml-utils.js", 11 | "mju": "./src/mjml-utils.js" 12 | }, 13 | "scripts": { 14 | "lint": "eslint src", 15 | "demo:watcher": "node ./src/mjml-utils.js --watch -i ./demo/templates/ -o ./demo/output/" 16 | }, 17 | "dependencies": { 18 | "chokidar": "^1.7.0", 19 | "inquirer": "^3.3.0", 20 | "mjml": "^3.3.5", 21 | "nodemailer": "^4.2.0", 22 | "yargs": "^9.0.1" 23 | }, 24 | "devDependencies": { 25 | "babel-preset-es2015": "^6.24.1", 26 | "babel-preset-react": "^6.24.1", 27 | "eslint": "^3.19.0", 28 | "eslint-config-airbnb": "^15.0.2", 29 | "eslint-plugin-import": "^2.7.0", 30 | "eslint-plugin-jsx-a11y": "^5.0.3", 31 | "eslint-plugin-react": "^7.4.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/builder/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const mjml2html = require('mjml').mjml2html; 4 | 5 | const builder = {}; 6 | 7 | function getTemplateFilename(filePath) { 8 | const split = filePath.split('/'); 9 | 10 | return split[split.length - 1]; 11 | } 12 | 13 | function mkdir(directory) { 14 | try { 15 | fs.readdirSync(directory); 16 | } catch (err) { 17 | fs.mkdirSync(directory); 18 | } 19 | } 20 | 21 | builder.build = (filePath, outputDir, extension) => { 22 | // No-op if filePath is not a file 23 | if (!fs.statSync(filePath).isFile()) { 24 | return; 25 | } 26 | 27 | const filename = getTemplateFilename(filePath).replace('.mjml', extension); 28 | 29 | try { 30 | const outputPath = path.join(process.cwd(), outputDir); 31 | mkdir(outputPath); 32 | 33 | const startTime = Date.now(); 34 | const data = fs.readFileSync(`${filePath}`, 'utf8'); 35 | const rendered = mjml2html(data); 36 | 37 | fs.writeFileSync(`${outputPath}/${filename}`, rendered.html); 38 | 39 | const endTime = Date.now(); 40 | const totalTime = endTime - startTime; 41 | console.log(`Rendered ${filename} in ${totalTime}ms`); // eslint-disable-line 42 | } catch (error) { 43 | console.error(`Unable to render ${filename}`); 44 | console.error(error.message); 45 | } 46 | }; 47 | 48 | builder.buildAll = (inputDir, outputDir, extension) => { 49 | const sourcePath = path.join(process.cwd(), inputDir); 50 | const templates = fs.readdirSync(sourcePath); 51 | 52 | if (!templates.length) { 53 | throw new Error('No templates to build'); 54 | } 55 | 56 | const outputPath = path.join(process.cwd(), outputDir); 57 | mkdir(outputPath); 58 | 59 | templates.forEach((template) => { 60 | builder.build(`${sourcePath}/${template}`, outputDir, extension); 61 | }); 62 | }; 63 | 64 | module.exports = builder; 65 | -------------------------------------------------------------------------------- /src/inject/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | // Take a compiled template and inject replacement values 4 | module.exports = (template, vars = {}) => 5 | new Promise((resolve, reject) => { 6 | fs.readFile( 7 | template, 8 | { encoding: 'utf8' }, 9 | (err, data) => { 10 | if (err) { 11 | return reject(err); 12 | } 13 | 14 | let finalTemplate = data; 15 | 16 | Object.keys(vars).forEach((key) => { 17 | const regex = new RegExp(`{${key}}`, 'g'); 18 | finalTemplate = finalTemplate.replace(regex, vars[key]); 19 | }); 20 | 21 | return resolve(finalTemplate); 22 | } 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /src/mjml-utils.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const argv = require('yargs').argv; 4 | const buildAll = require('./builder').buildAll; 5 | const watcher = require('./watcher'); 6 | const sender = require('./sender'); 7 | const inject = require('./inject'); 8 | const sendmail = require('./sendmail'); 9 | 10 | const extension = argv.e || '.html'; 11 | 12 | if (argv.build) { 13 | buildAll(argv.i, argv.o, extension); 14 | } 15 | 16 | if (argv.watch) { 17 | watcher(argv.i, argv.o, extension); 18 | } 19 | 20 | if (argv.send) { 21 | sender(argv.o); 22 | } 23 | 24 | // Non-cli utils 25 | module.exports = { 26 | inject, 27 | sendmail, 28 | }; 29 | -------------------------------------------------------------------------------- /src/sender/answers.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const nodemailer = require('nodemailer'); 3 | const promptToSend = require('./promptToSend'); 4 | 5 | module.exports = (answers, templateDir) => { 6 | const transport = nodemailer.createTransport({ 7 | service: 'gmail', 8 | auth: { user: answers.from, pass: answers.password }, 9 | }); 10 | 11 | const template = path.join(templateDir, answers.template); 12 | 13 | promptToSend(transport, answers.from, answers.to, template); 14 | }; 15 | -------------------------------------------------------------------------------- /src/sender/index.js: -------------------------------------------------------------------------------- 1 | const inquirer = require('inquirer'); 2 | const questions = require('./questions'); 3 | const handleAnswers = require('./answers'); 4 | 5 | module.exports = (templateDir) => { 6 | inquirer.prompt(questions(templateDir)) 7 | .then(answers => handleAnswers(answers, templateDir)); 8 | }; 9 | -------------------------------------------------------------------------------- /src/sender/promptToSend.js: -------------------------------------------------------------------------------- 1 | const inquirer = require('inquirer'); 2 | const send = require('./send'); 3 | 4 | const promptToSend = (transport, from, to, template) => { 5 | inquirer.prompt([{ 6 | type: 'confirm', 7 | name: 'send', 8 | default: true, 9 | message: 'Send test email', 10 | }]) 11 | .then((sendResponse) => { 12 | if (sendResponse.send) { 13 | send(template, from, to, transport, () => { 14 | promptToSend(transport, from, to, template); 15 | }); 16 | } 17 | }); 18 | }; 19 | 20 | module.exports = promptToSend; 21 | -------------------------------------------------------------------------------- /src/sender/questions.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | module.exports = (templateDir) => { 5 | const templates = fs.readdirSync(path.join(process.cwd(), templateDir)); 6 | 7 | if (!templates.length) { 8 | throw new Error('No templates to test'); 9 | } 10 | 11 | const filtered = templates.filter(template => /\.html$/.test(template)); 12 | const templateNames = filtered.map(template => template.replace('.html', '')); 13 | 14 | return [ 15 | { 16 | type: 'input', 17 | name: 'from', 18 | message: 'Enter your Gmail account username (not saved)', 19 | }, 20 | { 21 | type: 'password', 22 | name: 'password', 23 | message: 'Enter your Gmail account password (not saved)', 24 | }, 25 | { 26 | type: 'input', 27 | name: 'to', 28 | message: 'Enter the recipient email address (not saved)', 29 | }, 30 | { 31 | type: 'list', 32 | name: 'template', 33 | message: 'Which template do you want to test?', 34 | choices: templateNames, 35 | default: 0, 36 | }, 37 | ]; 38 | }; 39 | -------------------------------------------------------------------------------- /src/sender/send.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | module.exports = (template, from, to, transport, callback) => { 5 | const subject = new Date(); 6 | const templatePath = path.join(process.cwd(), `${template}.html`); 7 | const html = fs.readFileSync(templatePath, 'utf-8'); 8 | const mailConfig = { from, to, subject, html }; 9 | 10 | transport.sendMail(mailConfig, (error, info) => { 11 | if (error) { 12 | throw new Error(error); 13 | } 14 | 15 | console.log(`Message sent: ${info.response}`); // eslint-disable-line 16 | 17 | callback(); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/sendmail/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const mjml2html = require('mjml').mjml2html; 3 | 4 | const templateCache = {}; 5 | 6 | function sendmail({ to, subject, text, template, data, onError = () => {} }) { 7 | if (!sendmail.config.fromAddress) { 8 | throw new Error('mjml-utils sendmail missing fromAddress configuration'); 9 | } 10 | 11 | if (!sendmail.config.transport) { 12 | throw new Error('mjml-utils sendmail missing transport configuration (prior to v2.0.0, this was "transporter")'); 13 | } 14 | 15 | return new Promise((resolve, reject) => { 16 | function resolver(rawTemplate) { 17 | let { html } = mjml2html(rawTemplate, { filePath: template }); 18 | 19 | Object.keys(data).forEach((key) => { 20 | const regex = new RegExp(`{${key}}`, 'g'); 21 | html = html.replace(regex, data[key]); 22 | }); 23 | 24 | const mailOptions = { 25 | from: sendmail.config.fromAddress, 26 | html, 27 | subject, 28 | text, 29 | to, 30 | }; 31 | 32 | sendmail.config.transport.sendMail(mailOptions, (error) => { 33 | if (error) { 34 | onError(error); 35 | reject(error); 36 | } 37 | }); 38 | 39 | resolve(); 40 | } 41 | 42 | // Prefer cached template 43 | if (templateCache[template]) { 44 | resolver(templateCache[template]); 45 | return; 46 | } 47 | 48 | // No cached template, read from disk 49 | fs.readFile(template, 'utf8', (readFileError, rawTemplate) => { 50 | if (readFileError) { 51 | onError(readFileError); 52 | reject(readFileError); 53 | return; 54 | } 55 | 56 | templateCache[template] = rawTemplate; 57 | 58 | resolver(rawTemplate); 59 | }); 60 | }); 61 | } 62 | 63 | sendmail.config = function config({ fromAddress, transport }) { 64 | sendmail.config.fromAddress = fromAddress; 65 | sendmail.config.transport = transport; 66 | }; 67 | 68 | module.exports = sendmail; 69 | -------------------------------------------------------------------------------- /src/watcher/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const chokidar = require('chokidar'); 3 | const build = require('../builder').build; 4 | 5 | module.exports = (inputDir, outputDir, extension) => { 6 | if (!inputDir) { 7 | console.error('Error: Missing -i argument (input directory)\n'); 8 | return; 9 | } 10 | 11 | if (!outputDir) { 12 | console.error('Error: Missing -o argument (output directory)\n'); 13 | return; 14 | } 15 | 16 | const sourceDir = path.join(process.cwd(), inputDir); 17 | const watcher = chokidar.watch(sourceDir); 18 | 19 | watcher.on('ready', () => { 20 | console.log('\nTemplate watcher started\n'); 21 | }); 22 | 23 | watcher.on('error', (error) => { 24 | console.log(`Watcher error: ${error}`); 25 | }); 26 | 27 | watcher.on('change', (filePath) => { 28 | const isMjml = /\.mjml$/.test(filePath); 29 | 30 | if (isMjml) { 31 | build(filePath.replace(/\\/g, '/'), outputDir, extension); 32 | } 33 | }); 34 | }; 35 | --------------------------------------------------------------------------------