Email
Reference

Email

Render themed transactional email from a server-safe entry point for Gmail, Outlook, Apple Mail, and browser previews.

Email renderer

Import email tools from @solarius/onset/email. This is a separate implementation of the look, not a way to render Onset’s React components into a message. It has no React dependency and does not need ThemeProvider or either Onset stylesheet.

The email renderer emits literal colors, table layouts, and inline styles for mail clients that discard external CSS or lack web layout features. Theme values are resolved before rendering.

Render a message

Call createEmail() once with the same theme configuration your application gives to ThemeProvider. The returned toolkit binds every renderer to that theme. render() produces the complete text/html body, including the doctype, compatibility head, hidden preheader, responsive shell, and message card.

Invitation emailTSX
import { createEmail, emailPlainText } from "@solarius/onset/email";

const mail = createEmail({
  baseColor: organization.brandColor,
  fonts: appTheme.fonts,
  spacingBase: appTheme.spacingBase,
  radiusBase: appTheme.radiusBase,
});

const html = mail.render({
  title: "You're invited to Acme",
  preheader: "Your invitation expires in 48 hours.",
  children: [
    mail.header({
      name: "Acme",
      meta: "Production",
      logoUrl: "https://assets.example.com/acme.png",
      href: "https://app.example.com",
    }),
    mail.section([
      mail.heading("You're invited", { sub: "Platform team" }),
      mail.text("Sam invited you to the Acme workspace."),
      mail.button("Accept invitation", invitationUrl),
      mail.note("This invitation expires in 48 hours."),
    ]),
    mail.footer({
      note: "Sent by Acme.",
      links: [{ label: "Notification settings", href: preferencesUrl }],
    }),
  ],
});

const text = emailPlainText(
  "You're invited to Acme",
  "Sam invited you to the Acme workspace.",
  "Accept invitation: " + invitationUrl,
  "This invitation expires in 48 hours.",
);

await transport.send({ html, text, subject: "You're invited to Acme" });

Supply title for the browser tab when the HTML is opened as a page. Supply preheader for inbox preview text. Build the plain-text part with emailPlainText() or plainText() to preserve reading order.

EmailDocumentOptions props
PropTypeDefaultDescription
titlestring—Document title for browser and web previews; the mail transport still owns the subject.
preheaderstring—Hidden inbox-preview copy. Without it, clients scrape the first visible text.
childrenHtmlChild | HtmlChild[]—Top-level section rows. Use section(), header(), and footer() here.
widthnumber600Maximum content width in pixels.
backgroundstring—Hex color behind the message card; invalid values fall back to the theme page color.
langstring"en"Language of the document root.
webFontstring | false—Progressive web-font stylesheet URL. Pass false to suppress the default IBM Plex Sans request.

Sections, content, and inline markup

Document children must be rows created by header(), section(), or footer(). Other content renderers produce markup for section cells.

Content renderers

  • heading, paragraph, text, note, prose, and list render copy.
  • button and buttonRow render one or several calls to action.
  • callout, card, divider, and spacer group content.
  • keyValue, dataTable, metrics, and codeBlock render structured data.
  • badge, tag, statusDot, link, inlineCode, and strong compose inside other content.

Signatures at a glance

TSX
// Document rows
mail.header({ name, meta?, logoUrl?, logoWidth?, logoHeight?, href? })
mail.section(children, { background?, padding?, borderTop?, align? })
mail.footer({ note?, links?: Array<{ label, href }> })

// Text and actions; block options also accept gap?
mail.heading(text, { level?: 1 | 2, accent?, sub?, gap? })
mail.paragraph(html, { tone?, size?, align?, gap? })
mail.text(text, { tone?, size?, align?, gap? })
mail.note(text, { gap? })
mail.prose(text, { tone?, size?, align?, gap? })
mail.list(cells, { gap? })
mail.button(label, href, { variant?, align?, fullWidth?, gap? })
mail.buttonRow(buttons, { gap? })

// Containers and data
mail.callout(html, { tone?, title?, ground?, gap? })
mail.card(html, { title?, banded?, gap? })
mail.divider({ gap? })
mail.spacer(height?)
mail.keyValue(rows, { gap? })
mail.dataTable(columns, rows, { gap? })
mail.metrics(items, { gap? })
mail.codeBlock(text, { gap? })

// Inline Html
mail.badge(label, { tone?, ground?, solid? })
mail.tag(label)
mail.statusDot(tone, ground?)
mail.link(label, href, { tone?: "primary" | "dim" })
mail.inlineCode(text)
mail.strong(text)

Block renderers emit their own trailing gap. Override it with the shared gap option; use gap: "0" when a block is the final item inside a nested card. Do not add margins between siblings by hand: Outlook’s Word engine has no margin collapsing and ignores margins on tables.

Structured contentTSX
const details = mail.keyValue([
  { label: "Workspace", value: "Acme Production" },
  {
    label: "Role",
    value: { html: mail.badge("Administrator", { tone: "warn" }) },
  },
  { label: "Request ID", value: "req_01J...", mono: true },
]);

const events = mail.dataTable(
  [
    { header: "Service" },
    { header: "Status" },
    { header: "Latency", align: "right", mono: true },
  ],
  [
    ["API", { html: mail.badge("Operational", { tone: "ok" }) }, "184 ms"],
    ["Workers", { html: mail.badge("Degraded", { tone: "warn" }) }, "2.4 s"],
  ],
);

const body = mail.section([
  mail.heading("Incident update", { accent: mail.theme.color.warn }),
  mail.callout(
    mail.paragraph(mail.strong("Elevated error rate"), { gap: "0" }),
    { tone: "warn", title: "Investigating" },
  ),
  mail.card(details, { title: "Request", banded: true }),
  events,
]);

Cell slots accept plain text or { html: Html }. The wrapper distinguishes markup from text at runtime; the Html brand alone is a string.

Escaping and untrusted input

Plain-string component arguments are escaped by construction. Newlines are preserved where the API describes text, URLs are restricted to absolute http, https, mailto, and tel schemes, and caller-supplied colors must be #rgb or #rrggbb. Invalid URLs become # and invalid colors fall back to the supplied theme color.

Safe inline compositionTSX
const sentence = mail.paragraph(
  // Every fragment returns branded, escaped Html.
  joinHtml([
    escapeHtml(user.displayName),
    escapeHtml(" changed "),
    mail.inlineCode(resource.name),
    escapeHtml(". "),
    mail.link("Review change", reviewUrl),
  ]),
);

Theme resolution

createEmail(config) and resolveEmailTheme(config) accept theme configuration. Email defaults to light mode and a 14px base font size. Other scales derive from the web theme. Pass explicit values to override these defaults.

Share one application themeTSX
const themeConfig = {
  baseColor: tenant.brandColor,
  radiusBase: 5,
  spacingBase: 4,
  fonts: { sans: tenant.fontStack },
  status: tenant.statusColors,
};

// Browser
<ThemeProvider {...themeConfig}>{app}</ThemeProvider>;

// Server-side email
const mail = createEmail(themeConfig);

The resolved EmailTheme exposes literal color, space, size, radius, typography values, and a tone(name, ground?) helper. Pass the actual background as ground when placing a tone inside a band or tinted card so its wash is composited against the correct surface.

Lower-level API

Unbound renderers accept an EmailTheme explicitly. Use them for template systems that share a resolved theme.

Unbound rendererTSX
import {
  resolveEmailTheme,
  renderEmailDocument,
  section,
  heading,
  text,
  type EmailTheme,
} from "@solarius/onset/email";

function renderDigest(theme: EmailTheme, summary: string) {
  return renderEmailDocument(theme, {
    title: "Daily digest",
    children: section(theme, [
      heading(theme, "Daily digest"),
      text(theme, summary),
    ]),
  });
}

renderDigest(resolveEmailTheme({ baseColor: "#3b82f6" }), summary);

Exported substrate helpers include escapeHtml, escapeLines, joinHtml, safeUrl, safeColor, el, attrs, styleValue, styleAttr, mso, notMso, and the canonical presentation-table attributes and styles. Theme utilities include EMAIL_THEME_DEFAULTS, EMAIL_WEBFONT_URL, and emailFontStack(); layout helpers include toPx() and sectionBottomPad(). Treat raw, mso, and notMso as trusted-markup APIs.

Delivery and verification

The renderer returns HTML and does not send email. Configure the subject, recipients, and MIME transport through the mail provider. Use absolute links. Set webFont: false to omit the Google Fonts request.

When extending the renderer, run pnpm --filter onset check:email. The compatibility fixture renders every email component with hostile input and rejects custom properties, color-mix(), rgba(), flexbox, unsafe URLs, and unescaped content. Also test representative messages in the real clients your recipients use; no static check can reproduce every mail sanitizer and Word version.