@pulgueta/usesend-convex
v0.3.0
Published
The Convex useSend Component - send emails durably with batching, rate limiting, and webhook support.
Maintainers
Readme
useSend Convex Component
This component is the official way to integrate the useSend email service with your Convex project. useSend is an open-source alternative to Resend, Sendgrid, Mailgun, and Postmark.
Features:
- Queueing: Send as many emails as you want, as fast as you want - they'll all be delivered (eventually).
- Batching: Automatically batches large groups of emails and sends them to
useSend's
/emails/batchendpoint efficiently. - Durable execution: Uses Convex workpools to ensure emails are eventually delivered, even in the face of temporary failures or network outages.
- Idempotency: Manages useSend idempotency keys to guarantee emails are delivered exactly once, preventing accidental spamming from retries.
- Rate limiting: Honors API rate limits established by useSend.
- Webhook support: Receive real-time email delivery status updates with a
one-line
usesend.registerRoutes(http)setup. - Self-hosted support: Works with both useSend's hosted service and self-hosted instances.
See example/convex/example.ts for a demo of how
to incorporate this component into your application. Its public demo functions
require Convex authentication and only allow the identity whose token identifier
matches USESEND_EXAMPLE_ADMIN_TOKEN_IDENTIFIER.
Installation
npm install @pulgueta/usesend-convexGet Started
Create a useSend account and grab an API key. Set it to
USESEND_API_KEY in your deployment environment.
Next, add the component to your Convex app via convex/convex.config.ts. Every
environment variable the component can use is declared on the component and
bound when installing it — the recommended setup binds them by reference to your
deployment's env vars so the credential stays in deployment secret storage and
is resolved at send time (it is never stored in component documents by current
versions):
import { defineApp } from "convex/server";
import { v } from "convex/values";
import usesend from "@pulgueta/usesend-convex/convex.config.js";
const app = defineApp({
env: {
USESEND_API_KEY: v.string(),
USESEND_BASE_URL: v.optional(v.string()),
},
});
app.use(usesend, {
env: {
USESEND_API_KEY: app.env.USESEND_API_KEY,
// optionals
USESEND_BASE_URL: app.env.USESEND_BASE_URL,
},
});
export default app;If upgrading from <= 0.1.1, legacy email rows may still contain
options.apiKey. After deploying, call components.usesend.lib.scrubApiKeys
from an authenticated app mutation. Active legacy rows are failed while their
keys are removed; re-enqueue those emails after the upgrade. Legacy work is
never sent with a secret-bearing argument shape.
The component's env vars:
USESEND_API_KEY(required): the useSend API key used by the durable batch sender, resolved from deployment secret storage at send time.USESEND_BASE_URL(optional): base URL for self-hosted useSend instances. When bound and set it takes precedence for durable batch sends; otherwise the per-instancebaseUrloption (defaulthttps://app.usesend.com) is used.
USESEND_WEBHOOK_SECRET is intentionally not a component env var: webhook
verification runs in your app's HTTP action (see below), so the secret is read
app-side by the UseSend client.
Then you can use it in your Convex functions:
// convex/emails.ts
import { components } from "./_generated/api";
import { UseSend } from "@pulgueta/usesend-convex";
import { internalMutation } from "./_generated/server";
export const usesend = new UseSend(components.usesend);
export const sendTestEmail = internalMutation({
handler: async (ctx) => {
await usesend.sendEmail(ctx, {
from: "Me <[email protected]>",
to: "[email protected]",
subject: "Hi there",
html: "This is a test email",
});
},
});Then, calling sendTestEmail from anywhere in your app will send this test
email.
Advanced Usage
Setting up a useSend webhook
While the setup we have so far will reliably send emails, you don't have any feedback on anything delivering, bouncing, or triggering spam complaints. For that, we need to set up a webhook!
On the Convex side, register the component's routes on your HTTP router in
convex/http.ts:
import { httpRouter } from "convex/server";
import { usesend } from "./emails";
const http = httpRouter();
usesend.registerRoutes(http);
export default http;This mounts the webhook handler at /usesend/webhook. To mount it somewhere
else, pass a custom endpoint:
usesend.registerRoutes(http, {
endpoint: "/my/web/hook",
});If your Convex project is happy-leopard-123, you now have a useSend webhook
for your project running at
https://happy-leopard-123.convex.site/usesend/webhook — or at whichever
endpoint you passed above.
Navigate to the useSend dashboard and create a new webhook at that URL, matching
the endpoint you registered. Make sure to enable all the email.* events.
Finally, copy the webhook secret out of the useSend dashboard and set it to the
USESEND_WEBHOOK_SECRET environment variable in your Convex deployment.
Registering an email status event handler
If you have your webhook established, you can also register an event handler to get notifications when email statuses change.
import { components, internal } from "./_generated/api";
import { internalMutation } from "./_generated/server";
import { vOnEmailEventArgs, UseSend } from "@pulgueta/usesend-convex";
export const usesend = new UseSend(components.usesend, {
onEmailEvent: internal.emails.handleEmailEvent,
});
export const handleEmailEvent = internalMutation({
args: vOnEmailEventArgs,
handler: async (ctx, args) => {
console.log(`Email ${args.id} received event:`, args.event.type);
switch (args.event.type) {
case "email.delivered":
console.log("Email delivered!");
break;
case "email.bounced":
console.log("Email bounced");
break;
case "email.complained":
console.log("Email marked as spam");
break;
}
},
});UseSend component options
There is a UseSendOptions argument to the component constructor to help
customize its behavior:
baseUrl: The base URL for the useSend API (defaults tohttps://app.usesend.com). Set this if you're using a self-hosted useSend instance.webhookSecret: Optional override for the useSend webhook secret. If omitted, it is read from theUSESEND_WEBHOOK_SECRETenvironment variable.initialBackoffMs: Initial backoff for retries (default: 30 seconds).retryAttempts: Number of retry attempts (default: 5).requestTimeoutMs: Maximum time to wait for a useSend API response (default: 30 seconds).onEmailEvent: Your email event callback.
Using useSend Templates
You can use useSend templates to send emails with pre-designed templates from your useSend dashboard:
await usesend.sendEmail(ctx, {
from: "Me <[email protected]>",
to: "[email protected]",
template: {
id: "my-template-id",
variables: {
name: "John Doe",
verificationLink: "https://example.com/verify?token=abc123",
},
},
});Note: You cannot use both
templateandhtml/textin the same email.
Scheduling and threading emails
Pass scheduledAt (ISO 8601) to have useSend deliver the email at a later time,
or inReplyToId to thread it under a previously sent email:
await usesend.sendEmail(ctx, {
from: "Me <[email protected]>",
to: "[email protected]",
subject: "See you tomorrow",
html: "<p>Reminder!</p>",
scheduledAt: "2026-08-01T09:00:00Z",
});Emails already handed off to useSend with a future scheduledAt can be
rescheduled or cancelled from the useSend dashboard or through the
useSend REST API using the email's
usesendId.
Tracking, getting status, and cancelling emails
The sendEmail method returns a branded type, EmailId. You can use this for:
- Reassociating the original email during status changes in your email event handler.
- Checking on the status any time using
usesend.status(ctx, emailId). - Cancelling a
waitingemail usingusesend.cancelEmail(ctx, emailId). Once batching starts, useSend may already be processing it and local cancellation is no longer safe.
// Check email status
const emailStatus = await usesend.status(ctx, emailId);
if (emailStatus) {
console.log(emailStatus.status); // e.g., "delivered", "bounced", "sent"
console.log(emailStatus.bounced); // boolean
console.log(emailStatus.failed); // boolean
console.log(emailStatus.complained); // spam complaint (boolean)
console.log(emailStatus.deliveryDelayed); // boolean
console.log(emailStatus.opened); // if open tracking enabled (boolean)
console.log(emailStatus.clicked); // if click tracking enabled (boolean)
console.log(emailStatus.errorMessage); // error details (string | null)
}Self-hosted useSend
If you're running a self-hosted useSend instance, configure the baseUrl:
export const usesend = new UseSend(components.usesend, {
baseUrl: "https://your-usesend-instance.com",
});Data retention
This component retains "finalized" (delivered, cancelled, bounced) emails. It's
your responsibility to clear out those emails on your own schedule. You can run
cleanupOldEmails and cleanupAbandonedEmails from the dashboard or set up a
cron job:
// in convex/crons.ts
import { cronJobs } from "convex/server";
import { components, internal } from "./_generated/api.js";
import { internalMutation } from "./_generated/server.js";
const crons = cronJobs();
crons.interval(
"Remove old emails from the usesend component",
{ hours: 1 },
internal.crons.cleanupUseSend,
);
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export const cleanupUseSend = internalMutation({
args: {},
handler: async (ctx) => {
await ctx.scheduler.runAfter(0, components.usesend.lib.cleanupOldEmails, {
olderThan: ONE_WEEK_MS,
});
await ctx.scheduler.runAfter(
0,
components.usesend.lib.cleanupAbandonedEmails,
{ olderThan: 4 * ONE_WEEK_MS },
);
},
});
export default crons;Using React Email
The component ships with a React Email integration at
@pulgueta/usesend-convex/react-email. Author your emails as React components;
sendReactEmail renders them to email-client-safe HTML plus a plain-text
fallback (better accessibility and deliverability) and enqueues them through
the durable send pipeline.
Install React Email in your app to author templates (the ./react-email module
is an optional peer — it renders with your app's react-email install):
npm install react-email react-dom -EDefine a template (see react.email/docs for the client-compatibility rules — no flexbox/grid, pixel-based sizing, etc.):
// convex/emails/welcome.tsx
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Text,
} from "react-email";
export default function WelcomeEmail({ name }: { name: string }) {
return (
<Html lang="en">
<Head />
<Body style={{ fontFamily: "sans-serif" }}>
<Preview>Welcome aboard!</Preview>
<Container>
<Text>{`Welcome, ${name}!`}</Text>
<Button
href="https://example.com"
style={{ background: "#000", color: "#fff", padding: "12px 20px" }}
>
Get started
</Button>
</Container>
</Body>
</Html>
);
}Then render and send it from an action (use a Node action for maximum
compatibility with react-dom/server):
// convex/reactEmail.tsx
"use node";
import { internalAction } from "./_generated/server";
import { sendReactEmail } from "@pulgueta/usesend-convex/react-email";
import { v } from "convex/values";
import { usesend } from "./emails";
import WelcomeEmail from "./emails/welcome";
export const sendWelcomeEmail = internalAction({
args: { to: v.string(), name: v.string() },
returns: v.string(),
handler: async (ctx, args) => {
return await sendReactEmail(usesend, ctx, {
from: "Onboarding <[email protected]>",
to: args.to,
subject: `Welcome, ${args.name}!`,
react: <WelcomeEmail name={args.name} />,
});
},
});In a plain .ts action (no JSX), call the component directly instead:
react: WelcomeEmail({ name: args.name }). This also works for components typed
as React.FC, as long as the component doesn't call hooks (a direct call runs
outside React's renderer). And if you'd rather render yourself — e.g. with
react-email's render — pass the output straight to
usesend.sendEmail(ctx, { html, ... }).
If you only want the rendered output (e.g. to send it yourself through the
useSend REST API with attachments), use renderEmail:
import { renderEmail } from "@pulgueta/usesend-convex/react-email";
const { html, text } = await renderEmail(<WelcomeEmail name="Ada" />);See example/convex/emails/welcome.tsx for
a fuller template using Tailwind with pixelBasedPreset.
Sending emails manually
If you want to bypass the component's batching (e.g. to attach files) while
still tracking the email's delivery status through webhooks, use
sendEmailManually. It records the email in the component, you perform the
actual send in the callback (here by calling the
useSend REST API directly), and the
returned useSend ID links webhook events back to the record. The callback
receives the component's record ID, which doubles as a stable Idempotency-Key
so a retried send doesn't dispatch the email twice:
export const sendManualEmail = internalAction({
args: {},
returns: v.string(),
handler: async (ctx) => {
const from = "Acme <[email protected]>";
const to = ["[email protected]"];
const subject = "hello world";
const emailId = await usesend.sendEmailManually(
ctx,
{ from, to, subject },
async (recordId) => {
const response = await fetch("https://app.usesend.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.USESEND_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": recordId,
},
body: JSON.stringify({
from,
to,
subject,
html: "<p>it works!</p>",
attachments: [{ filename: "invoice.pdf", content: base64Pdf }],
}),
});
if (!response.ok) {
throw new Error(`useSend API error: ${response.status}`);
}
const result = await response.json();
return result.emailId;
},
);
return emailId;
},
});Development
To develop this component:
pnpm install
pnpm devThis will start a file watcher to rebuild the component, as well as the example project frontend and backend.
License
Apache-2.0
