Skip to content

Email and runtime services

View as Markdown

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.

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.

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:

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:

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.

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

SAPPORTA_MAIL_TRANSPORT=stream
SAPPORTA_MAIL_FROM=Task App <no-reply@example.com>
Terminal window
pnpm dev
# Trigger the workflow from the browser or its app-owned endpoint.
pnpm exec sapporta api post /api/tasks/1/complete --body '{}'

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 or application configuration.