Sending
ctx.email is a MailerService with three methods. sendTemplate renders a registered template and sends it, send takes a raw message, and renderTemplate stops at the HTML.
| Method | Takes | Returns |
|---|---|---|
sendTemplate(options) | A template key and typed input | Promise<void> |
send(options) | A raw subject and body | Promise<void> |
renderTemplate(options) | A template key and typed input | Promise<EmailResult> |
ctx.email is on every handler context: hooks, routes, jobs, services, and
email handlers. app.email is the same object outside a handler. Its type
carries no template keys, so sendTemplate there takes a plain string and an
unchecked input.
sendTemplate
Renders a registered template, then hands the result to send. Use it for
anything you declared in emails/.
await ctx.email.sendTemplate({
template: "welcome",
input: { name: "Ada", activationUrl: "https://acme.test/activate/abc" },
to: "ada@example.com",
});| Option | Type | Notes |
|---|---|---|
template | keyof AppEmailTemplates | Required. The camelCased filename. |
input | inferred from the schema | Required. Parsed by the template's Zod schema. |
to | string | string[] | Required. |
subject | string | Overrides the rendered subject. |
from | string | Falls back to defaults.from. |
cc, bcc | string | string[] | |
replyTo | string | |
locale | string | Reaches the handler as args.locale. |
ctx | { app?, db?, session? } | Escape hatch outside a request or job scope. |
No attachments here
sendTemplate has no attachments and no headers. To send a template with
either, call renderTemplate first, then pass the result to send with your
extras.
send
Takes a message you assembled yourself. This is the only method that carries attachments and custom headers.
await ctx.email.send({
to: "ada@example.com",
subject: "Your export is ready",
html: "<p>Download it <a href='https://acme.test/x'>here</a>.</p>",
attachments: [
{ filename: "report.csv", content: csvBuffer, contentType: "text/csv" },
],
});| Option | Type | Notes |
|---|---|---|
to | string | string[] | Required. |
subject | string | Required by the type. |
html | string | One of html or text must be there. |
text | string | Derived from html when you omit it. |
from | string | Falls back to defaults.from, then to a default. |
cc, bcc | string | string[] | |
replyTo | string | |
attachments | Array<{ filename; content: Buffer | string; contentType? }> | |
headers | Record<string, string> |
Pass neither html nor text and it throws No text or html provided.
renderTemplate
Runs the handler and returns { subject, html, text? }. It never touches the
adapter, so nothing is delivered.
const rendered = await ctx.email.renderTemplate({
template: "welcome",
input: { name: "Ada", activationUrl: "https://acme.test/activate/abc" },
});
await ctx.email.send({
...rendered,
to: "ada@example.com",
attachments: [{ filename: "terms.pdf", content: pdf }],
});It takes template, input, locale and ctx. The rest of the send options
do not apply.
What gets serialized
Every adapter receives a SerializableMailOptions, which is your message with
three fields settled.
| Field | After serialization |
|---|---|
from | Your value, else defaults.from, else noreply@example.com. |
text | Your value, else converted from html, else "". |
html | Your value, else "". |
from is always a real address. text and html are typed string, but the
one you did not supply arrives empty. A text-only message reaches the adapter
with html: "".
What each call throws
| Error | Cause |
|---|---|
Template "X" not found. | No template registered under that key. |
A ZodError | input failed the template's schema. |
Email handler for "X" must return a subject | The handler returned a falsy subject. |
Email handler for "X" must return html | The handler returned falsy html. |
email template 'X' handler needs app context | No ambient scope, and no ctx passed. |
No text or html provided | send got neither body. |
The first five come from renderTemplate, so they reach sendTemplate too.
It renders before it sends, and nothing goes out when one of them fires.
Types
Template keys and input types are inferred from your files, so sendTemplate
needs no annotations. To name a template's input type elsewhere, import the
template and unwrap it.
import type { InferEmailTemplateInput } from "questpie";
import type welcome from "@/questpie/server/emails/welcome";
type WelcomeInput = InferEmailTemplateInput<typeof welcome>;
// { name: string; activationUrl: string }The rest come from questpie/mailer, and from the root questpie export
as well.
| Type | What it is |
|---|---|
EmailTemplateDefinition | The { name, schema, handler } shape. |
EmailResult | { subject, html, text? }. |
EmailHandlerArgs | AppContext & { input, locale? }. |
MailOptions | The argument to send. |
SerializableMailOptions | What every adapter receives. |
MailerConfig | The email block in your runtime config. |
InferEmailTemplateInput | Pulls the input type off a template. |
EmailTemplateNames | The keys of a templates record. |
GetEmailTemplate | Indexes a templates record by key. |
Codegen also emits AppEmailTemplates, the record of every template in the
app. With no emails/ files it is Record<string, never>, which is why
sendTemplate has nothing to autocomplete in a fresh project.
Secret payloads
What to do when a job has to carry a short-lived bearer value your broker should never be able to read, and what QUESTPIE will and will not promise about it.
Adapters
An adapter is the delivery backend. QUESTPIE ships four, each on its own import path, and any class extending MailAdapter can be the fifth.