Send transactional email through any provider: you write one function, backd renders the messages.
backd never talks to a mail server. A realm names one delivery function: an internal, async function that receives a finished message and hands it to whatever provider you chose (Postmark, SES, Resend, Mailgun, SendGrid, …). backd does everything else: the templates, the tokens and links, the retries, the limits.
Configuration
Email is configured in realm.yaml; backd template realm writes the section commented, with every key explained. A realm without email sends no email at all.
email:
function: notifications/deliver # <database>/<function>
from: "Acme <no-reply@acme.example>"
reply_to: support@acme.example # optional
public_url: https://api.acme.example # optional: backd's public address, for links
default_locale: en
locales: [en] # every listed language needs every template and page
redirect_delay: 3s # optional: how long the result page waits
allowed_redirects: [https://app.acme.example, "acme://"] # where users may be sent afterwards
redirects: # optional: where, per flow, when the request didn't say
verify_email: https://app.acme.example/verified
limits: # defaults shown
per_recipient: { per_kind_per_hour: 3, per_day: 10 }
per_ip: { per_hour: 20 }
per_function: { per_hour: 200, per_invocation: 50 } # custom emails from functions
recipients_per_message: 10 # to + cc + bcc of one ctx.email.send()
functionis the delivery function. Startup fails if it doesn’t exist, isn’tinternal: trueor isn’tmode: async, naming the file.public_url(or theBACKD_URLenvironment variable, which everybackd serveandbackd workerreads) is the address the links in emails point at. Startup fails when a realm sends email and neither is set.localeslists the languages the realm’s emails exist in;default_localemust be one of them.- Email needs
auth: enabled: its users, tokens and counters live in the realm’s system database.
Templates
backd template realm --realm <realm> writes English templates for every kind of email under <realm>/email/:
<realm>/email/
verify-email/ en.subject.txt en.txt en.html
reset-password/ en.subject.txt en.txt en.html
account-exists/ …
password-changed/ …
welcome/ change-email/ email-changed/ invitation/ …
- A folder per kind, and per language a subject, a plain-text body and an HTML body. Edit them freely.
- Required kinds (
verify-email,reset-password,account-exists,password-changed) must exist whenever the realm configuresemail; every listed locale needs every template, or startup fails naming the missing files. - Subjects and text bodies use Go’s
text/template; HTML bodies usehtml/template, which escapes what it prints. Available:{{.Realm}},{{.User.Email}},{{.User.Locale}},{{.Link}},{{.ExpiresAt}}and{{.Data}}. Templates never receive text written by whoever caused the email, sobackdcan’t be used to send someone else’s words. email-changedgoes to the old address with the link to undo the change, and, after an administrator’s change, to the new address with no link: write{{if .Link}}…{{else}}…{{end}}as the default template does.- Templates are parsed and test-rendered at startup (a syntax error or an unknown field stops it), held in memory, and part of the config fingerprint.
Links and pages
The link in an email opens a page served by backd, so an app only chooses where users go afterwards. The link is public_url + /v1/<realm>/_auth/<flow>?token=…, and the flows are verify-email, reset-password, confirm-email-change, revert-email-change and accept-invitation.
GETshows the page,POSTacts. Opening the link only shows a form (a button, or a form with a new password) and never uses the token up, so mail scanners and link previewers that open every link can’t spoil it. The token is used when the person submits the form. A session is never issued by these pages: after verifying or resetting, users sign in as usual.- One error page. A token that is expired, already used, unknown or for another flow shows the same
invalid-tokenpage, so the page tells nobody which case it was. - Pages are templates you own:
backd template realmwrites them to<realm>/pages/<kind>/<locale>.html(verify-email,reset-password,confirm-email-change,revert-email-change,accept-invitation,resultandinvalid-token). Every listed locale needs every page, and they are parsed and test-rendered at startup like the email templates. A page can use{{.Realm}},{{.Locale}},{{.Action}}(where the form posts),{{.Token}}(the hidden field),{{.Error}}and{{.ErrorCode}}(password_mismatchorpassword_policy, to say it in the page’s own words),{{.Kind}},{{.Redirect}},{{.DelaySeconds}}and{{.BackURL}}. The page is in the language of the browser’sAccept-Language, matched againstemail.locales. - Where users go next. After a success the
resultpage waitsredirect_delay(3 seconds by default, at most 30) and sends the user to theredirect_tothe request stored with the token, or else toemail.redirects.<flow>(verify_email,reset_password,change_email,invitation). Both must be withinemail.allowed_redirects: origins such ashttps://app.acme.exampleor an app’s own scheme such asacme://. The address is checked when the email is requested and again when the page uses it. - Your own pages instead.
email.links.<flow>replaces backd’s address in the email with one of your app’s pages, which must be withinallowed_redirectsand contain{token}once (https://app.acme.example/reset?token={token}). That page sends the token to the JSON endpoint of the same flow,POST /v1/<realm>/_auth/<flow>with{"token"}(and{"password"}forreset-passwordandaccept-invitation), which answers204, or400 invalid_tokenfor any token that doesn’t work.
All the flows are there: email verification, password reset, email change (confirmation and undo) and emailed invitations. A page can also show {{.Email}}, the address its link is about: the new one for a change, the previous one for an undo, the invited one for an invitation.
Every page, in every state, sends Cache-Control: no-store, Referrer-Policy: no-referrer, a strict Content-Security-Policy (no scripts, no images, no frames, forms post only to backd) and frame-ancestors 'none', loads nothing from other origins, and is served without the token in any log: the access log records the route, never the query string.
Languages
Every user has a locale, and every email to them is rendered in it. A realm lists the languages it supports in email.locales and picks the fallback with email.default_locale (English only when neither is set).
email:
default_locale: en
locales: [en, es] # every listed language needs every template
- Allowed means translated. Every listed language needs every required template (
es.subject.txt,es.txt,es.htmlfor each kind), or startup fails naming the missing files. To add a language, copy a kind’s English files, translate them, and list it. - At sign-up the user’s language is the best listed match for the
localein the request or the browser’sAccept-Language: exact (es-MX), then the language (es), then the default, never failing. Changing it later is an explicit request,PATCH /_auth/me {"locale"}, and must name a listed language. See Sessions. - Which language an email uses: the one the request asked for when there is one, else the user’s own, else
default_locale.
The delivery function
The delivery function receives a finished message as ctx.input and hands it to your provider. It must be internal: true and mode: async; it typically declares the provider’s key as a secret and the provider’s host in network. The cookbook has a complete, tested one for Postmark, step by step, and a table of what changes for other providers.
// ctx.input
{
id: "d3c9ljp8hc2g00b6s1m0", // the same on every retry of this email
kind: "verify-email", // or any other kind
from: "Acme <no-reply@acme.example>",
reply_to: "support@acme.example", // or null
to: [{ email: "ana@example.com", name: null }],
cc: [], bcc: [],
subject: "Confirm your email address for blog",
text: "…", html: "…", // rendered by backd, in the user's language
locale: "en",
data: { link: "https://…", expires_at: "2026-10-03T12:30:00Z" } // for providers' own templates
}
- It returns an optional
{ message_id }, stored on the email job. idis stable: a provider with idempotency keys (Resend’sIdempotency-Key, for example) can use it to drop a repeat when the first attempt reached the provider but its answer was lost. Providers without that feature ignore it.- Failing. Throw
ctx.error(4xx, code, message)when the provider refuses the message (a bad address, for example): that is permanent and the email is not retried. Anything else (a thrown error, a timeout, the provider unreachable) is retried according to the function’sretry; give itretry: {attempts: 5, backoff: 1m}. - No waiting. The function runs on a worker; nothing in a request waits for it, so a failing provider never breaks sign-up.
How an email is sent
something asks for an email a worker picks it up
──────────────────────────────▶ email job ─────────────────────▶ creates the token (stores its hash),
(kind, user, locale, redirect) queued renders the message in memory,
calls the delivery function
- The email job holds no message. What is queued is a small record: the kind, the user’s id, the language and where the page after the link may send the user. No address, no text, no token. The worker builds the message when it runs, so the message exists only in memory and at your provider.
- Tokens are created at send time. The worker creates a token of 256 random bits for the links that need one (verifying an address, resetting a password, …) and stores only its SHA-256 hash, with its purpose, user, expiry and use. A token is redeemed once, in one atomic step: of two parallel attempts exactly one succeeds, and a success invalidates the user’s other outstanding tokens of the same purpose. Unknown, expired, used and wrong-purpose tokens are indistinguishable. A retry of the email makes a new token; an earlier one that was never delivered simply expires.
- Lifetimes: 48 hours to verify an address (
account.tokens.verify_email), 1 hour to reset a password (account.tokens.reset_password), 24 hours to confirm an email change (account.tokens.change_email), 7 days to undo one (account.tokens.revert_email_change), and as long as the invitation itself to accept one. - In the job list an email job appears with the delivery function as its
functionandorigin: backd:email.<kind>, and its attempts, next attempt and result (themessage_id); never a message, a link or an address.
Sending email from functions
A function that declares email: true in its function.yaml can send custom emails with ctx.email.send(), through the realm’s templates, languages, delivery function, queue and retries:
await ctx.email.send({ kind: "order-shipped", to_user: order._meta.owner, data: { order_no: order.no } }); // a user of the realm
await ctx.email.send({ kind: "invoice", to: [order.customer_email], cc: [accounting], data: { invoice_no: 7 } }); // addresses
kindis a folder of the realm’semail/(email/order-shipped/), with the same files and languages as the others, checked at startup. A function can’t send the kindsbackdsends itself (verify-email,reset-password, …). A template prints the function’sdataas{{.Data.order_no}}; if the function leaves a field out, that send fails permanently (the delivery function is never called) and shows in the job list.- Recipients:
to_user(a realm user’s id, in that user’s language, whichbackdlooks up) orto(addresses, in the realm’sdefault_localeunless you pass alocale), plus optionalccandbcc. Any recipient is allowed:backddoesn’t keep a list of who a function may write to. A function can’t send a subject or a body of its own: the text comes from the template. - It returns
{ id, status: "queued" }, the id of the email’s job. The email is sent by a worker, like the others: a slow provider never slows the function down. - Caps, which bound what a function can do whoever the recipients are, under
email.limits: messages per function per hour (per_function.per_hour, 200), per invocation (per_invocation, 50), recipients per message (recipients_per_message, 10), and the per-recipient limits that apply to every email. Refused attempts count, so a function that keeps trying is the one that gets stopped. Over a capctx.email.send()throws an error withcode: "email_limited"andretry_after(seconds); nothing is queued, and the function decides what to answer. - What
backdkeeps. An email job from a function stores the recipients’ addresses and thedatait was given, until job retention ends (the account emails store neither). Don’t put secrets indata. In the job list such a job hasorigin: function:<database>/<name>and itsemail_kind; filter byoriginto see (and count) what one function sent. Addresses never appear in logs or the audit trail. Startup logs each function that can send email.
Before you ship one:
- Require a verified user (
invoke: "user != nil && user.email_verified") and arate_limit. - Take recipients from your own data (an order’s owner), never from the input, and fix the
kindin the code. - Don’t print caller-written text in a template that goes to someone else, and don’t put secrets in
data. - Watch your provider’s bounce and complaint rates. At startup
backdwarns about a function that can send email and allows anonymous callers.
The safe shape, from the workshop realm: customers write to the shop’s own mailbox. The recipient and the kind are fixed in the code, the input schema refuses any other field, only verified users may call it and only three times an hour:
examples/config/workshop/main/_functions/contact/function.yaml
# Writes to the shop's own support mailbox: the safe shape of a function that
# sends email (docs: Functions -> Email -> Sending email from functions).
#
# - `email: true` lets it call ctx.email.send.
# - Only signed-in users with a verified address may call it, at most 3 times an
# hour: anyone else can't make it send anything.
# - The recipient is fixed in the code (never taken from the input), the kind
# is fixed (its template is email/contact-message/), and the input schema
# refuses any other field.
email: true
invoke: "user != nil && user.email_verified"
rate_limit: { per: user, limit: 3, window: 1h }examples/config/workshop/main/_functions/contact/index.ts
// contact: a customer writes to the shop. The email goes to one fixed address,
// the shop's own mailbox, so nobody can use this function to write to someone
// else; the caller's text only ever reaches that mailbox.
import type { Context } from "../lib/types.ts";
// Fixed in the code: never read from the input.
const SUPPORT = "support@workshop.example";
export default async function handler(ctx: Context) {
const { message } = ctx.input as { message: string };
const job = await ctx.email.send({
kind: "contact-message",
to: [SUPPORT],
data: { from: ctx.user?.email ?? "", message },
});
return { sent: true, email_job: job.id };
}And the refund function there emails the customer, with the recipient taken from the order:
examples/config/workshop/main/_functions/refund/index.ts
// The customer is told by email: a custom kind of the realm
// (email/refund-issued/), sent to the order's owner, a user of the realm. The
// recipient comes from the order, never from the request; a failure here
// (a limit, a template problem) must not undo or hide a refund that happened.
try {
await ctx.email.send({ kind: "refund-issued", to_user: order._meta.owner, data: { order_id, amount: (order.amount / 100).toFixed(2), reason: reason ?? "" } });
} catch (err) {
console.log(`the customer was not emailed: ${err instanceof Error ? err.message : err}`);
}@backd/functions-testing gives you ctx.email.send in tests, with the same refusals and caps (createContext({ email: true }), sentEmails()); test that your function ignores recipients in its input.
Developing without a provider
backd template email-capture --realm <realm> --database <database> adds a delivery function that stores each email instead of sending it: email-capture in the database’s _functions, and an outbox collection it writes to (schema, rules and indexes included). Point the realm at it, and run backd in dev mode:
email:
function: notifications/email-capture
from: "Dev <dev@example.com>"
public_url: http://localhost:8080
Every message, with its working links, shows up in outbox: read it with the API (GET /v1/<realm>/notifications/outbox, readable by anyone, since it exists only on a development machine) or in the Mailbox.
The Mailbox is a small web app of the local stack (make example, then https://localhost:8443/example/mailbox/): it lists a realm’s captured emails (?realm=<realm>, the workshop realm by default; the two expenses examples use it too, since they require verified addresses) and shows each one, text and HTML, with a button that opens its link, so you can click through verification and password reset by hand. The workshop realm is configured exactly like this, and its function is the tested one the template writes:
examples/config/workshop/notifications/_functions/email-capture/function.yaml
# A delivery function for local development: instead of sending, it stores
# each message (links included) in the `outbox` collection of this database,
# so you can read it, or open it in the Mailbox of the local stack, and click
# through verification and password reset.
#
# Point the realm at it in realm.yaml:
# email:
# function: notifications/email-capture
# from: "Dev <dev@example.com>"
#
# `dev_only: true`: backd refuses to start with this function unless
# BACKD_DEV=true, because the outbox holds working links. Replace it with your
# provider's delivery function (see the docs, Functions -> Email) before you
# deploy anywhere real.
internal: true
mode: async
dev_only: true
# Writes to the outbox as itself, whatever its (nonexistent) caller may do.
admin: trueexamples/config/workshop/notifications/_functions/email-capture/index.ts
// email-capture: stores each email backd renders in the `outbox` collection
// instead of sending it. Local development only (see function.yaml).
type Message = {
id: string;
kind: string;
from: string;
to: { email: string }[];
subject: string;
text: string;
html: string;
locale: string;
data: { link?: string; expires_at?: string };
};
export default async function handler(ctx: { input: unknown; admin: { db(name: string): any } }) {
const m = ctx.input as Message;
const doc = await ctx.admin.db("notifications").collection("outbox").create({
email_id: m.id,
kind: m.kind,
from: m.from,
to: m.to.map((t) => t.email),
subject: m.subject,
text: m.text,
html: m.html,
locale: m.locale,
link: m.data?.link ?? null,
});
console.log(`captured ${m.kind} email for ${m.to.map((t) => t.email).join(", ")}`);
return { message_id: doc.id };
}The local stack runs backd in dev mode for this (BACKD_DEV=true, with BACKD_DEV_ANY_ADDR=true because its ports are published on 127.0.0.1 only).
email-capture is dev_only: true: backd refuses to start with it unless BACKD_DEV=true, because the outbox holds working links. Replace it with your provider’s delivery function before you deploy anywhere real.
To unit-test your own delivery function or a function that sends email, @backd/functions-testing provides emailMessage() (a message in the contract’s shape, as ctx.input) and fakeEmail() (a fake ctx.email.send that records what a function sends).
Limits
So the realm can’t be used to flood an address or as a relay, backd limits how many emails it queues: per recipient, 3 emails of one kind per hour and 10 in all per day, and per client address, 20 email-sending requests per hour (the keys of email.limits change them). The counters live in the realm’s system database, shared by every instance, with the recipient only as a hash. A request made without a signed-in user (sign-up, a password-reset request) that goes over a limit is skipped silently, so the answer reveals nothing; a signed-in user’s request answers 429 with Retry-After. Functions that send email have caps of their own on top (per function, per invocation, per message).
Upgrading
Email adds a system collection (email_tokens) to the realm’s system database: run backd provision (or start with PROVISION_MODE=apply) after upgrading, as for any new system collection.