# Lettermint

A Lettermint component for Convex.

This is an upstream package. It has not been adapted or qualified for Bijection.

- Package: convex-lettermint
- Source: https://github.com/pierre-H/convex-lettermint
- npm: https://www.npmjs.com/package/convex-lettermint
- Directory: https://bijection.com/components/convex-lettermint

Read the upstream README for setup, license, and compatibility before using it.

## Upstream package documentation

Recorded 2026-10-23. This describes convex-lettermint; check the Bijection adaptation separately.

convex-lettermint integrates the Lettermint transactional email service with Convex, providing a durable email queue backed by Convex workpools. Emails are batched to Lettermint's `/send/batch` endpoint with idempotency keys derived from internal Convex email IDs, and delivery events are tracked via verified HMAC-signed webhooks. The component handles rate limiting, retry backoff, and status tracking out of the box.

### Use cases

- Sending transactional emails (welcome, password reset, notifications) from Convex mutations or actions with durable retry guarantees
- Tracking email delivery lifecycle events (delivered, bounced, opened, clicked) by wiring Lettermint webhooks to a Convex HTTP endpoint
- Queuing high volumes of outbound emails without overwhelming the Lettermint API, using built-in rate limiting and batching
- Cancelling pending emails before they are dispatched, or checking per-email status including bounce and complaint flags
- Sending emails with attachments or inline images by bypassing the queue with `sendEmailManually` and calling the Lettermint API directly inside a Convex action

### How it works

After installing the package and adding `app.use(lettermint)` in `convex/convex.config.ts`, you instantiate a `Lettermint` client with `new Lettermint(components.lettermint, options)`. Calling `lettermint.sendEmail(ctx, params)` enqueues the message and returns a branded `EmailId`. The component batches queued messages to Lettermint's `/send/batch` endpoint, using the internal `EmailId` as the `Idempotency-Key` to prevent duplicate sends across retries.

Failed API calls are retried using Convex workpools with configurable `initialBackoffMs` and `retryAttempts` options. `testMode` defaults to `true`, restricting delivery to Lettermint test domain addresses until explicitly disabled in `LettermintOptions`.

Webhook events are handled by mounting an HTTP endpoint in `convex/http.ts` that calls `lettermint.handleLettermintEventWebhook(ctx, req)`. The component verifies the `X-Lettermint-Signature` HMAC-SHA256 header and rejects stale or invalid requests before routing events to your registered `onEmailEvent` mutation callback, which receives a typed `{ id: EmailId; event: EmailEvent }` argument.

Data retention is managed manually via `cleanupOldEmails` and `cleanupAbandonedEmails` functions exposed on the component, which can be scheduled from a Convex cron job. For emails requiring attachments or features outside the batch API, `sendEmailManually` accepts a callback that constructs and fires the raw Lettermint API request, while still recording the email in the component's tracking layer.

### Upstream README

# Lettermint Convex Component

This component integrates the [Lettermint](https://lettermint.co) transactional
email service with Convex.

Note: This is an unofficial implementation. Lettermint and related trademarks
are the property of Lettermint, and all trademark rights are reserved by
Lettermint. This project is not affiliated with, endorsed by, or sponsored by
Lettermint.

Features:

- Queueing: Send many emails quickly; the component processes them durably.
- Batching: Sends queued emails to Lettermint's `/send/batch` endpoint.
- Durable execution: Uses Convex workpools to retry temporary failures.
- Idempotency: Uses Convex email IDs as Lettermint `Idempotency-Key` values.
- Rate limiting: Limits calls to the Lettermint Sending API.
- Webhooks: Verifies Lettermint HMAC signatures and tracks message status.

See [example](./example) for a demo of how to incorporate this component into
your application.

## Installation

```bash
npm install convex-lettermint
```

## Get Started

Create a [Lettermint](https://lettermint.co) project and create a Project API

Add the component to your Convex app via `convex/convex.config.ts`:

```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:

```ts
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.co`
- `softbounce@testing.lettermint.co`
- `hardbounce@testing.lettermint.co`
- `spamcomplaint@testing.lettermint.co`
- `dsn@testing.lettermint.co`

Set `testMode: false` in `LettermintOptions` before sending to real recipients.

## Webhooks

The component can receive Lettermint delivery, bounce, complaint, open, and click
events.

Mount an HTTP endpoint in `convex/http.ts`:

```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.

## Event Handler

Register a mutation to run when email events arrive:

```ts
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.

## Options

The `LettermintOptions` constructor argument customizes component behavior:

- `projectToken`: Provide the Lettermint Project API token instead of reading
  `LETTERMINT_PROJECT_TOKEN`.
- `webhookSecret`: Provide the webhook secret instead of reading
  `LETTERMINT_WEBHOOK_SECRET`.
- `testMode`: Only allow delivery to Lettermint test addresses. Defaults to
  `true`.
- `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.

## Sending Parameters

`sendEmail` supports the batch-safe Lettermint fields:

- `from`
- `to`
- `cc`
- `bcc`
- `subject`
- `html`
- `text`
- `replyTo`
- `headers`
- `route`
- `tag`
- `metadata`
- `settings.trackOpens`
- `settings.trackClicks`

Example:

```ts
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`.

## Tracking And Status

`sendEmail` returns a branded `EmailId`.

Use it to check status or cancel queued emails:

```ts
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.

## Data Retention

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:

```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 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;
```

## React Email

Use [React Email](https://react.email/) to generate HTML in a Convex Node action,
then pass the generated HTML to `sendEmail`.

```tsx
"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,
    });
  },
});
```

## Sending Manually And Attachments

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.

```ts
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;
      },
    );
  },
});
```