@redspringxyz/convex-loops-component
v0.1.3
Published
A Loops transactional email component for Convex.
Readme
Loops Convex Component
This component integrates the Loops transactional email service with your Convex project.
Features
- Queueing: Enqueue as many emails as you need; they are sent reliably in the background.
- Batching: Groups pending emails and sends them efficiently to Loops.
- Durable execution: Uses Convex workpools to retry and resume on transient failures.
- Idempotency: Ensures each email is sent exactly once using idempotency keys.
- Rate limiting: Honors Loops API limits via a built‑in rate limiter.
- Webhooks: Processes Loops webhooks and updates email status automatically.
See the example app in ./example for a working end‑to‑end setup.
Installation
npm install @redspringxyz/convex-loops-componentSetup
- Add the component in
convex/convex.config.ts:
import { defineApp } from "convex/server";
import loops from "@redspringxyz/convex-loops-component/convex.config";
const app = defineApp();
app.use(loops);
export default app;- Configure environment variables in your Convex deployment:
LOOPS_API_KEY(required): Loops API key for transactional sends.LOOPS_WEBHOOK_SECRET(optional): When set, webhook requests are signature‑verified.
Usage
Initialize the client in your Convex code and send a transactional email by template ID.
// convex/emails.ts
import { components, internal } from "./_generated/api";
import { internalAction, internalMutation } from "./_generated/server";
import { Loops, vOnEmailEventArgs } from "@redspringxyz/convex-loops-component";
export const loops = new Loops(components.loops, {
// Optional: receive callbacks after status updates
onEmailEvent: internal.emails.handleEmailEvent,
});
export const sendWelcome = internalAction({
args: {},
handler: async (ctx) => {
// Use your Loops transactional template ID
await loops.sendEmail(ctx, {
transactionalId: "your-transactional-id",
email: "[email protected]",
dataVariables: { name: "Ada" }, // Variables used by your Loops template
});
},
});
export const handleEmailEvent = internalMutation({
args: vOnEmailEventArgs, // { id: EmailId; event: EmailEvent }
handler: async (ctx, args) => {
// Example: react to delivery / bounce / complaint
console.log("Email update:", args.id, args.event.eventName);
},
});Webhook
Add an HTTP route that forwards Loops webhook events to the component. In the Loops dashboard, create a webhook pointing to this URL and enable email events. If you set LOOPS_WEBHOOK_SECRET, requests are signature‑verified.
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { loops } from "./emails";
const http = httpRouter();
http.route({
path: "/loops-webhook",
method: "POST",
handler: httpAction(async (ctx, req) => {
return await loops.handleLoopsWebhook(ctx, req);
}),
});
export default http;Example endpoint URL: https://<your-project>.convex.site/loops-webhook.
API
Sending
loops.sendEmail(ctx, { transactionalId, email, dataVariables, headers? })- Sends a single transactional email using the given template and variables.
- Returns an
EmailIdthat you can use to query status or cancel.
Status and retrieval
loops.status(ctx, emailId)→{ status, errorMessage, complained, opened } | nullloops.get(ctx, emailId)→ full record including:to,transactionalId,dataVariables, optionalheadersstatus(one ofwaiting,queued,cancelled,sent,delivered,delivery_delayed,bounced,failed)errorMessage?,complained,opened,providerMessageId?,finalizedAt,createdAt
Cancellation
loops.cancelEmail(ctx, emailId)- Works only before the email has been handed off (while
waitingorqueued).
- Works only before the email has been handed off (while
Webhook handling
loops.handleLoopsWebhook(ctx, req)- Parses and verifies (if configured) Loops webhook events and updates stored status.
- Triggers
onEmailEventif provided.
Options
apiKey?: string— defaults toprocess.env.LOOPS_API_KEY.webhookSecret?: string— defaults toprocess.env.LOOPS_WEBHOOK_SECRET.initialBackoffMs?: number— base delay for retries (default 30s).retryAttempts?: number— max retry attempts for background work (default 5).onEmailEvent?: FunctionReference<{ id: EmailId; event: EmailEvent }>— mutation to invoke after updates.
Data Retention and Cleanup
Finalized emails (e.g., delivered, bounced, cancelled, failed) are kept for a limited time. Schedule cleanup jobs to remove old data.
// convex/crons.ts
import { cronJobs } from "convex/server";
import { components, internal } from "./_generated/api";
import { internalMutation } from "./_generated/server";
const crons = cronJobs();
crons.interval(
"Remove old emails from the loops component",
{ hours: 1 },
internal.crons.cleanupLoops
);
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export const cleanupLoops = internalMutation({
args: {},
handler: async (ctx) => {
await ctx.scheduler.runAfter(0, components.loops.lib.cleanupOldEmails, {
olderThan: ONE_WEEK_MS,
});
await ctx.scheduler.runAfter(
0,
components.loops.lib.cleanupAbandonedEmails,
{
olderThan: ONE_WEEK_MS,
}
);
},
});
export default crons;Example App
The example/ directory shows a complete Convex app wired to Loops:
- Component mounting (
convex.config.ts) - Webhook routing (
convex/http.ts) - Sending and event callbacks (
convex/example.ts) - Cleanup crons (
convex/crons.ts)
Notes
- This component sends one transactional email per Loops API call; the component handles batching and rate limiting internally.
- Manual “send your own HTTP request” APIs have been removed; use
sendEmail.
