Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Email

Durable, transport-agnostic outbound transactional email queue — enqueue, retry, idempotent send, and per-message delivery status as a Bijection component

Bijection source
git clone https://github.com/bijectionhq/email.git
cd email
git checkout 4bd39035b59931103119dfe33f0da63f9d808d18

Bijection repository access is required. Request access.

Description

Durable, transport-agnostic outbound transactional email queue — enqueue, retry, idempotent send, and per-message delivery status as a Bijection component

Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.

Available as a reference

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. Exercise the component in your deployment according to your project’s verification rules.

Open source

Maintained source: bijectionhq/email. Based on vllnt/convex-email. License: MIT. Consult the repository for license terms, notices, and contributor attribution.

From the upstream publisher

Durable, transport-agnostic outbound transactional email queue — enqueue, retry, idempotent send, and per-message delivery status as a Convex component

Explore the demo ↗

Upstream overview

From the upstream package documentation.

@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 at VLLNT. Find the author on GitHub and X.

Use cases

From the upstream package documentation.

  • 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

From the upstream package documentation.

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 package FAQ

Does @vllnt/convex-email send emails itself or does the host application handle transport?

@vllnt/convex-email is host-driven: the component records queue state and enforces delivery semantics, but it never contacts an email provider directly. The host application claims a message with markSending(), calls its own transport (SMTP or JMAP), and then reports the outcome with markSent() or markFailed(). This keeps credentials and provider logic entirely in the host.

What transports does @vllnt/convex-email support?

The component ships two optional adapters. The SMTP adapter at @vllnt/convex-email/smtp wraps nodemailer and requires a use node action. The JMAP adapter at @vllnt/convex-email/jmap is fetch-based, runs in a plain Convex action without use node, and has zero additional dependencies. Both adapters work with any compliant server, not specific vendors.

How does retry work in the Email Queue component?

Calling email.markFailed() re-queues the message automatically and increments its attempt counter. Once attempts reaches maxAttempts the message transitions to a terminal failed state and is never re-queued. The markFailed() return value includes a retried boolean so the host action knows whether to schedule another delivery attempt.

Can I check the delivery status of an email in a Convex query?

Yes. email.get(ctx, messageId) returns a MessageView for a single message, and email.listByStatus(ctx, status, paginationOpts) returns a paginated list filtered by status. Both are standard Convex queries, so they update reactively in clients that subscribe to them.

What version of Convex is required and are there third-party runtime dependencies?

The queue core requires convex@^1.41.0 and has zero third-party runtime dependencies. The SMTP adapter has nodemailer@^8.0.4 as an optional peer dependency that you only install if you use that transport. The JMAP adapter adds no dependencies beyond Convex itself.

Use with agents and CLI

Give your coding agent the component’s source identity and setup guidance.

agent-prompt.md
Help me integrate Email with Bijection.

Read: https://bijection.com/components/email/SKILL.md

Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.

Upstream README.md

View repository ↗

Documentation from vllnt/convex-email, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.

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

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

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

// 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;
// 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: "<p>Hello</p>" },
      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

MethodKindResult
enqueue(ctx, messageId, to, from, transport, opts?)mutation{ messageId, deduplicated }
markSending(ctx, messageId)mutation{ attempts }
markSent(ctx, messageId, opts?)mutationnull
markFailed(ctx, messageId, opts?)mutation{ status, retried }
get(ctx, messageId)queryMessageView | null
listByStatus(ctx, status, paginationOpts)queryPaginationResult<MessageView>
prune(ctx, opts?)mutationnumber

Full reference: docs/API.md.

SMTP transport (optional)

A generic SMTP adapter ships at @vllnt/convex-email/smtp — it sends through any SMTP server (Stalwart, Postfix, any relay). The real send runs in the host's own "use node" action; the component never sends.

import { createSmtpSender } from "@vllnt/convex-email/smtp";

const send = createSmtpSender({
  host: process.env.SMTP_HOST!,
  port: 465,
  secure: true,
  auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
});

Full queue-to-SMTP wiring in docs/API.md.

JMAP transport (optional)

A generic JMAP adapter ships at @vllnt/convex-email/jmap — it sends over any JMAP server's HTTP API (Stalwart, Fastmail, Cyrus, Apache James). JMAP is HTTP, so it runs in a plain Convex action — no "use node", no nodemailer, zero extra deps. It's a protocol, not a vendor: ./jmap, never ./stalwart.

import { createJmapSender, discoverJmapSession } from "@vllnt/convex-email/jmap";

// Resolve account / identity / Sent mailbox once, then bind a sender over your `fetch`.
const config = await discoverJmapSession((u, i) => fetch(u, i), {
  sessionUrl: "https://mail.example.com/.well-known/jmap", // e.g. a Stalwart host
  token: process.env.JMAP_TOKEN!,
  from: "no-reply@app.com",
});
const send = createJmapSender(config, (u, i) => fetch(u, i));

To send some mail over SMTP and some over Stalwart's HTTP (JMAP), keep one queue and route per message by its stored transport tag (a senders map keyed by "smtp" / "jmap"). Full JMAP wiring + routing in docs/API.md.

Security

  • Auth-agnostic and provider-neutral — the host gates access and drives the transport; the component never authenticates or contacts a provider.
  • Tables are sandboxed (reached only via exported functions); the stored payload is opaque.
  • Terminal states are final, enqueue is idempotent, and time is server-sourced.

See docs/API.md.

Testing

pnpm test           # single run
pnpm test:coverage  # enforced 100% on covered files

Tests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.

Contributing

See CONTRIBUTING.md.

Author

Built by bntvllnt · bntvllnt.com · X @bntvllnt

Part of the @vllnt Convex component fleet — vllnt.com

If this is useful, sponsor the work.

License

MIT — see LICENSE.

Keywords