NotusBase

email

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!" });
  },
});
MethodRuns inWhat it does
send(ctx, { to, subject, html, text?, from?, redact? })mutation, actionWrites 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, actionThe same, with the subject and bodies of a template (see Templates). data is required
render(name, data?)anywhereRenders 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" } });

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:

statusMeaning
queuedIn the queue, or waiting for the next attempt. error carries the reason for the last one that failed
sentThe provider accepted it. With Resend, providerId is its id
failedGave up, with the reason in error: attempts ran out, or the error is not fixed by retrying
capturedStored, 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):

VariableWhat for
EMAIL_PROVIDERsmtp, resend or capture. Without it, in production, the send fails with the message on how to configure it
EMAIL_FROMDefault sender. Required with Resend; optional with SMTP
RESEND_API_KEYResend key
EMAIL_RESEND_BASETest 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 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