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.
| Adapter | Import from | Delivers through |
|---|---|---|
ConsoleAdapter | questpie/adapters/console | Your terminal. Nothing is sent. |
SmtpAdapter | questpie/adapters/smtp | SMTP, via nodemailer. |
ResendAdapter | questpie/adapters/resend | The Resend HTTP API. |
PlunkAdapter | questpie/adapters/plunk | The 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 });| Option | Type | Default | Notes |
|---|---|---|---|
logHtml | boolean | false | When false, the HTML body is replaced by a hint. |
logger | (message: string) => void | console.log | Where 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" })| Option | Type | Notes |
|---|---|---|
transport | SMTPTransport | SMTPTransport.Options | string | Required. 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.
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! });| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | Required. | |
baseUrl | string | "https://api.resend.com" | For Resend-compatible providers. Trailing slashes are stripped. |
idempotencyKey | string | (options) => string | undefined | Sets the Idempotency-Key header. | |
fetch | typeof fetch | global fetch | For tests and unusual runtimes. |
userAgent | string | "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! });| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | Required. Must be a secret key. | |
baseUrl | string | "https://next-api.useplunk.com" | Override for self-hosted Plunk. |
fromName | string | Used only when from is a bare address. | |
subscribed | boolean | Plunk's marketing flag. Leave it unset. | |
fetch | typeof fetch | global fetch | For 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.
Sending
ctx.email is a MailerService with three methods. sendTemplate renders a registered template and sends it, send takes a raw message, and renderTemplate stops at the HTML.
Merging
Every module you list is flattened into one ordered list, then folded left to right into a single app. This is that order, and the rule each key follows when two modules touch it.