Blazing-fast emails with Node and Rust

a piano

Creating emails programmatically can be pretty overwhelming if you haven’t done it before. Emails seem to exist in this alternate universe where most of the features we know in modern CSS and HTML aren’t available to us (here’s a useful site where you can lookup the exact features). We have to worry about supporting vastly different email clients like Gmail, Apple Mail, and Microsoft Outlook. We have to forget about modern luxuries like CSS flexbox and grid, and rely mostly on HTML tables and inline styles to make emails look how we want. Next time you’re checking your email, feel free to inspect the Devtools in your browser to see the kind of gobbledygook required to make all of this work 😅.

Now, if you’re using a batteries-included web framework like Django, Ruby on Rails, Adonis, etc., the framework will typically have a built-in utility for rendering and sending emails. But if you’re using a frontend-focused framework like Next.js or SvelteKit, or you’ve just got a barebones Node.js server, and you want to send some transactional emails (e.g. registration confirmations, password reset, etc.), there isn’t an obvious choice to make that happen. In fact, as with much of the JS/Node ecosystem, there are a lot of different choices out there, which can easily lead to decision fatigue.

So, here’s one option that I’ve found works well, piecing together some free and open-source tools!

Contents

All the pieces

MJML - the magical markup language

First, you should know about a handy tool called the MJML markup language, that is designed to make writing emails easier. You can think of MJML kinda like syntactic sugar over that aforementioned gobbledygook (it’s a lot more than syntactic sugar, but that’s the basic idea). For example, you can write something like this:

<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-family="Helvetica">
<h1>Hello</h1>
<p>Hello world!</p>
<p>And hello again.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

… And from there, the MJML engine will magically translate that into a whole bunch of HTML that should work in different email clients, and render what you might expect. You can check out a live, editable preview of this here, and even take a peek at what it’s generating by clicking “View HTML” at the top. Bottom line is, MJML provides us some nice building blocks and abstractions that should make the process of coding emails a bit smoother and easier.

Pretty neat, right? But you might be thinking… what does Rust have to do with this exactly?

mrml - the Rust of it all

Well, there is another handy little tool called mrml. It’s an implementation of the MJML engine written in Rust, and it can take your MJML markup and spit out the HTML very quickly while using a lot less memory than the original MJML library (there are some impressive stats listed in its GitHub repo, and my own limited testing backs it up). So we can plop this engine into our server without worrying too much about increasing load and costs. Yay! And fortunately, the mrml library includes a WebAssembly package that can be easily imported into a Node.js project.

But, we’re still missing something. For transactional emails, we are typically not sending the same exact email to every user. We need a way to easily insert dynamic data into the MJML markup before rendering the email, so we can include the user’s name, custom data, generated links, etc… We need a templating engine!

Eta - our simple templating engine

Again, there are so many choices for templating engines in Node/JS land. I like Eta - it’s lightweight, fast, easy to use, and well-maintained (as of late 2024). Eta will let us plug in our custom variables into the MJML markup before rendering the email. It also enables some handy ways to add conditionals and loops using JS syntax. To display a list of fruits, for example:

<ul>
<% fruits.forEach(fruit => { %>
<li><%= fruit %></li>
<% }) %>
</ul>

This can look a little strange if you’re not familiar with either Eta or EJS, the older templating engine it’s based on. But it’s basically just plain JavaScript syntax surrounded by tags. Eta will also take care of caching our email templates, which will be good for performance!

Summary - putting it all together

So now, we have all the pieces we need to render emails quickly in our code. Here’s a rundown of how this will work:

  1. We design and code our emails using MJML markup, and we’ll include Eta placeholders for any dynamic data in the email.
  2. When we want to send an email to a user, we run our MJML markup through the Eta templating engine to insert all dynamic data.
  3. We then run that final MJML markup through the mrml engine, which will turn it into the HTML gobbledygook suited for email clients.
  4. We now have the final HTML of the email, which we can send using nodemailer or any email API service like Mailersend, Postmark, Resend, etc.

Demo - task app with email notifications

Let’s say we’re building a task management app that can send email reminders to the user showing their upcoming tasks. I’ve put together a little prototype showing just the email rendering process. You can see the demo here, and view the whole code on GitHub. I’ll walk through some of the important parts below.

MJML

First, we’ll code our reminder email using MJML markup, and we’ll use Eta templating for any dynamic data. Notice how MJML enables us to organize our email code in a semi-HTML, semi-JSX kinda way:

<mjml>
<mj-head>
<mj-preview>Upcoming tasks</mj-preview>
<mj-attributes>
<mj-all font-family="Helvetica Neue" />
<mj-text padding="8px" />
<mj-class name="font-lg" font-size="20px" />
<mj-class name="paragraph" line-height="1.5" />
<mj-class name="p-md" padding="6px" />
</mj-attributes>
</mj-head>
<mj-body background-color="#fff">
<mj-section mj-class="p-md">
<mj-column>
<mj-text mj-class="font-lg"> Hi <%= it.name %>! </mj-text>
<mj-text mj-class="paragraph">
<%= it.appName %> here! This is your friendly reminder for these tasks:
</mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="p-md">
<mj-column>
<mj-table>
<tr style="border-bottom:1px solid #bbb;text-align:left;padding:15px 0;">
<th>Task</th>
<th width="50px" style="padding: 0 15px;">Priority</th>
<th style="padding: 0 0 0 15px;">Due date</th>
</tr>
<% it.tasks.forEach(({ priority, dueDate, description }) => { %>
<tr>
<td><%= description %></td>
<td width="50px" style="padding: 0 15px;"><%= priority %></td>
<td style="padding: 0 0 0 15px;"><%= dueDate %></td>
</tr>
<% }) %>
</mj-table>
</mj-column>
</mj-section>
</mj-body>
</mjml>

Inside the <mj-head> section, we’re taking advantage of some useful utilities that MJML provides, like mj-class to create reusable styles we can use throughout the markup, and mj-preview for the preview text that shows in your email inbox before you open the email. Then, inside <mj-body>, the body of our email is broken down into mj-sections, which are essentially rows within the email. Those rows can contain mj-columns as direct children. We also make use of the mj-table element to create a basic table of upcoming tasks.

You can also see where we’re inserting any dynamic data into the email using Eta templating syntax, using <%= ... %> for displaying data, such as <%= it.name %>, and <% ... %> for evaluating expressions like <% it.tasks.forEach... %>. Eta’s default behavior is to put all data on the global variable it, but this can be changed in its configuration.

Fastify and Eta

Now that we have our email marked up in MJML, we’re ready to setup our server for rendering. For my server, I’m using one of my favorite frameworks, Fastify. Fastify has an awesome plugin system that lets us easily add the services we need to it. We’ll create a plugin file and add the Eta templating engine using the @fastify/view package:

import fp from "fastify-plugin";
import fastifyView from "@fastify/view";
import { Eta } from "eta";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default fp(async function (fastify, opts) {
fastify.register(fastifyView, {
engine: { eta: new Eta() },
root: path.join(__dirname, "../templates"),
});
});

The important part here is that we are registering the fastifyView plugin with our server and using Eta as our engine. By registering the plugin, this adds a utility method to Fastify’s instance object, fastify.view(), which will let us quickly render any Eta template in the ../templates folder.

(There’s some boilerplate here: the fp wrapper is a Fastify-specific way to ensure that all routes and services will have access to this utility. And the __filename and __dirname boilerplate is necessary here because I’m using ESM - if you’re using CommonJS, those global variables are built in already.)

mrml

Next, we’ll create another plugin for our server, which will add a fastify.email() utility to our Fastify instance. This function will take in all the custom data needed to generate the email, then create the custom MJML markup using Eta, and finally run the MJML through the mrml engine to render the HTML email.

import fp from "fastify-plugin";
import { Engine } from "mrml/nodejs/mrml_wasm.js";
export default fp(async function (fastify, opts) {
const mrmlEngine = new Engine();
// Add a `fastify.email()` utility function to the fastify instance
fastify.decorate("email", async function ({ name, appName, tasks }) {
// Plugin the data variables into the MJML template, to create
// the custom MJML markup for this email
const mjml = await fastify.view("task-email.mjml", {
name,
appName,
tasks,
});
// Render the MJML markup using the mrml engine
const htmlResult = await mrmlEngine.toHtmlAsync(mjml);
if (htmlResult.type === "error") {
fastify.log.error(htmlResult, "Error while rendering email");
return null;
}
return htmlResult.content;
});
});

Notice that the mrml engine gives out a Rust-like result object. To be safe, we check if the result is an error, and then if not, we return the raw HTML that was generated.

API route

Finally, we can create an API route that takes in the dynamic data needed for the email, and then renders the email using our new fastify.email() utility method.

⚠️ Of course, please don’t use code like this in production - when you’re taking in inputs through an API route, they must be adequately validated before doing anything else! ⚠️

export default async function (fastify, opts) {
fastify.post("/", async function (request, reply) {
// process inputs from the request body
const { name, appName } = request.body;
const tasks = [];
[1, 2, 3].forEach((taskNum) => {
const description = request.body["description" + taskNum];
if (description) {
tasks.push({
description,
priority: Number(request.body["priority" + taskNum]) || 1,
dueDate: formatDate(request.body["dueDate" + taskNum]),
});
}
});
// render email using our `fastify.email()` utility
const html = await fastify.email({ name, appName, tasks });
if (!html) {
return reply.internalServerError("Error rendering email!");
}
// Send email with your chosen service (nodemailer / Resend / Postmark / etc.)
await myEmailService.send("user-email@example.com", html);
return { message: "Great success!" };
});
}