3.9 KiB
Yucca sends transactional email through Postmark. The first (and so far only) sender is the invite flow in yucca-admin-api; anything that later needs to email a user builds on the same three pieces.
Architecture
| Piece | Where | Role |
|---|---|---|
@common/emails |
packages/emails |
Svelte email templates (better-svelte-email, Tailwind 4) styled after the web UI's @immich/ui theme, prebuilt to plain JS. Exposes render*Email(props) → { subject, htmlBody, textBody }. |
EmailRepository |
@common/server/email |
Postmark HTTP client over global fetch (POST /email, POST /email/batch, ≤500 per batch). Stamps From, the outbound message stream, and an optional Tag. |
| Callers | e.g. AllowlistService |
Render a template, hand the result to EmailRepository, record the outcome. |
Services wire EmailRepository into their providers array like any other repository; templates
are imported as functions. The renderer never runs in the request path of the web app — emails are
rendered inside the NestJS process from the prebuilt @common/emails dist.
Degraded mode: without POSTMARK_SERVER_TOKEN the repository logs and skips every send.
Environments that don't care about email keep working; nothing crashes at boot.
Invite flow
POST /allowlist/invite and POST /allowlist/invite-batch (yucca-admin-api, driven by yuctl)
email every affected entry whose userAllowlist.inviteEmailSentAt is still null, then stamp it on
success. Re-running an invite is therefore a safe retry that only reaches the not-yet-emailed
entries; a rejected address stays null and is retried next time. The email carries the invite code
and links to ${WEB_BASE_URL}/login/invite.
Configuration
| Env var | Meaning | Default |
|---|---|---|
POSTMARK_SERVER_TOKEN |
Postmark server API token (secret) | unset → log-and-skip |
POSTMARK_API_URL |
Postmark endpoint; dev points it at the mock | https://api.postmarkapp.com |
EMAIL_FROM_ADDRESS |
From header |
FUTO Backups <noreply@backups.futo.cloud> |
WEB_BASE_URL |
Base for links in emails (yucca-admin-api) | http://localhost:5173 |
The boundary rule from feature-flags.md applies: these are deployment config
(env/Secret/cluster-settings), not feature flags. In staging/prod the token rides in the
TF-provisioned yucca-admin-api Secret; the from-address and WEB_BASE_URL come from the base
HelmRelease + cluster-settings.
Local dev
Nothing leaves the machine. The app speaks the real Postmark wire protocol to an in-repo mock, which delivers into a Mailpit inbox:
- compose (
mise dev):mock-postmark-provideronlocalhost:8093, Mailpit UI onhttp://localhost:8025. The yucca-admin-api dev env defaults point at the mock. - k3d/Tilt:
yucca-mock-postmark+yucca-mailpit(dev-only HelmReleases underkubernetes/apps/dev/local), same ports via the Tilt port-forwards. - e2e/tests: assert through Mailpit's REST API (
GET /api/v1/messages, search, message body); unit/integration tests mock or overrideEmailRepositoryinstead.
To send real email from a dev machine, set POSTMARK_API_URL + POSTMARK_SERVER_TOKEN in
.env (see .env.example).
Adding a template
- Add
packages/emails/src/emails/<name>.svelte(compose withsrc/lib/layout.svelteand@better-svelte-email/components; Tailwind classes only — the renderer inlines them). - Export a typed
render<Name>Email()frompackages/emails/src/index.ts. - Preview while iterating:
pnpm --filter @common/emails preview. - Call it from a service and pass the result to
EmailRepository.send/sendBatchwith atag.
Email HTML is its own dialect: no CSS variables, no oklch(), table-friendly markup — which is
why src/theme.ts mirrors the @immich/ui palette as literal hex and templates never import web
components directly.