# Transactional email

Import the optional email port from `@aotter/mantle/email`. Mantle supplies
`EmailSender`, `EmailSendArgs`, English `signInCodeEmail`, `signInLinkEmail`
and `adminInvitationEmail` templates, and `ConsoleEmailSender` for local
development. Applications select and configure their sender and Better Auth
plugins.

`send({ to, subject, text, html?, locale, category? })` returns `Promise<void>`.
The templates return `{ subject, text }`, with the same English text as 0.2;
they do not translate or consume `locale`. Pass the recipient's locale through
to the sender, which can select localized templates. Existing categories are
`auth.email-otp.<type>`, `auth.magic-link.sign-in` and `auth.admin-invitation`.

## Application wiring

```ts
import { emailOTP } from "better-auth/plugins";
import { hashEmailOtp } from "@aotter/mantle/auth/hardening";
import { signInCodeEmail, adminInvitationEmail, type EmailSender } from "@aotter/mantle/email";

const locale = (request?: Request) =>
  request?.headers.get("accept-language")?.split(",")[0]?.split(";")[0]?.trim() || "en";

// Include this plugin in the app's Better Auth options.
export const emailPlugin = (sender: EmailSender, secret: string) => emailOTP({
  storeOTP: { hash: otp => hashEmailOtp(secret, otp) },
  sendVerificationOTP: ({ email, otp, type }, ctx) => sender.send({
    to: email, ...signInCodeEmail(otp), locale: locale(ctx?.request),
    category: `auth.email-otp.${type}`,
  }),
});

// Use the configured, trusted application origin, never an incoming Host or
// Origin header. The application grants the OSS "admin" role before sending.
export const inviteEmail = (sender: EmailSender, origin: string, to: string, language: string) =>
  sender.send({
    to, ...adminInvitationEmail("admin", new URL("/admin/sign-in", origin).href),
    locale: language, category: "auth.admin-invitation",
  });
```

For magic links, pass Better Auth's generated URL to `signInLinkEmail(url)`;
configure Better Auth's `baseURL` and `trustedOrigins` to prevent untrusted
callback destinations. Templates preserve their input as plain text and never
interpret it as HTML. If a sender supplies `html`, it owns escaping untrusted
text and attributes and validating link destinations. Pass structured fields
to the provider; do not interpolate them into raw MIME headers.

Use `new ConsoleEmailSender()` only in explicitly selected local development.
It logs recipients, codes and links and sends nothing; never select it in
production or log live messages in a production sender. Provider failures
reject the promise; the application owns error handling and retry policy.

**Never let the response wait for the send.** Better Auth calls
`sendVerificationOTP` for `email-verification` and `forget-password` only when
the user exists, so a response that waits for the provider reveals whether an
account exists. Configure Better Auth's `advanced.backgroundTasks.handler` (on
Workers, `ctx.waitUntil`) so the send runs after the response, as 0.2 did; the
app-owned auth template wires it.

## Cloudflare Email Service binding

Use a verified sender and a Cloudflare Email Service `send_email` binding.
The binding composes the message natively; no mail SDK or MIME builder is needed.

```jsonc
{ "send_email": [{ "name": "EMAIL" }] }
```

```ts
import type { SendEmail } from "@cloudflare/workers-types";
import type { EmailSender } from "@aotter/mantle/email";

interface Env { EMAIL: SendEmail; EMAIL_FROM: string }
export const emailSender = (env: Env): EmailSender => ({
  async send({ to, subject, text, html }) {
    await env.EMAIL.send({ from: env.EMAIL_FROM, to, subject, text, html });
  },
});
```

The English example ignores `locale` and `category`; a localized provider may
use both. The binding's verified sender and recipient restrictions still apply.
This example does not provision a sender, enable an auth method or send mail
without application wiring.
