# Convex Email Ops (/docs/v/0.6.1/components/convex-email)



`@opencoredev/convex-email` packages [Email SDK](/docs) as a Convex component. Mutations enqueue email into Convex tables; the component sends it outside the request path with retries and [fallback adapters](/docs/v/0.6.1/concepts/fallbacks-and-retries), and every send has queryable status and event history.

```ts
// 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](/docs/v/0.6.1/adapters). 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 [#setup]

<Steps>
  <Step>
    ### Install both packages [#install-both-packages]

    <PackageInstallTabs packageName="@opencoredev/convex-email @opencoredev/email-sdk" />

    `@opencoredev/email-sdk` is a peer dependency — the component reuses its adapters and message shape.
  </Step>

  <Step>
    ### Register the component [#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:

    ```ts title="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:

    ```bash
    bun x convex env set RESEND_API_KEY re_xxx
    ```
  </Step>

  <Step>
    ### Create a client [#create-a-client]

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

    ```ts title="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,
    });
    ```

    <TypeTable
      type="{
  adapters: {
    description: &#x22;Serializable adapter configs. See Adapter configs below.&#x22;,
    type: &#x22;ConvexEmailAdapterConfig[]&#x22;,
  },
  defaultAdapter: {
    description: &#x22;Routing name to try first.&#x22;,
    type: &#x22;string&#x22;,
  },
  fallbackAdapters: {
    description: &#x22;Routing names tried in order after the default fails.&#x22;,
    type: &#x22;string[]&#x22;,
  },
  maxAttempts: {
    description: &#x22;Total send attempts before an email is marked failed.&#x22;,
    type: &#x22;number&#x22;,
    default: &#x22;3&#x22;,
  },
  retryBaseMs: {
    description:
      &#x22;Base for the retry backoff: retryBaseMs * 2^(attempt - 1), capped at 60 seconds.&#x22;,
    type: &#x22;number&#x22;,
    default: &#x22;1000&#x22;,
  },
}"
    />
  </Step>

  <Step>
    ### Send from a mutation [#send-from-a-mutation]

    ```ts title="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](/docs/v/0.6.1/reference/message); per-send args can also override `adapter`, `fallbackAdapters`, `maxAttempts`, `retryBaseMs`, and add `sendMetadata`.
  </Step>
</Steps>

## Adapter configs [#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](/docs/v/0.6.1/reference/cli) uses). Override the env-var *name* per config when you need several accounts of one provider:

```ts
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](/docs/v/0.6.1/adapters/field-support) applies unchanged here.

## Status and events [#status-and-events]

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

```ts title="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.

| Status       | Meaning                                              |
| ------------ | ---------------------------------------------------- |
| `queued`     | Waiting for the worker (or for the next retry time). |
| `processing` | A send attempt is in flight.                         |
| `sent`       | A provider accepted the message. Terminal.           |
| `failed`     | All attempts and fallbacks exhausted. Terminal.      |
| `canceled`   | Canceled 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:

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

## Batches [#batches]

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

```ts
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 [#configuration]

Runtime config lives in a Convex table, managed with `setConfig` / `getConfig`:

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

<TypeTable
  type="{
  testMode: {
    description:
      &#x22;Redirect every send's recipients to sandboxTo. With testMode on and no sandboxTo, sends fail before enqueueing.&#x22;,
    type: &#x22;boolean&#x22;,
  },
  sandboxTo: {
    description: &#x22;Recipients that replace the real to list while testMode is on.&#x22;,
    type: &#x22;string[]&#x22;,
  },
  defaultFrom: {
    description:
      &#x22;Sender used when a message omits from. Without either, the send fails at enqueue.&#x22;,
    type: &#x22;string&#x22;,
  },
  maxAttempts: {
    description: &#x22;Default total attempts for sends that do not set their own.&#x22;,
    type: &#x22;number&#x22;,
    default: &#x22;3&#x22;,
  },
  retryBaseMs: {
    description: &#x22;Default retry backoff base for sends that do not set their own.&#x22;,
    type: &#x22;number&#x22;,
    default: &#x22;1000&#x22;,
  },
  cleanupAfterDays: {
    description:
      &#x22;Prune terminal emails, webhook delivery records, and events older than this during the cron sweep. Unset disables cleanup.&#x22;,
    type: &#x22;number&#x22;,
  },
}"
/>

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 [#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.

```ts title="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.

<Callout type="warn" title="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.
</Callout>

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

## Attachments [#attachments]

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

```ts
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 [#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 [#testing]

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

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

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

```ts title="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 [#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 [#next]

<Cards>
  <Card title="Field support" href="/docs/v/0.6.1/adapters/field-support" description="Check which fields your fallback adapters can carry before routing through them." />

  <Card title="Fallbacks and retries" href="/docs/v/0.6.1/concepts/fallbacks-and-retries" description="How Email SDK classifies errors and orders routes — the same model this component queues." />

  <Card title="All adapters" href="/docs/v/0.6.1/adapters" description="Per-provider setup and credentials for every supported kind." />
</Cards>
