Rendering emails with Svelte

·

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.

  1. Ability to view the email in different languages
  2. Ability to modify the email data
  3. 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.

Email preview with MJML, handlebars, and SvelteKit

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.

Share this:

Published by

Dmitry Kudryavtsev

Dmitry Kudryavtsev

Senior Software Engineer / Tech Entrepreneur

With more than 14 years of professional experience in tech, Dmitry is a generalist software engineer with a strong passion to writing code and writing about code.


Technical Writing for Software Engineers - Book Cover

Recently, I released a new book called Technical Writing for Software Engineers - A Handbook. It’s a short handbook about how to improve your technical writing.

The book contains my experience and mistakes I made, together with examples of different technical documents you will have to write during your career. If you believe it might help you, consider purchasing it to support my work and this blog.

Get it on Gumroad or Leanpub


From Applicant to Employee - Book Cover

Were you affected by the recent lay-offs in tech? Are you looking for a new workplace? Do you want to get into tech?

Consider getting my and my wife’s recent book From Applicant to Employee - Your blueprint for landing a job in tech. It contains our combined knowledge on the interviewing process in small, and big tech companies. Together with tips and tricks on how to prepare for your interview, befriend your recruiter, and find a good match between you and potential employer.

Get it on Gumroad or LeanPub