QUESTPIE
Infrastructure

Email

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.

View markdown

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:

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

AdapterImportNeedsPick it when
ConsoleAdapterquestpie/adapters/consolenothingLocal development and tests. Nothing leaves the process.
SmtpAdapterquestpie/adapters/smtpnodemailer, an optional peerAny SMTP server. Your options reach sendMail untouched.
ResendAdapterquestpie/adapters/resendA Resend API keyYou want an HTTP API with idempotency keys.
PlunkAdapterquestpie/adapters/plunkA Plunk secret keyYou 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.

BehaviorConsoleSMTPResendPlunk
Deliversnoyesyesyes
Transportyour loggernodemailerHTTPSHTTPS
CC and BCCloggedyesyesthrows
Attachmentsfilenames onlyyesbase64base64
Bodytext, HTML opt-intext and HTMLtext and HTMLHTML, else text
Failurenevernodemailer'snon-2xxnon-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.

OptionTypeDefaultNotes
adapterMailAdapter | Promise<MailAdapter>none, requiredA promise is awaited on send, which is how the Ethereal helper plugs in.
defaults.fromstring"noreply@example.com"Used when a send call omits from. Set it, or your mail claims example.com.
templatesRecord<string, …>written by codegenCodegen 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.

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

src/questpie/server/lib/my-mail-adapter.ts
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.

  • 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 runtimeConfig puts email.

On this page