---
title: 'Email'
slug: email
description: 'Send transactional email with Mochi.email() — SMTP, a custom-send escape hatch, or Svelte templates rendered to inlined HTML.'
---
## Email
Send transactional mail — password resets, verification links, notifications — with `Mochi.email()`. Configure a **transport** once under `Mochi.serve({ email })`, then send from anywhere on the server: a page action, an API route, or a queue job.
```ts
import { Mochi } from 'mochi-framework';
await Mochi.serve({
routes: {/* … */},
email: {
from: 'noreply@acme.dev',
transport: { type: 'smtp', host: 'smtp.acme.dev', port: 587, auth: { user, pass } },
},
});
// from an action, API handler, queue job — anywhere server-side:
await Mochi.email({
to: 'alice@example.com',
subject: 'Reset your password',
html: '
Click here to reset.
',
});
```
With **no transport configured**, `Mochi.email()` defaults to the **dev** transport in development (captured to an in-memory viewer, never sent) and the **log** transport in production (logged, never sent). Neither delivers — configure a real transport before relying on delivery.
### The message
Envelope fields, plus the body — an HTML part (`html` or `component`) and a text part (`text`), see below:
```ts
await Mochi.email({
to: 'alice@example.com', // string or string[]
from: 'noreply@acme.dev', // optional — falls back to email.from
cc,
bcc,
replyTo, // optional
subject: 'Welcome',
component: './src/emails/Welcome.svelte', // the body (a Svelte template) — see "The body" below
props: { name: 'Alice' }, // props passed to the component
attachments: [{ filename: 'invoice.pdf', content: bytes }],
headers: { 'X-Entity': 'signup' },
});
```
`Mochi.email()` resolves the body (rendering `component` if given, deriving a plain-text part from HTML), fills `from` from `email.from`, normalizes recipients, sends, and resolves to a `MochiEmailResult` (`{ transport, messageId?, accepted?, rejected? }`).
Any address field (`from`, `to`, `replyTo`, …) accepts a display name via the standard `Name ` form — pass it through as one string and the recipient's client shows the name:
```ts
await Mochi.email({ from: 'Acme ', to: 'Alice ', subject, text });
```
The same applies to the `email.from` default in `Mochi.serve()` — `from: 'Acme '`. A bare address (`noreply@acme.dev`) sends with no display name.
### The body
A message has two independent parts:
- **The HTML part** — either an `html` string **or** a `component` (a Svelte template rendered to inlined HTML). If you pass both, `component` wins.
- **The text part** — the `text` string. Always recommended alongside HTML, but **auto-derived from the HTML when omitted**, so the message stays multipart (better deliverability).
```ts
// HTML + your own plain-text alternative (recommended):
await Mochi.email({ to, subject, html: 'Hi
', text: 'Hi' });
// A Svelte component + your own plain-text alternative:
await Mochi.email({ to, subject, component: './src/emails/Welcome.svelte', props: { name: 'Alice' }, text: 'Welcome, Alice' });
// HTML only — Mochi derives the text part for you:
await Mochi.email({ to, subject, html: 'Hi
' });
// Plain-text only — no HTML part at all:
await Mochi.email({ to, subject, text: 'Hi' });
```
Supplying `text` yourself is recommended: the auto-derived fallback is a naive tag-strip of your HTML, so a hand-written plain-text version usually reads better. The Svelte-component option is covered in [Svelte templates](#svelte-templates) below.
### Transports
Set `email.transport` to one of four shapes. Omit it for the environment default: **dev** in development, **log** in production.
**SMTP** — delivers over SMTP via [nodemailer](https://nodemailer.com/):
```ts
email: {
from: 'noreply@acme.dev',
transport: {
type: 'smtp',
host: 'smtp.acme.dev',
port: 587, // default 465 when secure, else 587
secure: false, // default: port === 465
auth: { user: '…', pass: '…' },
pool: true, // reuse a pooled connection
},
}
```
**Custom** — an escape hatch for any HTTP email API (Resend, SES, Postmark, …) with no SDK. Receives the fully resolved message; no SMTP library is loaded:
```ts
email: {
from: 'noreply@acme.dev',
transport: {
type: 'custom',
send: async (msg) => {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.RESEND_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: msg.from, to: msg.to, subject: msg.subject, html: msg.html }),
});
const { id } = await res.json();
return { messageId: id };
},
},
}
```
**Dev** (default in development) — sends nothing, but captures each message into an in-memory outbox you can browse. Used automatically in dev when `transport` is unset; set it explicitly with `{ type: 'dev' }`:
```ts
email: { from: 'noreply@acme.dev', transport: { type: 'dev' } }
```
**Log** (default in production) — logs a one-line summary and sends nothing. Used automatically in production when `transport` is unset; set it explicitly with `{ type: 'log' }`.
### The dev outbox
The `dev` transport stores every message in the running dev-server process and serves a viewer at **`/_mochi/email`** — a two-pane inbox that renders the exact HTML (in a sandboxed iframe), the plain-text alternative, the raw source, recipients, headers, and attachments. Each attachment is a link — click it to open the captured file inline in a new tab. When the `dev` transport is active, an envelope icon appears in the [debug bar](/docs/debug-bar/) linking straight to it.
The outbox is **in-memory and dev-only**: it holds the most recent 100 messages, is wiped on restart, and the `/_mochi/email` route is not registered in production. It's for previewing mail during development — not a delivery log.
Because delivery should differ between environments, pick the transport dynamically. `NODE_ENV` is the reliable signal in a server entry (the `isDev` virtual is only available inside `.svelte`/compiled code):
```ts
await Mochi.serve({
routes: {/* … */},
email: {
from: 'noreply@acme.dev',
transport: process.env.NODE_ENV === 'production' ? { type: 'smtp', host: 'smtp.acme.dev', port: 587, auth: { user, pass } } : { type: 'dev' }, // captured to /_mochi/email
},
});
```
Leaving `transport` unset gives you exactly this split automatically (`dev` in development, `log` in production).
### Svelte templates
Author an email body as a Svelte component instead of an HTML string. Pass its path as `component` (like `Mochi.page()`) plus `props`. Mochi SSR-renders it through the same pipeline as your pages and **inlines its scoped CSS** into `style=""` attributes (via [css-inline](https://github.com/Stranger6667/css-inline)) for email-client compatibility.
`
Welcome, {name}
```
```ts
await Mochi.email({
to: 'alice@example.com',
subject: 'Welcome to Acme',
component: './src/emails/Welcome.svelte',
props: { name: 'Alice' },
});
```
Email templates **always** render outside the request context — even when sent from within a route action — so request-context APIs (`getRequestContext`, `cookies`, `url`) throw regardless of where the send originates. Pass everything the template needs through `props`.
**No islands in emails.** A `mochi:hydrate*` island or a `mochi:defer*` server island — anywhere in the template, or in anything it imports, even behind a branch that never renders — is a hard error. Email clients run no JavaScript and can't make the follow-up request a deferred island needs; render the content inline instead.
CSS inlining is best-effort, and email clients support only a limited, inconsistent subset of CSS. Rules that can't be inlined (media queries, pseudo-classes) are left in a `