@yedoma-labs/tierde-mail
v0.9.1
Published
Modern email library — JSX templates, multi-provider, TypeScript-first
Maintainers
Readme
@yedoma-labs/tierde-mail
тиэрдэ (tierde) — Yakutian (Sakha) for "deliver / forward"
Modern email library for Node.js — JSX templates, multi-provider sending, TypeScript-first.
pnpm add @yedoma-labs/tierde-mail react react-domReact 19+ required as a peer dependency.
Quick start
import { createMailer, defineEmail, EmailTemplate, Heading, Text, Button } from '@yedoma-labs/tierde-mail';
import { resend } from '@yedoma-labs/tierde-mail/providers/resend';
// 1. Define a template
const WelcomeEmail = 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>
),
});
// 2. Create a mailer
const mailer = createMailer({
provider: resend({ apiKey: process.env.RESEND_API_KEY! }),
from: { email: '[email protected]', name: 'Acme' },
});
// 3. Send — TypeScript enforces the right props
await mailer.send(WelcomeEmail, {
to: '[email protected]',
props: { name: 'Alice', url: 'https://example.com/start' },
});Providers
| Provider | Package | Free tier | Notes |
|---|---|---|---|
| Resend | providers/resend | 3k/mo | |
| SMTP | providers/smtp | — | requires nodemailer peer dep |
| Mailpit / MailHog | providers/mailpit | local | SMTP catch-all for local dev |
| AWS SES | providers/ses | 62k/mo (EC2) | requires @aws-sdk/client-ses peer dep |
| SendGrid | providers/sendgrid | 100/day | |
| Postmark | providers/postmark | 100/mo trial | |
| Mailgun | providers/mailgun | 5k/mo | US/EU regions |
| Brevo | providers/brevo | 300/day | formerly Sendinblue |
| MailerSend | providers/mailersend | 3k/mo | |
| SparkPost | providers/sparkpost | none | EU region + sandbox mode |
| Mandrill | providers/mandrill | none | Mailchimp Transactional add-on |
Import each provider from its subpath:
import { resend } from '@yedoma-labs/tierde-mail/providers/resend';
import { smtp } from '@yedoma-labs/tierde-mail/providers/smtp';
import { mailpit } from '@yedoma-labs/tierde-mail/providers/mailpit';
import { ses } from '@yedoma-labs/tierde-mail/providers/ses';
import { sendgrid } from '@yedoma-labs/tierde-mail/providers/sendgrid';
import { postmark } from '@yedoma-labs/tierde-mail/providers/postmark';
import { mailgun } from '@yedoma-labs/tierde-mail/providers/mailgun';
import { brevo } from '@yedoma-labs/tierde-mail/providers/brevo';
import { mailersend } from '@yedoma-labs/tierde-mail/providers/mailersend';
import { sparkpost } from '@yedoma-labs/tierde-mail/providers/sparkpost';
import { mandrill } from '@yedoma-labs/tierde-mail/providers/mandrill';Resend
resend({ apiKey: 're_...' })SMTP / Nodemailer
smtp({ host: 'smtp.example.com', port: 587, auth: { user: '...', pass: '...' } })nodemailer is an optional peer dependency — install it separately:
pnpm add nodemailerMailpit / MailHog (local dev)
mailpit() // localhost:1025 (defaults)
mailpit({ host: 'mailpit', port: 1025 }) // custom host/portSpins up no extra services — just point at a running Mailpit or MailHog instance. TLS is disabled by default; rejectUnauthorized is false so self-signed certs work out of the box.
AWS SES
ses({ region: 'us-east-1' })Requires @aws-sdk/client-ses installed separately and AWS credentials configured via environment or IAM role.
SendGrid
sendgrid({ apiKey: 'SG...' })Postmark
postmark({ serverToken: '...' })Mailgun
mailgun({ apiKey: 'key-...', domain: 'mg.example.com' })
mailgun({ apiKey: 'key-...', domain: 'mg.example.com', region: 'eu' }) // EU regionTIERDE_PROVIDER=mailgun MAILGUN_API_KEY=key-... MAILGUN_DOMAIN=mg.example.comBrevo (formerly Sendinblue)
brevo({ apiKey: 'xkeysib-...' })TIERDE_PROVIDER=brevo BREVO_API_KEY=xkeysib-...MailerSend
mailersend({ apiToken: 'mlsn.abc...' })TIERDE_PROVIDER=mailersend MAILERSEND_API_TOKEN=mlsn.abc...SparkPost
sparkpost({ apiKey: 'sp-key' })
sparkpost({ apiKey: 'sp-key', baseUrl: 'https://api.eu.sparkpost.com' }) // EU tenant
sparkpost({ apiKey: 'sp-key', sandbox: true }) // sandboxTIERDE_PROVIDER=sparkpost SPARKPOST_API_KEY=sp-keyMandrill (Mailchimp Transactional)
mandrill({ apiKey: 'mc-key' })TIERDE_PROVIDER=mandrill MANDRILL_API_KEY=mc-keyLocal development
Contributors (working on this repo): build first, then run
node dist/bin/tierde.jsinstead ofnpx tierde.npx tierdealways fetches the published package and will not reflect local changes.pnpm build node dist/bin/tierde.js send welcome --to [email protected] --props '{"name":"Alice","loginUrl":"https://example.com"}'Users (consuming the package): use
npx tierdeas shown throughout this guide.
A docker-compose.yml is included at the repo root. It runs Mailpit — a catch-all SMTP sink that accepts every outbound email without delivering anything.
Start:
docker compose up -dStop:
docker compose down| Service | Endpoint | Purpose |
|---|---|---|
| Mailpit SMTP | localhost:1025 | catch-all SMTP sink |
| Mailpit UI | http://localhost:8025 | browse captured emails |
| WireMock | http://localhost:8080 | HTTP mock — all HTTP providers |
| LocalStack | http://localhost:4566 | AWS SES API mock |
Every address you send to is accepted — no DNS, no deliverability concerns.
Mailpit provider (direct SMTP)
import { createMailer } from '@yedoma-labs/tierde-mail';
import { mailpit } from '@yedoma-labs/tierde-mail/providers/mailpit';
const mailer = createMailer({
provider: mailpit(), // defaults: host localhost, port 1025
from: '[email protected]',
});Or via environment variables:
TIERDE_PROVIDER=mailpit
[email protected]Smoke-test via CLI:
docker compose up -d
# users (uses published package from npmjs)
TIERDE_PROVIDER=mailpit \
[email protected] \
npx tierde send welcome \
--to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# contributors (uses local package, build first: pnpm build)
TIERDE_PROVIDER=mailpit \
[email protected] \
node dist/bin/tierde.js send welcome \
--to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# open http://localhost:8025 to see the emailHTTP providers via WireMock
WireMock stubs all HTTP provider APIs — calls succeed and return a mock message ID without touching any real provider. Stub mappings live in scripts/wiremock/mappings/.
Every HTTP provider accepts an optional baseUrl (or PROVIDER_BASE_URL env var) to redirect at WireMock:
import { resend } from '@yedoma-labs/tierde-mail/providers/resend';
import { sendgrid } from '@yedoma-labs/tierde-mail/providers/sendgrid';
import { postmark } from '@yedoma-labs/tierde-mail/providers/postmark';
import { mailgun } from '@yedoma-labs/tierde-mail/providers/mailgun';
import { brevo } from '@yedoma-labs/tierde-mail/providers/brevo';
import { mailersend } from '@yedoma-labs/tierde-mail/providers/mailersend';
import { sparkpost } from '@yedoma-labs/tierde-mail/providers/sparkpost';
import { mandrill } from '@yedoma-labs/tierde-mail/providers/mandrill';
const WM = 'http://localhost:8080';
resend({ apiKey: 'test', baseUrl: WM })
sendgrid({ apiKey: 'test', baseUrl: WM })
postmark({ serverToken: 'test', baseUrl: WM })
mailgun({ apiKey: 'test', domain: 'mg.example.com', baseUrl: WM })
brevo({ apiKey: 'test', baseUrl: WM })
mailersend({ apiToken: 'test', baseUrl: WM })
sparkpost({ apiKey: 'test', baseUrl: WM })
mandrill({ apiKey: 'test', baseUrl: WM })Or via environment variables (swap TIERDE_PROVIDER and the matching key):
TIERDE_PROVIDER=resend RESEND_API_KEY=test RESEND_BASE_URL=http://localhost:8080 \
[email protected] \
npx tierde send welcome --to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'Run integration tests against WireMock:
docker compose up -d
[email protected] [email protected] \
TIERDE_TEST_WIREMOCK=true pnpm testSES provider (via LocalStack)
LocalStack mocks the SES API locally. The free community tier requires a one-time signup:
- Create a free account at app.localstack.cloud
- Go to Workspace → Auth Token in the dashboard
- Copy the token and export it:
export LOCALSTACK_AUTH_TOKEN=your-token-hereLocalStack accepts SES API calls but does not deliver emails. SMTP relay to Mailpit requires LocalStack Pro — with the community tier, use the Mailpit provider directly to preview email content.
Sender identity is verified automatically on startup via scripts/localstack/init-ses.sh. The verified address defaults to $TIERDE_FROM_EMAIL (or [email protected]). To verify a different address, export TIERDE_FROM_EMAIL before docker compose up -d.
Credentials: pass explicit mock credentials to prevent the AWS SDK from picking up ambient SSO session tokens from your environment:
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_SESSION_TOKEN= # clear any real SSO session tokenimport { 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]',
});Smoke-test via CLI:
export LOCALSTACK_AUTH_TOKEN=your-token-here
docker compose up -d
# users (uses published package from npmjs)
AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_SESSION_TOKEN= \
TIERDE_PROVIDER=ses SES_REGION=us-east-1 SES_ENDPOINT=http://localhost:4566 \
[email protected] \
npx tierde send welcome \
--to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# contributors (uses local package, build first: pnpm build)
AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_SESSION_TOKEN= \
TIERDE_PROVIDER=ses SES_REGION=us-east-1 SES_ENDPOINT=http://localhost:4566 \
[email protected] \
node dist/bin/tierde.js send welcome \
--to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'
# exits 0 = LocalStack accepted the callRetry / backoff
createMailer retries transient HTTP errors with exponential backoff. Disabled by default (maxRetries: 0).
const mailer = createMailer({
provider: resend({ apiKey: '...' }),
from: '[email protected]',
maxRetries: 3, // max attempts after the first failure
initialRetryDelayMs: 500, // first retry after 500 ms; doubles each attempt
});Default retry predicate retries HTTP 429 (rate-limited), 502, 503, 504 responses. Override with retryOn:
const mailer = createMailer({
provider: sendgrid({ apiKey: '...' }),
from: '[email protected]',
maxRetries: 2,
retryOn: (err) => err instanceof Error && err.message.includes('429'),
});In failover mode, each provider is retried independently before the next failover target is tried.
Multi-provider strategies
// Failover — tries each provider in order, falls back on error
const mailer = createMailer({
providers: [primary, backup],
strategy: 'failover',
from: '[email protected]',
});
// Round-robin — distributes sends across providers
const mailer = createMailer({
providers: [provider1, provider2],
strategy: 'round-robin',
from: '[email protected]',
});Middleware
middleware is an ordered array of transform functions that run on the fully-rendered EmailMessage before it reaches the provider. Each function receives the message and returns a (possibly modified) copy.
import type { MailMiddleware } from '@yedoma-labs/tierde-mail';
const mailer = createMailer({
provider: smtp({ ... }),
from: '[email protected]',
middleware: [myMiddleware],
});Type
type MailMiddleware = (message: EmailMessage) => EmailMessage | Promise<EmailMessage>;Tracking pixels
Providers like Resend, SendGrid, and Postmark handle open/click tracking automatically via their dashboards. For SMTP or self-hosted setups you can inject tracking yourself via middleware:
import type { MailMiddleware } from '@yedoma-labs/tierde-mail';
import { randomUUID } from 'node:crypto';
// Open tracking — 1×1 pixel appended to HTML body
export const trackOpens = (baseUrl: string): MailMiddleware =>
(msg) => ({
...msg,
html: msg.html + `<img src="${baseUrl}/${randomUUID()}" width="1" height="1" alt="" />`,
});
// Click tracking — rewrites href attributes through a redirect
export const trackClicks = (baseUrl: string): MailMiddleware =>
(msg) => ({
...msg,
html: msg.html.replace(
/href="(https?:[^"]+)"/g,
(_, url) => `href="${baseUrl}?url=${encodeURIComponent(url)}"`,
),
});
const mailer = createMailer({
provider: smtp({ ... }),
from: '[email protected]',
middleware: [
trackOpens('https://track.example.com/open'),
trackClicks('https://track.example.com/click'),
],
});Notes:
tierde-mail ships zero tracking. The middleware pipeline is a user-supplied hook — the library has no knowledge of or control over what you inject. Any open/click tracking you implement is your code, running under your own GDPR, CASL, and CAN-SPAM obligations; tierde-mail is not the data controller or processor for that data.
Middleware runs per-recipient — each send gets its own pixel URL.
Keep middleware synchronous when possible. Async middleware (e.g., DB writes) adds latency per send and is felt at batch scale. Record tracking events in
onResultafter the send confirms instead.Middleware does not run on the plain-text part. Link rewriting applies to HTML only.
Order matters: pixel injection before link rewriting is the conventional order.
The subject and all attachments are re-validated after middleware runs (CR/LF in the subject and unsafe attachment filenames/content-types/CIDs throw
TypeError). If your middleware adds attachments, validate them yourself with the exportedvalidateAttachmentto surface errors early:import { validateAttachment } from '@yedoma-labs/tierde-mail';
Inline image embedding
embedImages is a built-in middleware that fetches remote images and embeds them as CID inline attachments. Email clients that block remote image loading will still display inline-embedded images.
import { createMailer, embedImages } from '@yedoma-labs/tierde-mail';
const mailer = createMailer({
provider: smtp({ ... }),
from: '[email protected]',
middleware: [
embedImages([
'https://raw.githubusercontent.com/yedoma-labs/assets/main/resized/banner-resized.png',
]),
],
});Reference the image in your JSX template by its original URL — embedImages replaces the src with cid:<filename> and attaches the image inline before sending:
const MyEmail = defineEmail<{}>({
subject: () => 'Hello',
component: () => (
<EmailTemplate>
<Image
src="https://raw.githubusercontent.com/yedoma-labs/assets/main/resized/banner-resized.png"
alt="Banner"
width={600}
/>
</EmailTemplate>
),
});Pass no argument to embed all remote https:// images found in the rendered HTML:
middleware: [embedImages()]Provider support:
| Provider | CID inline |
|---|---|
| SMTP / Mailpit | ✅ native (nodemailer) |
| SendGrid | ✅ disposition: inline |
| Postmark | ✅ ContentID |
| Resend | ✅ inline: true |
| SES | ❌ SendEmailCommand does not support attachments — use SendRawEmailCommand directly |
Notes:
- Each unique URL is fetched once per send, even if it appears multiple times in the HTML.
- Fetched images are cached per
embedImagesinstance, keyed by URL. In a batch send the same banner is fetched once and reused for every recipient instead of re-fetched per send. Failed fetches are not cached, so a transient error retries on the next send. To pick up an image changed at its URL, create a fresh mailer (or callembedImages()again). - The server-supplied
Content-Typeis clamped to a rasterimage/*type (case-insensitive); anything else — includingtext/htmlandimage/svg+xml— falls back toimage/png, so a misbehaving CDN cannot inject active-content inline attachments. - Existing
attachmentson the message are preserved. - SSRF warning: when called without a URL list (
embedImages()), every remotesrcin the rendered HTML is fetched server-side. Do not use with templates whosesrcvalues come from untrusted user input.
Attachments
Pass file attachments via the attachments option on send or sendBatch.
await mailer.send(InvoiceEmail, {
to: '[email protected]',
props: { ... },
attachments: [
{
filename: 'invoice-2026-01.pdf',
content: pdfBuffer, // Buffer or base64 string
contentType: 'application/pdf',
},
],
});Allowed content types
| Category | Types |
|---|---|
| Documents | application/pdf, application/zip, application/octet-stream |
| Images | image/png, image/jpeg, image/gif, image/webp, any image/* |
| Text | text/plain, text/csv, text/html |
Any other content type throws a TypeError at send time (before the provider is called).
Batch attachments
await mailer.sendBatch(InvoiceEmail, {
// Shared: every recipient gets this
attachments: [
{ filename: 'terms.pdf', content: termsBuffer, contentType: 'application/pdf' },
],
recipients: [
{
to: '[email protected]',
props: { ... },
// Per-recipient: appended after shared attachments
attachments: [
{ filename: 'invoice-alice.pdf', content: alicePdf, contentType: 'application/pdf' },
],
},
{
to: '[email protected]',
props: { ... },
attachments: [
{ filename: 'invoice-bob.pdf', content: bobPdf, contentType: 'application/pdf' },
],
},
],
});Inline attachments (CID)
Set cid on an attachment to embed it inline. Reference it in JSX via src="cid:<cid>". See Inline image embedding for the built-in embedImages middleware that handles this automatically.
attachments: [
{
filename: 'logo.png',
content: logoBuffer,
contentType: 'image/png',
cid: 'logo.png',
},
]
// In JSX: <img src="cid:logo.png" alt="Logo" />Security
Email addresses are validated against RFC 5321 before any provider call. The full atext character class is enforced in the local part; domain labels must start and end with a letter or digit (hyphens allowed in the middle); bare hostnames (localhost, mailpit) and address literals ([127.0.0.1], [IPv6:...]) are accepted. Control characters (including CR and LF) are rejected to prevent header injection regardless of where they appear in the address.
Attachments:
- Filenames: no
..,/,\, or control characters. - CID values: no CR/LF or control characters (MIME header injection prevention).
- Content type is compared against an allowlist before the provider is called — disallowed types throw
TypeErrorwithout a network request. image/svg+xmlis blocked even though it matchesimage/*— SVG is active content.- Attachments are re-validated after middleware runs, so middleware-generated attachments cannot bypass these checks.
Environment-based setup
For twelve-factor apps, configure via environment variables:
import { createMailerFromEnv } from '@yedoma-labs/tierde-mail';
const mailer = createMailerFromEnv();Required variables:
| Variable | Values |
|---|---|
| TIERDE_PROVIDER | see table below |
| TIERDE_FROM_EMAIL | sender address |
| TIERDE_FROM_NAME | sender display name (optional) |
Provider-specific variables:
| TIERDE_PROVIDER | Required variables | Optional |
|---|---|---|
| resend | RESEND_API_KEY | RESEND_BASE_URL |
| smtp | SMTP_HOST | SMTP_PORT (587), SMTP_USER, SMTP_PASS, SMTP_SECURE |
| mailpit | — | MAILPIT_HOST (localhost), MAILPIT_PORT (1025) |
| ses | SES_REGION or AWS_REGION | SES_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
| sendgrid | SENDGRID_API_KEY | SENDGRID_BASE_URL |
| postmark | POSTMARK_SERVER_TOKEN | POSTMARK_BASE_URL |
| mailgun | MAILGUN_API_KEY, MAILGUN_DOMAIN | MAILGUN_REGION (us/eu), MAILGUN_BASE_URL |
| brevo | BREVO_API_KEY | BREVO_BASE_URL |
| mailersend | MAILERSEND_API_TOKEN | MAILERSEND_BASE_URL |
| sparkpost | SPARKPOST_API_KEY | SPARKPOST_BASE_URL |
| mandrill | MANDRILL_API_KEY | MANDRILL_BASE_URL |
Components
All components read colors, typography, and spacing from the active theme via React context.
| Component | Description |
|---|---|
| <EmailTemplate> | Root wrapper — sets <html>, <head>, <body>, accent bar, dark mode CSS |
| <Heading level={1–4}> | Headings h1–h4 |
| <Text size="sm\|md\|lg" muted> | Body text |
| <Button href variant="primary\|secondary\|outline"> | Table-based button (Outlook-safe) |
| <Footer> | Footer with top border |
| <Hr> | Horizontal rule |
| <Section> | Padded content block, optional background color |
| <Image> | Responsive image |
| <Link> | Inline anchor |
| <Preview> | Hidden preview text for email clients |
| <LogoHeader> | Branded logo block (uses theme.logo by default) |
| <Row> / <Column> | Table-based columns for multi-column layout |
Theming
Default theme
Every email uses defaultTheme (indigo/slate palette) unless overridden.
Custom theme
import { createTheme } from '@yedoma-labs/tierde-mail';
import type { Theme } from '@yedoma-labs/tierde-mail';
const myTheme = createTheme({
primary: '#e11d48', // rose-600
accentBar: '#e11d48',
borderRadius: '4px',
logo: 'https://cdn.example.com/logo.png',
logoAlt: 'Acme',
logoWidth: 120,
});Pass to any template or component:
<Welcome name="Alice" loginUrl="..." theme={myTheme} />
// or to EmailTemplate directly:
<EmailTemplate theme={myTheme}>
...
</EmailTemplate>Theme shape
| Field | Default | Description |
|---|---|---|
| primary | #4f46e5 | CTA button background, accent bar |
| primaryText | #ffffff | CTA button text |
| primaryHover | #4338ca | Hover color (for preview server) |
| secondary | #f1f5f9 | Secondary button background |
| secondaryText | #334155 | Secondary button text |
| background | #f8fafc | Page background |
| cardBackground | #ffffff | Email card background |
| accentBar | #4f46e5 | 4px top stripe color |
| textPrimary | #0f172a | Heading color |
| textSecondary | #334155 | Body text color |
| textMuted | #64748b | Muted / fine-print color |
| border | #e2e8f0 | Hr and footer border |
| fontFamily | system stack | Font family string |
| borderRadius | 12px | Card corner radius |
| buttonBorderRadius | 8px | Button corner radius |
| maxWidth | 600px | Email card max width |
| logo | — | Logo image URL (auto-rendered in header) |
| logoAlt | — | Logo alt text |
| logoWidth | 140 | Logo pixel width |
Dark mode
Dark mode is automatic — a @media (prefers-color-scheme: dark) block is injected into every email using class-based overrides (tierde-card, tierde-text-primary, tierde-footer, etc.). Email clients that support dark mode apply it; others ignore it.
Built-in templates
Import from @yedoma-labs/tierde-mail/templates:
import {
Welcome, PasswordReset, EmailVerification, TwoFactorAuth,
MagicLink, PasswordlessOtp, Invoice, OrderConfirmation,
ShippingUpdate, PaymentFailed, Subscription, TeamInvite,
AccountDeactivated, Notification, AbandonedCart, SecurityAlert,
ReviewRequest, PolicyUpdate, WeeklyDigest, OnboardingProgress,
CommentMention, RefundConfirmation, UsageAlert, BackInStock,
MaintenanceNotification, ExportReady, WinBack, SupportTicket,
Referral, FeatureAnnouncement, AccountLocked, AccountUnlocked,
RegistrationConfirmation, EmailChangeVerification, PhoneVerification,
ProfileUpdated, PasswordChangedConfirmation, LoginActivity,
DataExportRequest, AccountDeletionConfirmation, NewsletterConfirmation,
AppointmentReminder, EventInvitation, ApiKeyCreated, GiftCard,
} from '@yedoma-labs/tierde-mail/templates';All templates accept theme?: Theme, locale?: string, dir?: 'ltr' | 'rtl', and a strings? prop for overriding every piece of copy.
| Template | Required props |
|---|---|
| Welcome | name, loginUrl |
| PasswordReset | username, resetUrl |
| EmailVerification | name, verifyUrl |
| TwoFactorAuth | username, code |
| MagicLink | email, loginUrl |
| PasswordlessOtp | code |
| Invoice | customerName, invoiceNumber, items |
| OrderConfirmation | name, orderNumber, items, orderUrl |
| ShippingUpdate | name, orderNumber, status, trackingUrl |
| PaymentFailed | name, updateUrl |
| Subscription | name, event, planName, actionUrl |
| TeamInvite | inviterName, teamName, inviteUrl |
| AccountDeactivated | name, reactivateUrl |
| AbandonedCart | name, cartUrl, items[] |
| SecurityAlert | name, event, reviewUrl |
| ReviewRequest | name, reviewUrl |
| PolicyUpdate | policyType, effectiveDate, policyUrl |
| WeeklyDigest | name, weekOf, dashboardUrl |
| OnboardingProgress | name, steps[], dashboardUrl |
| CommentMention | name, event, actorName, contextName, commentUrl |
| RefundConfirmation | name, refundAmount |
| UsageAlert | name, resource, used, limit, percentUsed, severity, upgradeUrl |
| BackInStock | name, productName, productUrl |
| MaintenanceNotification | type |
| ExportReady | name, exportName, downloadUrl |
| WinBack | name, returnUrl, daysSince |
| SupportTicket | name, event, ticketId, ticketTitle, ticketUrl |
| Referral | name, event, referrerName, actionUrl |
| FeatureAnnouncement | name, featureName, description, ctaUrl |
| AccountLocked | name, reason, unlockUrl |
| AccountUnlocked | name, loginUrl |
| RegistrationConfirmation | name, dashboardUrl |
| EmailChangeVerification | name, newEmail, verifyUrl |
| PhoneVerification | name, phone, code |
| ProfileUpdated | name, changes[], accountUrl |
| PasswordChangedConfirmation | name, securityUrl |
| LoginActivity | name, events[], securityUrl |
| DataExportRequest | name, event, actionUrl |
| AccountDeletionConfirmation | name, event |
| NewsletterConfirmation | email, confirmUrl |
| AppointmentReminder | name, providerName, appointmentDate, appointmentTime |
| EventInvitation | name, eventName, eventDate, eventTime, registerUrl |
| ApiKeyCreated | keyName, event (created|revoked|expiring), manageUrl |
| GiftCard | recipientName, senderName, amount, code, redeemUrl |
| Notification | title, body |
String overrides (i18n)
Every template exports a TEMPLATE_STRINGS constant and a Strings interface:
import { Welcome, WELCOME_STRINGS } from '@yedoma-labs/tierde-mail/templates';
import type { WelcomeStrings } from '@yedoma-labs/tierde-mail/templates';
const es: WelcomeStrings = {
...WELCOME_STRINGS,
heading: (name) => `¡Bienvenido, ${name}!`,
ctaLabel: 'Comenzar',
footer: (year, app) => `© ${year} ${app}. Todos los derechos reservados.`,
};
await mailer.send(Welcome, {
to: '[email protected]',
props: { name: 'Carlos', loginUrl: '...', strings: es, dir: 'ltr', locale: 'es' },
});CLI
tierde dev
Start the preview server with all 45 built-in templates and sample data:
npx tierde dev
npx tierde dev --port 3001Opens at http://localhost:3000. Includes dark mode toggle, compare view, and live reload on server restart.
tierde render
Render a template to HTML (or plain text) without running the preview server:
# Render to stdout
npx tierde render welcome --props '{"name":"Alice","loginUrl":"https://example.com"}'
# Write to file
npx tierde render invoice --props '{"customerName":"Acme","invoiceNumber":"INV-001","items":[]}' -o out.html
# Render plain-text version
npx tierde render welcome --props '{"name":"Alice","loginUrl":"https://example.com"}' --texttierde send
Send a template via your configured provider — useful for smoke-testing credentials:
TIERDE_PROVIDER=resend RESEND_API_KEY=re_... [email protected] \
npx tierde send welcome \
--to [email protected] \
--props '{"name":"Alice","loginUrl":"https://example.com"}'Reads the same env vars as createMailerFromEnv(). Prints the message ID on success.
tierde eject
Copy any built-in template to your project for full customisation:
# Eject a single template
npx tierde eject --template welcome ./emails/Welcome.tsx
# List all available template names
npx tierde eject --list
# Eject all templates at once
npx tierde eject --all ./emails/Available template names: welcome, password-reset, email-verification, two-factor-auth, magic-link, passwordless-otp, invoice, order-confirmation, shipping-update, payment-failed, subscription, team-invite, account-deactivated, abandoned-cart, security-alert, review-request, policy-update, weekly-digest, onboarding-progress, comment-mention, refund-confirmation, usage-alert, back-in-stock, maintenance-notification, export-ready, win-back, support-ticket, referral, feature-announcement, account-locked, account-unlocked, registration-confirmation, email-change-verification, phone-verification, profile-updated, password-changed-confirmation, login-activity, data-export-request, account-deletion-confirmation, newsletter-confirmation, notification.
The ejected file imports only from @yedoma-labs/tierde-mail — no internal paths.
Batch sending
Send one template to many recipients with concurrency control:
const result = await mailer.sendBatch(WelcomeEmail, {
recipients: [
{ to: '[email protected]', props: { name: 'Alice', url: '...' } },
{ to: '[email protected]', props: { name: 'Bob', url: '...' } },
],
concurrency: 5, // max parallel sends per chunk (default 5)
delayMs: 200, // pause between chunks (default 0)
onResult: (r) => console.log(r.to, r.result?.id ?? r.error?.message),
});
console.log(`${result.sent} sent, ${result.failed} failed`);Individual failures are isolated — a single bounce does not abort the batch.
Rate limiting
Use maxPerSecond to stay within provider rate limits (e.g. Resend free tier: 2 req/s):
await mailer.sendBatch(NewsletterEmail, {
recipients: [...],
maxPerSecond: 2, // token-bucket: ≤2 sends per second
concurrency: 2, // max concurrent in-flight
});maxPerSecond and delayMs are mutually exclusive — maxPerSecond takes precedence when both are set.
Large batches — collectResults
By default sendBatch returns a results array holding one entry (with its props) per recipient. For very large batches that retention is O(n) heap. Set collectResults: false to skip it — sent/failed counts stay accurate, and you consume each result through onResult as it completes:
let sent = 0;
await mailer.sendBatch(NewsletterEmail, {
recipients: hundredsOfThousands,
maxPerSecond: 10,
collectResults: false, // results array stays empty; no per-recipient retention
onResult: (r) => {
if (r.result) sent++;
else logBounce(r.to, r.error);
},
});Webhooks
Verify and parse inbound event payloads from Resend, Postmark, and SendGrid:
import {
createResendWebhookHandler,
createPostmarkWebhookHandler,
createSendGridWebhookHandler,
} from '@yedoma-labs/tierde-mail/webhooks';
// Resend (Svix HMAC-SHA256)
const resendWebhooks = createResendWebhookHandler({ secret: process.env.RESEND_WEBHOOK_SECRET! });
// Postmark (HMAC-SHA256)
const postmarkWebhooks = createPostmarkWebhookHandler({ token: process.env.POSTMARK_WEBHOOK_TOKEN! });
// SendGrid (ECDSA P-256) — public key from SendGrid Dashboard → Settings → Mail Settings → Event Webhook
const sendgridWebhooks = createSendGridWebhookHandler({ publicKey: process.env.SENDGRID_WEBHOOK_PUBLIC_KEY! });
// In your HTTP handler (use express.raw() / Next.js route with { bodyParser: false }):
const event = resendWebhooks.verify(rawBody, req.headers);
// event.type: 'email.sent' | 'email.delivered' | 'email.bounced' | ...
// event.email: { id, to[], from, subject, timestamp }
// event.raw: original payload
// SendGrid delivers events as a batch array — use verifyBatch() to get all events:
const events = sendgridWebhooks.verifyBatch(rawBody, req.headers);
for (const event of events) { ... }WebhookVerificationError is thrown on invalid signature or expired timestamp. Default tolerance is 300 seconds (configurable via toleranceSeconds).
Unsubscribe headers
Add RFC 8058 one-click unsubscribe headers to any send:
import { unsubscribeHeaders } from '@yedoma-labs/tierde-mail';
await mailer.send(Newsletter, {
to: '[email protected]',
props: { ... },
headers: unsubscribeHeaders({
url: `https://example.com/unsubscribe?token=${token}`,
email: '[email protected]', // optional mailto fallback
oneClick: true, // default — adds List-Unsubscribe-Post header
}),
});Testing
import { captureEmails } from '@yedoma-labs/tierde-mail/testing';
const { mailer, inbox, clear } = captureEmails();
await mailer.send(WelcomeEmail, { to: '[email protected]', props: { ... } });
expect(inbox[0].subject).toBe('Welcome, Alice!');
expect(inbox[0].html).toContain('Get Started');
clear();Building custom templates
Use defineEmail to build your own type-safe templates:
import { defineEmail, EmailTemplate, Heading, Text, Button, Footer } from '@yedoma-labs/tierde-mail';
interface OrderConfirmationProps {
orderNumber: string;
total: number;
trackingUrl: string;
theme?: Theme;
}
export const OrderConfirmation = defineEmail<OrderConfirmationProps>({
subject: ({ orderNumber }) => `Order #${orderNumber} confirmed`,
component: ({ orderNumber, total, trackingUrl, theme }) => (
<EmailTemplate preview={`Order #${orderNumber} confirmed`} theme={theme}>
<Heading>Order confirmed</Heading>
<Text>Order #{orderNumber} — ${total.toFixed(2)}</Text>
<Button href={trackingUrl}>Track your order</Button>
<Footer>© {new Date().getFullYear()} Acme Inc.</Footer>
</EmailTemplate>
),
});The value defineEmail returns has the type DefinedEmail<Props> — use it when you need to annotate a template (e.g. a function that accepts any template):
import type { DefinedEmail } from '@yedoma-labs/tierde-mail';
function describe<P>(tmpl: DefinedEmail<P>, props: P): string {
return tmpl.subject(props);
}The older
EmailTemplateTypeexport is a deprecated alias ofDefinedEmailand still works.
Building custom providers
Implement the EmailProvider interface:
import type { EmailProvider, EmailMessage, SendResult } from '@yedoma-labs/tierde-mail';
export function myProvider(): EmailProvider {
return {
name: 'my-provider',
async send(message: EmailMessage): Promise<SendResult> {
// call your email API here
return { id: '...', provider: 'my-provider' };
},
};
}React integration
The /react subpath exports an <EmailPreview> component and renderEmailHtml() for embedding email previews in your Next.js admin or Storybook:
import { EmailPreview, renderEmailHtml } from '@yedoma-labs/tierde-mail/react';
import { WelcomeEmail } from '@/emails/WelcomeEmail';
// Server component (Next.js App Router)
export default function PreviewPage() {
const html = renderEmailHtml(WelcomeEmail, { name: 'Alice', url: '...' });
return <EmailPreview html={html} style={{ height: '700px' }} />;
}renderEmailHtml must be called server-side (Node.js). <EmailPreview> renders the HTML in an isolated <iframe srcDoc> so styles don't leak.
Preview server
The built-in preview server lets you browse all templates with live reload, dark mode toggle, and side-by-side comparison:
import { startPreviewServer } from '@yedoma-labs/tierde-mail/preview';
import * as templates from './src/templates/index.js'; // your templates
startPreviewServer({ templates, port: 3001 });Features:
- Live reload — server restart is detected via SSE; the browser refreshes automatically
- Dark mode — toggle in the toolbar injects
color-scheme: darkCSS and forces@media (prefers-color-scheme: dark)in the iframe - Compare — split view with a second template dropdown for side-by-side comparison
- Mobile preview — resize the iframe to 375px to simulate narrow viewports
For a quick look at a single built-in template, use tierde render:
npx tierde render welcome --props '{"name":"Alice","loginUrl":"https://example.com"}' -o preview.html
open preview.htmlLicense
MIT
