Email delivery
Inject an existing provider, use React Email, and record delivery results automatically.
convex-invite does not depend on an email provider. The host injects a
provider function through withDelivery(). The helper queues the message and
records the safe result.
issue() always works without delivery configuration. No send occurs unless
the host creates a delivery helper and calls deliver().
Inject an existing Resend client
Install and mount @convex-dev/resend in the host application. The host can
reuse the same client for all application emails.
import {
Resend,
type EmailId,
type SendEmailOptions,
} from "@convex-dev/resend";
import { Invitations } from "convex-invite";
import { components } from "./_generated/api";
export const resend = new Resend(components.resend, {
testMode: process.env.RESEND_TEST_MODE === "true",
});
const invitations = new Invitations(components.invite);
export const invitationDelivery = invitations.withDelivery<
SendEmailOptions,
EmailId
>({
transport: "resend",
enqueue: (ctx, message) => resend.sendEmail(ctx, message),
});convex-invite does not import or create Resend. It receives only the injected
enqueue() function.
Use a Resend dashboard template
await invitationDelivery.deliver(ctx, {
scope: args.scope,
invitationId: args.invitationId,
message: {
from: "Acme <invites@example.com>",
to: args.to,
subject: "You are invited to Acme",
template: {
id: "workspace-invitation",
variables: { inviteUrl },
},
},
});Use React Email
Keep the React Email component in the host application. Render it to HTML with the host email package:
import { render } from "@react-email/render";
import { ProjectInviteEmail } from "./emails/project-invite-email";
export async function renderProjectInviteEmail(props: {
inviteUrl: string;
projectName: string;
}) {
return await render(<ProjectInviteEmail {...props} />);
}React Email rendering uses the Node runtime. Render and deliver inside one host Node action:
"use node";
import { renderProjectInviteEmail } from "@acme/emails";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { invitationDelivery } from "./resend";
export const sendProjectInvitation = internalAction({
args: {
scope: v.string(),
invitationId: v.string(),
token: v.string(),
to: v.string(),
projectName: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const appOrigin = process.env.APP_ORIGIN;
if (!appOrigin) throw new Error("Missing APP_ORIGIN");
const inviteUrl = new URL(
`/invitations/${args.token}`,
appOrigin,
).toString();
const html = await renderProjectInviteEmail({
inviteUrl,
projectName: args.projectName,
});
await invitationDelivery.deliver(ctx, {
scope: args.scope,
invitationId: args.invitationId,
message: {
from: "Acme <invites@example.com>",
to: args.to,
subject: `Invitation to ${args.projectName}`,
html,
},
});
return null;
},
});The renderer and deliver() are normal TypeScript calls in the same action.
The helper adds no extra action. The HTML enters the Resend component when
sendEmail() queues it for durable delivery.
What deliver records
deliver() records queued after enqueue() succeeds. It records failed
with DELIVERY_ENQUEUE_FAILED if enqueue() throws. It does not store or log
provider error text.
Checked-in webhook adapter
The repository example injects a generic HTTP transport instead of Resend. It
posts { to, inviteUrl } to EMAIL_WEBHOOK_URL. This shows that the same API
works with any provider.
Later delivery events
Queueing does not confirm inbox delivery. Configure the Resend webhook and
onEmailEvent handler for sent, delivered, bounced, or failed feedback. Store
the returned EmailId in a host-owned mapping to the invitation ID. Keep raw
invitation tokens out of that mapping.
See the official Convex Resend documentation for webhook and production setup.