react-data-form

Email builder

The same field, an email palette, and a pure function that renders HTML Gmail and Outlook agree on.

An email builder is the example people ask for right after a page builder, and the form half of it is the same: one array field, a palette of block types, a payload of typed objects. What changes is the other half. A page is drawn by components; an email is a string of HTML that leaves the application and is drawn by Gmail, Outlook or Apple Mail — each with its own, much smaller idea of CSS.

The same field, another palette#

Eight block types — header, hero, text, button, image, two columns, divider and footer — each registered with addForm and tagged email-block. The field offers whatever carries that tag, exactly as page-block builds the landing page's palette.

emailBlocks.ts
import { addForm, SelectInputController, WysiwygInputController } from "react-data-form"

// Same shape as a page block: the identifying tag first, the palette's tag after.
addForm("email.text", {
  name: "Text",
  icon: AlignLeft,
  "@for": ["email.text", "email-block"],
  inputs: {
    title: { label: "Heading" },
    body: { label: "Body", controller: WysiwygInputController },
  },
})

An envelope beside the blocks#

An email has things that are not content: a subject, the grey preheader that follows it in the inbox, a sender name, a brand colour. They are not blocks — there is exactly one of each — so they are plain fields of the form, next to blocks. The payload becomes an object rather than a bare array, which is also the shape a newsletter is stored in.

emailForm.ts
const emailForm = {
  label: { submit: "Save the newsletter" },
  inputs: {
    // The envelope: fields of the form itself, not blocks.
    subject: { label: "Subject", required: true },
    preheader: { label: "Preheader" },
    fromName: { label: "Sender name" },
    brandColor: { label: "Brand colour", controller: SelectButtonInputController,
                  valueOptions: BRAND_COLORS },
    // The message: the same array field as the page builder, another palette.
    blocks: createFormArrayInputController({ forms: ["email-block"], draggable: true }),
  },
}

The builder, running#

The preview is not a set of React components that look like an email: it is the HTML renderEmail returns, in an <iframe srcdoc> that keeps the site's stylesheet out. What is on screen is what would be sent. The HTML and the plain-text part are one tab away, the weight is beside them, and Desktop / Mobile resize the frame to 600 and 375 pixels.

A newsletter — the envelope folds into Settings

The playground runs it at full size and saves into a Newsletters resource of its back office, edited there with the same builder.

Rendering is a pure function#

renderEmail takes the envelope and the blocks and returns the HTML, the text part and the weight in bytes. It imports nothing — no React, no DOM, no Tailwind — so the browser preview, the export button, the test script and the unit tests all run the same code, and the file can be copied into another project as it stands.

usage.ts
import { renderEmail } from "./renderEmail"

const { html, text, size } = renderEmail(
  { envelope: { subject, preheader, fromName, brandColor }, blocks },
  // Where the PNGs of the library are served. "cid:" for inline attachments.
  { assetBaseUrl: "https://example.com/email/" }
)

size < 102 * 1024 // or Gmail shows "[Message clipped]"

Why it looks like 2005#

The CSS every mail client agrees on is small, so the output is written in it and nothing else:

  • Tables for layout. A 600px column in <table role="presentation">, fluid below that. Outlook on Windows renders with Word, which knows neither flexbox nor grid; the two columns are inline blocks that wrap on a phone without a media query.
  • Inline styles. On every element. The one <style> in the head adds the phone layout and a dark palette, and the message still reads correctly when a client drops it.
  • Hosted images. Gmail blocks SVG and data: URIs, so the builder's SVG library is rasterised to PNG by a script and served by the site. Every <img> has an absolute URL, a width, a height and an alt text.
  • A bulletproof button. A padded link inside a coloured cell, with a VML shape for Outlook on Windows, which ignores padding on links.
  • Nothing typed becomes markup. Every field is escaped, and the rich text is reduced to p, br, strong, em, a, ul, ol and li, styled inline.
  • Light and dark. color-scheme is declared, the palette avoids pure black and white, and a logo sits on a ground of its own so an inverted mailbox does not swallow it.
the button, rendered
<table role="presentation" width="100%" style="max-width:600px;margin:0 auto;background:#ffffff;">
  <tr><td style="padding:16px 32px 24px;" align="center">
    <table role="presentation" align="center"><tr>
      <td bgcolor="#7c2d12" style="border-radius:6px;background:#7c2d12;">
        <!--[if mso]><v:roundrect href="https://…" fillcolor="#7c2d12" …>…</v:roundrect><![endif]-->
        <!--[if !mso]><!--><a href="https://…" style="display:inline-block;padding:14px 28px;color:#ffffff;…">Order Sunrise</a><!--<![endif]-->
      </td>
    </tr></table>
  </td></tr>
</table>

Testing in a real mailbox#

A preview proves the HTML; only a mailbox proves the email. The site is static and public, so it sends nothing — a form that mailed what it was given would be a relay for spam. A script sends from a developer's machine instead, through Brevo or any SMTP server, reading its credentials from the environment and masking them in its output.

# website/ — a developer's machine, never the deployed site
export SMTP_URL="smtp://login:smtp-key@smtp-relay.brevo.com:587"   # or BREVO_API_KEY=…
export MAIL_TEST_FROM="news@your-validated-domain.com"
export MAIL_TEST_TO="you@gmail.com"

node --experimental-strip-types scripts/send-test-email.ts              # the sample
node --experimental-strip-types scripts/send-test-email.ts export.html  # a "Download .html"
node --experimental-strip-types scripts/send-test-email.ts --dry-run    # build a .eml, send nothing

Until the PNGs are deployed a mailbox cannot fetch them, so the script attaches them inline and points the images at cid: — the reason assetBaseUrl accepts that value. The full recipe — the files to copy, the checklist for Gmail, Outlook and mail-tester — is in the README next to the renderer.

Not a sending service

The builder makes the message, not the campaign: no list, no scheduling, no tracking, no bounce handling. Hand the HTML and the text part to whatever already sends your mail.

Things worth knowing#

  • 102 KB. Past that weight Gmail clips the message and hides the rest — the footer and its unsubscribe link first. The builder shows the weight against that limit as you type.
  • Uploads cannot go in an email. A file read in the browser is a data: URI; the email palette only offers the hosted library, and the renderer drops anything else.
  • The unsubscribe link is required. In law for a marketing email, and in practice for Gmail; the footer block will not validate without it, and the test script repeats it as a List-Unsubscribe header.