Transactional email, with templates: the request is written in a table, in the caller's transaction, and the send goes out through a queue with retries and growing backoff, by the PocketBase dashboard's SMTP or by the Resend API. It does what the idea of the @convex-dev/resend component does, written again (see the NOTICE). It needs the queue module enabled.
Enabling
// convex/schema.ts
import { queueTables } from "notusbase-modules/queue/schema";
import { emailTables } from "notusbase-modules/email/schema";
export default defineSchema({ ...queueTables, ...emailTables, /* yours */ });
// convex/queue.ts
export * from "notusbase-modules/queue";
// convex/email.ts
export * from "notusbase-modules/email";
Using
import { Email } from "notusbase-modules/email";
const email = new Email({ from: "My Shop <[email protected]>" });
export const signUp = mutation({
args: { email: v.string() },
handler: async (ctx, { email: to }) => {
await ctx.db.insert("users", { email: to });
// In the same transaction: if the mutation fails from here on, the email never existed.
await email.send(ctx, { to, subject: "Welcome", html: "<p>Hi!</p>", text: "Hi!" });
},
});
| Method | Runs in | What it does |
|---|---|---|
send(ctx, { to, subject, html, text?, from?, redact? }) | mutation, action | Writes the request in email_messages, queues the send and returns the document id. In an action, it is a mutation of its own |
send(ctx, { to, template, data, from? }) | mutation, action | The same, with the subject and bodies of a template (see Templates). data is required |
render(name, data?) | anywhere | Renders a template without sending |
status(ctx, id) | query, mutation, action | { status, provider, providerId, error, attempts, queuedAt, doneAt }, or null |
to is an address or a list of up to 50. from beats new Email({ from }), which beats EMAIL_FROM; with SMTP, with none of the three, the dashboard's sender applies. Address, subject (no line break) and size (512 KiB) are checked in send, in the transaction: an invalid request makes the mutation fail.
Constructor options: from, templates and layout (see Templates), maxParallelism (default 5) and retry (default 8 attempts, from 5 s with factor 3, about 1 h 40 min; it is queue's RetryBehavior).
Templates
A template is the email as code, as Laravel's Mailables and Rails' Action Mailer do it: subject, HTML and text written as functions of variables with Convex validators, wrapped in a layout every email shares, sent by name and previewed in the dashboard.
// convex/mailer.ts
import { v } from "convex/values";
import { Email, accountInvite, button, html, layout, passwordReset, template } from "notusbase-modules/email";
const welcome = template({
args: { name: v.string(), plan: v.union(v.literal("free"), v.literal("pro")) },
subject: ({ name }) => `Welcome, ${name}`,
preheader: ({ plan }) => `Your ${plan} plan is ready`, // optional: the line after the subject in the inbox
html: ({ name }) => html`<p>Hi ${name}!</p>${button("Open the app", "https://example.com/app")}`,
text: ({ name }) => `Hi ${name}!\n\nOpen the app: https://example.com/app`, // optional
example: { name: "Ana", plan: "free" }, // the dashboard's preview and the tests use it
});
export const email = new Email({
from: "My Shop <[email protected]>",
layout: layout({ name: "My Shop", footer: "My Shop, 1 Main Street", color: "#16a34a" }),
templates: { welcome, passwordReset },
});
// in a mutation or action
await email.send(ctx, { to, template: "welcome", data: { name, plan: "pro" } });
- Typed variables.
datahas the type of that template'sargs, and so does each function: a name that does not exist, a missing variable, an extra one or one of the wrong type does not passtsc. At run timesendchecks the same thing, in the transaction, and throws naming the template and the variable (email template "welcome": plan is missing), without leaving an email behind.sendwithoutdatathrows too: theexampleserves only the preview andrender, never a real recipient. - The rendered email passes
send's checks. Subject and preheader are one line: a line break that comes from a variable becomes a space. An empty subject, one over 998 characters, an empty body or more than 512 KiB throwsemail template "welcome": ..., insend, inrenderand in the preview alike. htmlescapes. Every value interpolated inhtml`...`is escaped (& < > " '). Anotherhtml,raw(text)andbutton(label, url)go in as they are. Arrays are joined andnull,undefinedandfalseprint nothing, so a list is${items.map((i) => html<li>${i}</li>)}. A template whosehtmlreturns a plain string sends it as HTML, without escaping anything: use the tag.button(label, url, { color? })is a link drawn as a button with a table and inline styles, which every client shows. The URL must start withhttps://,http://ormailto:; anything else throws.- Layout.
layout({ name?, footer?, color? })is the default frame: a white card 560 px wide on a gray background,nameat the top,footerat the bottom, inline styles only, and the hidden preheader. Withoutlayoutinnew Email, templates getlayout()with nothing filled in;layout: falseturns it off, and a template can choose its own withlayout:(orfalse). The text version goes through the layout too (name, body,-- footer). Your own layout is an object{ html: ({ subject, body, preheader }) => html...${body}..., text?: ({ subject, body }) => string }. Emails sent withsubjectandhtmldirectly get no layout. - Without
text, the row has no text and the provider derives one from the HTML (the PocketBase SMTP and Resend both do). - The row keeps the name in
template, besides the renderedsubject,htmlandtext. Rendering happens insend: changing the template later does not change emails already queued. redact: true, in the template or in a plainsend, clearshtmlandtextfrom the row once the delivery ends (sentorfailed), for emails that carry a secret. See The body stays in the table.email.render(name, data?)returns{ subject, html, text?, preheader? }without sending, with theexamplewhendatais missing.- Examples are checked when the file loads. An
examplethat does not match itsargsfailstsc, and withouttscit fails the deploy:email template "welcome": name must be a string.
Ready templates: passwordReset and accountInvite
passwordReset ships with the module, in English, for "forgot my password". Its variables: url (required: the link with the token), name (optional, for the greeting) and minutes (optional: how long the link lasts, shown when it is a positive number). Subject "Reset your password". It is redact: true: the token leaves email_messages once the email is sent. Add it to templates and send it with { template: "passwordReset", data: { url, minutes: 60 } }. To change the words or the language, copy it into your project as another template with the same variables.
accountInvite is its pair for an account someone else created, which the person enters by choosing a password. Variables: url (required), name (optional) and days (optional: how long the link lasts, shown when it is a positive number). Subject "Your account is ready". Also redact: true. Send it with { template: "accountInvite", data: { url, days: 7 } }.
Preview in the dashboard
The Email screen of the PocketBase dashboard (in the top bar when the deployed code enabled the module) lists the templates, their variables and the rendered subject, preheader, HTML, text and source. The HTML shows inside an iframe with no permissions (no scripts, no navigation) and with a policy that loads nothing by URL: an image or style hosted elsewhere does not show, so opening the preview does not tell a tracker the superuser's IP. Inline styles and data: images show. The variables start as the template's example and can be edited as JSON. Nothing is sent from the screen.
The screen calls two internal queries that come from your Email. Export them in convex/email.ts, next to the module:
// convex/email.ts
export * from "notusbase-modules/email";
import { email } from "./mailer";
export const { templates, preview } = email.previews();
email:templates returns [{ name, args, example, text }] and email:preview ({ name, data? }) the render. Like the module's others, they are internal: the client never reaches them, and notusbase run email:preview '{"name":"welcome"}' and the kit's t.run do. Without the two exports, the screen shows this snippet.
Each email's status
The email_messages table shows up in the dashboard like the others. The status field:
status | Meaning |
|---|---|
queued | In the queue, or waiting for the next attempt. error carries the reason for the last one that failed |
sent | The provider accepted it. With Resend, providerId is its id |
failed | Gave up, with the reason in error: attempts ran out, or the error is not fixed by retrying |
captured | Stored, not sent (see below) |
attempts counts the deliveries tried and doneAt marks the end. An error that retrying does not fix fails at once, without spending the queue: no configuration, SMTP disabled in the dashboard, invalid address, Resend 4xx (except 409 and 429). Network, 5xx, 409, 429 and a transient SMTP refusal retry.
Two paths, by variable
App variables (notusbase env set NAME VALUE):
| Variable | What for |
|---|---|
EMAIL_PROVIDER | smtp, resend or capture. Without it, in production, the send fails with the message on how to configure it |
EMAIL_FROM | Default sender. Required with Resend; optional with SMTP |
RESEND_API_KEY | Resend key |
EMAIL_RESEND_BASE | Test only: replaces https://api.resend.com |
(a) EMAIL_PROVIDER=smtp. It uses the SMTP enabled in the PocketBase dashboard (Settings, Mail settings), the same one the alert email uses (NOTUSBASE_ALERT_EMAIL). Read on every send: enabling it in the dashboard applies without a restart. To reach the SMTP, the module's action uses a new server call, ctx.mail.send, and the server only answers it for email:... functions. Any other action gets the error only allowed to the email module, so the dashboard's SMTP does not become an open relay. With SMTP disabled in the dashboard, the email ends up failed with "SMTP disabled in the PocketBase dashboard".
(b) EMAIL_PROVIDER=resend. fetch to Resend's POST /emails, with RESEND_API_KEY and EMAIL_FROM (or from). Each email carries the header Idempotency-Key: notusbase-email-<id>, so a retry after a crash in the middle does not send twice. The key never goes into the table or the error. If the server's fetch lands on an internal address, which only happens in tests, allow it with NOTUSBASE_FETCH_ALLOW.
Under notusbase dev and outside production nothing goes out
Under notusbase dev, whatever EMAIL_PROVIDER is, the email goes through the queue and stays captured in the table, with everything (to, subject, html, text): check it in the dashboard. A server started without --env production (staging, development, or no flag at all) does the same: the binary tells the module it is not production. EMAIL_PROVIDER=capture does the same on any server, and it is what the kit sets.
In a test, the kit sets EMAIL_PROVIDER=capture by itself when the project imports notusbase-modules/email, and t.emails() waits for the jobs and returns the captured ones:
await t.client.mutation(api.users.signUp, { email: "[email protected]" });
const [mail] = await t.emails(); // { id, to: ["[email protected]"], subject: "Welcome", html, status: "captured", ... }
A template sent in the test comes back with its name in template, and its preview runs like any internal function:
await t.client.mutation(api.users.signUp, { email: "[email protected]", name: "Ana" });
const [mail] = await t.emails(); // mail.template === "welcome"
const { subject, html } = await t.run("email:preview", { name: "passwordReset" });
t.emails({ status: "failed" }) returns the ones that failed and t.emails({ status: null }), all of them.
Guarantees
- The request and the queue task enter in
send's transaction: if the mutation rolls back, neither exists. - Delivery runs at least once, and one that already went out (
statusother thanqueued) does not go out again. With Resend, the idempotency key covers a crash between sending and writing. With SMTP, that crash may deliver twice. - If
queuegives up (attempts exhausted, or the task was lost in a restart), its notification marks itfailed. - An email that ends
failedshows up in the failure log (notusbase logs, the functions screen) as one failed run: underemail:reportFailurewhen the error is one retrying does not fix (email <id> failed for good (<provider>): <reason>), underqueue:reportFailurewhen the attempts ran out. The line names the document inemail_messages, never the recipient: when a provider repeats an address in its error, as in a 550, the module writes<address>in its place, in theerrorof the table, in the line each failed attempt ofemail:deliverleaves and in the final report. The rest of the provider's text stays. email_messageskeeps the body and the recipient forever: delete them with your own job when you no longer need them. NotusBase does not delete them. See below for whatredactclears.
The body stays in the table
The row keeps the rendered html and text so a failed send can be retried and checked. When the email carries a secret, such as the reset link's token, that means the secret is readable in the dashboard, in email:list, in notusbase run email:get and in a backup, for as long as the row lives.
redact: true (in the template, or in a plain send) clears html and text from the row once the delivery ends, sent or failed. The subject, the recipient and the status stay. While it is queued the body stays, since a retry needs it. A captured email (under notusbase dev, EMAIL_PROVIDER=capture and the kit) keeps it, since nothing went out and the test reads it: use test tokens there. passwordReset and accountInvite are redact: true.
Internal functions
email:list ({ status?, limit? }), email:get, email:status, email:deliver, email:enqueue, email:attempt, email:finish, email:completed and email:reportFailure are internal*: the client cannot reach them, while t.run("email:list") and notusbase run can. So are email:templates and email:preview, which exist only when convex/email.ts exports email.previews().
Disabling
Remove convex/email.ts and ...emailTables. The email_messages table stays. A request still pending when you disable it fails in the queue for not finding email:deliver.
How it differs from the original
new Email(options)andemail.send(ctx, ...), with nocomponents.resend. Templates, layout and preview are NotusBase's own: the original sends the HTML it receives. No Resend event webhook (delivered, bounced, opened), no attachments,cc,bcc,replyToor headers. Canceling a send is also missing.- It has the dashboard's SMTP path, which the original does not.
- The status is only the send's:
sentmeans the provider accepted it, not that the inbox received it.