├── .github └── workflows │ └── test.yml ├── .gitignore ├── .yarn └── install-state.gz ├── .yarnrc.yml ├── LICENSE ├── README.md ├── docs ├── api.md ├── custom-provider.md ├── providers.md ├── storage.md └── templating.md ├── example ├── emails │ ├── layouts │ │ └── default.mjml │ └── monthly-newsletter.md ├── index.js └── package.json ├── lerna.json ├── package.json ├── packages ├── cli │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── commands │ │ │ ├── init.js │ │ │ └── preview.js │ │ ├── index.js │ │ └── util │ │ │ ├── fs.js │ │ │ └── log.js │ └── templates │ │ ├── layouts │ │ └── default.mjml │ │ └── monthly-newsletter.md ├── engine │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.js │ │ └── lib │ │ │ ├── Frontmatter.js │ │ │ ├── Macaw.js │ │ │ └── Template.js │ └── tests │ │ ├── emails │ │ ├── example-custom-layout.md │ │ ├── example-invalid-frontmatter.md │ │ ├── example-invalid-mjml.md │ │ ├── example-no-frontmatter.md │ │ ├── example-partial.md │ │ ├── example-twig-frontmatter-2.md │ │ ├── example-twig-frontmatter-3.md │ │ ├── example-twig-frontmatter.md │ │ ├── example-twig-subject.md │ │ └── layouts │ │ │ ├── custom.mjml │ │ │ ├── default.mjml │ │ │ ├── invalid.mjml │ │ │ ├── partials │ │ │ ├── from-email.mjml │ │ │ └── name.mjml │ │ │ └── twig-test.mjml │ │ ├── index.test.js │ │ └── lib │ │ ├── Macaw.test.js │ │ └── Template.test.js ├── macaw │ ├── .npmignore │ ├── cli.js │ ├── index.js │ └── package.json ├── preview-ui │ ├── .env │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── preview.png │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ └── src │ │ ├── App.js │ │ ├── index.css │ │ └── index.js ├── provider-sendgrid │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.js │ └── tests │ │ └── index.test.js ├── storage-fs │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.js │ └── tests │ │ ├── fixtures │ │ ├── empty-file.txt │ │ └── file.txt │ │ └── index.test.js ├── storage-s3 │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.js │ └── tests │ │ └── index.test.js └── uglify-js-noop │ ├── index.js │ └── package.json └── yarn.lock /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [ 12.x, 14.x, 16.x ] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | cache: 'yarn' 18 | - name: Install dependencies 19 | run: yarn 20 | - name: Run tests 21 | run: yarn test 22 | - name: Code coverage 23 | run: bash <(curl -s https://codecov.io/bash) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | lib-cov 3 | coverage 4 | *.lcov 5 | node_modules 6 | package-lock.json 7 | build 8 | packages/cli/emails 9 | packages/macaw/README.md 10 | -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tschoffelen/macaw/02f2a30fb1bc140c46a78bcf0421a1e093c7e17d/.yarn/install-state.gz -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Schof.co 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Macaw

2 | 3 | # Scalable email templates for node.js 4 | 5 | [![Build Status](https://travis-ci.com/macaw-email/macaw.svg?branch=master)](https://travis-ci.com/macaw-email/macaw) 6 | [![Codecov](https://codecov.io/gh/macaw-email/macaw/branch/master/graph/badge.svg)](https://codecov.io/gh/macaw-email/macaw) 7 | [![NPM](https://img.shields.io/npm/v/macaw.svg)](https://npmjs.org/macaw) 8 | 9 | Macaw is a suite of tools that helps you design and send transactional emails. It aims to solve the problems that usually occur at the intersection of design, copywriting and engineering. 10 | 11 | **Macaw is:** 12 | 13 | - A node.js package that helps you to use templates to send transactions emails 14 | - A CLI tool that helps you structure and preview templates 15 | 16 | **Macaw is _not_:** 17 | 18 | - A Mailchimp-esque drag-and-drop email builder 19 | - A email sending service 20 | 21 | # Quick example 22 | 23 | Some lines of code usually say more than any documentation could, so here's a simple example of how this library could be used: 24 | 25 | ```js 26 | const macaw = require("macaw"); 27 | const sendgrid = require("@macaw-email/provider-sendgrid"); 28 | 29 | const mailer = macaw({ 30 | provider: sendgrid({ apiKey: "aaaaa-bbbbbbb-ccccccc-ddddddd" }) 31 | }); 32 | 33 | const template = await mailer.template("monthly-newsletter", { 34 | customerName: "Example Business", 35 | greeting: "Hi, Thomas!" 36 | }); 37 | 38 | await template.send({ 39 | to: { 40 | name: "Thomas Schoffelen", 41 | email: "thomas@schof.co" 42 | } 43 | }); 44 | ``` 45 | 46 | # Getting started 47 | 48 | Quickly get up and running by executing this command in your project's root directory: 49 | 50 | ```shell 51 | npx macaw init 52 | ``` 53 | 54 | This will: 55 | 56 | 1. Create a `emails` directory with an example template and layout file. 57 | 2. Add `macaw` to your package.json file. 58 | 59 | Take a look at the files created in the `emails` directory, and use the documentation links below to learn what's next. 60 | 61 | # Documentation 62 | 63 | - [Templates and layouts](docs/templating.md) 64 | - [Email sending providers](docs/providers.md) 65 | - [Template storage sources](docs/storage.md) 66 | - [Custom provider implementations](docs/custom-provider.md) 67 | - [API classes reference](docs/api.md) 68 | 69 | 70 | 71 |

72 | 73 | --- 74 | 75 |
76 | 77 | Get professional support for this package → 78 | 79 |
80 | 81 | Custom consulting sessions availabe for implementation support and feature development. 82 | 83 |
84 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | | [Macaw](../README.md) / [Documentation](../README.md#documentation) | 2 | | :------------------------------------------------------------------ | 3 | 4 | 5 | # API reference 6 | 7 | ## Classes 8 | 9 | - [Macaw](#macaw) - main mailer instance class 10 | - [Template](#template) - template instance class 11 | 12 | ## Macaw 13 | 14 | Usually, rather than initiating this class directly, you'd use the factory 15 | function `macaw`. 16 | 17 | ```js 18 | const mailer = macaw({ templatesDirectory: "emails" }); 19 | ``` 20 | 21 | ### `mailer.template()` 22 | 23 | Load a template from storage, and set any variables that should be made 24 | available within the template. Returns a Template instance. 25 | 26 | Note that this method is async! 27 | 28 | ```js 29 | const template = await mailer.template("newsletter", { name: "John" }); 30 | ``` 31 | 32 | ##### Parameters 33 | 34 | - **String `templateName`** - The name of the template to load, excluding directory name and extension 35 | - **Object `data`** - Key-value object with template variable values 36 | 37 | ##### Return value 38 | 39 | - **Promise<[Template](#template)>** - Template instance 40 | 41 | ## Template 42 | 43 | Use the `template()` function on your Macaw instance to initiate a template instance. 44 | 45 | ### `template.render()` 46 | 47 | Parse the template and return raw HTML output. 48 | 49 | ##### Return value 50 | 51 | - **String** - Parsed template HTML 52 | 53 | ### `template.send()` 54 | 55 | Send the template via the `provider` specified in the Macaw options. 56 | 57 | Every provider requires their own set of `sendOptions`, so have a look 58 | at the README file for the provider to find out what to pass along. 59 | 60 | ##### Parameters 61 | 62 | - **Object `sendOptions`** - Options to be passed to provider 63 | 64 | ##### Return value 65 | 66 | - **Promise<any>** - Response from provider 67 | 68 | ### `template.data` 69 | 70 | **Object** Frontmatter data in markdown template. 71 | 72 | ### `template.markdown` 73 | 74 | **String** Raw markdown body of template (with frontmatter stripped out). 75 | 76 | ### `template.mjml` 77 | 78 | **String** Raw MJML layout file content. 79 | -------------------------------------------------------------------------------- /docs/custom-provider.md: -------------------------------------------------------------------------------- 1 | | [Macaw](../README.md) / [Documentation](../README.md#documentation) | 2 | | :------------------------------------------------------------------ | 3 | 4 | 5 | # Custom provider integration 6 | 7 | Documentation coming soon. 8 | 9 | For now, have a look at [provider-sendgrid](../packages/provider-sendgrid/src/index.js) as an example. 10 | -------------------------------------------------------------------------------- /docs/providers.md: -------------------------------------------------------------------------------- 1 | | [Macaw](../README.md) / [Documentation](../README.md#documentation) | 2 | | :------------------------------------------------------------------ | 3 | 4 | 5 | # Email providers 6 | 7 | Using Macaw, you can not only render your email templates, but also directly send them, using one of the built-in email provider helpers. 8 | 9 | If your preferred email provider isn't supported, you can [write your own provider logic](./custom-provider.md). 10 | 11 | ## [Sendgrid](https://sendgrid.net) 12 | 13 | First install the Sendgrid provider package: 14 | 15 | ``` 16 | yarn add @macaw-email/provider-sendgrid 17 | ``` 18 | 19 | When initiating your instance of Macaw, pass in Sendgrid as your provider: 20 | 21 | ```js 22 | const sendgrid = require("@macaw-email/provider-sendgrid"); 23 | 24 | const mailer = macaw({ 25 | provider: sendgrid({ apiKey: "aaaaa-bbbbbbb-ccccccc-ddddddd" }) 26 | }); 27 | ``` 28 | 29 | You can find your API key in the Sendgrid developer console. 30 | 31 | Then you can load a template and send it: 32 | 33 | ```js 34 | const template = await mailer.template("monthly-newsletter", { 35 | greeting: "Hello, world" 36 | }); 37 | 38 | await template.send({ 39 | subject: "Hello, world!", 40 | to: { 41 | name: "Thomas Schoffelen", 42 | email: "thomas@schof.co" 43 | }, 44 | from: { 45 | name: "Mark from Startup X", 46 | email: "noreply@startup-x.com" 47 | } 48 | }); 49 | ``` 50 | 51 | The `template.send()` function accepts any parameters that are accepted by the [Sendgrid Node API](https://github.com/sendgrid/sendgrid-nodejs/blob/master/use-cases/kitchen-sink.md). It requires at least a `subject`, `to` and `from` field to be set. 52 | -------------------------------------------------------------------------------- /docs/storage.md: -------------------------------------------------------------------------------- 1 | | [Macaw](../README.md) / [Documentation](../README.md#documentation) | 2 | | :------------------------------------------------------------------ | 3 | 4 | 5 | # Template storage 6 | 7 | By default, Macaw expects your template files to exist in a local directory named `emails` relative to your script file. You can change this path, or ask Macaw to load the templates from other sources, like Amazon S3. 8 | 9 | ## Local templates directory 10 | 11 | When initiating your Macaw instance, pass along a `templatesDirectory` in the options to change the email template storage path: 12 | 13 | ```js 14 | const mailer = macaw({ 15 | templatesDirectory: "resources/misc/emails" 16 | }); 17 | ``` 18 | 19 | ## Layout files path 20 | 21 | By default Macaw expects layout files to exist in a subdirectory of the `templatesDirectory` called `layouts`. Of course this can be changed as well: 22 | 23 | ```js 24 | const mailer = macaw({ 25 | layoutsDirectory: "mjml-layout-files" // relative to templatesDirectory 26 | }); 27 | ``` 28 | 29 | ## Loading templates from AWS S3 30 | 31 | First add `@macaw-email/storage-s3` to your project: 32 | 33 | ``` 34 | yarn add @macaw-email/storage-s3 35 | ``` 36 | 37 | Then reference it in your code: 38 | 39 | ```js 40 | const macaw = require("macaw"); 41 | const s3 = require("@macaw-email/storage-s3"); 42 | 43 | const mailer = macaw({ 44 | storage: s3("my-bucket-name") 45 | }); 46 | ``` 47 | 48 | The `s3()` function accepts three parameters: 49 | 50 | - `bucketName` – **required**, string that contains the S3 bucket name from where Macaw will read your templates. 51 | - `s3Options` – optional, object of options that can be passed in to the S3 constructor to specify things like region and credentials. 52 | - `aws` – optional, override the default AWS object loaded from `aws-sdk`. Useful for testing/mocking. 53 | 54 | Notes: 55 | 56 | - When loading from S3, the `templatesDirectory` option is ignored (expects templates to be in the root of the bucket), but `layoutsDirectory` is still taken into account, and defaults to `layouts`. 57 | -------------------------------------------------------------------------------- /docs/templating.md: -------------------------------------------------------------------------------- 1 | | [Macaw](../README.md) / [Documentation](../README.md#documentation) | 2 | | :------------------------------------------------------------------ | 3 | 4 | 5 | # Building templates 6 | 7 | Macaw provides a neat structure for managing email templates, separating out copy and layouts as re-usable components: 8 | 9 | - A **layout** is written in [MJML](https://mjml.io/), a very simple HTML-like markup language that allows you to design responsive and cross-client compatible emails. 10 | - In Macaw, an email **template** is a simple Markdown file with some frontmatter to choose what layout to use, and optionally set a subject, etc. This makes it easy to edit the copy of emails, even for non-developers. 11 | 12 | Keeping these two things separate allows easy editing by each stakeholder (e.g. a designer can work on the layout files, a marketing team member can work on the texts in the actual templates). 13 | 14 | ## Layout files 15 | 16 | Layout files determine the design of the email. It's similar to a HTML template, but built using **[MJML](https://mjml.io/)**, a HTML-like language that makes it easy to build email templates that look good in every email client, without having to resort to all the tricks you usually need to make even the most basic things work. 17 | 18 | Macaw automatically compiles those MJML files to compatible HTML when you use it to preview a template or send it. 19 | 20 | Learn about how MJML works by checking out their [Getting Started tutorial](https://mjml.io/getting-started/1), followed by their [components documentation](https://mjml.io/documentation/#standard-body-components) to learn about all the fun things you can do with it. 21 | 22 | They also provide a [library of templates](https://mjml.io/templates) you can use as a starting point for your own. 23 | 24 | ### Layout variables 25 | 26 | In your layout files, you can use any **[Twig](https://twig.symfony.com/)** to make the template dynamic based on any of the variables that are added in when you send the email, or any variables in the front matter of template files (see below). Some examples: 27 | 28 | If you want to add a personal greeting, that would probably look something like this: 29 | 30 | ```xml 31 | Hi, {{to.name}}! 32 | ``` 33 | 34 | Or if you wanted to conditionally include or exclude components: 35 | 36 | ```xml 37 | {% if discounted %} 38 | There's now a discount! Order fast, stock is limited! 39 | {% endif %} 40 | ``` 41 | 42 | ### Template body 43 | 44 | One of those special variables is called `body`, and it contains the HTML for the main content, created from parsing a markdown template (see below). Usually you'd include it in your template like this: 45 | 46 | ```xml 47 | 48 | {{body | raw}} 49 | 50 | ``` 51 | 52 | (the `| raw` here tells Twig to allow HTML in this variable!) 53 | 54 | ## Template files 55 | 56 | **[Markdown](https://daringfireball.net/projects/markdown/)** is used to write the content for your emails, in files ending in `.md` in your `emails` directory. This is a way of creating plain text files that can still contain bold, italic, links etc. 57 | 58 | It abstracts away all of the layout, so that you can focus on what is really important: the content. No need to know any HTML for this part. 59 | 60 | ### Front matter 61 | 62 | If you open up the example template, you'll see that there's some information at the top of the file before the actual content starts: 63 | 64 | ```md 65 | --- 66 | layout: default 67 | subject: November newsletter 68 | preview: Here's your news for November. 69 | --- 70 | 71 | Rest of the email here... 72 | ``` 73 | 74 | This is what we call 'front matter'. It is the meta data that is attached 75 | to the email. 76 | 77 | Front matter is a list of names and values, separated by a colon. This information is passed along to the layout file, so that you can use these variables to customize the way the email is displayed. 78 | 79 | Macaw requires only two values to be present here: 80 | 81 | - **layout**: this determines what layout file to use. If you set this to `newsletter` for example, it will use the file `emails/layouts/newsletter.mjml` as the layout. If you omit it, it load the default template (`emails/layouts/default.mjml`). 82 | - **subject**: the email subject line 83 | 84 | You're free to add whatever other variables you might need in here. 85 | 86 | ## Template variables 87 | 88 | It is also possible to use variables in your email body. For example, if you 89 | type: 90 | 91 | ```md 92 | Hello team at {{name}}! 93 | ``` 94 | 95 | It will be shown as: 96 | 97 | > Hello team at Customer X! 98 | 99 | Anything between double curly braces will be parsed as a variable using **[Twig](https://twig.symfony.com/)**. 100 | 101 | What variables are available, depends on what you pass into the parser when you parse the template. You can test this out using the preview tool (by running `yarn macaw` in your project). 102 | 103 | For example, you might pass in this JSON object: 104 | 105 | ```json 106 | { 107 | "subject": "Hello, world", 108 | "to": { 109 | "name": "John", 110 | "email": "john@example.com" 111 | } 112 | } 113 | ``` 114 | 115 | Which you can then use in your template like this: 116 | 117 | ```md 118 | Hi {{to.name}}, 119 | 120 | You are receiving this email because you signed up for the newsletter using your email address {{to.email}}. 121 | ``` 122 | 123 | Which would result in this output: 124 | 125 | > Hi John, 126 | > 127 | > You are receiving this email because you signed up for the newsletter using your email address john@example.com. 128 | -------------------------------------------------------------------------------- /example/emails/layouts/default.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{subject}} 4 | {{preview}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{title}} 12 | 13 | 14 | {{subtitle}} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {{body | raw}} 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /example/emails/monthly-newsletter.md: -------------------------------------------------------------------------------- 1 | --- 2 | subject: November newsletter 3 | title: The monthly newsletter 4 | subtitle: November 2019 5 | preview: Here's your news for November. 6 | --- 7 | 8 | Hi {{ name }}, 9 | 10 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque felis ante, ullamcorper ut est a, pellentesque sodales libero. Donec tempor velit nec purus dignissim viverra. Pellentesque aliquet nisl et ex laoreet accumsan vel vitae est. Nunc sollicitudin purus sed massa elementum, quis venenatis tellus laoreet. Cras semper sodales nunc, placerat hendrerit ex aliquam non. 11 | 12 | Morbi molestie ut libero ac porttitor. Nunc eget justo magna. Phasellus in tincidunt eros. Donec fringilla tortor ac sodales pretium. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Etiam rutrum condimentum nisi, sed ullamcorper nibh luctus sed. Donec at justo in ante mollis tristique. 13 | 14 | All the best, 15 | 16 | The team at Startup X 17 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | const macaw = require("macaw"); 2 | const sendgrid = require("@macaw-email/provider-sendgrid"); 3 | 4 | (async () => { 5 | const mailer = macaw({ 6 | provider: sendgrid({ 7 | apiKey: "aaaaa-bbbbbbb-ccccccc-ddddddd" 8 | }) 9 | }); 10 | 11 | const template = await mailer.template("monthly-newsletter", { 12 | name: "John" 13 | }); 14 | 15 | console.log(template.render()); 16 | })(); 17 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@macaw-email/example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "macaw": "*", 9 | "@macaw-email/provider-sendgrid": "*" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "useWorkspaces": true, 7 | "command": { 8 | "publish": { 9 | "allowBranch": "master", 10 | "message": "chore(release): %s", 11 | "ignoreChanges": [ 12 | "**/*.md", 13 | "**/*.lock", 14 | "**/*.test.js" 15 | ] 16 | } 17 | }, 18 | "changelog": { 19 | "repo": "macaw-email/macaw", 20 | "labels": { 21 | "enchancement": ":rocket:(Enhancement)", 22 | "bug": ":bug:(Bug Fix)", 23 | "doc": ":doc:(Refine Doc)", 24 | "feat": ":sparkles:(Feature)" 25 | }, 26 | "cacheDir": ".changelog" 27 | }, 28 | "version": "1.7.0" 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "devDependencies": { 8 | "husky": ">=4", 9 | "lerna": "^3.20.2", 10 | "lint-staged": ">=10", 11 | "prettier": "^1.19.1" 12 | }, 13 | "resolutions": { 14 | "uglify-js": "file:./packages/uglify-js-noop" 15 | }, 16 | "overrides": { 17 | "uglify-js": "file:./packages/uglify-js-noop" 18 | }, 19 | "engines": { 20 | "node": ">=10" 21 | }, 22 | "scripts": { 23 | "test": "lerna run test -- --coverage", 24 | "lint": "prettier --write \"{packages/*/src/**,example/*}\"" 25 | }, 26 | "husky": { 27 | "hooks": { 28 | "pre-commit": "lint-staged || true" 29 | } 30 | }, 31 | "lint-staged": { 32 | "*.{js,css,md}": "prettier --write" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/.npmignore: -------------------------------------------------------------------------------- 1 | tests/ 2 | coverage/ 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | # Command-line utility for Macaw 2 | 3 | **[Macaw](https://macaw.email/) is a simple library to streamline email templating.** 4 | 5 | Please check the [Macaw documentation](https://macaw.email/) for more information. 6 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@macaw-email/cli", 3 | "version": "1.7.0", 4 | "description": "CLI for Macaw.", 5 | "main": "src/index.js", 6 | "author": "Thomas Schoffelen ", 7 | "license": "MIT", 8 | "homepage": "https://macaw.email", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/macaw-email/macaw.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/macaw-email/macaw/issues" 15 | }, 16 | "engines": { 17 | "node": ">=10" 18 | }, 19 | "scripts": { 20 | "start": "./src/index.js" 21 | }, 22 | "dependencies": { 23 | "@includable/open-browser": "^1.1.0", 24 | "@macaw-email/engine": "^1.7.0", 25 | "@macaw-email/preview-ui": "^1.6.0", 26 | "chalk": "^3.0.0", 27 | "chokidar": "^3.3.1", 28 | "commander": "^4.1.1", 29 | "express": "^4.17.1", 30 | "get-port": "^5.1.1", 31 | "mrm-core": "^4.0.2", 32 | "socket.io": "^2.3.0" 33 | }, 34 | "devDependencies": { 35 | "jest": "^25.1.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/cli/src/commands/init.js: -------------------------------------------------------------------------------- 1 | const chalk = require("chalk"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const { packageJson, install } = require("mrm-core"); 5 | 6 | const { log, error, success } = require("../util/log"); 7 | 8 | const isUsingYarn = () => fs.existsSync("yarn.lock"); 9 | 10 | module.exports = async (dir = "emails") => { 11 | const emailsPath = path.join(dir); 12 | const templatesPath = path.resolve( 13 | path.join(__dirname, "..", "..", "templates") 14 | ); 15 | 16 | // First check if there isn't already an "emails" directory 17 | if (fs.existsSync(emailsPath)) { 18 | error( 19 | "Hold on!", 20 | 'There seems to already be a "' + 21 | dir + 22 | '" directory in this \nproject. ' + 23 | "Can't finish the Macaw setup." 24 | ); 25 | process.exit(1); 26 | } 27 | 28 | // Create "emails" directory 29 | log("Creating " + dir + " directory..."); 30 | fs.mkdirSync(emailsPath); 31 | fs.mkdirSync(path.join(emailsPath, "layouts")); 32 | 33 | // Copy over templates 34 | log("Adding templates..."); 35 | fs.copyFileSync( 36 | path.join(templatesPath, "layouts", "default.mjml"), 37 | path.join(emailsPath, "layouts", "default.mjml") 38 | ); 39 | fs.copyFileSync( 40 | path.join(templatesPath, "monthly-newsletter.md"), 41 | path.join(emailsPath, "monthly-newsletter.md") 42 | ); 43 | 44 | // Install macaw 45 | await install("macaw"); 46 | 47 | // Update package.json 48 | await packageJson() 49 | .appendScript("macaw", "macaw") 50 | .save(); 51 | 52 | // Show nice message 53 | success( 54 | "All done!", 55 | "We've created an \"" + 56 | dir + 57 | '" directory with some sample\ntemplates to get you started.\n\n' + 58 | "Run " + 59 | chalk.blue((isUsingYarn() ? "yarn" : "npm run") + " macaw") + 60 | " to preview the templates in the" + 61 | "\nbrowser while you edit them. " 62 | ); 63 | process.exit(0); 64 | }; 65 | -------------------------------------------------------------------------------- /packages/cli/src/commands/preview.js: -------------------------------------------------------------------------------- 1 | const chalk = require("chalk"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const express = require("express"); 5 | const http = require("http"); 6 | const getPort = require("get-port"); 7 | const openBrowser = require("@includable/open-browser"); 8 | const socketio = require("socket.io"); 9 | const chokidar = require("chokidar"); 10 | const macaw = require("@macaw-email/engine"); 11 | 12 | const { log, error } = require("../util/log"); 13 | const { getTemplates } = require("../util/fs"); 14 | 15 | const render = async (socket, engine, [template, data]) => { 16 | try { 17 | const instance = await engine.template(template, data); 18 | socket.emit("render", await instance.render()); 19 | } catch (e) { 20 | socket.emit("render-error", e.message); 21 | } 22 | }; 23 | 24 | module.exports = async options => { 25 | const emailsPath = path.resolve(options.source); 26 | 27 | // First check if there is an "emails" directory 28 | if (!fs.existsSync(emailsPath)) { 29 | error( 30 | "Hold on!", 31 | 'The directory "' + 32 | options.source + 33 | "\" doesn't seem to exist here.\n\n" + 34 | "Run " + 35 | chalk.blue("npx macaw init") + 36 | " first to set up your project." 37 | ); 38 | process.exit(1); 39 | } 40 | 41 | const engine = macaw({ 42 | templatesDirectory: emailsPath 43 | }); 44 | 45 | const port = await getPort({ port: getPort.makeRange(4000, 4100) }); 46 | log(`Starting local previewer on port ${port}...`); 47 | 48 | const app = express(); 49 | const srv = http.createServer(app); 50 | const io = socketio(srv); 51 | 52 | app.use( 53 | express.static(path.dirname(require.resolve("@macaw-email/preview-ui"))) 54 | ); 55 | 56 | io.on("connection", socket => { 57 | socket.emit("templates", getTemplates(emailsPath)); 58 | socket.on("data", data => { 59 | socket.latestPayload = data; 60 | render(socket, engine, data); 61 | }); 62 | }); 63 | 64 | chokidar 65 | .watch(emailsPath, { 66 | ignored: /(^|[\/\\])\../, 67 | ignoreInitial: true 68 | }) 69 | .on("all", () => { 70 | setTimeout(() => { 71 | try { 72 | io.emit("templates", getTemplates(emailsPath)); 73 | for (const socket of Object.values(io.sockets.connected)) { 74 | if (socket.latestPayload) { 75 | render(socket, engine, socket.latestPayload); 76 | } 77 | } 78 | } catch (e) { 79 | log(e); 80 | } 81 | }, 100); 82 | }); 83 | 84 | srv.listen(port, function() { 85 | openBrowser(`http://localhost:${port}/`); 86 | }); 87 | }; 88 | -------------------------------------------------------------------------------- /packages/cli/src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const commander = require("commander"); 4 | const pkg = require("../package.json"); 5 | 6 | const program = new commander.Command(); 7 | 8 | program 9 | .command("init") 10 | .description("Set up your emails directory structure.") 11 | .action(() => require("./commands/init")()); 12 | 13 | program 14 | .command("preview") 15 | .description("Set up your emails directory structure.") 16 | .option('-s, --source ', 'The source of your templates', 'emails') 17 | .action((options) => require("./commands/preview")(options)); 18 | 19 | program.on("command:*", () => { 20 | console.error( 21 | "Invalid command: %s\nSee --help for a list of available commands.", 22 | program.args.join(" ") 23 | ); 24 | process.exit(1); 25 | }); 26 | 27 | if (process.argv.length === 2) { 28 | return program.emit("command:preview"); 29 | } 30 | 31 | program.version("macaw " + pkg.version, "-v, --version").parse(process.argv); 32 | -------------------------------------------------------------------------------- /packages/cli/src/util/fs.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | const getTemplates = emailsPath => 4 | fs 5 | .readdirSync(emailsPath) 6 | .filter( 7 | file => 8 | !["layouts", "readme.md"].includes(file.toLowerCase()) && 9 | file.includes(".md") 10 | ) 11 | .map(file => file.replace(".md", "")); 12 | 13 | module.exports = { 14 | getTemplates 15 | }; 16 | -------------------------------------------------------------------------------- /packages/cli/src/util/log.js: -------------------------------------------------------------------------------- 1 | const chalk = require("chalk"); 2 | 3 | const log = message => console.log(message); 4 | 5 | const message = message => 6 | log(`\n\n ${message.split("\n").join("\n ")}\n\n`); 7 | 8 | const error = (title, description) => 9 | message(`${chalk.red.bold(title)}\n\n${description}`); 10 | 11 | const success = (title, description) => 12 | message(`${chalk.green.bold(title)}\n\n${description}`); 13 | 14 | module.exports = { 15 | log, 16 | message, 17 | error, 18 | success 19 | }; 20 | -------------------------------------------------------------------------------- /packages/cli/templates/layouts/default.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{subject}} 4 | {{preview}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{title}} 12 | 13 | 14 | {{subtitle}} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {{body | raw}} 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/cli/templates/monthly-newsletter.md: -------------------------------------------------------------------------------- 1 | --- 2 | subject: November newsletter 3 | title: The monthly newsletter 4 | subtitle: November 2019 5 | preview: Here's your news for November. 6 | --- 7 | 8 | Hi {{ to.name }}, 9 | 10 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque felis ante, ullamcorper ut est a, pellentesque sodales libero. Donec tempor velit nec purus dignissim viverra. Pellentesque aliquet nisl et ex laoreet accumsan vel vitae est. Nunc sollicitudin purus sed massa elementum, quis venenatis tellus laoreet. Cras semper sodales nunc, placerat hendrerit ex aliquam non. 11 | 12 | Morbi molestie ut libero ac porttitor. Nunc eget justo magna. Phasellus in tincidunt eros. Donec fringilla tortor ac sodales pretium. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Etiam rutrum condimentum nisi, sed ullamcorper nibh luctus sed. Donec at justo in ante mollis tristique. 13 | 14 | All the best, 15 | 16 | The team at Startup X 17 | -------------------------------------------------------------------------------- /packages/engine/.npmignore: -------------------------------------------------------------------------------- 1 | tests/ 2 | coverage/ 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /packages/engine/README.md: -------------------------------------------------------------------------------- 1 | # Macaw templating engine 2 | 3 | **[Macaw](https://macaw.email/) is a simple library to streamline email templating.** 4 | 5 | Please check the [Macaw documentation](https://macaw.email/) for more information. 6 | -------------------------------------------------------------------------------- /packages/engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@macaw-email/engine", 3 | "version": "1.7.0", 4 | "description": "Template engine for Macaw.", 5 | "main": "src/index.js", 6 | "author": "Thomas Schoffelen ", 7 | "license": "MIT", 8 | "homepage": "https://macaw.email", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/macaw-email/macaw.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/macaw-email/macaw/issues" 15 | }, 16 | "engines": { 17 | "node": ">=10" 18 | }, 19 | "scripts": { 20 | "test": "jest" 21 | }, 22 | "dependencies": { 23 | "@macaw-email/storage-fs": "^1.5.0", 24 | "js-yaml": "^3.14.1", 25 | "mjml": "^4.14.1", 26 | "showdown": "^1.9.1", 27 | "twig": "^1.16.0" 28 | }, 29 | "devDependencies": { 30 | "jest": "^24.9.0" 31 | }, 32 | "overrides": { 33 | "uglify-js": "file:../uglify-js-noop" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/engine/src/index.js: -------------------------------------------------------------------------------- 1 | const Macaw = require("./lib/Macaw"); 2 | 3 | /** 4 | * Macaw class factory. This is what you will use to initiate your personal 5 | * Macaw instance. 6 | * 7 | * @example const mailer = macaw({ templatesDirectory: 'emails' }); 8 | * 9 | * @param {object} options Options to pass to Macaw class, see below 10 | * @returns {Macaw} 11 | */ 12 | const macaw = options => new Macaw(options); 13 | 14 | module.exports = macaw; 15 | -------------------------------------------------------------------------------- /packages/engine/src/lib/Frontmatter.js: -------------------------------------------------------------------------------- 1 | const parser = require("js-yaml"); 2 | const { twig } = require("twig"); 3 | 4 | const optionalByteOrderMark = "\\ufeff?"; 5 | const platform = typeof process !== "undefined" ? process.platform : ""; 6 | const pattern = `^(${optionalByteOrderMark}(= yaml =|---)$([\\s\\S]*?)^(?:\\2|\\.\\.\\.)\\s*$${ 7 | platform === "win32" ? "\\r?" : "" 8 | }(?:\\n)?)`; 9 | const regex = new RegExp(pattern, "m"); 10 | 11 | const computeLocation = (match, body) => { 12 | let line = 1; 13 | let pos = body.indexOf("\n"); 14 | const offset = match.index + match[0].length; 15 | 16 | while (pos !== -1) { 17 | if (pos >= offset) { 18 | return line; 19 | } 20 | line++; 21 | pos = body.indexOf("\n", pos + 1); 22 | } 23 | 24 | return line; 25 | }; 26 | 27 | const parse = async (string, data, preprocess) => { 28 | const match = regex.exec(string); 29 | if (!match) { 30 | return { 31 | attributes: {}, 32 | body: string, 33 | bodyBegin: 1 34 | }; 35 | } 36 | 37 | let yaml = match[match.length - 1].replace(/^\s+|\s+$/g, ""); 38 | let parsedYaml = yaml; 39 | try { 40 | if (preprocess) { 41 | yaml = await preprocess[0](yaml); 42 | yaml = await preprocess[1](yaml); 43 | } 44 | parsedYaml = twig({ data: yaml }).render(data); 45 | } catch (e) { 46 | // revert to plain string 47 | console.log(e); 48 | } 49 | const attributes = parser.load(parsedYaml) || {}; 50 | const body = string.replace(match[0], ""); 51 | const line = computeLocation(match, string); 52 | 53 | return { 54 | attributes: attributes, 55 | body: body, 56 | bodyBegin: line, 57 | frontmatter: yaml 58 | }; 59 | }; 60 | 61 | module.exports = async (string, data = {}, preprocess) => { 62 | string = string || ""; 63 | 64 | const lines = string.split(/(\r?\n)/); 65 | if (lines[0] && /= yaml =|---/.test(lines[0])) { 66 | return parse(string, data, preprocess); 67 | } 68 | 69 | return { 70 | attributes: {}, 71 | body: string, 72 | bodyBegin: 1 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /packages/engine/src/lib/Macaw.js: -------------------------------------------------------------------------------- 1 | const fsStorage = require("@macaw-email/storage-fs"); 2 | 3 | const Template = require("./Template"); 4 | 5 | const defaultOptions = { 6 | templateFileExtension: "md", 7 | templatesDirectory: "emails", 8 | layoutsDirectory: "layouts", 9 | storage: fsStorage(), 10 | markdown: { 11 | noHeaderId: true, 12 | simplifiedAutoLink: true, 13 | encodeEmails: false, 14 | backslashEscapesHTMLTags: false, 15 | ghCodeBlocks: false, 16 | excludeTrailingPunctuationFromURLs: true 17 | }, 18 | mjml: { 19 | validationLevel: "soft" 20 | } 21 | }; 22 | 23 | /** 24 | * Constructor for the Macaw engine class. 25 | * 26 | * Usually, rather than initiating this class directly, you'd use the factory 27 | * function `macaw`. 28 | * 29 | * ```js 30 | * const mailer = macaw({ templatesDirectory: 'emails' }); 31 | * ``` 32 | * 33 | * @param {Object} options Engine options 34 | * @param {string} options.templateFileExtension File extension appended to 35 | * template files - defaults to `md`. 36 | * @param {string} options.templatesDirectory Directory to use to look for 37 | * template files - defaults to `emails`. 38 | * @param {string} options.layoutDirectory Layouts directory name, needs to be 39 | * a subdirectory of the templatesDirectory - defaults to `layouts`. 40 | * @param {Object} options.storage Define this to override the default storage 41 | * engine, needs to be an object with a method `getObject` - defaults to 42 | * `fsStorage`. 43 | * @param {Object} options.provider Specify email provider, needs to be a 44 | * object with a method `send`, have a look at any of the included 45 | * providers (e.g. Sendgrid) for an example on how to create your own. 46 | * @param {Object} options.markdown Set custom options for markdown rendering 47 | * engine. See the [Showdown docs](https://github.com/showdownjs/showdown#valid-options) 48 | * for more info on valid options. 49 | * @param {Object} options.mjml Set custom options for the 50 | * [MJML renderer](https://github.com/mjmlio/mjml#inside-nodejs). 51 | */ 52 | class Macaw { 53 | /** 54 | * Constructor. 55 | * 56 | * @param {Object} options Engine options 57 | */ 58 | constructor(options = {}) { 59 | this.options = { ...defaultOptions, ...options }; 60 | this.options.storage.setOptions({ ...this.options, storage: undefined }); 61 | } 62 | 63 | /** 64 | * Load a template from storage, and set any variables that should be made 65 | * available within the template. Returns a Template instance. 66 | * 67 | * Note that this method is async! 68 | * 69 | * ```js 70 | * const template = await mailer.template('newsletter', { name: 'John' }); 71 | * ``` 72 | * 73 | * @param {string} templateName The name of the template to load, excluding directory name and extension 74 | * @param {{[string]: any}} data An object of variables to set in the template 75 | * @returns {Promise