QUESTPIE
Adapters

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 the ctx.email send 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.adapter at Console, SMTP, Resend, or Plunk; nothing in your templates or send calls changes.
  • One contract, four implementations. Every adapter extends MailAdapter and 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. ConsoleAdapter logs the rendered email to your logger instead of sending it, no SMTP server, no API key.
  • Production-ready HTTP and SMTP. ResendAdapter and PlunkAdapter POST to their providers' HTTP APIs; SmtpAdapter wraps nodemailer for any SMTP server.
  • Bring your own. Implement MailAdapter.send and 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:

src/questpie/server/questpie.config.ts
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
OptionTypeDefaultNotes
logHtmlbooleanfalseWhen false, the HTML body is replaced with a one-line hint instead of printed (HTML is verbose).
logger(message: string) => voidconsole.logWhere 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" });
OptionTypeNotes
transportSMTPTransport | SMTPTransport.Options | stringPassed 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 });
OptionTypeDefaultNotes
apiKeystringnone (required)Your Resend API key (or a key for a Resend-compatible provider).
baseUrlstring"https://api.resend.com"Point at a Resend-compatible API. Trailing slashes are stripped.
userAgentstring"questpie-resend-adapter"Resend requires a User-Agent for direct HTTP calls.
idempotencyKeystring | ((options) => string | undefined)noneStatic or per-email. When present, sets the Idempotency-Key header.
fetchtypeof fetchglobal fetchOverride for tests or non-standard runtimes.
afterSendCallback(response: ResendSendResponse, options) => void | Promise<void>noneRuns 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 });
OptionTypeDefaultNotes
apiKeystringnone (required)A Plunk secret key, public keys only track events.
baseUrlstring"https://next-api.useplunk.com"Override for self-hosted Plunk.
fromNamestringnoneDisplay name, applied only when from is a bare email (no "Name <email>" form).
subscribedbooleannoneSubscribe recipients to marketing in Plunk. Leave unset for transactional mail.
fetchtypeof fetchglobal fetchOverride for tests or non-standard runtimes.
afterSendCallback(response: PlunkSendResponse, options) => void | Promise<void>noneRuns 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:

EnvironmentUseWhy
Local developmentConsoleAdapterNo external service; the rendered email lands in your logs.
Production (generic SMTP)SmtpAdapterWorks 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 inboxcreateEtherealSmtpAdapter()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.from is the only fallback sender. If a send call omits from and you set no defaults.from, mail goes out as noreply@example.com. Set defaults.from in config.
  • adapter may be a Promise. MailerConfig.adapter accepts MailAdapter | Promise<MailAdapter>; the promise is awaited lazily on the first send (how createEtherealSmtpAdapter() 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 or success: 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:

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}`,
        "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:

src/questpie/server/questpie.config.ts
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";
  • Emails, templates, EmailResult, and the ctx.email.send / sendTemplate service 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.

On this page