Until recently, changing the wording of a password-reset mail in our framework meant editing a TypeScript file and shipping a release. The same went for the PDF a rental app sends as an invoice. Nobody on the operations side could touch either one.
Looking at why, we found two mail systems that did not know about each other. One stored the mail as a JSON list of sections (text, button) in code, with the branding hardcoded next to it. The other stored a Markdown document in the database and rendered it through MJML, and exactly one app used it. PDFs were a third thing: HTML built in code and printed with Puppeteer. The tenant's logo and colors lived in two places, the static mail branding in app code and the branding tokens per tenant. The mail message type had no concept of attachments or inline images at all.
One model for mail and PDF
Every mail and every PDF is now a template: a slug, a locale, a document, and a small JSON meta object. The document is structured content that tiptap edits in the browser. The server rebuilds it through a sanitizer, so only known node types survive a save. Meta is validated per kind: a mail needs a subject and may have preview text, a layout, a category and a sender name. A PDF has page format, margins and a file name. Images and files are linked resources, uploaded once and referenced by key.
Layout is a separate template. A mail layout (MJML) and a PDF letterhead each have exactly one required slot for the content, and they read the tenant's branding tokens instead of carrying their own copy. Changing the layout changes every mail that uses it, without touching any sender.
Features no longer send sections. They declare which variables a mail needs and seed a default template at boot. This is the declaration for the waitlist notice in the framework:
export const WAITLIST_ADMIN_NOTICE_VARIABLES: TemplateVariables = {
name: templateVariable.text({ required: true }),
details: templateVariable.list(
{ text: templateVariable.text({ required: true }) },
{ required: true },
),
};
The variable types are text, link, money, date and list (the schema also knows rich text and percent). Each one carries a label and an example value, and the example is what the preview fills in.
Scopes and the four-step lookup
A template can exist at two scopes: the system default that the feature ships, and a tenant customisation. When a handler asks for a template, the resolver walks four steps: the tenant in the requested locale, the system in the requested locale, the tenant in the fallback locale, the system in the fallback locale. A tenant overrides a default by having its own row, and no application code changes.
Who may edit sits outside the model. By default the platform admin edits the system templates, and an app can open individual templates to its tenant admins. Mails that must stay as shipped can be marked system only, and tenants neither see nor override them.
The editor
The overview lists mails, PDFs and layouts in one table. Each row shows where the template comes from (system or tenant customisation), which languages exist as filled chips, the status with its version, and which feature uses it. The usage column comes from the feature registry, so it is read from the code that sends the mail and nobody types it in.
The mail editor has the placeholder rail on the left with type, required marker and a check for whether the placeholder is used. The middle holds subject, preview text and the content with placeholders as chips and blocks for buttons, images and repeated rows. The right side has layout, sender, category and attachments. In the screenshot, a required placeholder, the due date, appears nowhere in the subject, preview text or content. The footer says so and the publish button is disabled. Saving a draft still works.
That rule is enforced on the server and not only in the editor. Saving rejects unknown placeholders. Saving an active template and publishing both check that every required variable is used. The editor only shows what the server would say.
A draft over an active template does not replace it. The active version keeps being served while the edit sits beside it, and publish switches over. The first version got this wrong: a draft saved over an active tenant template overwrote the same row, the resolver fell back to the system text, and the tenant's active wording was gone. The draft now lives in its own column, and publish promotes it.
PDFs
PDFs use the same document model, rendered through Puppeteer in a separate service that runs Chromium behind an egress guard. The preview is the real render. It shows the letterhead from the layout, the repeating rows from a list variable and the page count, and the footer line reports the last test render, here 2 pages, 67 KB, 2.3 seconds. Page format, margins, the file name pattern and the PDF metadata sit in the side panel. A generated PDF is stored with the template id and version it came from, so you can say later which wording a customer received.
Without a running Puppeteer service the PDF editor is locked, because a preview that cannot render would only mislead.
What is not in this release
Everything above is in the open framework, including the editors, the preview and the test mail. The AI assistant that edits templates from a sentence ("put the logo in the sign-up mail") is a separate piece that sits in our enterprise layer. It reads a template as a list of blocks and patches the draft through structured operations, and it never writes raw HTML. It shows the result as a preview first and saves it as a draft. Publishing stays a decision of the person using it. The same validation applies, so a suggestion that removes a required placeholder is rejected. Previewing a template with a real record is part of that layer too. This post is about the open part, so we only mention the assistant here.
Also still missing: version history with restore, columns, PDFs without Puppeteer, and an editor control for conditional blocks. The renderer already shows or hides a paragraph or table row depending on whether a variable has a value; the editor just has no button for it yet.
If you let non-developers edit transactional mail, what did you put between them and a broken invoice mail? We block publishing on a missing required placeholder and keep the active version served. We would like to hear what else you check before a template goes live.



Top comments (0)