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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Document title for browser and web previews; the mail transport still owns the subject. |
preheader | string | — | Hidden inbox-preview copy. Without it, clients scrape the first visible text. |
children | HtmlChild | HtmlChild[] | — | Top-level section rows. Use section(), header(), and footer() here. |
width | number | 600 | Maximum content width in pixels. |
background | string | — | Hex color behind the message card; invalid values fall back to the theme page color. |
lang | string | "en" | Language of the document root. |
webFont | string | 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, andlistrender copy.buttonandbuttonRowrender one or several calls to action.callout,card,divider, andspacergroup content.keyValue,dataTable,metrics, andcodeBlockrender structured data.badge,tag,statusDot,link,inlineCode, andstrongcompose inside other content.
Signatures at a glance
// 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.
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.
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.
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.
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.