# Adapters (/docs/code/emails/adapters)

---
title: Adapters
description: An adapter is the delivery backend. QUESTPIE ships four, each on its own import path, and any class extending MailAdapter can be the fifth.
kind: reference
package: questpie
---

| 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.

<Callout type="info" title="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.
</Callout>

## Console

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

```ts
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.

```ts
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](https://ethereal.email)
account and returns an `SmtpAdapter` wired to log a preview URL after each
send. No inbox to configure.

```ts title="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.

```ts
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.

```ts
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.

<Callout type="warn" title="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.
</Callout>

## Your own

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

```ts
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.
