Emails play an important role in my project JustFax Online.
Before I implemented emails, I would spend time answering customers’ requests for fax status updates.
This means that I had to monitor each fax transmission (and some can take up to 30 minutes) and send an email when it succeeded or failed.
And so the obvious solution was to implement email notifications.
I hacked something together using MJML, handlebars, fluent for localization and Postmark.
Postmark is a great service by the way.
I’m not affiliated with them, but they have superb customer support.
I tweeted this:
And after 7 minutes they approved my account.
Highly recommend!
But let’s talk about emails.
Tables. Tables everywhere.
If you ever had a chance to design emails, you know that it’s one of the most terrible jobs to do.
Emails are still designed using HTML tables, and getting them right on every client—is an impossible task.
Luckily, there are tools like MJML which provide a DSL similar to HTML in order to design your emails.
With compilers available for all major languages, it’s a great solution for responsive emails.
Of course for the hip-and-cool there are React.email, VueEmail, and Svelte Email.
But I chose MJML.
In addition to MJML, I used handlebars as a lightweight templating language.
Emails rarely need complex logic, so handlebars with its simple syntax was a perfect choice.
JustFax is available currently in 3 languages: English, German, and French.
This means that emails need to be available in the same languages.
I use Fluent by Mozilla as my i18n framework through a set of crates for Rust: the low level fluent, and the higher level fluent-templates which was designed specifically to be used in template engines like tera and handlebars.
MJML files would sit in the same monorepo as the rest of the code, and through the MJML npm package, they will be compiled to HTML in CI/CD.
In runtime, Rust will load the HTML files, initialize Fluent and handlebars, and upon successful (or failed) fax delivery—would send the correct email.
This was good enough for the MVP, but it had some problems I wanted to improve on.
Preview.
Previewing emails was hard.
While the MJML tool comes with an online app (and a one you can download), frequent readers of this blog know that I’m stuck in Vim and I won’t use any other editor.
But even if you decide to use MJMLs own tool, or the live editor, you still can’t preview handlebars templates.
So the entire process of previewing your emails would turn into a nightmare of copy-pasting stuff around.
Modification.
Email modification is another thing that was hard.
Because there was no proper preview, modifying the emails became a risky task.
I would either break some variable, misspell a translation key, or would run into handlebars compilation errors, which happened in runtime.
Armed with the desire to improve these two problems, I started to look for solutions.
Attempt #01—compiled templates for Rust
Rust has this notion of compiled templates.
There are two popular crates for this: askama and yarte.
The former is for lovers of Jinja syntax, while the latter is better suited for handlebars/mustache enthusiasts.
The idea behind compiled templates is that through macro programming, they will analyze the template code and extract all the needed variables to a generated struct.
This means that you can’t accidentally forget to specify a variable or mistype it.
If you do, your code won’t compile.
I tried yarte, but it was somewhat slow, and I got many errors with it.
On top of it, while yarte solves the type-safety aspect of templates, it does not compile MJML to html, or allow me to preview my templates.
And after some brainstorming, and inspired by the recent change I made for the frontend (article is coming, drop a follow on Twitter or Mastodon, or subscribe to the newsletter—to make sure you won’t miss it), I asked my self “what would happen if I try to generate emails statically, like with static site generators?”.
Attempt #02—statically generated email templates
What if we take the same principles behind static site generators like Astro or SvelteKit, and use them to generate emails?
Vite
My first attempt was using Vite.
If you put all your templates in a Vite project, add some plugins—namely vite-plugin-mjml and vite-plugin-handlebars—you can achieve an amazing result!
Inside your Vite config, you can define the following:
export default defineConfig({
plugins: [
mjml({
input: "src",
output: "emails",
extension: ".html",
watch: true,
}),
handlebars(),
],
});
This will take all files inside src
directory, pass them through MJML compiler and put the result with .html
extension inside emails
directory.
It will also watch for changes, so if you change the template, you will get hot reloading.
Navigating then to localhos:4321/emails/template_name.html
will render the compiled MJML in the browser.
Not bad!
But if you actually try this, you will see that handlebars variables are all empty.
We can do better.
Using the handlebars plugin, we can define pageData
:
const pageData = {
"/emails/template_name.html": {
someVar: "someValue",
foo: "bar"
}
};
---
handlebars({
context(path) {
return pageData[path];
}
})
If we go to the same path again now, we will see that our handlebars variables are properly replaced with the provided data from pageData
!
Specifying strict: true
inside the handlebars plugin, will allow us to see errors about any missing variables from the template.
This is good enough for most use cases.
But not for me!
I wanted more.
- Ability to view the email in different languages
- Ability to modify the email data
- Ability to preview email in desktop and mobile
And so, I embarked on my next journey—SvelteKit.
SvelteKit
Everything I described above requires a bit more than just static HTML served in a browser.
It requires some sort of server and logic.
And SvelteKit allows for that.
I’ll first put a small gif of the resulting product, and then I’ll walk you through the important steps of how to achieve this.
Since SvelteKit is based on Vite, the build process remains as described above with the only exception that we no longer need the handlebars plugins.
Having the MJML plugin will allow the build process to compile MJML into HTML, which I then can render in Rust with handlebars.
During CI/CD, the resulting directory that contains the HTML of the emails, is simply copied into the Docker container, and read by Rust upon startup.
However, in order to achieve the preview and navigation, we will have to implement some routes.
We will need two route: ’/’ and ‘/render’ with the last being server only route.
Main route ’/’
The purpose of this route is mainly UI.
It should display a list of email templates, and allow the user to select a particular template.
It consists of two files.
+page.server.ts
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
const files = import.meta.glob("../../mjml/*.mjml", { query: "raw" });
const fileNames = Object.keys(files).map((name) =>
name.replaceAll("../../mjml/", ""),
);
return { emails: fileNames };
};
There is nothing special going on there.
We just import all the *.mjml
files, clean their names, and return them to the page.
The other file is page.svelte
, but I won’t include it here since it’s mostly UI.
The important bit is to declare data import export let data: PageData;
so that the result of the load
function will be available to the page.
After that, just render a button for each email template:
{#each data.emails as email}
<button on:click={() => sendRequest(email, {})}
class={`flex items-center font-medium gap-2 justify-between w-full my-1 borer border-gray-400 rounded-md hover:bg-gray-200 px-4 py-2 ${activeEmail === email ? 'bg-gray-200' : ''}`}>
{email}
</button>
{/each}
You might have noticed the sendRequest
callback, let’s talk about it.
function sendRequest(file: string, context: object) {
activeEmail = file;
fetch("/render", {
method: "POST",
body: JSON.stringify({
file,
context,
}),
}).then((res) =>
res.json().then((json) => {
html = json.html;
content = { json: json.context };
validator = createAjvValidator({ schema: json.schema });
updateFrameContent();
}),
);
}
Upon selection of the email template, we will issue a POST request to /render
with the name of the template.
The request is the name of the file, and a context
object which will be used to render the template by handlebars.
Initially, the context
object is empty.
The response is a JSON that contains the parsed and rendered HTML; an updated context
object; a JSON schema schema
; and errors
object that MJML produced.
I ended up not using the errors
, as I achieved what I needed, but in theory you can create a nice validation to inform the frontend that your MJML template is broken.
Render route /render
The render endpoint is purely server side, and resides inside a /render/+server.ts
file.
I’ll dump the code first, and then we will discuss the important parts.
import { type RequestHandler } from "@sveltejs/kit";
import fs from "node:fs";
import mjml2html from "mjml";
import Handlebars from "handlebars";
import { getTranslationFunction } from "../../i18n";
import Ajv, { type AnySchema } from "ajv";
interface RequestBody {
file: string;
context?: Record<string, string | number>;
}
const ajv = new Ajv({ useDefaults: true });
export const POST: RequestHandler = async ({ request }) => {
const body: RequestBody = await request.json();
const mjml = fs.readFileSync(`./mjml/${body.file}`);
const res = mjml2html(mjml.toString());
const schema = JSON.parse(
fs
.readFileSync(`./schemas/${body.file.replace(".mjml", ".json")}`)
.toString(),
);
const validate = ajv.compile(schema as unknown as AnySchema);
const context = body.context ?? {};
if (!validate(context)) {
return new Response(JSON.stringify({ errors: validate.errors }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}
const t = await getTranslationFunction(context.lang?.toString() ?? "en");
Handlebars.registerHelper("fluent", function (key) {
return t(key);
});
const tpl = Handlebars.compile(res.html, {});
const output = tpl(context);
return new Response(
JSON.stringify({
html: output,
schema,
context,
errors: res.errors,
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
);
};
The code accepts a JSON object with file
and context
.
file
is a path to a template file inside the mjml
folder (or any other folder name you decide to store your MJML files in).
context
is an object to render handlebars with.
The code first reads the template and parses the MJML into HTML.
It then reads a json
file from schemas
folder that have the same name as the template file.
This JSON file contains a JSON schema.
This is the cool part, because this file acts as both documentation and for initial preview purposes.
I can use this JSON schema as a reference in order to define the corresponding Rust struct, or if I want to take it further, I can add a compile step that will generate the struct for me during build based on the schema.
At the moment, I do it manually.
Next, I compile the JSON schema and validate it.
Since ajv
is created with useDefaults: true
, and all fields in the schema will have default values, this gives me the initial context
object when I send {}
in the first request.
I then get my t()
function which is used for translation, and bound to the context.lang
or en
.
Since returning the t()
requires promises to load the .ftl
files and initialize FluentBundle
, I had to redeclare handlebars fluent
helper for each request, as handlebars does not support asynchronous helpers.
It’s an MVP in the end ;)
Lastly, I compile the template with handlebars and send back the response, together with the schema, the updated context, and any errors from MJML.
Tying it up together
I won’t bore you with the frontend code, but I do want to mention some tools I used.
The emails’ HTML is rendered inside an iframe.
Every time there is a new response, I call this function which updates the iframe content:
function updateFrameContent() {
if (html !== null) {
frame.src = "about:blank";
frame.contentWindow?.document.open();
frame.contentWindow?.document.write(html);
frame.contentWindow?.document.close();
}
}
frame
is a variable that is bound to the iframe: <iframe class="w-full bg-white h-full mx-auto" title="email" bind:this={frame}></iframe>
.
For the context
preview and modification, I used svelte-jsoneditor
packages.
It has a lot of features, and even able to validate the JSON using JSON schema, which ties everything together nicely.
I listen to onChange
event of the svelte-jsoneditor
, and upon every correct JSON, I reissue the request:
onChange={(c) => {
if ('json' in c && typeof c.json === 'object' && c.json !== null) {
onContextChange(c.json);
} else if ('text' in c) {
try {
const json = JSON.parse(c.text);
onContextChange(json);
} catch (e) {
console.error(e);
}
}
}}
There are some UI things to handle resizing the iframe from full width to 360px for mobile view, and another button to replace the iframe with a Highlight
component from svelte-highlight
for the HTML source code.
Conclusion
I’m very happy with this solution!
I’m able to preview the emails with all kinds of different values.
It’s even possible to create dynamic svelte forms based on the JSON schema, so if your template can accept an enum, you can have a drop-down list of all the possible values.
I’m also able to view emails in different languages, something that I previously was able to do only after the emails were sent, using Postmark dashboard.
The JSON schema acts as a documentation to create proper types in Rust backend, and can be further processed in order to automate types creation.
The only downside of this approach is that during build, the entire boilerplate is being built as well.
In my CI/CD pipeline I copy only the resulting templates’ HTML, but nevertheless I spend computing power to generate code I don’t need.
I bet it’s possible to configure Vite with some option or flag in order to only run the MJML plugin, but I didn’t bother with it since this process runs in parallel to Rust compilation, and that takes way more time that generating the HTML templates and everything else.
Emails are a pain in the butt to work with.
I’m happy that we have tools like MJML, and I’m glad to share my knowledge and, hopefully, help you improve your email workflow.