This component integrates the Lettermint transactional email service with Convex.
This project is an independent, unofficial Convex component for Lettermint. It is not affiliated with, endorsed by, sponsored by, or maintained by Lettermint.
Lettermint and related names, logos, product names, and trademarks are the property of Lettermint or their respective owners. All rights are reserved by their respective owners. Any use of Lettermint marks in this project is for descriptive and referential purposes only.
Features:
- Queueing: Send many emails quickly; the component processes them durably.
- Batching: Sends queued emails to Lettermint's
/send/batchendpoint. - Durable execution: Uses Convex workpools to retry temporary failures.
- Idempotency: Uses Convex email IDs as Lettermint
Idempotency-Keyvalues. - Rate limiting: Limits calls to the Lettermint Sending API.
- Webhooks: Verifies Lettermint HMAC signatures and tracks message status.
See example for a demo of how to incorporate this component into your application.
npm install convex-lettermintCreate a Lettermint project and create a Project API
Add the component to your Convex app via convex/convex.config.ts:
import { defineApp } from "convex/server";
import lettermint from "convex-lettermint/convex.config.js";
const app = defineApp();
app.use(lettermint);
export default app;Use it from a Convex function:
import { components } from "./_generated/api";
import { Lettermint } from "convex-lettermint";
import { internalMutation } from "./_generated/server";
export const lettermint = new Lettermint(components.lettermint, {});
export const sendTestEmail = internalMutation({
handler: async (ctx) => {
await lettermint.sendEmail(ctx, {
from: "Me <test@mydomain.com>",
to: "ok@testing.lettermint.co",
subject: "Hi there",
html: "This is a test email",
});
},
});testMode is enabled by default. In test mode, recipients must use the
Lettermint test domain @testing.lettermint.co. Examples include:
ok@testing.lettermint.cosoftbounce@testing.lettermint.cohardbounce@testing.lettermint.cospamcomplaint@testing.lettermint.codsn@testing.lettermint.co
Set testMode: false in LettermintOptions before sending to real recipients.
The component can receive Lettermint delivery, bounce, complaint, open, and click events.
Mount an HTTP endpoint in convex/http.ts:
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { lettermint } from "./sendEmails";
const http = httpRouter();
http.route({
path: "/lettermint-webhook",
method: "POST",
handler: httpAction(async (ctx, req) => {
return await lettermint.handleLettermintEventWebhook(ctx, req);
}),
});
export default http;Create a webhook in the Lettermint dashboard for that URL and subscribe to the
message events you need. Copy the webhook secret and set it to
LETTERMINT_WEBHOOK_SECRET in your Convex deployment environment.
Lettermint signs webhooks with X-Lettermint-Signature. The component verifies
the HMAC-SHA256 signature and rejects invalid or stale webhook requests.
Register a mutation to run when email events arrive:
import { components, internal } from "./_generated/api";
import { internalMutation } from "./_generated/server";
import { Lettermint, vOnEmailEventArgs } from "convex-lettermint";
export const lettermint = new Lettermint(components.lettermint, {
onEmailEvent: internal.example.handleEmailEvent,
});
export const handleEmailEvent = internalMutation({
args: vOnEmailEventArgs,
handler: async (ctx, args) => {
// args provides { id: EmailId; event: EmailEvent; }
if (args.event.event === "message.delivered") {
// Handle delivery.
}
},
});Supported outbound Lettermint events include message.created, message.sent,
message.delivered, message.hard_bounced, message.soft_bounced,
message.spam_complaint, message.failed, message.suppressed,
message.policy_rejected, message.unsubscribed, message.opened, and
message.clicked. webhook.test is acknowledged and ignored.
The LettermintOptions constructor argument customizes component behavior:
projectToken: Provide the Lettermint Project API token instead of readingLETTERMINT_PROJECT_TOKEN.webhookSecret: Provide the webhook secret instead of readingLETTERMINT_WEBHOOK_SECRET.testMode: Only allow delivery to Lettermint test addresses. Defaults totrue.initialBackoffMs: Initial retry backoff for failed API calls. Defaults to 30 seconds.retryAttempts: Maximum retry attempts for failed API calls. Defaults to 5.onEmailEvent: Mutation callback for Lettermint webhook events.
sendEmail supports the batch-safe Lettermint fields:
fromtoccbccsubjecthtmltextreplyToheadersroutetagmetadatasettings.trackOpenssettings.trackClicks
Example:
await lettermint.sendEmail(ctx, {
from: "Notifications <notifications@mydomain.com>",
to: "ok@testing.lettermint.co",
subject: "Welcome!",
html: "<p>Welcome to our app.</p>",
route: "transactional",
tag: "welcome-email",
metadata: {
user_id: "user_123",
},
settings: {
trackOpens: false,
trackClicks: true,
},
});Templates are not supported by the Lettermint Sending API schema used by this
component. Generate dynamic HTML yourself or use React Email before calling
sendEmail.
sendEmail returns a branded EmailId.
Use it to check status or cancel queued emails:
const emailId = await lettermint.sendEmail(ctx, {
from: "Me <test@mydomain.com>",
to: "ok@testing.lettermint.co",
subject: "Hi",
text: "Hello",
});
const emailStatus = await lettermint.status(ctx, emailId);
if (emailStatus) {
console.log(emailStatus.status);
console.log(emailStatus.bounced);
console.log(emailStatus.failed);
console.log(emailStatus.complained);
console.log(emailStatus.opened);
console.log(emailStatus.clicked);
console.log(emailStatus.errorMessage);
}
await lettermint.cancelEmail(ctx, emailId);If an email has already been sent to Lettermint, it cannot be cancelled.
This component retains finalized emails and delivery events. You can clear old
data with cleanupOldEmails and cleanupAbandonedEmails, either from the
Convex dashboard or a cron job:
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 Lettermint component",
{ hours: 1 },
internal.crons.cleanupLettermint,
);
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export const cleanupLettermint = internalMutation({
args: {},
handler: async (ctx) => {
await ctx.scheduler.runAfter(0, components.lettermint.lib.cleanupOldEmails, {
olderThan: ONE_WEEK_MS,
});
await ctx.scheduler.runAfter(
0,
components.lettermint.lib.cleanupAbandonedEmails,
{ olderThan: 4 * ONE_WEEK_MS },
);
},
});
export default crons;Use React Email to generate HTML in a Convex Node action,
then pass the generated HTML to sendEmail.
"use node";
import { action } from "./_generated/server";
import { render, pretty } from "@react-email/render";
import { Button, Html } from "@react-email/components";
import { components } from "./_generated/api";
import { Lettermint } from "convex-lettermint";
export const lettermint = new Lettermint(components.lettermint, {
testMode: false,
});
export const sendEmail = action({
args: {},
handler: async (ctx) => {
const html = await pretty(
await render(
<Html>
<Button href="https://example.com">Click me</Button>
</Html>,
),
);
await lettermint.sendEmail(ctx, {
from: "Me <test@mydomain.com>",
to: "recipient@example.com",
subject: "Hi there",
html,
});
},
});Attachments are intentionally not part of the durable queue API. Use
sendEmailManually when you need Lettermint features that should be sent
immediately, including attachments or inline images.
import { components } from "./_generated/api";
import { internalAction } from "./_generated/server";
import { Lettermint } from "convex-lettermint";
export const lettermint = new Lettermint(components.lettermint, {});
export const sendManualEmail = internalAction({
args: {},
handler: async (ctx) => {
const from = "Acme <onboarding@example.com>";
const to = ["ok@testing.lettermint.co"];
const subject = "hello world";
const html = "<p>it works!</p>";
await lettermint.sendEmailManually(
ctx,
{ from, to, subject },
async (emailId) => {
const response = await fetch("https://api.lettermint.co/v1/send", {
method: "POST",
headers: {
"x-lettermint-token": process.env.LETTERMINT_PROJECT_TOKEN!,
"Content-Type": "application/json",
"Idempotency-Key": emailId,
},
body: JSON.stringify({
from,
to,
subject,
html,
attachments: [
{
filename: "invoice.pdf",
content: "base64-encoded-content",
},
],
}),
});
if (!response.ok) {
throw new Error(`[Email] Failed to send: ${await response.text()}`);
}
const data = (await response.json()) as { message_id: string };
return data.message_id;
},
);
},
});