I shipped a SaaS that sent six transactional emails: welcome, password reset, verification, invoice, payment failed, team invite. Every one was a hand-maintained HTML string with inline styles, nested <table> tags for Outlook, and a prefers-color-scheme block I copy-pasted and always got slightly wrong.
Then we switched from Resend to SES for cost. The send code was tangled into the Resend SDK. I touched twelve files. A week later a designer asked to change the button radius across all emails. Twelve files again.
That is the problem @yedoma-labs/tierde-mail solves.
What problem it solves
Before - a password reset email:
const html = `<!DOCTYPE html><html><head><style>
@media (prefers-color-scheme: dark) { .card { background: #1a1a1a !important; } }
</style></head><body>
<table width="100%"><tr><td align="center">
<table class="card" width="600" style="background:#fff;border-radius:12px">
<tr><td style="padding:32px;font-family:-apple-system,sans-serif">
<h1 style="color:#0f172a">Reset your password</h1>
...nested tables for the button...
`;
await resend.emails.send({ from, to, subject: 'Reset', html });
Miss one inline style and Gmail eats it. Change a color and you hand-edit every template. Swap providers and you rewrite the send call everywhere.
After:
import { defineEmail, EmailTemplate, Heading, Text, Button } from '@yedoma-labs/tierde-mail';
export const PasswordReset = defineEmail<{ username: string; resetUrl: string }>({
subject: ({ username }) => `Reset your password, ${username}`,
component: ({ resetUrl }) => (
<EmailTemplate preview="Reset your password">
<Heading>Reset your password</Heading>
<Text>Click below. Link expires in 1 hour.</Text>
<Button href={resetUrl}>Reset password</Button>
</EmailTemplate>
),
});
Dark mode, Outlook-safe buttons, inlined CSS, and plain-text fallback are generated for you. Providers live behind one send() call.
Who needs this
Backend and full-stack devs on Node.js who send transactional email and are tired of:
- Maintaining HTML strings by hand.
- Provider SDKs leaking into business logic.
- No type safety on template props (
{ name }vs{ username }- caught at runtime, in production).
If you send three or more distinct emails, or you might ever switch providers, this pays off. If you send one hard-coded email and never touch it, you don't need this.
How it works - under the hood
Three design decisions matter.
Templates are JSX, rendered server-side to HTML. The library uses React's server-side renderer to transform JSX components into static HTML. Components like <Button> and <Heading> compile to semantically correct table-based markup with inline styles (Outlook email clients still require tables). CSS is inlined at render time via @css-inline, so no external stylesheet requests (email clients block them). A plain-text version is auto-derived with html-to-text and sent as the text/plain MIME part. React 19 is a peer dependency - you control the version.
Props are typed end to end. defineEmail<Props>() creates a branded type that ties the subject function, the component, and mailer.send() together. The type parameter flows through all call sites: if you pass { name: 'Alice' } to mailer.send() but the template expects { name, loginUrl }, TypeScript fails at build time, not inbox time. This catches 90% of template bugs before they ship.
Providers implement one interface. Every provider — Resend, SMTP, SES, SendGrid, Postmark, Mailgun, Brevo, MailerSend, SparkPost, Mandrill, Mailpit — conforms to the same contract: a function returning { name: string; send(message: EmailMessage): Promise<SendResult> }. The mailer only knows that interface. Swapping providers is a one-line change. Failover and round-robin strategies wrap multiple providers, so you can fail over to SMTP if Resend is down.
Theme context for consistency. Theme values (colors, radius, fonts, spacing) flow through React context via <ThemeProvider>. One createTheme() call restyles every email component at render time, so a rebrand means updating theme, not 45 templates.
Render pipeline
defineEmail → JSX component → React SSR → HTML string
↓
@css-inline (inline all CSS)
↓
[HTML + plain-text version ready]
↓
provider.send(message) → API/SMTP
Under the hood, the render phase is synchronous - no network I/O. Providers handle async delivery.
Installation
Where to get it:
- npm:
@yedoma-labs/tierde-mail - GitHub:
yedoma-labs/tierde-mail - Latest version: 0.9.0
Install core + peer dependencies:
Most providers (Resend, SendGrid, Postmark, Mailgun, Brevo, MailerSend, SparkPost, Mandrill) use native fetch — no extra peer dependencies. Only three providers need additional packages:
With pnpm (recommended):
# Core + all HTTP providers (Resend, SendGrid, Postmark, Mailgun, Brevo, MailerSend, SparkPost, Mandrill)
pnpm add @yedoma-labs/tierde-mail react react-dom
# + SMTP / Mailpit (self-hosted, most control)
pnpm add @yedoma-labs/tierde-mail react react-dom nodemailer
# + AWS SES (high volume, lowest cost)
pnpm add @yedoma-labs/tierde-mail react react-dom @aws-sdk/client-ses
# + Postmark (uses the official Postmark SDK)
pnpm add @yedoma-labs/tierde-mail react react-dom postmark
Or with npm:
npm install @yedoma-labs/tierde-mail react react-dom
npm install @yedoma-labs/tierde-mail react react-dom nodemailer
npm install @yedoma-labs/tierde-mail react react-dom @aws-sdk/client-ses
npm install @yedoma-labs/tierde-mail react react-dom postmark
Node version: Node 22+. TypeScript 5.0+ recommended (strict mode works).
Getting started
Define, wire a provider, send:
import { createMailer, defineEmail, EmailTemplate, Heading, Text, Button } from '@yedoma-labs/tierde-mail';
import { resend } from '@yedoma-labs/tierde-mail/providers/resend';
const Welcome = defineEmail<{ name: string; url: string }>({
subject: ({ name }) => `Welcome, ${name}!`,
component: ({ name, url }) => (
<EmailTemplate preview={`Welcome, ${name}!`}>
<Heading>Welcome, {name}!</Heading>
<Text>Your account is ready.</Text>
<Button href={url}>Get Started</Button>
</EmailTemplate>
),
});
const mailer = createMailer({
provider: resend({ apiKey: process.env.RESEND_API_KEY! }),
from: { email: 'hello@example.com', name: 'Acme' },
});
await mailer.send(Welcome, {
to: 'user@example.com',
props: { name: 'Alice', url: 'https://example.com/start' },
});
For local development without real credentials, see Local testing with Docker below.
Usage examples
1. Preview every built-in template in the browser. 45 ready-made templates ship with the package. Start the preview server with sample data:
npx tierde dev --port 3000
tierde dev → http://localhost:3000
45 templates loaded · dark-mode toggle · compare view · live reload
Open it, flip dark mode, resize to 375px to check mobile. No boilerplate.
2. Render a template to HTML from the CLI - useful in CI or for a quick eyeball:
npx tierde render welcome \
--props '{"name":"Alice","loginUrl":"https://example.com"}' \
-o welcome.html
Add --text for the plain-text version. Send a real smoke test through your configured provider:
TIERDE_PROVIDER=resend RESEND_API_KEY=re_... TIERDE_FROM_EMAIL=you@example.com \
npx tierde send welcome \
--to me@example.com \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# → sent: 0a1b2c3d-...
3. Batch send with rate limiting and failure isolation. Send a newsletter to thousands without tripping provider limits or aborting on one bounce:
const result = await mailer.sendBatch(Welcome, {
recipients: users.map((u) => ({ to: u.email, props: { name: u.name, url: u.link } })),
maxPerSecond: 2, // token-bucket - stays under Resend free tier
concurrency: 2,
onResult: (r) => console.log(r.to, r.result?.id ?? r.error?.message),
});
console.log(`${result.sent} sent, ${result.failed} failed`);
// → 4821 sent, 3 failed
For very large lists, add collectResults: false so the batch doesn't retain one result object (plus its props) per recipient — counts stay accurate and you handle each outcome in onResult:
await mailer.sendBatch(Welcome, {
recipients: hundredsOfThousands,
maxPerSecond: 10,
collectResults: false, // results array stays empty; no O(n) retention
onResult: (r) => (r.error ? logBounce(r.to, r.error) : metrics.sent++),
});
4. Test sends without hitting a network. The testing util captures emails in memory:
import { captureEmails } from '@yedoma-labs/tierde-mail/testing';
const { mailer, inbox } = captureEmails();
await mailer.send(Welcome, { to: 'test@example.com', props: { name: 'Alice', url: '...' } });
expect(inbox[0].subject).toBe('Welcome, Alice!');
expect(inbox[0].html).toContain('Get Started');
Local testing with Docker
No real provider credentials needed for local work. The repo ships a docker-compose.yml with a complete mock stack.
Prerequisites
- Docker + Docker Compose
- AWS CLI (only if you want to run LocalStack commands manually — the init script handles verification automatically)
- Optional: a LocalStack auth token if you want LocalStack Pro features (Community edition works for SES without one — set
LOCALSTACK_AUTH_TOKEN=to an empty string or remove the env var from your.env)
One-command stack
# from repo root (or copy docker-compose.yml to your project)
docker compose up -d
Services and ports:
| Service | Port | Purpose |
|---|---|---|
| Mailpit |
1025 (SMTP), 8025 (UI) |
Catches all mail; browse at http://localhost:8025 |
| WireMock | 8080 |
HTTP stubs for all HTTP providers (Resend, SendGrid, Postmark, Mailgun, Brevo, MailerSend, SparkPost, Mandrill) |
| LocalStack | 4566 |
SES-compatible AWS endpoint |
The LocalStack container auto-verifies your sender address on startup via the bundled scripts/localstack/init-ses.sh ready-hook — no manual aws ses verify-email-identity step.
Wiring providers to local mocks
Mailpit (SMTP provider)
import { mailpit } from '@yedoma-labs/tierde-mail/providers/mailpit';
const mailer = createMailer({
provider: mailpit(), // default: localhost:1025
from: 'hello@example.com',
});
// Send anything — open http://localhost:8025 to see it
Bare-hostname addresses like test@localhost and inbox@mailpit are valid per RFC 5321 and accepted by the validator. No dot in the domain is fine for local development.
Resend → WireMock
import { resend } from '@yedoma-labs/tierde-mail/providers/resend';
const mailer = createMailer({
provider: resend({
apiKey: 'test-key', // any non-empty string
baseUrl: 'http://localhost:8080',
}),
from: 'hello@example.com',
});
// WireMock stubs POST /emails → returns { id: "wiremock-resend-message-id" }
SendGrid → WireMock
import { sendgrid } from '@yedoma-labs/tierde-mail/providers/sendgrid';
const mailer = createMailer({
provider: sendgrid({
apiKey: 'test-key',
baseUrl: 'http://localhost:8080',
}),
from: 'hello@example.com',
});
// WireMock stubs POST /v3/mail/send → 202
Postmark → WireMock
import { postmark } from '@yedoma-labs/tierde-mail/providers/postmark';
const mailer = createMailer({
provider: postmark({
serverToken: 'test-token',
baseUrl: 'http://localhost:8080',
}),
from: 'hello@example.com',
});
// WireMock stubs POST /email → 200
Mailgun → WireMock
import { mailgun } from '@yedoma-labs/tierde-mail/providers/mailgun';
const mailer = createMailer({
provider: mailgun({
apiKey: 'test-key',
domain: 'mg.example.com',
baseUrl: 'http://localhost:8080',
}),
from: 'hello@example.com',
});
// WireMock stubs POST /v3/mg.example.com/messages → 200
Brevo → WireMock
import { brevo } from '@yedoma-labs/tierde-mail/providers/brevo';
const mailer = createMailer({
provider: brevo({ apiKey: 'test-key', baseUrl: 'http://localhost:8080' }),
from: 'hello@example.com',
});
// WireMock stubs POST /v3/smtp/email → 201
MailerSend → WireMock
import { mailersend } from '@yedoma-labs/tierde-mail/providers/mailersend';
const mailer = createMailer({
provider: mailersend({ apiToken: 'test-token', baseUrl: 'http://localhost:8080' }),
from: 'hello@example.com',
});
// WireMock stubs POST /v1/email → 202
SparkPost → WireMock
import { sparkpost } from '@yedoma-labs/tierde-mail/providers/sparkpost';
const mailer = createMailer({
provider: sparkpost({ apiKey: 'test-key', baseUrl: 'http://localhost:8080' }),
from: 'hello@example.com',
});
// WireMock stubs POST /api/v1/transmissions → 200
Mandrill → WireMock
import { mandrill } from '@yedoma-labs/tierde-mail/providers/mandrill';
const mailer = createMailer({
provider: mandrill({ apiKey: 'test-key', baseUrl: 'http://localhost:8080' }),
from: 'hello@example.com',
});
// WireMock stubs POST /api/1.0/messages/send → 200
SES → LocalStack
import { ses } from '@yedoma-labs/tierde-mail/providers/ses';
const mailer = createMailer({
provider: ses({
region: 'us-east-1',
endpoint: 'http://localhost:4566',
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
}),
from: 'dev@example.com', // must match TIERDE_FROM_EMAIL in docker-compose.yml
});
Using createMailerFromEnv with the local stack
All mock URLs are configurable via env vars — no code changes needed between local and production:
# .env.local
# shared
TIERDE_FROM_EMAIL=dev@example.com
TIERDE_FROM_NAME=Local Dev
# pick one:
# Mailpit
TIERDE_PROVIDER=mailpit
MAILPIT_HOST=localhost
MAILPIT_PORT=1025
# Resend → WireMock
TIERDE_PROVIDER=resend
RESEND_API_KEY=test-key
RESEND_BASE_URL=http://localhost:8080
# SendGrid → WireMock
TIERDE_PROVIDER=sendgrid
SENDGRID_API_KEY=test-key
SENDGRID_BASE_URL=http://localhost:8080
# Postmark → WireMock
TIERDE_PROVIDER=postmark
POSTMARK_SERVER_TOKEN=test-token
POSTMARK_BASE_URL=http://localhost:8080
# Mailgun → WireMock
TIERDE_PROVIDER=mailgun
MAILGUN_API_KEY=test-key
MAILGUN_DOMAIN=mg.example.com
MAILGUN_BASE_URL=http://localhost:8080
# Brevo → WireMock
TIERDE_PROVIDER=brevo
BREVO_API_KEY=test-key
BREVO_BASE_URL=http://localhost:8080
# MailerSend → WireMock
TIERDE_PROVIDER=mailersend
MAILERSEND_API_TOKEN=test-token
MAILERSEND_BASE_URL=http://localhost:8080
# SparkPost → WireMock
TIERDE_PROVIDER=sparkpost
SPARKPOST_API_KEY=test-key
SPARKPOST_BASE_URL=http://localhost:8080
# Mandrill → WireMock
TIERDE_PROVIDER=mandrill
MANDRILL_API_KEY=test-key
MANDRILL_BASE_URL=http://localhost:8080
# SES → LocalStack
TIERDE_PROVIDER=ses
SES_REGION=us-east-1
SES_ENDPOINT=http://localhost:4566
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
import { createMailerFromEnv } from '@yedoma-labs/tierde-mail';
const mailer = createMailerFromEnv(); // reads above env vars
Guard: if SES_ENDPOINT is set but AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY are missing, createMailerFromEnv throws a descriptive error rather than silently falling through to ambient AWS credentials (SSO sessions, named profiles).
Smoke-testing via CLI
# fire a real send through whichever provider is configured in env
TIERDE_PROVIDER=resend RESEND_API_KEY=test-key RESEND_BASE_URL=http://localhost:8080 \
TIERDE_FROM_EMAIL=dev@example.com \
npx tierde send welcome \
--to me@example.com \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# → sent: wiremock-resend-message-id
Advanced patterns
Environment-aware provider selection
const mailer = createMailer({
provider: process.env.NODE_ENV === 'production'
? resend({ apiKey: process.env.RESEND_API_KEY! })
: resend({
apiKey: 'test-key',
baseUrl: 'http://localhost:8080', // WireMock — same code path as prod
}),
from: 'hello@example.com',
});
Or use mailpit() if you want to read the email in a browser rather than assert on a mock response.
Retry / exponential backoff
Transient failures (rate limits, intermittent 5xx) are retried automatically with exponential backoff. Disabled by default — enable per mailer:
const mailer = createMailer({
provider: resend({ apiKey: '...' }),
from: 'hello@example.com',
maxRetries: 3, // retry up to 3 times after the first failure
initialRetryDelayMs: 500, // first retry after 500ms, then 1s, 2s
});
Default predicate retries HTTP 429 (rate-limited), 502, 503, and 504. Override with retryOn:
const mailer = createMailer({
provider: sendgrid({ apiKey: '...' }),
from: 'hello@example.com',
maxRetries: 2,
retryOn: (err) => err instanceof Error && err.message.includes('503'),
});
In failover mode each provider is retried independently before the next failover target is tried.
Multi-provider failover
import { failoverProvider } from '@yedoma-labs/tierde-mail/strategies';
const provider = failoverProvider([
resend({ apiKey: process.env.RESEND_API_KEY }),
mailpit(), // fallback to local for dev
]);
Dynamic theming per email type
const PasswordReset = defineEmail<{ ... }>({
component: (props) => (
<ThemeProvider theme={createTheme({ primaryColor: '#dc2626' })}>
<EmailTemplate>
{/* Red theme for security emails */}
</EmailTemplate>
</ThemeProvider>
),
});
Derived/computed props in templates
const Invoice = defineEmail<{ items: Item[]; tax: number }>({
component: (props) => {
const subtotal = props.items.reduce((s, i) => s + i.price, 0);
const total = subtotal + props.tax;
return (
<EmailTemplate>
<Text>Total: ${total.toFixed(2)}</Text>
</EmailTemplate>
);
},
});
Conditional content
const Newsletter = defineEmail<{ tier: 'free' | 'pro'; proFeatures?: string[] }>({
component: ({ tier, proFeatures }) => (
<EmailTemplate>
{tier === 'pro' && proFeatures && (
<Section>
<Heading level={2}>Your Pro Features</Heading>
{proFeatures.map((f) => <Text key={f}>{f}</Text>)}
</Section>
)}
</EmailTemplate>
),
});
Attachments
Pass PDFs, images, or CSVs alongside any email:
await mailer.send(InvoiceEmail, {
to: 'customer@example.com',
props: { invoiceNumber: 'INV-2026-001', total: 149.00 },
attachments: [
{
filename: 'invoice-2026-001.pdf',
content: pdfBuffer, // Buffer or base64 string
contentType: 'application/pdf',
},
],
});
For batch sends with a shared attachment (e.g. terms PDF) plus a per-recipient one (personal invoice):
await mailer.sendBatch(InvoiceEmail, {
attachments: [
{ filename: 'terms.pdf', content: termsBuffer, contentType: 'application/pdf' },
],
recipients: customers.map((c) => ({
to: c.email,
props: { invoiceNumber: c.invoiceNumber, total: c.total },
attachments: [
{ filename: `invoice-${c.invoiceNumber}.pdf`, content: c.pdfBuffer, contentType: 'application/pdf' },
],
})),
});
Allowed content types: application/pdf, application/zip, image/*, text/plain, text/csv. Anything else throws before a provider call is made.
Inline images (embedded)
Use embedImages middleware to fetch remote images and embed them inline. Recipients see the image even with remote image loading blocked:
import { createMailer, embedImages } from '@yedoma-labs/tierde-mail';
const mailer = createMailer({
provider: smtp({ ... }),
from: 'hello@example.com',
middleware: [
embedImages([
'https://cdn.example.com/logo.png',
]),
],
});
Your template references the image by its original URL — embedImages rewrites src to cid:logo.png at send time and attaches the file inline. Fetched images are cached per middleware instance, so a batch send fetches each image once and reuses it for every recipient rather than re-fetching per send.
Architecture decisions and trade-offs
Why server-side render, not client-side?
Email clients don't run JavaScript. Rendering must happen at send time on the server. This is a feature: your templates are guaranteed safe from XSS and have no runtime dependencies in the inbox.
Why React?
React's JSX syntax is already familiar to 90% of Node devs. The ecosystem of component libraries and tooling is mature. You get free refactoring and testing tools (Babel, TypeScript, testing libraries) without learning a new template language.
Why not inline all CSS?
Some complex layouts benefit from a <style> block (e.g., dark mode with @media). Inlining still happens for component-level styles; you can opt out per email with the inlineCSS flag if you need pixel-perfect control.
Provider lock-in - solved
The provider interface is minimal. Write your own in ~20 lines:
import nodemailer from 'nodemailer';
export const mySmtpProvider = (config: SMTPConfig) => ({
name: 'my-smtp',
async send(message: EmailMessage) {
const transporter = nodemailer.createTransport(config);
const result = await transporter.sendMail({
from: message.from,
to: message.to,
subject: message.subject,
html: message.html,
text: message.text,
});
return { id: result.messageId, accepted: [message.to] };
},
});
const mailer = createMailer({ provider: mySmtpProvider(smtpConfig), ... });
Debugging and monitoring
Enable debug logging
const mailer = createMailer({
provider: resend({ apiKey: '...' }),
from: 'hello@example.com',
debug: process.env.DEBUG === 'true',
});
// Logs render time, provider response, rendered HTML size
Inspect rendered HTML before sending
import { renderTemplate } from '@yedoma-labs/tierde-mail/render';
const { html, text } = await renderTemplate(Welcome, {
name: 'Alice',
url: 'https://example.com',
});
console.log(html); // Full rendered email - paste into email client to preview
Common gotchas
- React key warnings in batch sends. When rendering lists in templates, always key them:
{items.map((item) => <Item key={item.id} {...item} />)}
-
Async data in props. Templates can't fetch data at render time. Compute everything before calling
mailer.send():
// Wrong: props.user = await db.getUser(id) happens inside component
// Right: fetch outside, pass as prop
const user = await db.getUser(id);
await mailer.send(Welcome, { to: user.email, props: { user } });
Image URLs must be absolute. Email clients don't resolve relative URLs. Use
https://cdn.example.com/image.png, not/images/....Provider API keys in environment. Never commit
.envfiles with real keys. Useprocess.env.RESEND_API_KEY!with the!to assert at runtime if missing.
Production checklist
- [ ] Email address validation is automatic. tierde-mail validates every address against RFC 5321 before any provider call — full
atextcharacter class, quoted-string locals, domain label rules, address literals ([127.0.0.1]), bare hostnames (localhost), and control-character (header-injection) rejection all enforced. ThrowsTypeErrorbefore any network request. No extra sanitization step needed. - [ ] Provider secrets in env vars. Never commit API keys; use
.env.local(git-ignored). - [ ] Test email rendering. Run
npx tierde devand preview every email variant in dark mode. - [ ] Local mock smoke test. Run
docker compose up -dand firenpx tierde sendagainst WireMock/LocalStack before touching real credentials. - [ ]
SES_ENDPOINTguard. If using SES locally, always setAWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY;createMailerFromEnvwill throw if you forget. - [ ] Webhook handlers. Set up bounce/complaint/delivery handlers for Resend, Postmark, SendGrid, and Mailgun to clean your list. All four have built-in webhook verification via
@yedoma-labs/tierde-mail/webhooks. - [ ] Rate limits. If batch-sending, honor provider limits (Resend: 100/sec free tier, SES: 14 per second base).
- [ ] Monitoring. Log
result.idfor every send; wire that to observability (Sentry, DataDog) for delivery tracking. - [ ] Unsubscribe footer. GDPR/CAN-SPAM requires unsubscribe link.
<Footer unsubscribeUrl={...} />handles it. - [ ] Tracking compliance. tierde-mail ships zero tracking — the middleware pipeline is a user hook. If you add open/click tracking via middleware, consent, data retention, and regulatory obligations (GDPR, CASL, CAN-SPAM) are your responsibility as the implementer.
Resources
-
Docs: See
README.mdin the repo for full API reference, all providers, and examples. -
GitHub:
yedoma-labs/tierde-mail- open issues, contribute templates. -
npm:
@yedoma-labs/tierde-mail- version history, package info. -
Built-in templates: Run
npx tierde listto see all 45 ready-made templates. Eject any withnpx tierde eject welcome --output src/emails/. - TypeScript types: Full type definitions ship with the package; autocomplete works in VS Code/JetBrains.
Closing
tierde-mail exists because email rendering and email delivery should be two separate concerns, and neither should require hand-maintained table HTML. The 45 built-in templates are WCAG AA contrast-checked in CI and you can tierde eject any of them into your repo to own the source.
Limitations to know:
- Node-only (server-side render needs Node; no edge runtime rendering yet, but delivery can be edge-triggered).
- React 19 is a hard peer dependency (controls your version independently).
- Webhook verification covers Resend, Postmark, SendGrid, and Mailgun; SMTP, SES, and
nodemailerare optional peers you install yourself. - CSS support is email-safe subset (no flexbox, limited grid); table-based layouts are automatic.
- Plain-text fallback is auto-derived; customize with
textFallbackoption if needed.
Performance: Template render is <5ms for typical emails; async time is 100% provider latency.
If you send transactional email from TypeScript and control your stack, it's worth an afternoon to wire in. You'll be grateful the next time a designer asks to change the button color, or a compliance audit forces a provider switch.













