Durable transactional email queue for Convex with idempotent enqueue, host-driven SMTP and JMAP transport, and automatic retry with configurable budgets.
npm install @vllnt/convex-emailEmail Queue is a durable, transport-agnostic outbound transactional email queue as a Convex component: the host enqueues a message, its own action sends it over SMTP or JMAP, and delivery status streams live to clients via reactive Convex queries. The component never contacts a mail provider — the host claims each message (`markSending`), dispatches it, and reports the outcome (`markSent`/`markFailed`); `markFailed` re-queues until `maxAttempts`, then records a terminal `failed` that no late callback can overwrite. Tables are sandboxed, enqueue is idempotent via `idempotencyKey`, time is server-sourced, and it runs identically on Convex Cloud and self-hosted convex-backend.
The Email Queue component lets you call email.enqueue() inside a Convex mutation to record the send intent, then schedule a separate internalAction to claim the message with markSending(), dispatch it over SMTP or JMAP, and report the outcome with markSent() or markFailed(). The mutation returns immediately with a messageId while delivery happens asynchronously.
Calling email.markFailed() on a message re-queues it automatically until the attempt count reaches maxAttempts, at which point it transitions to a terminal failed state. The return value includes a retried boolean so the host action can schedule another flush attempt with a backoff delay using ctx.scheduler.runAfter().
The JMAP adapter at @vllnt/convex-email/jmap is fetch-based and runs in a plain Convex action with no use node directive and no extra dependencies. It supports any JMAP server including Stalwart, Fastmail, and Cyrus, and requires only a session URL and bearer token.
Pass an idempotencyKey option to email.enqueue() keyed to a stable business identifier such as welcome:{userId}. If the same key is enqueued again, the component returns deduplicated: true and skips insertion, so retrying the enclosing mutation never creates a duplicate message.
@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.
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.
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.
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.
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.