Transactional email — Resend / Postmark / SendGrid / 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.
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 }
})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) |
|---|---|---|---|
| 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 (429 / 5xx / network) drove the retry.
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.
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/components'
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 | opened | clicked) for'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.
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.comSee also
- Webhooks — mounting the provider bounce/complaint query
- Rate limiting — the sibling interceptor plugin
- Multi-tenancy — where per-tenant suppression scoping comes from