🍡 mochi

SSR framework for Svelte 5 + Bun with islands-based selective hydration

On this page

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.

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, or queue job:
await Mochi.email({
  to: 'alice@example.com',
  subject: 'Reset your password',
  html: '<p>Click <a href="…">here</a> to reset.</p>',
});

The message

Envelope fields, plus the body:

await Mochi.email({
  to: 'alice@example.com', // string or string[]
  from: 'noreply@acme.dev', // optional — falls back to email.from
  cc,
  bcc,
  replyTo,
  subject: 'Welcome',
  component: './src/emails/Welcome.svelte', // the body — see "The body"
  props: { name: 'Alice' },
  attachments: [{ filename: 'invoice.pdf', content: bytes }],
  headers: { 'X-Entity': 'signup' },
});

Mochi.email() resolves the body, fills from from email.from, normalizes recipients, sends, and resolves to a MochiEmailResult ({ transport, messageId?, accepted?, rejected? }).

Any address field accepts a display name in the standard Name <addr> form:

await Mochi.email({ from: 'Acme <noreply@acme.dev>', to: 'Alice <alice@example.com>', subject, text });

The body

A message has two parts:

  • The HTML part — 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. Mochi derives it from the HTML when you omit it, so the message stays multipart.
// HTML + your own plain-text alternative (recommended):
await Mochi.email({ to, subject, html: '<h1>Hi</h1>', 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:
await Mochi.email({ to, subject, html: '<h1>Hi</h1>' });

// Plain-text only:
await Mochi.email({ to, subject, text: 'Hi' });

Supply text yourself when you can. The auto-derived fallback strips tags from your HTML, so a hand-written version usually reads better.

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 through nodemailer:

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,
  },
}

Custom — an escape hatch for any HTTP email API (Resend, SES, Postmark) with no SDK. Receives the resolved message. No SMTP library is loaded:

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) — captures each message into an in-memory outbox you can browse. Set it explicitly with { type: 'dev' }.

Log (default in production) — logs a one-line summary. Set it explicitly with { type: 'log' }.

The dev outbox

The dev transport stores each message in the dev-server process and serves a viewer at /_mochi/email. The viewer renders the HTML in a sandboxed iframe, plus the plain-text part, the raw source, recipients, headers, and attachments. When the dev transport is active, an envelope icon in the debug bar links to it.

The dev outbox: a list of four captured messages on the left, and on the right the selected message's from, to and date, an attachment chip, Preview / Text / Source tabs, and the rendered email body
The outbox after sending four messages. The Preview / Text / Source tabs switch between the rendered HTML, the plain-text alternative, and the raw source.

Leaving transport unset gives you the dev/log split automatically. To pick the transport by hand, branch on NODE_ENV (the isDev constant is only available inside compiled code, not a server entry):

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' },
  },
});

Svelte templates

Author a body as a Svelte component. Pass its path as component (like Mochi.page()) plus props. Mochi renders it and inlines its scoped CSS into style="" attributes (via css-inline) for email-client compatibility.

Keep templates in src/emails/. mochi-framework build walks that directory and compiles every .svelte under it into the manifest, so production sends need neither the compiler nor your Svelte sources.

<!-- ./src/emails/Welcome.svelte -->
<script lang="ts">
  let { name }: { name: string } = $props();
</script>

<div class="card"><h1>Welcome, {name}</h1></div>

<style>
  .card {
    padding: 24px;
  }
  h1 {
    color: #6b46c1;
  }
</style>
await Mochi.email({
  to: 'alice@example.com',
  subject: 'Welcome to Acme',
  component: './src/emails/Welcome.svelte',
  props: { name: 'Alice' },
});

Sending in the background

Delivery is slow and can fail. Offload the send to a Mochi.queue() so the action returns immediately and a background worker runs Mochi.email() with retries.

// jobs.server.ts
import { Mochi } from 'mochi-framework';

export const emailQueue = Mochi.queue<{ to: string; name: string }>('emails', {
  concurrency: 5,
  retryLimit: 2,
  process: async (job) => {
    await Mochi.email({
      to: job.data.to,
      subject: 'Welcome to Acme',
      component: './src/emails/Welcome.svelte',
      props: { name: job.data.name },
    });
  },
});

Intercepting messages

The email:message filter runs on the fully-resolved message right before the transport. It can rewrite the message (audit BCC, List-Unsubscribe headers, a staging catch-all) or return null to suppress the send. Prefer it over a custom transport when you only need to touch the message.

await Mochi.serve({
  filters: {
    'email:message': (message) => ({ ...message, bcc: [...(message.bcc ?? []), 'audit@app.dev'] }),
  },
  routes,
});

Observability

Each send emits email:sent ({ to, subject, transport, messageId?, duration }). Failures emit email:error ({ to, subject, transport, error }). A send vetoed by the email:message filter emits email:sent with transport: 'suppressed'. consoleLogger() formats them as MAIL lines. Successful deliveries log at info. The two “did not deliver” cases — the log transport and any email:error — log at warn.

Keeping recipients and subjects out of logs

MAIL lines print recipients and the subject. Both are PII, so email.filterPii defaults to true in production (redacted) and false in development. Set it explicitly to override:

await Mochi.serve({
  email: {
    from: 'noreply@acme.dev',
    transport: { type: 'smtp', host: 'smtp.acme.dev', port: 587, auth: { user, pass } },
    filterPii: true, // redact recipients + subject from MAIL log lines
  },
  routes,
});

The transport, error, and duration are still logged, so the lines stay useful. filterPii affects consoleLogger() output only. The email:sent / email:error events still carry the real to and subject, so your own mochiEvents subscribers get the full values.