Skip to content

refactor(email): replace react-email with Maizzle-compiled templates - #428

Merged
JeremyFunk merged 3 commits into
mainfrom
maizzle-email-port
Aug 12, 2026
Merged

refactor(email): replace react-email with Maizzle-compiled templates#428
JeremyFunk merged 3 commits into
mainfrom
maizzle-email-port

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replaces react-email with Maizzle v6 in @maple/email. No functional change — same exports, same props, visually identical output — but react, @react-email/* and their tailwind/prettier/prism machinery (~2MB of module eval that we were already dynamic-importing around) drop out of the API worker entirely. The email package now has zero runtime dependencies.

Architecture

Maizzle's render() runs on Node and cannot execute inside a Worker (and our emails are per-request dynamic — loops and conditionals over live data — so their static build-time pattern doesn't fit either). Compilation and rendering are therefore split:

  • Authoring: Vue SFCs — one root per email (emails/*.vue, ~30 lines each) composing 30 shared Maple* components (components/): header, section/panel chrome, service/error/detail rows, delta pills, sparkline bars. Fragment components' props default to their own [[token]] placeholders; dynamic regions appear in roots as literal [[#slot]] holes.
  • Build (bun run --cwd packages/email build): @maizzle/framework v6 + Tailwind v4 render each root and each fragment component once, flatten every class into inline styles, and emit readable, checked-in modules under src/generated/ (build:check guards drift; output is byte-deterministic). Build-time guards assert no whitespace creeps into tight inline joins and no truncated line-height ratios survive (see below).
  • Runtime (src/template.ts, ~60 lines, zero deps): splices page + fragments and HTML-escapes every interpolated value; [[#slot]] markup can only come from other compiled templates, substituted text is never rescanned, unknown tokens throw instead of shipping [[…]] to an inbox.
  • deriveDigestStatus, formatters and all prop types are unchanged in weekly-digest-core.ts; DigestService/alert-email render synchronously now (Effect.try instead of Effect.tryPromise + dynamic import).

Fidelity verification

Rendered all 4 sample variants (digest healthy/watch/critical, alert) with the old and new stack, compared DOM-normalized (element counts, computed styles incl. table attributes, text) and as headless-Chrome screenshots:

  • Alert notification: pixel-identical (0 differing pixels at 640×2400).
  • Digest variants: <0.015% differing pixels, max channel delta 28 — sub-pixel antialiasing noise only.
  • DOM comparison: identical element counts (268/257/258/83), 0 style diffs, identical text.
  • Two regressions were caught and fixed along the way, each now guarded:
    1. Maizzle's default cellpadding/cellspacing injection on every table lost the browser-default 2px border-spacing react-email's plain layout tables kept (~37px cumulative) → useTransformers: { addAttributes: false }, table census now matches golden exactly.
    2. Tailwind v4/lightningcss truncates unitless line-heights (1.4285714…1.42857 = 19.9999px), shifting text lines by 1px → named text sizes pinned to exact px line-heights + assertExactLineHeights build guard.
  • Hostile-input escaping suite (script/img/event-handler/attribute-breakout payloads in org names, service names, error messages, URLs): all green.
  • turbo typecheck (api + email + dependents): 5/5. API tests: 1690 passed.

Notes

  • packages/email/emails and components are oxfmt-ignored: template whitespace is load-bearing (a reflowed newline becomes a rendered space).
  • .vue <script setup lang="ts"> blocks aren't covered by tsc --noEmit (no vue-tsc in the repo) — they're thin prop declarations; adding vue-tsc is a possible follow-up.
  • escapeHtml prevents attribute breakout but doesn't filter URL schemes (javascript: in a config-provided URL would land in href) — same behavior as react-email's <Link>; cheap follow-up if we want it hardened.
  • email dev preview server is replaced by bun run --cwd packages/email preview (renders the 4 sample variants to /tmp/maple-email-preview/).

🤖 Generated with Claude Code

JeremyFunk and others added 3 commits August 11, 2026 21:01
Rebuild @maple/email on Maizzle: templates are authored in
packages/email/emails/*.html (Tailwind classes + [[token]] placeholders +
fragment markers) and compiled at build time — Tailwind flattened into
inline styles via @maizzle/framework — into checked-in generated modules
under src/generated/. A zero-dependency runtime (template.ts) splices
fragments and HTML-escapes every interpolated value; Maizzle/PostHTML
cannot run on Workers (posthtml-expressions needs new Function), so
request-time rendering is plain string interpolation.

Same exports and props; deriveDigestStatus and all formatters unchanged
in weekly-digest-core. DigestService/alert-email now render
synchronously (Effect.try), and react, @react-email/* and the preview
server drop out of apps/api entirely.

Verified against the react-email output in headless Chrome: the alert
notification is pixel-identical; digest variants differ only in
antialiasing noise (<0.015% of pixels). Table census (presentational vs
default border-spacing) matches the original exactly; hostile-input
escaping suite passes.

Regenerate templates with: bun run --cwd packages/email build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the v5 raw-HTML + fragment-marker sources with Maizzle v6 Vue
components: one root SFC per email composing ~30 shared components
(header, sections, panels, rows, pills), each fragment a component whose
props default to its [[token]] placeholders. Compiled output keeps the
exact PAGE/FRAGMENTS shape, so the zero-dependency runtime, generated-
module consumers and apps/api are byte-for-byte untouched.

Tailwind v4's named text sizes are pinned to exact px line-heights
(--text-sm--line-height: 20px, …) — lightningcss truncates the unitless
ratios (1.4285714… → 1.42857), which shifted text lines by 1px vs the
react-email output. A build-time guard (assertExactLineHeights) now
rejects truncated ratios, alongside the tight-join whitespace guard.

Verified in headless Chrome against the react-email golden renders:
alert notification pixel-identical, digest variants at antialiasing
noise (<0.015%), identical to the v5 port's parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports main's BrandMark change (143fd52 "feat: fix old logo") into the Maizzle
v6 Vue authoring, and drops the react-email sources main still had.

Conflicts:

- `src/weekly-digest.tsx` / `src/alert-notification.tsx` (modify/delete) —
  removed; this branch renders from `emails/*.vue`. Main's change to them was
  the logo swap, ported to `components/MapleBrandMark.vue`: the same hosted PNG
  at the same URL, emitting exactly what react-email's `<Img>` did (attribute
  order, the `display/outline/border/text-decoration` reset, and the
  `<link rel="preload" as="image">` React 19 injected alongside it, declared
  here via `useHead`). `src/brand-mark.tsx` removed with them — nothing outside
  the package imported `@maple/email/brand-mark`.
- `package.json` — kept this branch's v6 devDependencies and scripts. Main's
  only change was the `./brand-mark` export, which dies with the file.
- `bun.lock` — regenerated with `bun install` over the merged manifests.

Verified against a golden regenerated from main's own react-email sources: 0
element, style and text diffs across all four sample variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JeremyFunk
JeremyFunk merged commit 5ddbd47 into main Aug 12, 2026
30 of 37 checks passed
@JeremyFunk
JeremyFunk deleted the maizzle-email-port branch August 12, 2026 21:04
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit 064443c · View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant