Emails
An email template is a file that returns a subject and some HTML. QUESTPIE registers it, types its input from a Zod schema, and hands the result to whichever provider you configured.
You need to send a confirmation. Where does the HTML live, and how does it reach an inbox? This page keeps the two apart. You write the first. You configure the second, and you can swap it without touching the first.
Declare it
Put a file under your emails/ directory. Import email from
questpie/services and default-export the call.
import { email } from "questpie/services";
import { z } from "zod";
export default email({
name: "welcome",
schema: z.object({
name: z.string(),
activationUrl: z.string().url(),
}),
handler: ({ input }) => ({
subject: `Welcome, ${input.name}!`,
html: `<h1>Welcome, ${input.name}!</h1>
<p>Your account is ready.</p>
<a href="${input.activationUrl}">Activate your account</a>`,
}),
});Three fields, and that is the whole definition.
| Field | Type | What it does |
|---|---|---|
name | string | A label. The registry key comes from the filename. |
schema | z.ZodSchema<TInput> | Parses input on every render. It also types it. |
handler | (args) => EmailResult | Promise<EmailResult> | Builds the mail. Sync or async. |
Then register it:
questpie generate # collects emails/ into the template registryquestpie add email welcome writes the file and runs codegen for you.
Set an adapter
The adapter delivers. You set it once, in your runtime config.
import { ConsoleAdapter } from "questpie/adapters/console";
import { runtimeConfig } from "questpie/app";
export default runtimeConfig({
// ...app, db, secret
email: {
adapter: new ConsoleAdapter({ logHtml: false }),
defaults: { from: "Acme <hello@acme.test>" },
},
});Every starter ships that block. Take it out and the app stops booting. The
email service is built at startup and throws
QUESTPIE: 'email.adapter' is required. when there is none.
Adapters covers SMTP, Resend, Plunk and writing your own. Swapping one changes nothing in your templates or your send calls.
Send it
ctx.email is the mailer. It sits on every handler context: hooks, routes,
jobs, services, and email handlers themselves. The key is email, not
mailer.
import { job } from "questpie/services";
import { z } from "zod";
export default job({
name: "send-welcome",
schema: z.object({
to: z.string().email(),
name: z.string(),
token: z.string(),
}),
handler: async ({ payload, email }) => {
await email.sendTemplate({
template: "welcome", // autocompletes
input: {
name: payload.name,
activationUrl: `https://acme.test/activate/${payload.token}`,
},
to: payload.to,
});
},
});input is inferred from that template's schema. Drop activationUrl and it is
a compile error, not a runtime one.
What lands
Run the job with the console adapter and you get this:
============================================================
📧 EMAIL (Console Adapter - Development Mode)
============================================================
From: Acme <hello@acme.test>
To: ada@example.com
Subject: Welcome, Ada!
============================================================
Text Content:
WELCOME, ADA!
Your account is ready.
Activate your account [https://acme.test/activate/abc]
(HTML content available but not logged. Set logHtml: true to see it)
============================================================Two fields were filled in on the way out. from fell back to defaults.from,
and then to noreply@example.com if you set neither. text was derived from
your html by html-to-text, because the handler returned none.
The filename is the key
Codegen scans emails/ and features/*/emails/, one template per file. The
key is the filename in camelCase, so emails/new-blog-post.ts registers as
newBlogPost. That key is what sendTemplate autocompletes.
.ts, .tsx and .mts are all read. Any index, _-prefixed, .test.,
.spec. or .d.ts file is skipped. Subdirectories are not scanned.
`name` is decorative
The mailer keys its registry by the filename and never reads name. Renaming
the file renames the template. Changing name does nothing. Keep the two in
step anyway, so the file and the string agree.
The handler runs inside your app
A handler receives the whole app context, spread in, plus input and an
optional locale. So it can read the database while it builds the mail.
handler: async ({ input, collections, t, locale }) => {
const order = await collections.orders.findOne({
where: { id: input.orderId },
});
if (!order) throw new Error(`Order ${input.orderId} not found`);
return {
subject: t("order.receipt.subject", {}, locale),
html: `<h1>Order ${order.id}</h1><p>Total: ${order.total}</p>`,
};
},Those services come from the ambient context. Requests, CRUD calls, jobs and
admin actions all establish one, so a handler called from any of them is fine.
A bare script is not. A handler that then reaches for collections throws
email template 'welcome' handler needs app context. Pass ctx: { app } to
sendTemplate when you really are outside a scope.
locale is passed through untouched. Nothing translates on its own. Reach for
t(key, params, locale) when you want that.
Do not send inside an open transaction
afterChange runs inside the write transaction. Mail sent there is wrong if
the write rolls back. Publish a job, or wrap the send in onAfterCommit. See
Hooks.
Where each topic lives
| Topic | Page |
|---|---|
| Every option on the three mailer methods | Sending |
| Console, SMTP, Resend, Plunk, and your own | Adapters |
| Running the send off the request path | Jobs |
| Firing one after a write commits | Hooks |
How the generator finds emails/ | Codegen |
| The Zod schema the template validates against | Validation |
Next
Sending is the other two methods. send
takes one-off mail with attachments. renderTemplate gives you the HTML
without the send.