Email adapter
The email adapter is the delivery backend for outgoing mail, set one in config and QUESTPIE sends every template and one-off message through it. Swap SMTP, Resend, Plunk, or Console without touching a single template.
The email adapter is the one piece of config that decides how your mail is delivered. QUESTPIE ships four, ConsoleAdapter, SmtpAdapter, ResendAdapter, PlunkAdapter, and you pick exactly one. Your templates and your ctx.email.send(...) / ctx.email.sendTemplate(...) calls never change when you swap providers; only the adapter in runtimeConfig does.
Prerequisites: read Emails first, it owns templates,
EmailResult, and thectx.emailsend service. This page owns the adapter slot: which provider delivers the mail, and how to configure each one.
What it does
- Swap providers in one line. Point
email.adapterat Console, SMTP, Resend, or Plunk; nothing in your templates or send calls changes. - One contract, four implementations. Every adapter extends
MailAdapterand implements a single method,send(options). The framework's own four use that exact base class, there is no privileged internal API. - Console for local dev.
ConsoleAdapterlogs the rendered email to your logger instead of sending it, no SMTP server, no API key. - Production-ready HTTP and SMTP.
ResendAdapterandPlunkAdapterPOST to their providers' HTTP APIs;SmtpAdapterwraps nodemailer for any SMTP server. - Bring your own. Implement
MailAdapter.sendand wire your class in, the same way the built-in adapters do.
Quick start
Wire one adapter into your runtime config. For local development, log mail to the console instead of sending it:
import { runtimeConfig } from "questpie/app";
import { ConsoleAdapter } from "questpie/adapters/console";
import { SmtpAdapter } from "questpie/adapters/smtp";
import { env } from "@/lib/env.js";
export default runtimeConfig({
app: { url: env.APP_URL },
db: { url: env.DATABASE_URL },
email: {
// The provider that delivers mail. Pick exactly one.
adapter:
env.NODE_ENV === "production"
? new SmtpAdapter({ transport: env.SMTP_URL })
: new ConsoleAdapter(),
defaults: { from: "no-reply@example.com" }, // fallback From for every message
},
});That email block is a MailerConfig. adapter is the only field you must set; defaults.from fills in the From address when a send call omits one. (Templates are populated by codegen from emails/*.ts, see Emails; you don't list them here.)
You must set an adapter, even in dev
The core email service throws at app build if email.adapter is missing: "QUESTPIE: 'email.adapter' is required. Provide adapter in .build({ email: { adapter: ... } })". The mailer does have a ConsoleAdapter fallback for the no-adapter case, but the core service never reaches it, it constructs MailerService only after asserting your adapter exists. So always set adapter; use new ConsoleAdapter() explicitly for local development rather than relying on a fallback.
The adapter contract
Every adapter extends one tiny abstract class:
abstract class MailAdapter {
abstract send(options: SerializableMailOptions): Promise<void>;
}By the time send is called, the mailer has already serialized the message: it filled from (from the call, then defaults.from, then "noreply@example.com"), derived plain text from html when text was missing, and guaranteed at least one body exists. So from, text, and html always arrive as present strings (never undefined), you never re-derive them. Note the nuance: from is always non-empty (it's defaulted), and at least one of text/html is non-empty, but the other may be the empty string "". A plain-text-only send (text set, html omitted) reaches your adapter with html: "", guard for that if your provider rejects an empty HTML body.
type SerializableMailOptions = {
from: string; // present + non-empty (defaulted at serialize)
to: string | string[];
cc?: string | string[];
bcc?: string | string[];
subject: string;
text: string; // present (derived from html if absent); may be "" if html was provided
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 }>;
};The concrete adapters are not re-exported from the root questpie barrel, only the abstract MailAdapter base and the mailer types are (via questpie/mailer). Import each adapter from its own subpath: questpie/adapters/console, questpie/adapters/smtp, questpie/adapters/resend, questpie/adapters/plunk.
Console, questpie/adapters/console
Logs the rendered email to your logger instead of sending it. The default dev choice: no SMTP server, no API key, no network.
import { ConsoleAdapter } from "questpie/adapters/console";
new ConsoleAdapter(); // both options optional
new ConsoleAdapter({ logHtml: true }); // also print the HTML body| Option | Type | Default | Notes |
|---|---|---|---|
logHtml | boolean | false | When false, the HTML body is replaced with a one-line hint instead of printed (HTML is verbose). |
logger | (message: string) => void | console.log | Where each line goes. |
It logs From / To / CC / BCC / Reply-To / Subject, the plain-text body, attachment filenames, and (only when logHtml: true) the HTML. It never sends.
SMTP, questpie/adapters/smtp
Sends through any SMTP server via nodemailer. The general-purpose production choice.
import { SmtpAdapter } from "questpie/adapters/smtp";
new SmtpAdapter({
transport: {
host: "smtp.example.com",
port: 587,
secure: false,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
},
});
// or pass a connection string:
new SmtpAdapter({ transport: "smtp://user:pass@smtp.example.com:587" });| Option | Type | Notes |
|---|---|---|
transport | SMTPTransport | SMTPTransport.Options | string | Passed straight to nodemailer.createTransport, an options object, an SMTPTransport instance, or a connection-string URL. |
afterSendCallback | (info: SentMessageInfo) => void | Promise<void> | Runs after each send. Handy for logging provider message IDs or preview URLs. |
SmtpAdapter also exposes verify(): Promise<boolean>, which proxies nodemailer's SMTP connection check.
Ethereal test inbox
createEtherealSmtpAdapter() (from questpie/adapters/smtp) creates a throwaway Ethereal test account and returns an SmtpAdapter pre-wired to log a preview URL after each send, no real inbox needed:
import { createEtherealSmtpAdapter } from "questpie/adapters/smtp";
export default runtimeConfig({
// ...
email: { adapter: createEtherealSmtpAdapter() }, // returns Promise<SmtpAdapter>
});It returns a Promise<SmtpAdapter>, which MailerConfig.adapter accepts directly (the mailer awaits the promise lazily on first send). Dev/testing only, it makes a network call to create the account.
Resend, questpie/adapters/resend
Sends through the Resend HTTP API (or any Resend-compatible provider). Prefer the resendAdapter() factory over new ResendAdapter(...).
import { resendAdapter } from "questpie/adapters/resend";
resendAdapter({ apiKey: env.RESEND_API_KEY });| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | none (required) | Your Resend API key (or a key for a Resend-compatible provider). |
baseUrl | string | "https://api.resend.com" | Point at a Resend-compatible API. Trailing slashes are stripped. |
userAgent | string | "questpie-resend-adapter" | Resend requires a User-Agent for direct HTTP calls. |
idempotencyKey | string | ((options) => string | undefined) | none | Static or per-email. When present, sets the Idempotency-Key header. |
fetch | typeof fetch | global fetch | Override for tests or non-standard runtimes. |
afterSendCallback | (response: ResendSendResponse, options) => void | Promise<void> | none | Runs with the parsed JSON body (e.g. the message id) after Resend accepts the email. |
Buffer attachments are base64-encoded automatically. On a non-2xx response it throws Resend API error (<status> <statusText>): <body>.
Plunk, questpie/adapters/plunk
Sends through Plunk's transactional email API (or a self-hosted Plunk). Prefer the plunkAdapter() factory.
import { plunkAdapter } from "questpie/adapters/plunk";
plunkAdapter({ apiKey: env.PLUNK_SECRET_KEY });| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | none (required) | A Plunk secret key, public keys only track events. |
baseUrl | string | "https://next-api.useplunk.com" | Override for self-hosted Plunk. |
fromName | string | none | Display name, applied only when from is a bare email (no "Name <email>" form). |
subscribed | boolean | none | Subscribe recipients to marketing in Plunk. Leave unset for transactional mail. |
fetch | typeof fetch | global fetch | Override for tests or non-standard runtimes. |
afterSendCallback | (response: PlunkSendResponse, options) => void | Promise<void> | none | Runs with the parsed body after Plunk accepts the email. |
Plunk rejects CC and BCC
PlunkAdapter throws if a message sets cc or bcc, Plunk's transactional API doesn't document those fields. It also treats a success: false body as an error even on an HTTP 2xx response. If you need CC/BCC, use SMTP or Resend.
Choosing an adapter
One recommended path, not a menu:
| Environment | Use | Why |
|---|---|---|
| Local development | ConsoleAdapter | No external service; the rendered email lands in your logs. |
| Production (generic SMTP) | SmtpAdapter | Works with any SMTP provider; full message support (CC/BCC/attachments). |
| Production (Resend) | resendAdapter() | First-class HTTP API with idempotency keys. |
| Production (Plunk) | plunkAdapter() | Plunk's transactional API (no CC/BCC). |
| Automated tests with a real inbox | createEtherealSmtpAdapter() | Throwaway account + a preview URL per send. |
A common setup branches on NODE_ENV (see the Quick start) so dev logs to the console and production sends for real.
Gotchas
- The adapter is required even in dev, the core email service asserts it at app build. See the Quick-start callout.
defaults.fromis the only fallback sender. If a send call omitsfromand you set nodefaults.from, mail goes out asnoreply@example.com. Setdefaults.fromin config.adaptermay be aPromise.MailerConfig.adapteracceptsMailAdapter | Promise<MailAdapter>; the promise is awaited lazily on the first send (howcreateEtherealSmtpAdapter()plugs in).- HTTP adapters surface provider errors as thrown
Errors, wrap sends you can't afford to lose, or dispatch them from a job so failures retry. Resend throws on non-2xx; Plunk throws on non-2xx orsuccess: false. - Plunk drops CC/BCC, it throws rather than silently dropping them. See the Plunk callout.
Custom adapter
The adapter is an extension point: any class extending MailAdapter works, and the framework's own four use that exact base class, there's no internal-only API. Implement the single send(options: SerializableMailOptions) method. Because the mailer serializes before handing off, from, text, and html are already present strings, so you don't re-derive plain text or default the sender. Just remember that one of text/html may be "" (a text-only send forwards html: ""), if your provider rejects an empty field, omit it instead of passing the empty string:
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}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: options.from, // already defaulted (non-empty)
to: options.to,
subject: options.subject,
text: options.text, // present; may be "" if only html was sent
html: options.html, // present; may be "" on a text-only send
}),
});
if (!res.ok) {
throw new Error(`Mail provider error (${res.status}): ${await res.text()}`);
}
}
}Then set it in config, same slot as any built-in adapter:
import { MyMailAdapter } from "@/questpie/server/lib/my-mail-adapter.js";
export default runtimeConfig({
// ...
email: { adapter: new MyMailAdapter(env.MAIL_API_KEY) },
});That's the whole contract.
TypeScript
Import the adapter base class and the contract type from questpie/mailer; import each concrete adapter and its options from its own subpath:
import { MailAdapter } from "questpie/mailer";
import type { SerializableMailOptions, MailerConfig } from "questpie/mailer";
import { ConsoleAdapter, type ConsoleAdapterOptions } from "questpie/adapters/console";
import { SmtpAdapter, type SmtpAdapterOptions } from "questpie/adapters/smtp";
import { resendAdapter, type ResendAdapterOptions } from "questpie/adapters/resend";
import { plunkAdapter, type PlunkAdapterOptions } from "questpie/adapters/plunk";Related
- Emails, templates,
EmailResult, and thectx.email.send/sendTemplateservice this adapter delivers for. - Configuration, where
runtimeConfig({ email })lives and how adapters are wired. - Environment, typed
env()for API keys and SMTP credentials. - Jobs, dispatch sends from a job so provider failures retry.
Storage adapter
Point your file uploads at the local disk, S3, R2, or any of 40+ Files SDK backends with one config block, and get a typed Files instance on app.storage, automatic signed URLs for private files, and direct-to-storage uploads, all without touching your collections.
KV adapter
A typed key-value store on your app context, ctx.kv.get/set/delete/has/clear with TTL and tag-based invalidation. In-memory by default; swap to Redis or Cloudflare KV with one config line and no app-code change.