├── .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 |
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 |
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 |
--------------------------------------------------------------------------------