QUESTPIE
CodeEmails

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.

View markdown
AdapterImport fromDelivers through
ConsoleAdapterquestpie/adapters/consoleYour terminal. Nothing is sent.
SmtpAdapterquestpie/adapters/smtpSMTP, via nodemailer.
ResendAdapterquestpie/adapters/resendThe Resend HTTP API.
PlunkAdapterquestpie/adapters/plunkThe Plunk HTTP API.

You set one on email.adapter in your runtime config. Your templates and your send calls do not change when you swap it.

Adapters live on their own subpaths

The root questpie export carries the abstract MailAdapter and the mailer types, not the four concrete classes. Import each one from the path in the table above.

Console

Prints the message and returns. Nothing leaves the machine. This is what every starter ships.

import { ConsoleAdapter } from "questpie/adapters/console";

new ConsoleAdapter({ logHtml: false });
OptionTypeDefaultNotes
logHtmlbooleanfalseWhen false, the HTML body is replaced by a hint.
logger(message: string) => voidconsole.logWhere the lines go. Not the app logger.

Both are optional, so new ConsoleAdapter() works. Attachments are listed by filename and content type, never dumped.

SMTP

Sends over SMTP with nodemailer. transport goes straight to nodemailer.createTransport, so it takes an options object, a transport instance, or a connection URL.

import { SmtpAdapter } from "questpie/adapters/smtp";

new SmtpAdapter({
	transport: {
		host: "smtp.example.com",
		port: 587,
		secure: false,
		auth: { user: "", pass: "" },
	},
});
// or: new SmtpAdapter({ transport: "smtp://user:pass@smtp.example.com:587" })
OptionTypeNotes
transportSMTPTransport | SMTPTransport.Options | stringRequired. Passed to createTransport.
afterSendCallback(info) => void | Promise<void>Runs after each send, with nodemailer's info.

The message reaches sendMail whole, so cc, bcc, replyTo, headers and attachments all work. verify() proxies nodemailer's connection check and resolves to a boolean.

Ethereal

createEtherealSmtpAdapter() opens a throwaway Ethereal account and returns an SmtpAdapter wired to log a preview URL after each send. No inbox to configure.

src/questpie/server/questpie.config.ts
import { createEtherealSmtpAdapter } from "questpie/adapters/smtp";
import { runtimeConfig } from "questpie/app";

export default runtimeConfig({
	email: { adapter: createEtherealSmtpAdapter() },
});

It returns a Promise<SmtpAdapter>, which adapter accepts as-is. The mailer awaits it before every send. The account is created when you call the helper, so keep this out of production.

Resend

Posts to POST {baseUrl}/emails with a bearer token. resendAdapter() is a one-line factory around new ResendAdapter(...). Either works.

import { resendAdapter } from "questpie/adapters/resend";

resendAdapter({ apiKey: process.env.RESEND_API_KEY! });
OptionTypeDefaultNotes
apiKeystringRequired.
baseUrlstring"https://api.resend.com"For Resend-compatible providers. Trailing slashes are stripped.
idempotencyKeystring | (options) => string | undefinedSets the Idempotency-Key header.
fetchtypeof fetchglobal fetchFor tests and unusual runtimes.
userAgentstring"questpie-resend-adapter"Resend wants one on direct HTTP calls.
afterSendCallback(response, options) => void | Promise<void>Receives the parsed body, { id? }.

replyTo is sent as reply_to and attachment buffers are base64 encoded. A non-2xx response throws, for example Resend API error (422 Unprocessable Entity): …, carrying the response body.

Plunk

Posts to POST {baseUrl}/v1/send. plunkAdapter() is the matching factory.

import { plunkAdapter } from "questpie/adapters/plunk";

plunkAdapter({ apiKey: process.env.PLUNK_SECRET_KEY! });
OptionTypeDefaultNotes
apiKeystringRequired. Must be a secret key.
baseUrlstring"https://next-api.useplunk.com"Override for self-hosted Plunk.
fromNamestringUsed only when from is a bare address.
subscribedbooleanPlunk's marketing flag. Leave it unset.
fetchtypeof fetchglobal fetchFor tests.
afterSendCallback(response, options) => void | Promise<void>Receives the parsed body.

The body is your html, falling back to text. A from of the form Name <a@b.test> is split into an email and a name. A bare address takes fromName instead.

Plunk refuses cc and bcc

Setting either throws before the request goes out, because Plunk's transactional API does not document them. Plunk also reports failure as success: false inside an HTTP 200, and the adapter treats that as an error too.

Your own

Extend MailAdapter and implement one method. That is the entire contract.

import { MailAdapter } from "questpie/mailer";
import type { SerializableMailOptions } from "questpie/mailer";

export class MyAdapter extends MailAdapter {
	async send(options: SerializableMailOptions): Promise<void> {
		await fetch("https://my-provider.test/send", {
			method: "POST",
			body: JSON.stringify(options),
		});
	}
}

Then set email: { adapter: new MyAdapter() }.

options.from is always a non-empty string by the time you see it. text and html are both typed string, but the one the caller did not supply arrives as "". Check before you forward either.

On this page