10. Email for real
email-capture delivers in dev (the Mailbox), postmark for real — templates, hosted pages, ctx.email.send, emailed invitations.
Invitations emailed themselves? Not yet — chapter 4 handed you a token to pass along. This chapter gives the realm an email pipeline: a delivery function, templates for every flow backd sends, hosted pages the links open, and one custom kind of your own.
The realm side
Add to config/shelf/realm.yaml (keeping auth, signup and roles as they are):
email:
function: mail/email-capture
from: "Shelf <no-reply@shelf.example>"
allowed_redirects: [http://localhost:8080]
redirects:
verify_email: http://localhost:8080/
reset_password: http://localhost:8080/
change_email: http://localhost:8080/
invitation: http://localhost:8080/
account:
allow_email_change: true
function:— one function (in the newmaildatabase) delivers every emailbackdrenders.email-capturestores them in themail/outboxcollection instead of sending —dev_only: true, it only runs underBACKD_DEV, which the tutorial’scompose.yamlsets (a real deployment doesn’t, andbackdthen refuses to start with it configured) (Email).- Templates —
email/<kind>/<locale>.{subject.txt,txt,html}, one folder per kindbackdsends (verify-email, reset-password, invitation, welcome, account-exists, change-email, email-changed, password-changed) plus custom kinds likeshelf-digest. Every locale you list needs every kind — startup fails naming what’s missing. - Pages —
pages/<kind>/<locale>.html: the hosted pages the email links open.GETshows a form,POSTconsumes the token — mail scanners can never spend your links. allowed_redirects/redirects— after a flow’s page, the reader lands back at the app, and only at origins you list.
On a checkout, backd template realm --realm shelf writes all of email/ and pages/ for you, and backd template email-capture --realm shelf --database mail the delivery function and outbox; the tutorial’s config/ is mounted read-only into the container, so fetch the finished files instead:
fetch the chapter's files (optional — the listings above are the same files)
for f in email/account-exists/en.html email/account-exists/en.subject.txt email/account-exists/en.txt email/change-email/en.html email/change-email/en.subject.txt email/change-email/en.txt email/email-changed/en.html email/email-changed/en.subject.txt email/email-changed/en.txt email/invitation/en.html email/invitation/en.subject.txt email/invitation/en.txt email/password-changed/en.html email/password-changed/en.subject.txt email/password-changed/en.txt email/reset-password/en.html email/reset-password/en.subject.txt email/reset-password/en.txt email/shelf-digest/en.html email/shelf-digest/en.subject.txt email/shelf-digest/en.txt email/verify-email/en.html email/verify-email/en.subject.txt email/verify-email/en.txt email/welcome/en.html email/welcome/en.subject.txt email/welcome/en.txt pages/accept-invitation/en.html pages/confirm-email-change/en.html pages/invalid-token/en.html pages/reset-password/en.html pages/result/en.html pages/revert-email-change/en.html pages/verify-email/en.html mail/_functions/deno.json mail/_functions/email-capture/function.yaml mail/_functions/email-capture/index.ts mail/_functions/email-capture/index.test.ts mail/outbox/schema.json mail/outbox/indexes.json mail/outbox/collection.yaml main/_functions/digest/function.yaml main/_functions/digest/index.ts main/_functions/digest/index.test.ts; do
mkdir -p "config/shelf/$(dirname "$f")"
curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
done(That last group replaces digest/ with its finished version: chapter 9’s schedule: plus email: true, and the ctx.email.send call shown below.) Rebuild and restart: docker compose run --rm functions-build && docker compose restart backd. For real delivery, mail/_functions/postmark/ is the swap — email.function: mail/postmark, plus its two secrets (POSTMARK_TOKEN, POSTMARK_FROM) and a verified sender.
A kind of your own: shelf-digest
digest gained email: true — ctx.email.send queues one email per member through the same pipeline (render, delivery function, retries):
await ctx.email.send({
kind: "shelf-digest", // email/shelf-digest/
to_user: member.user_id, // their language; backd looks the user up
data: { count: 3, since: "2026-10-01", assets: ["Handbook", "…"] },
})
The template prints {{.Data.count}}, {{range .Data.assets}} — a function supplies data, never text: nobody can make your realm send words you didn’t write (Sending email from functions).
What changes in the app
- Invite a member gains email the invitation —
invitations.sendqueues theinvitationtemplate; the mail lands in the Mailbox below (the dev outbox: its rule isread: "true", so anyone can read it — which is whyemail-captureonly runs in dev). - Forgot your password? works — a reset email, a hosted page, and the link never spent by a scanner.
- The account page offers Change email (
account.allow_email_change) — the new address confirms, the old one can undo. - Mailbox (Admin view) lists captured emails — open the verify/reset/accept link right there.
You should see
- Inviting by email lands
invitationin the Mailbox; its link opens the hosted accept-invitation page, which sends you back to the app. - Run by hand → digest produces a
shelf-digestper member in the Mailbox, next to the in-app notifications. - Requesting a password reset lands
reset-password; the hosted page asks for the new password, then redirects to the app. deno testcovers the digest’sctx.email.send(sentEmails()) and email-capture’s outbox write.
Next: chapter 11 — the internet calls you: the import webhook and verifying the sender.