lee.david.cs

Writing Emails in React

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 address
3 to: "bar@example.com, baz@example.com", // list of receivers
4 subject: "Hello ✔", // Subject line
5 text: "Hello world?", // plain text body
6 html: "<b>Hello world?</b>", // html body
7});

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?

Introducing MJML-React

We've identified 2 pain-points of emails in Node.js thus far.

  1. Template literals lose types and intellisense
  2. Poor CSS support on email clients leads to messy table layouts

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`;
14
15const info = await transporter.sendMail({
16 from: '"Fred Foo 👻" <foo@example.com>', // sender address
17 to: "bar@example.com, baz@example.com", // list of receivers
18 subject: "Hello ✔", // Subject line
19 html
20});

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";
2
3export const MjmlButton: FC = ({ children, ...props }) => {
4 return createElement("mj-button", props, children);
5};
6
7/* ... */
8
9import mjml2html from "mjml";
10import type { ReactElement } from "react";
11import { renderToStaticMarkup } from "react-dom/server";
12
13const render = (email: ReactElement): string => {
14 return mjml2html(renderToStaticMarkup(email));
15};
16
17/* ... */
18
19const email = (
20 <Mjml>
21 {/* ... */}
22 <MjmlButton />
23 {/* ... */}
24 </Mjml>
25);
26
27const html: string = render(email);

You might say, "great, but wrapping every mjml component then typing all of the props seems like a pain".

Open-source has you covered

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";
4
5export const createEmailHtml = <P extends {}>(
6 template: ComponentType<P>,
7 props: P
8): string => {
9 const mjmlElement = createElement(template, props);
10
11 const { html, errors } = render(mjmlElement);
12
13 if (errors?.length) {
14 throw new Error(errors[0]);
15 }
16
17 return html;
18};
19
20export 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 });
28
29 return await transporter.sendMail({
30 sender: process.env.EMAIL_SENDER_USER!,
31 ...options
32 });
33};
1// email-base component
2
3import 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 RequiredChildrenProps
16} from "mjml-react";
17
18export interface EmailBaseProps extends MjmlBodyProps, RequiredChildrenProps {
19 breakpoint?: number;
20 title: string;
21 preview: string;
22}
23
24export const EmailBase: FC<EmailBaseProps> = ({
25 breakpoint,
26 children,
27 title,
28 preview,
29 width,
30 ...restBodyProps
31}) => {
32 const fontFamily = "Roboto";
33 const fontHref = `http://fonts.googleapis.com/css?family=${fontFamily}:400,100,100`;
34
35 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 users
2
3import { EmailBase } from "./email-base.component.tsx";
4import { MjmlColumn, MjmlSection, MjmlText } from "mjml-react";
5import React, { FC } from "react";
6
7export interface HelloEmailProps {
8 firstName: string;
9 lastName: string;
10}
11
12export const HelloEmail: FC<HelloEmailProps> = ({
13 firstName,
14 lastName
15}) => {
16 const fullName: string = [firstName, lastName].join(" ");
17
18 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 above
2
3/* ... */
4
5// Get user from your ORM
6const user = await prisma.user.findUnique({ where: { id } });
7
8if (!user) {
9 return null;
10}
11
12const { email, firstName, lastName } = user;
13
14const 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 autocompletion
19 html: createEmailHtml(HelloEmail, {
20 firstName,
21 lastName
22 })
23 })
24 .then(() => true)
25 .catch(() => false);
26
27/* ... */

With this, we've now also solved pain-point (#1), since we now have types and intellisense provided by TypeScript on our IDE.

Visual testing with Storybook

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 storybook
2
3import React, { FC } from "react";
4
5export interface MjmlMounterProps {
6 children: string;
7}
8
9export const MjmlMounter: FC<MjmlMounterProps> = ({ children }) => {
10 return <div dangerouslySetInnerHTML={{ __html: children }} >;
11};
1// Script file to generate our email stories, for storybook to read
2
3// All email template component types exported from an index.ts file
4import * 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";
10
11const toStory = (key: string, text: string) => stripIndent`
12import { MjmlMounter } from "@myapp/server/emails";
13import React from "react";
14
15const html: string = \`${text}\`;
16
17export default { title: "emails/${key}" };
18
19export const Standard = () => <MjmlMounter>{html}</MjmlMounter>;
20
21Standard.parameters = {
22 layout: "fullscreen"
23};
24`;
25
26const generateTemplates = () => {
27 const emailHtmlDict: Record<string, string> = Object.keys(templates).reduce((acc, key) => {
28 const template = templates[key];
29
30 // This will fill your stories with your emails' default prop values
31 const withDefaultProps = createElement(template);
32 const { html, errors } = render(withDefaultProps, {
33 minify: false
34 });
35
36 if (errors?.length) {
37 throw new Error(errors[0]);
38 }
39
40 return { ...acc, [key]: html };
41 }, {});
42
43 // Prepare directory to generate stories into
44 fs.ensureDirSync("outputDir");
45 fs.emptyDirSync("outputDir");
46
47 Object.keys(emailHtmlDict).forEach((key) => {
48 const outputPath = path.join("outputDir", `${key}.stories.tsx`);
49 const emailHtml = emailHtmlDict[key];
50
51 fs.ensureFileSync(outputPath);
52 fs.writeFileSync(outputPath, toStory(key, emailHtml), {
53 encoding: "utf8",
54 flag: "w"
55 });
56 });
57};
58
59generateTemplates();
1/* ... */
2
3// Ensure that your prop types stay as non-nullable, so that defaults aren't used in real-use
4export interface HelloEmailProps {
5 firstName: string;
6 lastName: string;
7}
8
9// Add default prop values for your generated stories
10export 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