# Emails (/docs/code/emails)

---
title: Emails
description: 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.
kind: guide
package: questpie
---

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.

```ts title="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.

| 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:

```bash
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.

```ts title="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](/docs/code/emails/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`.

```ts title="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:

```text
============================================================
📧 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.

<Callout type="warn" title="`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.
</Callout>

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

```ts title="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.

<Callout type="warn" title="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](/docs/schema/hooks).
</Callout>

## Where each topic lives

| Topic                                         | Page                                   |
| --------------------------------------------- | -------------------------------------- |
| Every option on the three mailer methods      | [Sending](/docs/code/emails/sending)   |
| Console, SMTP, Resend, Plunk, and your own    | [Adapters](/docs/code/emails/adapters) |
| Running the send off the request path         | [Jobs](/docs/code/jobs)                |
| Firing one after a write commits              | [Hooks](/docs/schema/hooks)            |
| How the generator finds `emails/`             | [Codegen](/docs/code/codegen)          |
| The Zod schema the template validates against | [Validation](/docs/schema/validation)  |

## Next

**[Sending](/docs/code/emails/sending)** is the other two methods. `send`
takes one-off mail with attachments. `renderTemplate` gives you the HTML
without the send.
