Send and track email
Queue email from Convex mutations, expose reactive status, and inspect the complete event timeline.
Queue from a mutation
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.
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
| Field | Meaning |
|---|---|
status | Whether the component queued and handed the message to a provider. |
deliveryStatus | What a provider webhook later reported about mailbox delivery. |
attemptedAdapters | Every named adapter attempted across the current record. |
providerMessageId | The provider receipt used to correlate webhook events. |
lastError | The last component send failure, if the send is retrying or terminal. |
nextAttemptAt | When 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.
