# Mail

> Transactional email — Resend / Postmark / SendGrid / SMTP, React-Email templates with auto-discovery, per-tenant suppression, bounce/complaint handling, durable delivery via workflows.



---

<!-- source: en/plugins/mail.md -->
## Mail

_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

```ts
// 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:

```ts
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):

```ts
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()`) |

```ts
// 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:

```tsx
// 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**:

```ts
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:

```tsx
export const welcomeDe = defineEmail({ name: 'welcome', locale: 'de', /* … */ })
```

```ts
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.

```ts
mailPlugin({ provider: 'resend', from: '…', suppression: 'postgres' })
```

Manage it from a handler — the `MailService` exposes the list directly:

```ts
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:

```ts
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`):

```tsx
// 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 | 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:

```ts
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:

```ts
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:

```ts
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:

```tsx
// 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.

```ts
mailPlugin({ provider: 'resend', from: '…', allowlist: ['me@acme.com'] })
// or env: MAIL_ALLOWLIST=me@acme.com,qa@acme.com
```

## See also

- [Webhooks](/docs/plugins/webhooks) — mounting the provider bounce/complaint query
- [Rate limiting](/docs/plugins/ratelimit) — the sibling interceptor plugin
- [Multi-tenancy](/docs/multi-tenancy/overview) — where per-tenant suppression scoping comes from
