Meet MailJot
Author: Andrew Porter
Published: 11/4/2026
-
A design-first email rendering service where every template is a REST-exposed and schema-validated React component. Generate HTML and plaintext from one source, with no guessing about what hits the inbox.
The Issue
Every company ends up having to send customer-facing emails, and email is where our refined and modernized frontend discipline gets tossed in the sarlacc pit. Hand-crafted HTML has to survive clients that render like it’s 2007, while the plaintext version is an entire second document that somebody has to keep in sync. Nothing is tested, nothing is validated, and the data going into emails is whatever the calling code felt like passing that day. All of that trouble and it’s still anyone’s guess what the emails are going to look like when they show up in your customers’ inboxes - there’s got to be a better way to approach the problem.
Our Take
The “our company” email journey always starts with good intentions - just a basic set of emails: a welcome message for new accounts, plus order confirmations and refund notices. Then the “template engine” you picked turns out to be just a string-interpolation library with lots of opinions, and the plaintext version drifts out of date the very first time the intern in Marketing edits the HTML content. Friday afternoon your Outlook Admin discovers that an order confirmation has been going out to customers with Hello {.{userDbRecord["FirstName"]}}, at the top of each one, thanks to a typo in the template from… last month. Previews mean sending test emails to your own inbox and squinting hard, and “does this change break anything?” is answered with a shrug and a prayer. Meanwhile the email generation logic is smeared across your application code, so the person who can make the email look right isn’t the person who can get it deployed safely.
We’d all be much happier if we could treat email as a modern rendering problem that deserves the same treatment as any other service in the stack: a small, versioned API with a strict contract, previewable in a browser, thoroughly tested in CI, and deployable with standard containerized workflows. So… we built it. Today we’re announcing MailJot - it’s Open Source, it’s on our GitHub, and it does one job really well: turn a validated template into a finished email.
What is MailJot?
The name is the pitch: a jot is a quick note, and writing an email should feel like one. Make a folder, write the email, see it rendered in your browser a second later with real data, and then move on - no HTML and CSS compatibility mysticism, no test sends to your own inbox, and no guessing what your customers are going to see. The machinery exists so that the writing part can be the only part you have to think about.
MailJot is a filesystem-driven email rendering service. At its core, each email is a React Email component paired with a Zod schema for its input, and the engine exposes every document as a REST endpoint. GET the schema, POST a payload, and you get back two finished documents - the full HTML, and the plaintext, plus a subject - all produced from the same base component, so the two can never drift apart:
curl -X POST localhost:3000/v1/render/accounts/welcome \
-H 'Content-Type: application/json' \
-d '{"name":"Alice"}'
{
"template": "accounts/welcome",
"version": "1.0.0",
"subject": "Welcome aboard, Alice!",
"html": "<!DOCTYPE html>...",
"text": "Welcome aboard, Alice!"
}
Send a payload that doesn’t fit the input schema for that document and you get a field-level 422 and nothing is rendered. The same Zod schema is also served as JSON Schema at /v1/schemas/{template}, so whatever calls MailJot can generate its own types from the one source of truth.
At runtime it’s a plain container: the production image holds just the engine (Node, Express, React Email and Zod), with your compiled templates and transports mounted in read-only. The design-time tools - Storybook (for design previews) and Faker (for sample data) both stay on your local machine and never ship in the image.
A quick note on terminology: a template is an email waiting to be built, a document is what comes out the other side (subject, HTML and plaintext). One version number N covers the API prefix /vN, the engine’s router bundle, and the templates/vN/ and transports/vN/ directories, and each version is a complete set with no fallthrough. A released version never changes shape - a breaking change is a new v2, running right next to v1.
Why We Built It
This one is about generating reference architecture as much as it’s about generating email. The rest of the family so far has been infrastructure - SOCKS serves files, TIBIA puts them on the internet, KARPAL schedules workloads, and FiMOr knows who your users are. Every one of those eventually needs to tell a human something - and when we wired up FiMOr’s verification and recovery flows, the default Kratos emails were exactly what you’d expect from a default: functional, plain, and completely unrelated to the product they were sent from. The same pattern shows up across the stack: there’s a store that needs order confirmations, a device fleet that needs status alerts, auth services that need recovery emails, and mailing lists that need blog updates - but each one is responsible for its own rendering, data formats, and template folder.
What we actually wanted was to enjoy writing emails again - to treat a new email as a small, low-stakes thing you can get down in a few minutes rather than a sprint-draining ticket nobody wants to touch. So we put a stake in the ground about how the problem should be shaped, and built it to be a pattern as much as a product. The filesystem is the registry. An exported React component under templates/v1/ is a template - no route registration, no config file to update, nothing to forget. A directory under transports/v1/ is a delivery plugin. The engine discovers both on startup and the contract test runs over every one of them, so they’re covered by the test suite without anyone remembering to add it.
What’s In The Box
The writing loop itself:
- A Real Preview: Storybook builds each template into a document through the actual engine, so what you see - HTML, plaintext, schema, validation feedback - is what the API will return. Edit, glance, edit. You can use Tailwind in templates, too, with one documented caveat about
remunits. - One Component, Two Documents: HTML and plaintext are both mandatory and both come from the same React Email component, so you write the email once and never maintain a second copy. Building a document is all or nothing - and an integrity check refuses to return one that’s empty, has a multi-line subject, or contains
undefined,NaN,[object Object]or an unresolved{{ }}. TheHello undefined,email can’t be sent, because it can’t be built. - Strict Contracts: Inputs are validated with
z.strictObject- unknown fields are rejected, not silently ignored - before anything renders. The schema doubles as your checklist of what the email needs, written down before you write a word of copy. Errors share one shape everywhere ({ error: { code, message, details? } }) and internals go to the log, never the response. - Helper Scripts:
scripts/renderbuilds a template by hand without sending anything, so you can check a draft from the terminal, andscripts/senderruns the transport layer manually (defaulting to the harmlessLoggingtransport).
Playing it safe when deployed:
- Rendering: Every template is a REST endpoint at
/v1/render/{template}that returns the subject, HTML and plaintext but sends nothing, so rendering works on its own with no delivery attached. AGETendpoint brings back the formal schema so there’s never a question about what the template needs to become a document. - Pluggable Transports: Rendering can optionally be passed to a delivery mechanism. Drop a transport plugin into the transports folder and the sender routes light up automatically; the plugin contract is just a single
send(message)call wired up any way you’d like. We shipLogging,RecordingandBlackHoletransports - all of which make development and CI safe by default. When you’re ready to land real emails we also ship aMailjettransport that can serve as a base for integrating any other modern mail-sending service. - Independent Release Lanes: The production image holds only the core rendering engine. Compiled templates and transports are mounted read-only, so a typo fix or a wording tweak ships on its own, without rebuilding or redeploying the service. Whoever owns the copy can change the email without touching the engine, and reloading existing content is a simple container restart.
- Logs You Can Actually Ship: One JSON object per line on stdout with a request ID on every line, and a hard rule about what never gets logged: bodies, subjects, recipients, rendered content, credentials, headers, and provider error text. A canary test pushes secrets and recipient addresses through the entire system and fails the build if any of them reach the log.
Testing
And speaking of testing - an email you’ve only ever eyeballed is an email you’re afraid to touch, so MailJot treats the test suite as part of the writing experience, not a chore bolted on afterward. It’s Vitest, npm test runs everything once, and most of what you need is already there before you’ve written a single line:
- Free Coverage For Every New Template: A contract test walks every template and transport in every version directory and checks every single one. The exports are present,
metadata.idmatches the directory, every example inexamplespasses the schema, and each one builds into a document that clears the integrity checks. It builds each example twice and requires identical output, proving rendering is deterministic. Write the email, add an example, and it’s covered - there are no additional engine tests to write. - Tests Only For Your Own Logic: A dynamic subject, a conditional section or a table of charges can have its own
*.test.tsin the template’s directory, asserting on the real HTML and plaintext. The bundledbilling/receipttemplate, for example, checks that the totals are computed correctly and that the plaintext table keeps its columns aligned. - Deliberately Broken Templates: The engine’s own tests run against fixtures built to fail in one specific way. Every failure mode has a test that triggers it and checks the exact error code and that nothing internal made it into the response.
- Real HTTP, Real Logs: The API is tested over actual requests, not a mocked Express, and the logging canary from above runs in the same suite. Tests are deterministic - no network, no clock, no unseeded randomness - and
npm run typecheckcovers the tests too, since Vitest on its own won’t catch type errors.
And now the real numbers, since they’re pretty unusual for a document generation service: over 460 tests across 36 files, running in under five seconds, with 99.6% statement, 98.4% branch, 99.7% line and 100% function coverage. For a service whose entire job is turning JSON into an email, that’s a lot of safety net - and every one of those tests is deterministic, so a red build actually means something. Coverage only proves the lines ran, not that we asserted the right things - which is why the broken-template fixtures and the canary exist. Together they’re what let you trust a system that is one of the faces of your company, where every email it sends influences customer’s impression of you.
A Design Studio For Other People’s Emails
Another part of MailJot that we’re excited about is the one we didn’t plan for ahead of time: the design studio and writing experience turned out to be incredibly useful even when MailJot never sends a thing. Plenty of systems send their own emails from templates they render themselves, so they’ll never call MailJot to send an email - Ory Kratos, for instance, renders Go templates and has no preview tooling worth the name. Rather than hand-write HTML for it, we treat MailJot as the design studio and Kratos as the runtime:
- Write the email like any other template - React, Tailwind, a schema, a Storybook entry - and enjoy the same design experience.
- Build it once through the
Recordingtransport, withALL_CAPSstand-in strings where the external service will put real data. - A script converts the stand-ins into the other system’s expressions, like
{{ .RecoveryCode }}. - Mount the resulting static files into the Kratos container, and MailJot is no longer involved in the process.
The stand-ins exist because the engine refuses to build a document containing {{ }}, and that check is far too useful to weaken for a single use case, so the engine stays untouched. The repo ships all twelve Kratos courier emails as a worked example - which means the next time you stand up FiMOr, your users’ recovery emails can look like they came from the same company as your app.
Honest Limitations
We’re dedicated to talking about the soft underbelly of our creations, and MailJot is young:
- There is no built-in rate limiting, and only the sender routes are authenticated - with a single shared bearer token. Run it behind a firewall-protected reverse proxy that terminates TLS and enforces limits, and give
/sendermuch stricter limits than/render: every request can send a real email, so a leaked token costs money and sender reputation. TIBIA is a natural fit for this role. - The render timeout bounds async work only, not synchronous CPU-bound code in a template.
- Templates control their own
<Head>. There’s no injected charset tag, viewport tag or width, and dark mode, Outlook (Word) rendering and image blocking all depend on the markup you write. MailJot guarantees the document is complete and consistent - it can’t promise how every inbox on earth will draw it, so you still need to test in real clients. - Editing a template or transport that has already loaded needs a restart (Node can’t unload a dynamic
.jsmodule). Adding a new one doesn’t; Node will find and cache it from a running service without complaint. - Templates receive everything they display as pre-formatted input and look nothing up, by design. Extending MailJot into a dynamic rendering service would not be difficult, however - the engine is all built on Node and TypeScript, with no magic going on behind the scenes.
- Doing it - you own the upgrades. The engine, templates and transports release separately, which is flexible, and also means three things to keep synchronized. It is not zero effort.
Get It / What’s Next
MailJot is available now on our GitHub.
git clone https://github.com/315Concepts/mailjot-email
cd mailjot-email
npm install
npm run dev # API on http://localhost:3000
npm run storybook # template preview on http://localhost:6006
npm test
Then POST to /v1/render/accounts/welcome as above, open Storybook to see the same email in the browser, and copy templates/v1/accounts/welcome to jot down your own - there’s nothing to register, so the only thing you do is write. The README and the docs/ folder cover the full architecture, authoring, sending, testing, and how to write your own transport.
Email doesn’t have to be the chore everyone puts off. As with the rest of the family, the code is out in the open so you can use it, break it, and improve it. If you hit a rough edge, want a transport for your favorite provider, or want MailJot to do something it doesn’t yet - open an Issue or send a Pull Request. The best fixes tend to come from someone else’s “oh, that’s why nobody does it that way” moment.
And if you just want to scream into the darkness, BlackHole is your favorite transport. It swallows everything, and it’s very proud of that.
