Email SDK
IntegrationsConvex

Send and track email

Queue email from Convex mutations, expose reactive status, and inspect the complete event timeline.

Queue from a mutation

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

export const sendWelcomeEmail = mutation({
  args: { userId: v.id("users") },
  returns: v.string(),
  handler: async (ctx, { userId }) => {
    const user = await ctx.db.get(userId);
    if (!user) throw new Error("User not found.");

    return await email.send(ctx, {
      to: user.email,
      subject: "Welcome to Acme",
      text: `Hi ${user.name}, your account is ready.`,
      idempotencyKey: `welcome:${userId}`,
    });
  },
});

send writes the queue record in the calling mutation's transaction and returns its component id. If the outer mutation rolls back, the email is not queued. Provider delivery starts after the transaction commits.

Use a stable idempotency key for every user-visible message that application code may enqueue again. Reusing the key returns the existing email id.

Expose reactive status

Component functions are not directly callable from a browser. Wrap the query in your app and apply the same authorization rules as the related application data.

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

export const status = query({
  args: { emailId: v.string() },
  handler: async (ctx, args) => {
    await requireEmailOperationsAccess(ctx);
    return await email.status(ctx, args);
  },
});

export const events = query({
  args: { emailId: v.string() },
  handler: async (ctx, args) => {
    await requireEmailOperationsAccess(ctx);
    return await email.listEvents(ctx, args);
  },
});

A subscribed client updates as the record moves through queued, processing, sent, failed, or canceled. Webhooks add deliveryStatus values delivered, bounced, or complained without discarding queue history.

Understand the two status layers

FieldMeaning
statusWhether the component queued and handed the message to a provider.
deliveryStatusWhat a provider webhook later reported about mailbox delivery.
attemptedAdaptersEvery named adapter attempted across the current record.
providerMessageIdThe provider receipt used to correlate webhook events.
lastErrorThe last component send failure, if the send is retrying or terminal.
nextAttemptAtWhen the next automatic retry becomes eligible.

A sent queue status is not proof of inbox delivery. Treat webhook-backed delivered as the positive delivery signal.

Batch enqueue

const ids = await email.sendBatch(ctx, messages);

sendBatch accepts at most 100 messages per mutation and returns ids in input order. Duplicate idempotency keys within the batch resolve to the same stored email.

Cancel before processing

const canceled = await email.cancel(ctx, { emailId });

Cancellation returns true only while the record is still queued. It cannot recall a provider request that already started.

Process webhooks

Turn provider events into normalized delivery state.

Test and recover

Inspect failures and start a safe manual retry.

On this page