Transactional and broadcast email — Lettermint / Resend / Postmark / SendGrid / SES / Mailgun / SMTP, React-Email templates with auto-discovery, per-tenant suppression, bounce/complaint handling, durable delivery via workflows.
@voltro/plugin-mail is transactional email behind one MailService. It ships
provider adapters, React-Email templates discovered from *.email.tsx files, a
per-tenant suppression list fed by bounce/complaint webhooks, and a dev
allowlist. Durable delivery rides the framework's workflow engine — there is no
separate queue/worker to run.
Email components are imported from react-email (version 6.9.5 or later in the supported major). When upgrading older templates, replace @react-email/components in both dependencies and imports. Keep the @react-email/render rendering peer and verify a rendered email after reinstalling.
Quick start
// app.config.ts
import { mailPlugin } from '@voltro/plugin-mail'
export default {
type: 'api' as const,
name: 'myApi',
plugins: [
mailPlugin({
provider: 'resend', // reads RESEND_API_KEY
from: 'Acme <hello@acme.com>',
// suppression: 'postgres', // multi-node bounce/complaint list
// allowlist: ['me@acme.com'], // dev-only: don't email real users
}),
],
}In any handler, pull the service off the plugin's services layer:
import { MailService } from '@voltro/plugin-mail'
import { Effect } from 'effect'
export default (input: { email: string }) =>
Effect.gen(function* () {
const mail = yield* MailService
yield* mail.send({ to: input.email, subject: 'Hi', html: '<p>welcome</p>' })
return { sent: true }
})Wiring that happens in app.config.ts, before any handler runs, takes the
service off the plugin itself — mailPlugin() returns it as service, the
same one handlers yield:
import { mailPlugin } from '@voltro/plugin-mail'
import { authRoutesPlugin, mailSender } from '@voltro/plugin-auth'
const mail = mailPlugin({ from: 'Acme <hello@acme.com>' })
export default {
type: 'api' as const,
name: 'myApi',
plugins: [mail, authRoutesPlugin({ sendEmail: mailSender(mail.service) /* … */ })],
}Attachments
Both raw and template messages take an attachments list — MailAttachment is { filename, content, contentType? } where content is base64. It maps to each transport's native attachment shape (Resend / Postmark / SendGrid / SMTP):
yield* mail.send({
to: user.email,
subject: 'Your invoice',
html: '<p>Attached.</p>',
attachments: [{ filename: 'invoice.pdf', content: pdfBase64, contentType: 'application/pdf' }],
})Providers
One MailProvider contract, several adapters. provider is a string (built
from env) or a MailProvider object (the exported factories / bring-your-own).
| Provider | provider |
Transport | Key (env) |
|---|---|---|---|
| Lettermint | 'lettermint' |
REST (fetch) |
LETTERMINT_PROJECT_TOKEN |
| Resend | 'resend' |
REST (fetch) |
RESEND_API_KEY |
| Postmark | 'postmark' |
REST (fetch) |
POSTMARK_SERVER_TOKEN |
| SendGrid | 'sendgrid' |
REST (fetch) |
SENDGRID_API_KEY |
| SES | 'ses' |
SESv2 REST (HttpClient, SigV4) | AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN? |
| Mailgun | 'mailgun' |
REST (HttpClient, form) | MAILGUN_API_KEY, MAILGUN_DOMAIN, MAILGUN_BASE_URL? |
| SMTP | 'smtp' |
nodemailer | SMTP_URL or SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS |
| console | 'console' |
logs to stdout | — (zero-config default) |
| memory | 'memory' |
in-process buffer | — (tests: readMailBuffer()) |
// string + env (default: MAIL_PROVIDER env, else 'console')
mailPlugin({ provider: 'postmark', from: '…' })
// explicit factory — full control / DI
import { resendProvider } from '@voltro/plugin-mail'
mailPlugin({ provider: resendProvider({ apiKey: process.env.RESEND_API_KEY! }), from: '…' })Resend / Postmark / SendGrid use fetch; SES + Mailgun go through the
framework-native @effect/platform HttpClient (SES is SigV4-signed with
node:crypto — no @aws-sdk); SMTP uses nodemailer (optional dep, loaded
only when used). None pull a vendor SDK. Policy — default from, transient
retry, suppression, allowlist, scheduling, batching, idempotency — lives in the
service, not the adapters. A failed send fails with a typed MailError whose
transient flag drives the retry — by the protocol's own rules: HTTP 408 / 429 /
5xx and network failures are retryable, other HTTP 4xx are not; SMTP is the
other way round (RFC 5321 §4.2.1): a 4xx reply is temporary and retried, a 5xx
reply is permanent and is not. Before this SMTP was classified by the HTTP rule,
so a temporarily refused mail was never retried and a permanently refused one
was retried forever.
SES + Mailgun setup. SES resolves from AWS_REGION +
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN
for STS creds); the endpoint is derived from the region. Mailgun resolves from
MAILGUN_API_KEY (sent as the HTTP basic-auth password for user api) +
MAILGUN_DOMAIN, with MAILGUN_BASE_URL=https://api.eu.mailgun.net for the EU
region. Secrets are read from env / options and never logged.
Lettermint setup. An EU provider on its own servers and IP ranges. One
Lettermint project has a transactional and a broadcast route, sent from separate
IP pools; stream picks the route. LETTERMINT_PROJECT_TOKEN is the project's
API token. Scheduling (scheduledAt), idempotency (idempotencyKey, also sent
as Lettermint's Idempotency-Key) and batching (500 per request) are native. For
renamed routes, build it yourself:
lettermintProvider({ projectToken, routes: { transactional: 'app', broadcast: 'news' } }).
Transactional and broadcast mail
stream says which kind of mail a message is. transactional (the default) is
one-to-one mail a person's own action triggered — an invitation, a receipt, a
password reset. broadcast is one-to-many — a newsletter, a campaign:
yield* mail.sendBatch(subscribers.map((s) => ({
to: s.email,
subject: 'Release notes 0.82',
html, text,
stream: 'broadcast',
unsubscribe: { oneClickUrl: `https://api.acme.com/newsletter/unsubscription?id=${s.id}&sig=${s.sig}` },
metadata: { subscriberId: s.id },
})))Providers that separate the two send them from different infrastructure — Lettermint routes, Postmark message streams — so a newsletter's complaints never cost a password reset its inbox placement. Other providers send both the same way.
A broadcast must let its recipient leave in one click. Mailbox providers
require it of bulk senders (RFC 8058). If the provider unsubscribes recipients
itself (managesUnsubscribe — Lettermint's broadcast route adds its own link and
headers and suppresses whoever uses them), nothing else is needed. Otherwise the
message carries unsubscribe: { oneClickUrl, mailto? } and the service writes the
List-Unsubscribe and List-Unsubscribe-Post headers; a broadcast with neither,
or with a non-HTTPS URL, fails with a permanent MailError instead of going out
unleavable. The endpoint behind oneClickUrl receives
POST List-Unsubscribe=One-Click from the mail client — no page, no login.
metadata is stored with the message by the provider and returned on its
delivery events (MailEvent.metadata), never added to the mail: how a bounce
finds its way back to the row that caused it. Lettermint and Postmark carry it.
mail.provider.managesUnsubscribe tells the sender whether the provider already
puts an unsubscribe link in the mail — then the body needs none of its own.
Templates — *.email.tsx
Author emails as React-Email components. defineEmail pairs a name + an
effect/Schema for the props + the subject line + the render function:
// apps/api/emails/welcome.email.tsx
import { defineEmail } from '@voltro/plugin-mail'
import { Html, Head, Body, Text, Button } from 'react-email'
import { Schema } from 'effect'
export const welcome = defineEmail({
name: 'welcome',
props: Schema.Struct({ name: Schema.String }),
preview: { name: 'Mario' }, // dashboard preview pre-fill
subject: (p) => `Welcome, ${p.name}!`,
render: (p) => (
<Html>
<Head />
<Body>
<Text>Hi {p.name},</Text>
<Button href="https://acme.com/dashboard">Open dashboard</Button>
</Body>
</Html>
),
})Send it by name — props are validated against the Schema, the subject is derived, and the component is rendered to HTML + plain-text:
yield* mail.send({ to: user.email, template: 'welcome', props: { name: user.name } })Auto-discovery: voltro dev walks *.email.tsx files and registers their
defineEmail(...) exports automatically — you don't list them in
app.config.ts. (To register by hand, e.g. in a test, pass
mailPlugin({ templates: [welcome] }); explicit templates win over discovered
ones.)
Locales: add a locale and the matching variant is picked, falling back to
the base template:
export const welcomeDe = defineEmail({ name: 'welcome', locale: 'de', /* … */ })yield* mail.send({ to, template: 'welcome', props, locale: 'de' }) // → welcome.de, else welcomeRendered output is cached by (template, locale, props). react +
@react-email/render are optional peers — apps that never send a template
never load them.
Suppression — bounces & complaints
Bounced or complained addresses land on a per-tenant suppression list; the send
path silently drops suppressed recipients (and skips the send entirely if none
remain). Backend is 'memory' (default, single-node) or 'postgres' (own pool
from the same DB_*/PG_* env; creates _voltro_mail_suppression). Both fail
OPEN — a suppression-db blip never blocks legitimate mail.
mailPlugin({ provider: 'resend', from: '…', suppression: 'postgres' })Manage it from a handler — the MailService exposes the list directly:
const mail = yield* MailService
yield* mail.suppress(tenantId, 'dead@x.com', 'manual')
yield* mail.unsuppress(tenantId, 'recovered@x.com')
const blocked = yield* mail.isSuppressed(tenantId, 'dead@x.com')Pass tenantId on a message to scope the check; omit it for app-global
suppression:
yield* mail.send({ to: user.email, tenantId: user.tenantId, subject: '…', html: '…' })Bounce / complaint webhooks
Providers POST delivery events; parseMailEvent normalises each provider's
shape and handleMailEvents applies them — bounces + complaints auto-suppress.
Mount it from a *.webhook.tsx incoming query (signature-verified by
@voltro/plugin-webhooks):
// apps/api/webhooks/resend.webhook.tsx
import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
import { genericProvider } from '@voltro/plugin-webhooks/providers'
import { handleMailEvents, memorySuppressionStore } from '@voltro/plugin-mail'
import { Effect, Schema } from 'effect'
// Use the SAME suppression store the mail plugin uses (export it from a
// shared module, or use the postgres store which is process-global).
const suppression = memorySuppressionStore()
export default defineIncomingWebhook({
id: 'resend',
provider: genericProvider(), // verifies the signing secret
payload: Schema.Any,
handler: async (ctx) =>
Effect.runPromise(handleMailEvents('resend', ctx.body, suppression, ctx.headers['x-tenant'] ?? null)),
})parseMailEvent(provider, payload)→ normalisedMailEvent[](delivered | bounced | complained | unsubscribed | unsuppressed | opened | clicked) for'lettermint','resend','postmark', and'sendgrid'(SendGrid posts an array).handleMailEvents(provider, payload, store, tenantId?)parses and suppresses bounced/complained addresses in one call, returning the events for logging/auditing.
The webhook the plugin mounts
With a provider that verifies its own webhook (Lettermint), the plugin mounts the endpoint itself — and bounces and complaints then land in the plugin's OWN suppression store, the one every send checks:
mailPlugin({
provider: 'lettermint',
from: 'Acme <hello@mail.acme.com>',
suppression: 'postgres',
webhook: {
// secret: default MAIL_WEBHOOK_SECRET · path: default /webhooks/mail
onEvent: async (event, { store }) => {
if (event.type === 'unsubscribed') await removeSubscriber(store, event.email)
},
},
})The signature is checked over the raw body (HMAC-SHA256 over
<timestamp>.<body>, five minutes' tolerance, several signatures accepted during
a secret rotation); a forged or stale delivery gets 401. onEvent sees every
verified event after suppression; when it fails the endpoint answers 500 and
the provider redelivers — act idempotently, keyed on event.eventId. store is
the app's boot store, as on any plugin route. Without a secret the endpoint
answers 503, and a production boot refuses.
An unsubscribed event is NOT suppressed app-wide: the person left one list,
not the invitations and receipts they still expect. An unsuppressed event —
a suppression lifted in the provider's dashboard — lifts it here too, or the app
would keep dropping mail the provider would now deliver.
Only bounces, complaints, unsubscribes and lifted suppressions change anything;
every other event is answered 200 and passed to onEvent. Subscribe the
provider's webhook to just those — for Lettermint message.hard_bounced,
message.spam_complaint, message.unsubscribed, suppression.added and
suppression.removed — and opens and clicks never reach your server.
Send-time scheduling
Pass scheduledAt: Date to deliver later. Providers that schedule natively —
Resend (scheduled_at), SendGrid (send_at), Mailgun (o:deliverytime) —
get it on the wire. For the rest (console / memory / smtp / ses) the
service holds the message and a background flush loop delivers it once the
due time passes:
yield* mail.send({
to: user.email,
subject: 'Trial reminder',
html: '<p>Your trial ends tomorrow.</p>',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})A held send returns { id: 'scheduled', provider: 'scheduled' } right away; a
scheduledAt in the past sends immediately. The hold store is in-memory by
default (single-node) — pass a durable ScheduleStore via
mailPlugin({ schedule }) for cross-replica, restart-surviving schedules, or
use a workflow step (below) for the crash-proof path.
Bulk / batch send
sendBatch sends N personalized messages (each with its own subject,
body, and template vars) in one provider call where the transport supports it
(Resend /emails/batch, SendGrid N personalizations), falling back to a
looped send otherwise. Allowlist, suppression, default-from, scheduling and
idempotency apply per entry:
const mail = yield* MailService
yield* mail.sendBatch([
{ to: 'ana@x.com', template: 'welcome', props: { name: 'Ana' } },
{ to: 'bob@x.com', template: 'welcome', props: { name: 'Bob' } },
])It returns one SendResult per input, in order.
Idempotency — webhook-retry double-send guard
Pass idempotencyKey to guard against an at-least-once webhook / retry
double-send outside a workflow step. The first send with a given key (per
tenant) delivers and records its result; a replay with the same key is a no-op
that returns the recorded SendResult — one delivery:
yield* mail.send({ to: user.email, subject: 'Receipt', html, idempotencyKey: webhookEvent.id })The reservation is an atomic INSERT … ON CONFLICT DO NOTHING, so exactly one
replica wins the key fleet-wide. Unlike suppression this store fails closed
— a dedup-DB blip skips the send rather than risk a double. In-memory by
default; set mailPlugin({ idempotency: 'postgres' }) for cross-replica dedup.
Durable delivery — via workflows, not a queue
There is no separate mail queue or worker. Durable execution in Voltro is
@effect/workflow (the framework forbids parallel job runners). For a send
that must survive a crash / retry from where it died, call mail.send inside a
workflow step — the step result is journaled and replayed on resume:
// apps/api/workflows/onboarding.workflow.tsx
import { workflow, step } from '@voltro/workflow'
import { MailService } from '@voltro/plugin-mail'
import { Effect, Schema } from 'effect'
export const Onboard = workflow({
name: 'onboarding',
payload: { email: Schema.String, name: Schema.String },
success: Schema.Struct({ ok: Schema.Boolean }),
})
const buildExecute = () => ({ email, name }: { email: string; name: string }) =>
Effect.gen(function* () {
const mail = yield* MailService
yield* step({
name: 'send-welcome',
success: Schema.Struct({ id: Schema.String, provider: Schema.String }),
execute: mail.send({ to: email, template: 'welcome', props: { name } }),
})
return { ok: true }
})
export default buildExecuteA plain mail.send (outside a workflow) retries transient failures in-process
but does not survive a crash. Use the workflow step when durability matters
(onboarding, billing receipts); a bare send is right for best-effort
notifications.
Dev safety — allowlist
Outside production, set an allowlist so a staging run can't email real users. Recipients not on the list are dropped (logged); production bypasses it.
mailPlugin({ provider: 'resend', from: '…', allowlist: ['me@acme.com'] })
// or env: MAIL_ALLOWLIST=me@acme.com,qa@acme.comInspect mail and render previews
The Cloud and local app inspectors expose Product services → Email. Choose the configured Mail instance when the app has several. Each view reads that instance's actual inspect mount, including aliases. Switching instances clears the previous instance's messages, template selection and rendered preview.
The outbox is a bounded, process-local buffer of recent send attempts, not a complete delivery history. It resets when the process restarts. With the memory provider, records describe captured messages rather than delivery to a mailbox. The template list is declaration evidence. Both responses retain their scope and origin; failed reads remain errors instead of appearing as empty lists.
Select a template and edit its JSON props to render its subject, HTML and plain
text without sending mail. Rendering requires inspect write access; in Cloud it
also requires an owner or admin role. The preview is sandboxed. Changing the
template or props discards any earlier preview that is still completing. Custom
dashboard hosts must derive Capabilities.canPreviewMail from the operator's
access and their configured write credential.
The dashboard renders template previews on the selected replica, matching the template list shown for that process. Select one replica for a preview when comparing processes; the dashboard does not choose a different process silently. Preview rendering does not send mail.
Related features
- Webhooks — mounting the provider bounce/complaint query
- Rate limiting — the sibling interceptor plugin
- Multi-tenancy — where per-tenant suppression scoping comes from