Outgoing mail leaves through one adapter you name in config. Console prints it, SMTP, Resend and Plunk deliver it, and neither your templates nor your send calls move when you swap.
Emails owns the templates and the ctx.email.send and
ctx.email.sendTemplate calls that render them. This page owns the slot
underneath, the provider that carries a message off your server, and how you
point it somewhere else without touching a template.
The default
ConsoleAdapter, and only outside production. Leave email.adapter out and the
app still starts. The first send prints to stdout in development. In production
the same send throws:
QUESTPIE: Email adapter is not configured. Provide adapter in .build({ email: { adapter: ... } })The check sits on the send, not on the boot, so an app that never sends mail needs no email config at all. What the starters write for you is the adapter made explicit:
import { runtimeConfig } from "questpie/app";
import { ConsoleAdapter } from "questpie/adapters/console";
export default runtimeConfig({
email: { adapter: new ConsoleAdapter({ logHtml: false }) },
});ConsoleAdapter logs From, To, CC, BCC, Reply-To, Subject, the plain-text body
and attachment filenames, then returns. No server, no key, no network. It is
what create-questpie picks unless you ask for a different provider.
The console default is not a production default
It prints your mail and returns. Nothing is sent. Development is the only place it runs, and in production the same call throws instead. Set a real adapter before you ship.
The adapters
Four ship with QUESTPIE, each behind its own entry point so you pull in only the
client you actually use. None of them is re-exported from the root questpie
barrel.
| Adapter | Import | Needs | Pick it when |
|---|---|---|---|
ConsoleAdapter | questpie/adapters/console | nothing | Local development and tests. Nothing leaves the process. |
SmtpAdapter | questpie/adapters/smtp | nodemailer, an optional peer | Any SMTP server. Your options reach sendMail untouched. |
ResendAdapter | questpie/adapters/resend | A Resend API key | You want an HTTP API with idempotency keys. |
PlunkAdapter | questpie/adapters/plunk | A Plunk secret key | You already run Plunk, hosted or your own. |
The starters depend on nodemailer directly, so SMTP works out of the box
there. Elsewhere questpie/adapters/smtp imports it at load and fails without
it. Resend and Plunk also export resendAdapter() and plunkAdapter() factories.
What actually changes when you swap
Your send calls are identical across all four. These are not.
| Behavior | Console | SMTP | Resend | Plunk |
|---|---|---|---|---|
| Delivers | no | yes | yes | yes |
| Transport | your logger | nodemailer | HTTPS | HTTPS |
| CC and BCC | logged | yes | yes | throws |
| Attachments | filenames only | yes | base64 | base64 |
| Body | text, HTML opt-in | text and HTML | text and HTML | HTML, else text |
| Failure | never | nodemailer's | non-2xx | non-2xx or success: false |
Plunk posts one body, html || text, so a message carrying both loses its plain
text alternative.
Plunk rejects CC and BCC
PlunkAdapter throws before the request when a message sets either, because
Plunk's transactional API does not document those fields. It also treats a
success: false body as a failure on an HTTP 2xx. Need CC, use SMTP or
Resend.
Configuring
The email key of questpie.config.ts is a MailerConfig. It has three fields.
| Option | Type | Default | Notes |
|---|---|---|---|
adapter | MailAdapter | Promise<MailAdapter> | none, required | A promise is awaited on send, which is how the Ethereal helper plugs in. |
defaults.from | string | "noreply@example.com" | Used when a send call omits from. Set it, or your mail claims example.com. |
templates | Record<string, …> | written by codegen | Codegen fills this from emails/*.ts. You do not list templates by hand. |
Swapping to Resend
One entry changes. Nothing in a template or a send call does.
import { runtimeConfig } from "questpie/app";
import { ConsoleAdapter } from "questpie/adapters/console";
import { ResendAdapter } from "questpie/adapters/resend";
import { env } from "@/lib/env.js";
export default runtimeConfig({
email: {
adapter:
env.MAIL_ADAPTER === "resend"
? new ResendAdapter({
apiKey: requiredEnv(env.RESEND_API_KEY, "RESEND_API_KEY"),
})
: new ConsoleAdapter({ logHtml: false }),
defaults: { from: "QUESTPIE <no-reply@example.com>" },
},
});That branch is what create-questpie scaffolds, requiredEnv included. It
writes that guard into the same file because the env it generates declares
RESEND_API_KEY optional while apiKey is not. It also writes MAIL_ADAPTER,
defaulting to console, plus SMTP_HOST and SMTP_PORT or PLUNK_SECRET_KEY.
QUESTPIE reads none of them, your env module does.
Each adapter takes more than a key. Base URLs for compatible providers,
idempotency keys, a fetch override, an after-send callback. Those live on
Adapter options, with the Ethereal inbox.
The interface
An adapter is one method. Extend MailAdapter from questpie/mailer, implement
send, and wire the instance into the same slot a built-in uses.
abstract class MailAdapter {
abstract send(options: SerializableMailOptions): Promise<void>;
}By the time send runs the mailer has serialized the message. It filled from
from the call, then defaults.from, then "noreply@example.com", derived text
from html when text was missing, and threw if the message carried neither.
type SerializableMailOptions = {
from: string; // always present, defaulted at serialize time
to: string | string[];
cc?: string | string[];
bcc?: string | string[];
subject: string;
text: string; // derived from html when the call omitted it
html: string; // present, may be "" on a text-only send
replyTo?: string;
headers?: Record<string, string>;
attachments?: Array<{
filename: string;
content: Buffer | string;
contentType?: string;
}>;
};So from, text and html always arrive as strings and you never re-derive
them. html is "" on a text-only send, and that is the one thing to guard.
Writing your own
import { MailAdapter } from "questpie/mailer";
import type { SerializableMailOptions } from "questpie/mailer";
export class MyMailAdapter extends MailAdapter {
constructor(private apiKey: string) {
super();
}
async send(options: SerializableMailOptions): Promise<void> {
const res = await fetch("https://mail.example.com/send", {
method: "POST",
headers: { Authorization: `Bearer ${this.apiKey}` },
body: JSON.stringify({
...options,
html: options.html || undefined, // "" on a text-only send
text: options.text || undefined,
}),
});
if (!res.ok) throw new Error(`Mail error ${res.status}`);
}
}Then email: { adapter: new MyMailAdapter(env.MAIL_API_KEY) }, and that is the
whole contract. questpie/mailer exports MailAdapter, MailerConfig,
MailOptions and SerializableMailOptions. Each adapter's own options type
comes from its own entry point.
A throw from `send` is a lost message
Nothing retries for you. The HTTP adapters raise on a provider error and SMTP raises on a transport error, both straight out of your handler. Dispatch sends you cannot lose from a job so the queue retries them.
Related
- Emails, templates,
EmailResult, and the send service this adapter delivers for. - Adapter options, every knob on all four, and the Ethereal test inbox.
- Configuration, where
runtimeConfigputsemail.