Email SDK
Components

Convex Email Ops

Queue email from Convex mutations with durable retries, fallback adapters, reactive status, provider webhooks, and a safe test mode.

@opencoredev/convex-email packages Email SDK as a Convex component. Mutations enqueue email into Convex tables, and the component sends it outside the request path with retries and fallback adapters. Every send keeps a queryable status and event history.

// In any mutation:
const emailId = await email.send(ctx, {
  from: "Acme <hello@acme.com>",
  to: "user@example.com",
  subject: "Welcome",
  text: "Your account is ready.",
});

Use it when email should behave like app state: queued, retried, observable, and portable across the component's 20 supported provider kinds. For a Resend-only app that wants the official integration, use @convex-dev/resend instead. This component earns its place when provider portability, fallback routing, status history, or test-safe multi-provider operations matter.

Setup

Install both packages

npm install @opencoredev/convex-email @opencoredev/email-sdk

@opencoredev/email-sdk is a peer dependency because the component reuses its adapters and message shape.

Register the component

Convex components do not automatically see the parent app's environment, so declare the provider env vars your app owns and pass them through:

convex/convex.config.ts
import { defineApp } from "convex/server";
import { v } from "convex/values";
import convexEmail from "@opencoredev/convex-email/convex.config.js";

const app = defineApp({
  env: {
    RESEND_API_KEY: v.optional(v.string()),
    SMTP_HOST: v.optional(v.string()),
    SMTP_PORT: v.optional(v.string()),
    SMTP_USER: v.optional(v.string()),
    SMTP_PASS: v.optional(v.string()),
  },
});

app.use(convexEmail, {
  env: {
    RESEND_API_KEY: app.env.RESEND_API_KEY,
    SMTP_HOST: app.env.SMTP_HOST,
    SMTP_PORT: app.env.SMTP_PORT,
    SMTP_USER: app.env.SMTP_USER,
    SMTP_PASS: app.env.SMTP_PASS,
  },
});

export default app;

Set the secrets as Convex environment variables:

bun x convex env set RESEND_API_KEY re_xxx

Create a client

Adapter configs are serializable: kind plus env-var names, never secret values. The component resolves credentials from Convex env at runtime.

convex/email.ts
import { components } from "./_generated/api";
import { ConvexEmail } from "@opencoredev/convex-email";

export const email = new ConvexEmail(components.convexEmail, {
  adapters: [
    { kind: "resend" },
    { kind: "smtp", name: "backup-smtp" },
  ],
  defaultAdapter: "resend",
  fallbackAdapters: ["backup-smtp"],
  maxAttempts: 3,
});

Prop

Type

Send from a mutation

convex/users.ts
import { mutation } from "./_generated/server";
import { email } from "./email";

export const sendWelcomeEmail = mutation({
  args: {},
  handler: async (ctx) => {
    return await email.send(ctx, {
      from: "Acme <hello@acme.com>",
      to: "user@example.com",
      subject: "Welcome",
      text: "Your account is ready.",
      idempotencyKey: "welcome:user_123",
    });
  },
});

send enqueues the message and returns the email's document id. If an email with the same idempotencyKey already exists, the existing id is returned and nothing new is enqueued.

Messages take the standard EmailMessage fields. On top of those, per-send args can override adapter, fallbackAdapters, maxAttempts, and retryBaseMs, or add sendMetadata.

Adapter configs

Every Email SDK adapter has a config kind, plus memory for tests: resend, postmark, sendgrid, ses, smtp, brevo, cloudflare, iterable, loops, mailchimp, mailersend, mailgun, mailpace, mailtrap, plunk, scaleway, sequenzy, sparkpost, unosend, zeptomail.

Each kind reads its standard env vars by default (RESEND_API_KEY, POSTMARK_SERVER_TOKEN, SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS, the same names the CLI uses). Override the env-var name per config when you need several accounts of one provider:

adapters: [
  { kind: "resend" },                                          // reads RESEND_API_KEY
  { kind: "resend", name: "marketing", apiKeyEnv: "RESEND_MARKETING_KEY" },
]

Override fields follow the credential: apiKeyEnv, serverTokenEnv (Postmark), tokenEnv (ZeptoMail), domainEnv (Mailgun), accountIdEnv (Cloudflare), projectIdEnv/secretKeyEnv/regionEnv (Scaleway), accessKeyIdEnv/secretAccessKeyEnv (SES), hostEnv/portEnv/userEnv/passEnv (SMTP). Non-serializable Email SDK options (custom fetch, SMTP tls, function-valued Iterable dataFields) are intentionally not part of component config.

name sets the routing name used in defaultAdapter and fallbackAdapters, and defaults to the kind. Pick fallbacks that support the fields you send. Field support applies unchanged here.

Status and events

Every email row tracks its lifecycle. Read it reactively from queries:

convex/emails.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { email } from "./email";

export const emailStatus = query({
  args: { emailId: v.string() },
  handler: async (ctx, args) => {
    const status = await email.status(ctx, args); // full row or null
    const events = await email.listEvents(ctx, args); // ordered history
    return { status, events };
  },
});

status returns the stored email: status, message, attemptedAdapters, attemptCount/maxAttempts, providerMessageId, lastError, deliveryStatus, and timestamps. listEvents returns the append-only history. Both are typed: status resolves to ConvexEmailDoc | null and listEvents to ConvexEmailEventDoc[], and the package exports both types (plus ConvexEmailDeliveryStatus) for annotating your own query results.

StatusMeaning
queuedWaiting for the worker (or for the next retry time).
processingA send attempt is in flight.
sentA provider accepted the message. Terminal.
failedAll attempts and fallbacks exhausted. Terminal.
canceledCanceled before processing started. Terminal.

status covers the send lifecycle and ends at "a provider accepted the message". What happened after acceptance lives in deliveryStatus, filled in by webhooks: delivered, bounced, or complained, plus deliveredAt on delivery. It stays unset until a matching webhook arrives.

Event types: queued, processing, provider_attempt, sent, retry_scheduled, failed, canceled, webhook. provider_attempt and retry_scheduled carry the adapter, attempt number, and delay, so the full routing story is queryable.

Cancel an email that has not started processing:

const canceled = await email.cancel(ctx, { emailId });
// true if it was still queued; false once processing, sent, failed, or canceled

Batches

sendBatch enqueues up to 100 messages in one mutation and returns their ids in order:

const ids = await email.sendBatch(ctx, [
  { from: "Acme <hello@acme.com>", to: "a@example.com", subject: "Hi", text: "..." },
  { from: "Acme <hello@acme.com>", to: "b@example.com", subject: "Hi", text: "..." },
]);

Larger arrays throw before enqueueing anything. Split them in your app so Convex mutation limits stay predictable.

Configuration

Runtime config lives in a Convex table, managed with setConfig / getConfig. setConfig replaces the stored document rather than merging into it, so pass every field you want to keep; omitted fields are cleared:

await email.setConfig(ctx, {
  testMode: true,
  sandboxTo: ["dev@example.com"],
  defaultFrom: "Acme <hello@acme.com>",
  cleanupAfterDays: 30,
});

Prop

Type

The fail-before-enqueue rule is deliberate: enabling testMode without sandboxTo rejects sends instead of guessing, so a misconfigured staging deploy can never email real users.

exposeApi() publishes send, sendBatch, status, listEvents, and cancel as Convex functions. By default it leaves out setConfig and getConfig. If you need those, pass { includeConfigApi: true }, and only from a module behind your own auth checks.

Webhooks

registerRoutes mounts POST <pathPrefix>/webhooks/<provider> on your HTTP router and records deliveries as webhook events. Deliveries are stored by provider and delivery id, so provider retries stay idempotent.

When a webhook matches a stored email by provider message id, the component also normalizes the provider's event name onto the email's deliveryStatus. Resend email.delivered, Postmark Delivery, Mailgun delivered, and the like become delivered; hard bounces become bounced; spam complaints become complained.

Only permanent failures count as bounced. That is deliberate: a retryable soft bounce should not poison the status of an email that later delivers. Mailgun failed events map to bounced only when severity is "permanent"; a "temporary" failure lands in the event history but leaves deliveryStatus alone. Postmark Bounce records map to bounced only when Type is a permanent class (HardBounce, BadEmailAddress, ManuallyDeactivated); Transient, SoftBounce, and other retryable types are stored without touching the status, so a later Delivery still lands as delivered.

Bounces and complaints are sticky against delivered: a delivered event that arrives late or out of order never overwrites them. Between bounced and complained themselves, the most recent webhook wins; both mean "stop mailing this recipient", and the full sequence stays in the event history. Opens, clicks, and other events also stay in the event history without touching deliveryStatus.

convex/http.ts
import { httpRouter } from "convex/server";
import { email } from "./email";

const http = httpRouter();

email.registerRoutes(http, {
  pathPrefix: "/email", // default
  providers: ["resend"], // default
  verify: async ({ headers, body }) => {
    return headers["x-webhook-secret"] === process.env.EMAIL_WEBHOOK_SECRET;
  },
});

export default http;

This creates POST /email/webhooks/resend. The verify callback receives { provider, request, body, headers } and must return true to accept the delivery. Anything else gets a 401.

Always verify in production

The route is public on your Convex deployment. Omitting verify is for local development only; production routes must check the provider's signature or a shared secret.

To run webhooks through your own route instead, call email.processWebhook(ctx, { provider, headers, body }) from an HTTP action after verifying.

Attachments

Attachments accept inline content (string, with contentEncoding: "raw" | "base64") or a url the worker fetches at send time:

attachments: [
  { filename: "invoice.pdf", url: "https://files.acme.com/invoices/inv_123.pdf" },
]

URL fetching is restricted to public HTTPS hosts: http:, localhost, internal hostnames, IP-literal hosts, and URLs with embedded credentials are all rejected. For anything else, fetch the bytes in your app and pass content instead.

Recovery and cleanup

A built-in cron sweeps every 5 minutes:

  • Queue recovery processes due emails whose scheduled run was missed.
  • Stale processing recovery retries an email stuck in processing past its timeout, but only if it has an idempotencyKey. Without one the component fails it closed, because the provider request may already have delivered.
  • Cleanup prunes expired terminal emails, webhook delivery records, and event history when cleanupAfterDays is set.

This is the practical reason to set idempotencyKey on transactional sends: it makes recovery safe and deduplicates repeated enqueues.

Testing

Use the memory adapter for a client that records instead of sending:

export const email = new ConvexEmail(components.convexEmail, {
  adapters: [{ kind: "memory" }],
  defaultAdapter: "memory",
});

@opencoredev/convex-email/test ships helpers for convex-test:

convex/email.test.ts
import { convexTest } from "convex-test";
import { memoryAdapter, registerConvexEmail, testEmailConfig } from "@opencoredev/convex-email/test";
import schema from "./schema";

const t = convexTest(schema, import.meta.glob("./**/*.ts"));
registerConvexEmail(t); // mounts the component as "convexEmail"

memoryAdapter(name?) builds a memory adapter config, and testEmailConfig is a ready-made config (testMode on, sandbox recipient, fast retries) for exercising the full queue flow in tests.

Scope

Convex Email Ops is transactional email operations: queueing, retries, fallback routing, idempotency, webhook records, queryable status. It is not a campaign builder, contact database, template editor, analytics dashboard, or inbound processor. Provider accounts still decide whether a send lands: domain verification, sandbox rules, rate limits, and suppression lists live with the provider.

Next

On this page