Mail

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 welcome

Rendered 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) → normalised MailEvent[] (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 buildExecute

A 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.com

Inspect 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.

  • Webhooks — mounting the provider bounce/complaint query
  • Rate limiting — the sibling interceptor plugin
  • Multi-tenancy — where per-tenant suppression scoping comes from