---
title: "Email and runtime services"
description: "Inject configured runtime services into app-owned routes and workflows."
canonical: "https://sapporta.com/docs/guides/operations/email-and-runtime-services/"
---

> Documentation index: https://sapporta.com/llms.txt

Generated Sapporta projects construct long-lived runtime services during boot
and pass them into `loadApp()`. The mailer is the built-in example: Nodemailer
configuration stays at the process boundary while routes receive a small typed
service.

This guide shows how to send development email, inject the mailer into a domain
workflow, and switch to SMTP in production. The same pattern works for queues,
object storage, or external API clients that need explicit construction and
cleanup.

```text
Add an email to <workflow> using the generated Sapporta mailer. Keep transport
configuration in boot, inject the service into domain code, and show the full
message with stream transport before configuring production delivery.
```

## Keep construction at boot

`readProjectAuthEnv()` parses the server environment. `createSapportaMailer()`
constructs one Nodemailer transport, default sender, and `sendMail()` helper.
`boot.ts` passes that service into the application entry point:

```ts
const projectEnv = readProjectAuthEnv();
const mailer = createSapportaMailer(projectEnv.mail);

const apiApp = new TsRestApi<SapportaEnv>();
loadApp(apiApp, { conn, mailer });
```

The route or domain service accepts the capability it uses instead of importing
environment parsing or the boot module:

```ts
import type { SapportaMailer } from "../../mailer.js";

export interface CompletionNoticeInput {
  recipient: string;
  taskTitle: string;
}

export async function sendCompletionNotice(
  mailer: Pick<SapportaMailer, "sendMail">,
  input: CompletionNoticeInput,
) {
  await mailer.sendMail({
    to: input.recipient,
    subject: `Completed: ${input.taskTitle}`,
    text: `The task "${input.taskTitle}" is complete.`,
  });
}
```

Call the mail service after the database transaction commits. Nodemailer is an
external effect and should not run inside the synchronous SQLite transaction
callback. If delivery must be durable, insert an outbox record in the
transaction and process it separately.

## Inspect mail locally

Stream transport does not deliver the message. It writes the complete generated
email to the API console, including sender, recipient, subject, text, and HTML.

```ini
SAPPORTA_MAIL_TRANSPORT=stream
SAPPORTA_MAIL_FROM=Task App <no-reply@example.com>
```

```bash
pnpm dev
# Trigger the workflow from the browser or its app-owned endpoint.
pnpm exec sapporta api post /api/tasks/1/complete --body '{}'
```

<!--
Screenshot brief
Suggested asset: /assets/guides/operations/stream-email.png
Setup: Configure stream transport, start the API, and trigger one task-completion notification to a test address.
Frame: Capture the API console from the generated-email marker through the text body. Redact real addresses and secrets.
Visible proof: The default sender, intended recipient, subject, and completion message are visible without an external delivery.
Alt text: Sapporta development console showing a task-completion email generated by stream transport.
-->

Production uses `SAPPORTA_MAIL_TRANSPORT=smtp` with either `SMTP_URL` or the
individual `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, and `SMTP_PASS`
values. `disabled` is an explicit production choice when the app should skip
delivery.

The workflow now depends on a typed mail capability rather than process-global
configuration. The non-obvious boundary is transaction timing: commit domain
state before sending, or use an outbox when state and eventual delivery must be
coordinated. Continue with
[custom endpoints](/docs/guides/app-owned-features/custom-api-endpoints.md) or
[application configuration](/docs/guides/operations/application-configuration.md).

## Related reference

- [Runtime services](/docs/reference/server/runtime-services.md)
- [Environment variables](/docs/reference/project/environment-variables.md)
