- Status: done
- Date: 2026-06-12
- Specs touched:
SERVICE_PROVISIONING.md(# BYO outgoing mail — new section, injection-family table, locked decisions),APP_MANIFEST.md(# D3 — new section, locked decisions),SETTINGS.md(panel inventory),DASHBOARD.md(consent dialog),DECISIONS.md2026-06-12,NEXT.md(# Outgoing mail Tier-3 entry; mail passwords folded into # App-secret injection hardening),docs/dev/catalog-import-gaps.md(kimai/giteasmtp-relayflips)
Closes #122, the smtp-relay gap-class the catalog import sprint kept hitting (ghost, kimai, gitea, docuseal). Apps that send email — password resets, reminders, invites — now have a malmo story: the admin registers their own SMTP account(s) in Settings → Outgoing email, each mail-capable app binds to one (install-time picker, rebindable later), and the brain direct-injects the bound provider's credentials as MALMO_MAIL_* env vars. No malmo-run relay or smarthost — residential IPs can't deliver mail, so the app dials the admin's real provider itself over its declared internet permission.
- Two tables:
mail_providers(label UNIQUE, encryption CHECKnone|starttls|tls; password plaintext-at-rest with a comment pointing atNEXT.md# App-secret injection hardening) andinstance_mail_bindings(instance_id PK, FKs to both sides with ON DELETE CASCADE — deleting a provider unbinds its apps, uninstalling an app drops its binding). - CRUD following the house conventions:
isUniqueErr→ErrConflict(duplicate label),RowsAffected == 0→ErrNotFoundon update/delete,ListMailProvidersordered by label.GetInstanceMailProvider(JOIN) returnsErrNotFoundwhen unbound — that absence iswriteEnv's signal to inject nothing.
- New optional top-level
mail:block, a*Mailpointer mirroring*HealthProbe(nil = absent). v1 admits onlyoptional: true: an explicitoptional: false— or a baremail: {}— is rejected at parse, because an app that can't run unbound couldn't be installed on a box with zero registered providers.
Installgains amailProviderIDparam: a guard at the top rejects a mail election on a non-mail manifest before any state is written; step 5d (after service grants, before isolation) persists the binding with rollback on failure — a deleted provider is caught by the FK.writeEnvappendsmailEnvLineswhen bound: the discreteMALMO_MAIL_HOST/_PORT/_USER/_PASSWORD/_FROM/_ENCRYPTIONplus a Symfony-styleMALMO_MAIL_DSN(smtps://for implicit TLS,smtp://otherwise; credentials URL-escaped viaurl.UserPassword).RebindMail(ctx, id, providerID)— providerID""unbinds. Per-instance lock,ErrNoMailSupportbackstop (the API's 422), brain-commits-first: binding row → surgical.envrewrite →compose up -donly if running (env is read at container create; stopped instances pick the change up at next start).rewriteEnvMailre-stamps only theMALMO_MAIL_*lines, leaving every other line byte-identical — a fullwriteEnvwould need install-time isolation state (folder elections aren't persisted) and could never accidentally re-roll a stable secret.
- Provider CRUD at
/api/v1/mail-providers(admin + elevation for writes; audit success and failure per the elevation-class rule; passwords write-only — an empty password on update keeps the stored one).POST /{id}/testsends a real message synchronously vianet/smtp(implicit-TLS dial or STARTTLS, AUTH PLAIN when a username is set, 15s deadline; failure → 502 with the SMTP error). GET /mail-providers/options(id + label, any authenticated user) feeds the rebind picker — members can rebind their own personal apps without admin read access. The install dialog's picker rides the install plan instead (InstallPlanDTO.mailwith the provider options, attached when the manifest declares mail).POST /appsacceptsconfig.mail_provider_id, validated authoritatively (mail-capable manifest + provider exists → 422s audit as failed installs).PUT /apps/{id}/mail-bindingrunsRebindMailas a job behindauthorizeAppMutation(household = admin, personal = owner/admin).GET /apps/{id}enrichesmail_supported+mail_provider_idfor the detail page.- New audit actions:
mail.provider.{create,update,delete,test},app.mail.rebind.
OutgoingEmailSection.vue(Settings → Outgoing email, admin-only nav + redirect): add/edit/delete withwithElevation, per-row inline test-send, delete confirm spells out that bound apps stop sending.InstallDialog.vue: radio picker for mail-capable apps — None default (install with email off), a sole registered provider preselected, admin-only "add an account" link into Settings.InstalledAppDetailSection.vue: "Send email as" select (None + options), runs the rebind job and notes the brief restart.
mail: {optional: true}; compose mapsMAILER_URL: "${MALMO_MAIL_DSN:-null://null}"andMAILER_FROM: "${MALMO_MAIL_FROM:-kimai@example.com}"— bound delivers, unbound keeps Symfony's null transport (upstream's documented "mail off" value) instead of a broken empty DSN. Description softened to point at the Settings flow.
- Store/manifest/lifecycle/API unit + boundary tests: CRUD round-trips and conflict/not-found mapping; parse rejections; install bound (all 7 vars + escaped DSN in
.env) vs unbound (nothing) vs mail-election-on-non-mail-app (no state written) vs missing-provider (FK fires, instance rolled back); rebind on a running app (env re-stamped, non-mail lines byte-identical, compose up called) and unbind (vars stripped); API fences (admin 403s, elevation 403, member rebind of own personal allowed), audit on success and failure for create/update/delete/test/rebind/install, password never echoed, and a test-send delivered end-to-end through an in-process SMTP sink (plus a refused-connection 502). make check+make check-webgreen; OpenAPI + TS types regenerated.- Live-verified in the inner loop against a local SMTP sink: provider add + test-send delivered; Kimai installed bound (container env resolves
MAILER_URLto the provider DSN) and its password-reset mail delivered with the provider's from address; rebind to None recreates the container onto the null-transport compose defaults (a reset request then degrades silently, app stays up) and rebind back restores the DSN; the audit feed recordsmail.provider.create/testand bothapp.mail.rebinds.
- Provider edits/deletes don't re-stamp bound apps. A bound instance keeps the previously injected values until its next rebind or reinstall; running containers keep theirs until the next recreate. Accepted v1 lag, stated in the spec and the Settings UI ("apps pick up changes the next time they restart or rebind"). An explicit "apply now" action is the deferred answer (
NEXT.md# Outgoing mail). - Provider passwords are plaintext at rest in the brain's SQLite and in bound instances'
.env— same status as managed-service credentials, folded intoNEXT.md# App-secret injection hardening (and the only credential in that bucket that unlocks an external account). - Gitea's manifest/compose not updated — the mechanism flip is recorded in the ledger with the exact
GITEA__mailer__*mapping for the revisit (Gitea consumes discrete fields, not a DSN, and needs anENABLEDguard for the unbound case). - No required-mail manifests (
optional: falserejected) and no box-default provider; both are deliberate deferrals (NEXT.md# Outgoing mail).
- Gitea re-import with the
MALMO_MAIL_*→GITEA__mailer__*mapping (ledger entry carries the recipe). - The
NEXT.md# Outgoing mail ladder: box-default provider, brain-sent email riding the same registry (theOUTGOING_MAIL.mdpromotion trigger), re-stamp-on-edit as an explicit action. - At-rest encryption of
mail_providers.passwordwith the rest of the app-secret hardening bucket.