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