fix: drop the runtime bundler from the mail send path - #56
Merged
Conversation
- Move Maizzle from runtime rendering to build-time compilation via scripts/compile-templates.ts - Compiled markup and plaintext variants are checked in as compiled-templates.json - sendEmail() now selects variant and substitutes values instead of calling render() - Necessary for serverless deployments that lack bundler and platform-native binary - Move @maizzle/* to devDependencies since compilation is now build-only
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR fundamentally changes mail sending from runtime Maizzle rendering to build-time compilation with runtime substitution. While well-tested, the architectural change introduces new interpolation/escaping logic and variant selection that warrants human review. You can add or adjust custom eligibility rules. Learn more. |
…ative-binding # Conflicts: # pnpm-lock.yaml
Maizzle's `render()` starts a Vite SSR server, so every send loaded `vite` and its native rolldown binding. Nitro traced both into the Vercel function, where the platform binary is absent and the filesystem is read-only, and the function crashed on the first message with "Cannot find native binding". The path the templates were read from also no longer existed once Nitro bundled the package into one chunk. Templates are now rendered once at build time (`aube run mail:compile`) into a checked-in artifact the package bundles: one variant per combination of the conditional fields the registry declares, with every other field left as a placeholder. `sendEmail` picks the variant its context matches and substitutes the values, escaping them for the HTML part and leaving them bare for the plaintext one. Maizzle moves to devDependencies, which drops the bundler, esbuild and tailwind out of the deployed function: 142 MB and 307 packages down to 20 MB and 64.
`packages/mail` is a workspace package like any other, but its name was missing from the scope list the semantic-pull-request check enforces, so a title naming the package it changes was rejected.
RedStar071
pushed a commit
that referenced
this pull request
Aug 20, 2026
Picks up #56. The one conflict is the build-time-capture callout on the environment-variables page, where main edited text inside a block this branch had already converted to MDC; main's wording wins, in the MDC form. The two `BETTER_AUTH_*` containers main added on the same page are converted the same way, so the page carries no VitePress syntax. Verified by rebuilding the docs app: prerender succeeds, both restored callouts render, and all @include directives still resolve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SK7GND614AB6qpZ51aPFx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Mail templates are rendered once at build time instead of on every send.
packages/mailships a checked-in artifact (src/util/compiled-templates.json) written byaube run mail:compile;sendEmailselects the variant its context matches and substitutes the values into it.@maizzle/frameworkmoves todevDependencies.Why
render()from Maizzle starts a Vite SSR server, so every send loadedviteand its native rolldown binding. Nitro traced both into the Vercel function, which has neither the platform binary nor a writable working directory, and the function crashed on the first message:The same path was broken a second way:
emailsDirectoryresolved../emails/fromimport.meta.url, which after Nitro bundles the package into one chunk points atchunks/nitro/../emails/— a directory that does not exist in the output.Rendering is a build-time capability, so it belongs on the build side of the boundary. The registry stays in
packages/mail, the compiler is a package script beside it (same shape aspackages/i18n's tooling andpackages/database's checked-in migrations), and the deployed app only interpolates strings. Nothing outsidepackages/mailchanged except the docs and the newmail:compiletask.Templates that branch (
v-ifon an optionalnameororganizationName) are compiled once per combination of those fields being filled — declared asconditionalin the registry — so no branch has to be taken at send time. Every other field is compiled as a placeholder, substituted HTML-escaped into the HTML part and bare into the plaintext part.Verification
aube run check:repoaube run lint:ciaube run typecheckaube testaube run buildDeployment bundle, built with
NITRO_PRESET=vercel aube exec turbo run build --filter=@agent-zero/dashboardand inspected at.vercel/output/functions/__fallback.func/:node_modulesrolldown,vite,@maizzle/*,esbuildThe compiled markup is inlined in the server chunk (
__AZ_MAIL_*placeholders present), and the bundle no longer resolves any path underemails/.Safety and compatibility
observemode as read-only, or explained the policy change above.New tests in
packages/mail/src/util/compiled.test.ts, all rendering through the real Maizzle pipeline:aube run mail:compilefails the suite;<script>alert("x")</script> & Coreaches the recipient as text, and an accept URL keeps its&query separators inside thehref.The existing
mail.test.tssuite is unchanged and still passes, including the guards on inlined styles, surviving dark-mode classes, and the optional halves of a private invitation rendering away rather than empty.Agent context
@maizzle/frameworkimportingvitefromrender/createRenderer.js, confirmed it against the previously built.output/server/node_modules, wrote the compiler, the runtime substitution and the tests, and ran the full check set plus the Vercel-preset build. The before/after bundle numbers above are measured from those builds, not estimated. Every file in the diff was reviewed by hand.Reviewer notes
src/util/compiled-templates.jsonis generated and checked in, like the Drizzle migrations: regenerate withaube run mail:compile, never edit it.&and"where Vue wrote&and"; that is safe for markup Maizzle produced and unsafe for a value substituted afterwards, soescapeMailHtmlre-applies the full escape. The one visible consequence is that a URL now keeps&in itshrefwhere a live render had&.mailTemplateIdsis written out by hand rather than derived fromObject.keys, so nothing has to assert a widenedstring[]back into the id type; a test holds the list to the registry.sendEmailnow throws when no compiled variant matches, which can only happen if the artifact and the registry disagree. The freshness test is what keeps that unreachable.apps/mail-previewis untouched: it still runs the Maizzle CLI against the sameemails/sources through its symlink.origin/main; the only conflict waspnpm-lock.yaml, resolved by taking main's lockfile and reinstalling, so its only difference from main is thepackages/maildependency move.Note
Replace runtime Maizzle rendering with precompiled template variants in
sendEmailsendEmailin mail.ts now selects the matching precompiled variant by conditional-field key, then interpolates escaped values into HTML and raw values into plaintext, throwing if the variant is missingfieldsmetadata (interpolated vs conditional) to drive variant selection and compilationmail:compilescripts at the root and package level, wired into Turbo without caching, plus amailscope in the semantic PR validatorsendEmailthrows if the expected compiled variant is absent incompiled-templates.json; templates must be recompiled viaaube run mail:compilewhenever template markup or conditional-field declarations changeMacroscope summarized 70212a3.