Email Queue

Durable transactional email queue for Convex with idempotent enqueue, host-driven SMTP and JMAP transport, and automatic retry with configurable budgets.

Installation

npm install @vllnt/convex-email

About Email Queue

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

Benefits

Use cases

how to send transactional email from a Convex mutation without blocking

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.

how to add retry logic to outbound email in Convex

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().

how to send email from Convex without a Node.js action

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.

idempotent email sending in Convex to prevent double sends on retry

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.

Frequently asked questions

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.

Links