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 });
Enter fullscreen mode Exit fullscreen mode
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>
),
});
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]', name: 'Acme' },
});
await mailer.send(Welcome, {
to: '[email protected]',
props: { name: 'Alice', url: 'https://example.com/start' },
});
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
tierde dev → http://localhost:3000
45 templates loaded · dark-mode toggle · compare view · live reload
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Add --text for the plain-text version. Send a real smoke test through your configured provider:
TIERDE_PROVIDER=resend RESEND_API_KEY=re_... [email protected] \
npx tierde send welcome \
--to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# → sent: 0a1b2c3d-...
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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++),
});
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]', props: { name: 'Alice', url: '...' } });
expect(inbox[0].subject).toBe('Welcome, Alice!');
expect(inbox[0].html).toContain('Get Started');
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Services and ports:
Service Port Purpose Mailpit1025 (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: '[email protected]',
});
// Send anything — open http://localhost:8025 to see it
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]',
});
// WireMock stubs POST /emails → returns { id: "wiremock-resend-message-id" }
Enter fullscreen mode Exit fullscreen mode
SendGrid → WireMock
import { sendgrid } from '@yedoma-labs/tierde-mail/providers/sendgrid';
const mailer = createMailer({
provider: sendgrid({
apiKey: 'test-key',
baseUrl: 'http://localhost:8080',
}),
from: '[email protected]',
});
// WireMock stubs POST /v3/mail/send → 202
Enter fullscreen mode Exit fullscreen mode
Postmark → WireMock
import { postmark } from '@yedoma-labs/tierde-mail/providers/postmark';
const mailer = createMailer({
provider: postmark({
serverToken: 'test-token',
baseUrl: 'http://localhost:8080',
}),
from: '[email protected]',
});
// WireMock stubs POST /email → 200
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]',
});
// WireMock stubs POST /v3/mg.example.com/messages → 200
Enter fullscreen mode Exit fullscreen mode
Brevo → WireMock
import { brevo } from '@yedoma-labs/tierde-mail/providers/brevo';
const mailer = createMailer({
provider: brevo({ apiKey: 'test-key', baseUrl: 'http://localhost:8080' }),
from: '[email protected]',
});
// WireMock stubs POST /v3/smtp/email → 201
Enter fullscreen mode Exit fullscreen mode
MailerSend → WireMock
import { mailersend } from '@yedoma-labs/tierde-mail/providers/mailersend';
const mailer = createMailer({
provider: mailersend({ apiToken: 'test-token', baseUrl: 'http://localhost:8080' }),
from: '[email protected]',
});
// WireMock stubs POST /v1/email → 202
Enter fullscreen mode Exit fullscreen mode
SparkPost → WireMock
import { sparkpost } from '@yedoma-labs/tierde-mail/providers/sparkpost';
const mailer = createMailer({
provider: sparkpost({ apiKey: 'test-key', baseUrl: 'http://localhost:8080' }),
from: '[email protected]',
});
// WireMock stubs POST /api/v1/transmissions → 200
Enter fullscreen mode Exit fullscreen mode
Mandrill → WireMock
import { mandrill } from '@yedoma-labs/tierde-mail/providers/mandrill';
const mailer = createMailer({
provider: mandrill({ apiKey: 'test-key', baseUrl: 'http://localhost:8080' }),
from: '[email protected]',
});
// WireMock stubs POST /api/1.0/messages/send → 200
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]', // must match TIERDE_FROM_EMAIL in docker-compose.yml
});
Enter fullscreen mode Exit fullscreen mode
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
[email protected]
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
Enter fullscreen mode Exit fullscreen mode
import { createMailerFromEnv } from '@yedoma-labs/tierde-mail';
const mailer = createMailerFromEnv(); // reads above env vars
Enter fullscreen mode Exit fullscreen mode
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 \
[email protected] \
npx tierde send welcome \
--to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# → sent: wiremock-resend-message-id
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]',
});
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]',
maxRetries: 3, // retry up to 3 times after the first failure
initialRetryDelayMs: 500, // first retry after 500ms, then 1s, 2s
});
Enter fullscreen mode Exit fullscreen mode
Default predicate retries HTTP 429 (rate-limited), 502, 503, and 504. Override with retryOn:
const mailer = createMailer({
provider: sendgrid({ apiKey: '...' }),
from: '[email protected]',
maxRetries: 2,
retryOn: (err) => err instanceof Error && err.message.includes('503'),
});
Enter fullscreen mode Exit fullscreen mode
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
]);
Enter fullscreen mode Exit fullscreen mode
Dynamic theming per email type
const PasswordReset = defineEmail<{ ... }>({
component: (props) => (
<ThemeProvider theme={createTheme({ primaryColor: '#dc2626' })}>
<EmailTemplate>
{/* Red theme for security emails */}
</EmailTemplate>
</ThemeProvider>
),
});
Enter fullscreen mode Exit fullscreen mode
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>
);
},
});
Enter fullscreen mode Exit fullscreen mode
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>
),
});
Enter fullscreen mode Exit fullscreen mode
Attachments
Pass PDFs, images, or CSVs alongside any email:
await mailer.send(InvoiceEmail, {
to: '[email protected]',
props: { invoiceNumber: 'INV-2026-001', total: 149.00 },
attachments: [
{
filename: 'invoice-2026-001.pdf',
content: pdfBuffer, // Buffer or base64 string
contentType: 'application/pdf',
},
],
});
Enter fullscreen mode Exit fullscreen mode
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' },
],
})),
});
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]',
middleware: [
embedImages([
'https://cdn.example.com/logo.png',
]),
],
});
Enter fullscreen mode Exit fullscreen mode
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), ... });
Enter fullscreen mode Exit fullscreen mode
Debugging and monitoring
Enable debug logging
const mailer = createMailer({
provider: resend({ apiKey: '...' }),
from: '[email protected]',
debug: process.env.DEBUG === 'true',
});
// Logs render time, provider response, rendered HTML size
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Common gotchas
- React key warnings in batch sends. When rendering lists in templates, always key them:
{items.map((item) => <Item key={item.id} {...item} />)}
Enter fullscreen mode Exit fullscreen mode
-
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 } });
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기