The send_mail counterpart with one twist that makes development and review calm:
every email is always recorded to the mailbox table (and mirrored to the server
console). Real delivery is additive.
The contract
Loading diagram...
- No
SMTP_HOST→ outbox only. Perfect for dev: the reset link, the invite, the notification are all readable in the outbox UI instead of a mail spool. SMTP_HOSTset → additionally delivered for real (a hosted SMTP endpoint, or an internal relay).- Errors never bubble. A failed email must not fail the action that triggered it (assigning a task, resetting a password).
The types
Live from the source — the kind drives icons and filtering in the outbox surfaces:
export type EmailKind =
| "task_assigned"
| "project_invitation"
| "mention"
// Triggered by the Hatchet `sendNotification` job once a document's AI
// analysis is ready (the "fast lane").
| "document_processed"
// Better Auth's password-reset flow (wired in @repo/auth).
| "password_reset";
export interface OutgoingEmail {
kind: EmailKind;
toEmail: string;
toUserId?: string | null;
subject: string;
body: string;
meta?: Record<string, unknown>;
}Adding a kind is a code-only change (the column is a plain-text enum — no migration).
Reading the outbox
listMailbox(limit) is the read side — wire-friendly rows (ISO dates) for whatever
surface the consumer builds. The example renders it twice: a per-project outbox panel
and the global /admin/outbox, both through the same presentational OutboxLog
component. Access control belongs to the caller's server function, not the block.
Configuration
| Variable | Meaning |
|---|---|
SMTP_HOST | enables real delivery |
SMTP_PORT | default 587 |
SMTP_SECURE | "true" = implicit TLS (465); default STARTTLS |
SMTP_USER / SMTP_PASS | optional — internal relays often need none |
MAIL_FROM | sender, e.g. Toolkit <no-reply@example.com> |