February 18, 2021
If you've ever worked on HTML emails in Node.js, there's a good chance you've worked with the nodemailer module, and also maintained HTML templates written as template literals in JavaScript.
1const info = await transporter.sendMail({2 from: '"Fred Foo 👻" <foo@example.com>', // sender address3 to: "bar@example.com, baz@example.com", // list of receivers4 subject: "Hello ✔", // Subject line5 text: "Hello world?", // plain text body6 html: "<b>Hello world?</b>", // html body7});
Example taken from nodemailer.com
If you're a React + TypeScript developer like me, losing all of your static typing and intellisense feels like a hit to your developer experience (DX).
Not only that, but also a lot of CSS has lackluster support on email clients, which can lead you to writing complex HTML table
structures to ensure consistent layouts.
1<table cellpadding="0" cellspacing="0" border="0">2 <tr>3 <td colspan="3" height="120px">....</td>4 </tr>5 <tr>6 <td class="menu" valign="top">...</td>7 <td class="content" valign="top">...</td>8 <td class="aSide" valign="top">...</td>9 </tr>10 <tr>11 <td colspan="3">...</td>12 </tr>13</table>
Example taken from smashingmagazine.com/2009/04/from-table-hell-to-div-hell
If maintaining emails in this way is so unpleasant, how can we improve our DX?
We've identified 2 pain-points of emails in Node.js thus far.
If you read the title to this post, you already know where this is headed.
Mailjet has created an open-source email framework called MJML (Mailjet Markup Language) which provides an elegant abstraction over the table
layouts above, as well as many ready-made components to use.
1<mjml>2 <mj-body>3 <mj-section>4 <mj-column>5 <mj-image width="100px" src="https://mjml.io/assets/img/logo-small.png"></mj-image>6 <mj-divider border-color="#F45E43"></mj-divider>7 <mj-text font-size="20px" color="#F45E43" font-family="helvetica">Hello World</mj-text>8 </mj-column>9 </mj-section>10 </mj-body>11</mjml>
Example taken from mjml.com
"Say goodbye to endless HTML table nesting or email client specific CSS. Building a responsive email is super easy with tags such as <mj-section>
and <mj-column>
" (MJML documentation)
So we've solved pain-point (#2), but with this, our server's email code will look like this:
1const html: string = `2<mjml>3 <mj-body>4 <mj-section>5 <mj-column>6 <mj-image width="100px" src="https://mjml.io/assets/img/logo-small.png"></mj-image>7 <mj-divider border-color="#F45E43"></mj-divider>8 <mj-text font-size="20px" color="#F45E43" font-family="helvetica">Hello World</mj-text>9 </mj-column>10 </mj-section>11 </mj-body>12</mjml>13`;1415const info = await transporter.sendMail({16 from: '"Fred Foo 👻" <foo@example.com>', // sender address17 to: "bar@example.com, baz@example.com", // list of receivers18 subject: "Hello ✔", // Subject line19 html20});
As you can see, we're still without our types or intellisense becauses everything is a string. Thankfully, with React's createElement
you can render any arbitrary XML tag, which makes integrating with MJML a breeze.
1import React, { createElement, FC } from "react";23export const MjmlButton: FC = ({ children, ...props }) => {4 return createElement("mj-button", props, children);5};67/* ... */89import mjml2html from "mjml";10import type { ReactElement } from "react";11import { renderToStaticMarkup } from "react-dom/server";1213const render = (email: ReactElement): string => {14 return mjml2html(renderToStaticMarkup(email));15};1617/* ... */1819const email = (20 <Mjml>21 {/* ... */}22 <MjmlButton />23 {/* ... */}24 </Mjml>25);2627const html: string = render(email);
You might say, "great, but wrapping every mjml component then typing all of the props seems like a pain".
Good thing we have mjml-react and @types/mjml-react which does it all for us.
Now, all we need to do is install dependencies, define our API, and build out our emails:
1npm install mjml mjml-react @types/mjml-react
1import { render } from "mjml-react";2import { createElement, ComponentType } from "react";3import { createTransport, SendMailOptions } from "nodemailer";45export const createEmailHtml = <P extends {}>(6 template: ComponentType<P>,7 props: P8): string => {9 const mjmlElement = createElement(template, props);1011 const { html, errors } = render(mjmlElement);1213 if (errors?.length) {14 throw new Error(errors[0]);15 }1617 return html;18};1920export const sendEmail = async (options: SendMailOptions) => {21 const transporter = createTransport({22 service: "gmail",23 auth: {24 user: process.env.EMAIL_SENDER_USER!,25 pass: process.env.EMAIL_SENDER_PASSWORD!26 }27 });2829 return await transporter.sendMail({30 sender: process.env.EMAIL_SENDER_USER!,31 ...options32 });33};
1// email-base component23import React, { FC } from "react";4import {5 Mjml,6 MjmlAll,7 MjmlAttributes,8 MjmlBody,9 MjmlBodyProps,10 MjmlBreakpoint,11 MjmlFont,12 MjmlHead,13 MjmlPreview,14 MjmlTitle,15 RequiredChildrenProps16} from "mjml-react";1718export interface EmailBaseProps extends MjmlBodyProps, RequiredChildrenProps {19 breakpoint?: number;20 title: string;21 preview: string;22}2324export const EmailBase: FC<EmailBaseProps> = ({25 breakpoint,26 children,27 title,28 preview,29 width,30 ...restBodyProps31}) => {32 const fontFamily = "Roboto";33 const fontHref = `http://fonts.googleapis.com/css?family=${fontFamily}:400,100,100`;3435 return (36 <Mjml>37 <MjmlHead>38 <MjmlTitle>{title}</MjmlTitle>39 <MjmlPreview>{preview}</MjmlPreview>40 <MjmlFont name={fontFamily} href={fontHref} />41 <MjmlAttributes>42 <MjmlAll fontFamily={fontFamily} fontSize={14} color="black" />43 </MjmlAttributes>44 {breakpoint && <MjmlBreakpoint width={breakpoint} />}45 </MjmlHead>46 <MjmlBody {...restBodyProps}>47 {children}48 </MjmlBody>49 </Mjml>50 );51};
1// email template to send to users23import { EmailBase } from "./email-base.component.tsx";4import { MjmlColumn, MjmlSection, MjmlText } from "mjml-react";5import React, { FC } from "react";67export interface HelloEmailProps {8 firstName: string;9 lastName: string;10}1112export const HelloEmail: FC<HelloEmailProps> = ({13 firstName,14 lastName15}) => {16 const fullName: string = [firstName, lastName].join(" ");1718 return (19 <EmailBase title="Hello!" preview="Hello from David Lee!">20 <MjmlSection>21 <MjmlColumn>22 <MjmlText>Hello {fullName}</MjmlText>23 </MjmlColumn>24 </MjmlSection>25 </EmailBase>26 );27};
Finally, you can send this email populating any data needed.
1import { createEmailHtml, HelloEmail, sendEmail } from "@myapp/server/email"; // Defined by us above23/* ... */45// Get user from your ORM6const user = await prisma.user.findUnique({ where: { id } });78if (!user) {9 return null;10}1112const { email, firstName, lastName } = user;1314const didSend: boolean =15 await sendEmail({16 to: email,17 subject: "Hello from David Lee",18 // With the magic of type generics, the props in parameter 2 have autocompletion19 html: createEmailHtml(HelloEmail, {20 firstName,21 lastName22 })23 })24 .then(() => true)25 .catch(() => false);2627/* ... */
With this, we've now also solved pain-point (#1), since we now have types and intellisense provided by TypeScript on our IDE.
If you're someone who works a lot with Storybook you'll quickly find that mjml-react
unfortunately does not work outside of Node.js. This is because, mjml-react
depends on mjml
, which depends on the fs
module (at the time of writing this post).
We can get around this to some extent by generating our stories via a separate Node.js script every time we need to update our emails.
1// mount raw string html for rendering emails to storybook23import React, { FC } from "react";45export interface MjmlMounterProps {6 children: string;7}89export const MjmlMounter: FC<MjmlMounterProps> = ({ children }) => {10 return <div dangerouslySetInnerHTML={{ __html: children }} >;11};
1// Script file to generate our email stories, for storybook to read23// All email template component types exported from an index.ts file4import * as templates from "@myapp/server/emails/templates";5import { stripIndent } from "common-tags";6import fs from "fs-extra";7import { render } from "mjml-react";8import path from "path";9import { ComponentType, createElement } from "react";1011const toStory = (key: string, text: string) => stripIndent`12import { MjmlMounter } from "@myapp/server/emails";13import React from "react";1415const html: string = \`${text}\`;1617export default { title: "emails/${key}" };1819export const Standard = () => <MjmlMounter>{html}</MjmlMounter>;2021Standard.parameters = {22 layout: "fullscreen"23};24`;2526const generateTemplates = () => {27 const emailHtmlDict: Record<string, string> = Object.keys(templates).reduce((acc, key) => {28 const template = templates[key];2930 // This will fill your stories with your emails' default prop values31 const withDefaultProps = createElement(template);32 const { html, errors } = render(withDefaultProps, {33 minify: false34 });3536 if (errors?.length) {37 throw new Error(errors[0]);38 }3940 return { ...acc, [key]: html };41 }, {});4243 // Prepare directory to generate stories into44 fs.ensureDirSync("outputDir");45 fs.emptyDirSync("outputDir");4647 Object.keys(emailHtmlDict).forEach((key) => {48 const outputPath = path.join("outputDir", `${key}.stories.tsx`);49 const emailHtml = emailHtmlDict[key];5051 fs.ensureFileSync(outputPath);52 fs.writeFileSync(outputPath, toStory(key, emailHtml), {53 encoding: "utf8",54 flag: "w"55 });56 });57};5859generateTemplates();
1/* ... */23// Ensure that your prop types stay as non-nullable, so that defaults aren't used in real-use4export interface HelloEmailProps {5 firstName: string;6 lastName: string;7}89// Add default prop values for your generated stories10export const HelloEmail: FC<HelloEmailProps> = ({11 firstName = "Test",12 lastName = "User"13}) => {14 /* ... */15};
This looks unpleasant I know. Unfortunately, this is more-or-less my workflow at the time of writing this post.
At this point, you can launch your Storybook and run your script via ts-node to see your email templates as stories.
Happy coding! -- David Lee