QUESTPIE
Code

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.

View markdown

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.

src/questpie/server/emails/welcome.ts
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.

FieldTypeWhat it does
namestringA label. The registry key comes from the filename.
schemaz.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 registry

questpie 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.

src/questpie/server/questpie.config.ts
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.

src/questpie/server/jobs/send-welcome.ts
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.

emails/order-receipt.ts, the handler
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

TopicPage
Every option on the three mailer methodsSending
Console, SMTP, Resend, Plunk, and your ownAdapters
Running the send off the request pathJobs
Firing one after a write commitsHooks
How the generator finds emails/Codegen
The Zod schema the template validates againstValidation

Next

Sending is the other two methods. send takes one-off mail with attachments. renderTemplate gives you the HTML without the send.

On this page