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; the component sends it outside the request path with retries and fallback adapters, and every send has 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 20 supported providers. 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 — 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; per-send args can also override adapter, fallbackAdapters, maxAttempts, retryBaseMs, and 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; it 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, and timestamps. listEvents returns the append-only history.

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.

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:

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 — it omits setConfig/getConfig by default. Pass { includeConfigApi: true } 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.

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; 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 — an email stuck in processing past its timeout is retried only if it has an idempotencyKey. Without one the component fails it closed, because the provider request may already have delivered.
  • Cleanup — when cleanupAfterDays is set, prunes expired terminal emails, webhook delivery records, and event history.

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