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

// 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 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 | 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 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

See also

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