# Email Durable, transport-agnostic outbound transactional email queue — enqueue, retry, idempotent send, and per-message delivery status as a Bijection component - Package: @bijectionhq/email - Category: Messaging - Status: Reference - License: MIT - Bijection source (repository access required): https://github.com/bijectionhq/email/tree/4bd39035b59931103119dfe33f0da63f9d808d18 - Upstream: https://github.com/vllnt/convex-email/tree/904430f4615f8489ba57b8e6878c57bb020f8943 - Documentation: https://docs.bijection.com/components/using - Directory: https://bijection.com/components/email ## Get the source Bijection repository access is required. Request access at https://bijection.com/contact. ```sh git clone https://github.com/bijectionhq/email.git cd email git checkout 4bd39035b59931103119dfe33f0da63f9d808d18 ``` ## Before using this component Read the package README at the recorded revision for setup, dependencies, and API examples. Upstream documentation describes the original package and may differ from the Bijection adaptation. This is a source candidate for evaluation. Catalog inclusion does not establish npm availability or runtime qualification. Check compatibility with your SDK and deployment before adoption. External providers run their own compute. ## Upstream package documentation Recorded 2026-10-23. This describes @vllnt/convex-email; check the Bijection adaptation separately. @vllnt/convex-email stores outbound messages and their delivery lifecycle in Convex. Your application sends through its chosen transport and reports the outcome, giving your product one place to read queued, sending, sent, or failed state. enqueue stores a message with an optional deduplication key. Your worker claims it with markSending, calls the provider, and reports markSent or markFailed. Failure handling observes a retry budget. Optional SMTP and JMAP adapters support host-side delivery. The component does not contact a provider or schedule your sender by itself. Your host owns authentication, recipient policy, templates, credentials, and retry scheduling. Deduplicated enqueue does not guarantee exactly-once external delivery when a provider response is uncertain. Maintained by [bntvllnt](https://bntvllnt.com) at [VLLNT](https://vllnt.com). Find the author on [GitHub](https://github.com/bntvllnt) and [X](https://x.com/bntvllnt). ### Use cases - SaaS onboarding: queue welcome messages or account invitations. - Commerce and mobile products: track receipt or confirmation emails after a verified event. - Learning apps: deliver course notifications or activity summaries. - Operational tools: expose email status for support and failed-delivery handling. ### How it works enqueue stores a message with an optional deduplication key. Your worker claims it with markSending, calls the provider, and reports markSent or markFailed. Failure handling observes a retry budget. Optional SMTP and JMAP adapters support host-side delivery. The component does not contact a provider or schedule your sender by itself. Your host owns authentication, recipient policy, templates, credentials, and retry scheduling. Deduplicated enqueue does not guarantee exactly-once external delivery when a provider response is uncertain. ### Upstream README # @vllnt/convex-email A durable, transport-agnostic outbound transactional email queue, as a Convex component — the host enqueues a message, its own transport sends it, and clients poll the delivery status. ```ts const email = new Email(components.email); await email.enqueue(ctx, messageId, to, from, "smtp", { idempotencyKey }); await email.markSending(ctx, messageId); // host sends, then: await email.markSent(ctx, messageId, { providerId }); // or markFailed → auto-retry ``` ## Features - **Enqueue-and-return** — insert a `queued` message, get its id back; the caller never blocks on the send. - **Host-driven transport** — the host claims (`markSending`), sends, and reports (`markSent`/`markFailed`); the component never reaches a provider. - **Optional generic SMTP adapter** — `@vllnt/convex-email/smtp` sends through any SMTP server; `nodemailer` is an optional peer dep. - **Optional generic JMAP adapter** — `@vllnt/convex-email/jmap` sends over any JMAP server's HTTP API (Stalwart, Fastmail, Cyrus) from a plain action — `fetch`-based, no `"use node"`, no extra dep. - **Idempotent enqueue** — an `idempotencyKey` dedups a re-enqueue so a retry can't double-send. - **Retry with budget** — `markFailed` re-queues until `attempts` hit `maxAttempts`, then lands in terminal `failed`. - **Terminal states are final** — a late or duplicate delivery callback can never overwrite a recorded outcome. - **Poll or subscribe** — `get` / `listByStatus` update live in a reactive Convex query. - **Server-sourced time, typed opaque payload, bounded prune cron, mount-safe.** ## Installation ```bash pnpm add @vllnt/convex-email ``` Peer dependency: `convex@^1.41.0`. The queue core has zero third-party runtime deps. `nodemailer@^8.0.4` is an optional peer dep — only for the SMTP transport. The JMAP transport (`@vllnt/convex-email/jmap`) is `fetch`-based and needs no extra dependency. ## Usage ```ts // convex/convex.config.ts import { defineApp } from "convex/server"; import email from "@vllnt/convex-email/convex.config"; const app = defineApp(); app.use(email); export default app; ``` ```ts // convex/notify.ts — host owns auth AND the transport; pass opaque refs in. import { components } from "./_generated/api"; import { mutation, internalAction } from "./_generated/server"; import { v } from "convex/values"; import { Email } from "@vllnt/convex-email"; const email = new Email<{ subject: string; html: string }>(components.email, { payloadValidator: v.object({ subject: v.string(), html: v.string() }).parse, }); // 1) Enqueue (records intent only) and schedule the send. export const sendWelcome = mutation({ args: { userId: v.string(), to: v.string() }, handler: async (ctx, { userId, to }) => { const messageId = crypto.randomUUID(); await email.enqueue(ctx, messageId, to, "no-reply@app.com", "jmap", { payload: { subject: "Welcome", html: "
Hello
" }, idempotencyKey: `welcome:${userId}`, // a retry never double-sends }); await ctx.scheduler.runAfter(0, internal.notify.flush, { messageId }); return { messageId }; }, }); // 2) The host's transport sender claims, dispatches, and reports the outcome. export const flush = internalAction({ args: { messageId: v.string() }, handler: async (ctx, { messageId }) => { await email.markSending(ctx, messageId); try { const providerId = await sendOverJmap(messageId); // your transport await email.markSent(ctx, messageId, { providerId }); } catch (e) { const { retried } = await email.markFailed(ctx, messageId, { error: String(e) }); if (retried) await ctx.scheduler.runAfter(backoffMs(), internal.notify.flush, { messageId }); } }, }); ``` ## API Reference | Method | Kind | Result | |--------|------|--------| | `enqueue(ctx, messageId, to, from, transport, opts?)` | mutation | `{ messageId, deduplicated }` | | `markSending(ctx, messageId)` | mutation | `{ attempts }` | | `markSent(ctx, messageId, opts?)` | mutation | `null` | | `markFailed(ctx, messageId, opts?)` | mutation | `{ status, retried }` | | `get(ctx, messageId)` | query | `MessageView \| null` | | `listByStatus(ctx, status, paginationOpts)` | query | `PaginationResult