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