Size: M · Area: backend · Depends on: none
Generalize BYO-mail injection so an app whose mail config is a boot-validated enum (rather than a boolean or a free string) can be auto-wired. Today the brain injects malmo-canonical MALMO_MAIL_* values and relies on the compose file to adapt them, but compose environment: interpolation can only substitute / default (:-) / test-presence (:+) — it cannot remap one enum's tokens onto another's. Two shipped apps are degraded purely by this, and it recurs for any enum-mail app (gap-class mail-enable-enum).
Spec / source of truth: docs/specs/SERVICE_PROVISIONING.md # BYO outgoing mail (MALMO_MAIL_*); docs/specs/APP_MANIFEST.md # D3 (Outgoing mail). Read both end-to-end first.
The two failures this retires
- twenty — enable-driver enum.
EMAIL_DRIVER ∈ {logger, smtp}, boot-rejects empty string. The established gate ${MALMO_MAIL_HOST:+smtp} expands to "" on an unbound box → container exits 1 (EMAIL_DRIVER must be one of the following values / Config variables validation failed). Twenty ships degraded, mail configured manually in-app.
- vaultwarden — security enum.
SMTP_SECURITY ∈ {off, starttls, force_tls}; the brain injects MALMO_MAIL_ENCRYPTION ∈ {none, starttls, tls} plus the USE_TLS/USE_SSL booleans. none→off, tls→force_tls is a value remap → impossible in compose, so the mail: block is left unwired and email features (email 2FA, new-device alerts, invites) stay off. (Store row: apps/vaultwarden/status.yml, limitation outbound-mail-enum-mismatch.)
Root cause is shared: compose interpolation is the wrong layer for value mapping. internal/lifecycle/mail.go already concedes this — it hand-computes MALMO_MAIL_USE_TLS/USE_SSL/DSN in Go because "compose can't derive these from the string." This is the same problem one step further; the fix is to generalize that projection, not add a new layer.
The fix
Let the manifest declare, per app-owned mail env var, the token for each malmo state, and have the brain stamp the already-resolved value directly into the app's .env under the app's own name — the same direct-stamp convention config: fields already use (no MALMO_* indirection):
mail:
optional: true
env:
SMTP_SECURITY: # vaultwarden
from: encryption # provider field: none|starttls|tls
map: { none: "off", starttls: "starttls", tls: "force_tls" }
EMAIL_DRIVER: # twenty
from: bound # synthetic: bound|unbound
map: { bound: "smtp", unbound: "logger" }
Two coupled changes make this work, and the second also fixes twenty's actual boot bug:
- Manifest-declared value map, resolved by the brain — closes both the security-enum and enable-enum facets with one mechanism; the existing
USE_TLS/USE_SSL/DSN projections become special cases of it.
- Always stamp mail-declaring apps, even when unbound — emit the
unbound tokens (e.g. EMAIL_DRIVER=logger, SMTP_SECURITY=off) instead of stripping every MALMO_MAIL_* line. This is what makes the enable-enum present-and-valid on an unbound box, killing the empty-string boot-reject that today's :+ gate causes.
Rejected alternatives: (a) inject more spellings from the brain (MALMO_MAIL_SECURITY_VW…) — app-specific tokens don't belong in the brain and it doesn't scale; (b) a pure-compose trick — genuinely impossible, no ternary/remap in interpolation.
Do:
internal/manifest/manifest.go — extend the Mail struct (currently {Optional bool}) with Env map[string]MailEnvMap, where each entry has From (encryption | bound, extensible) and Map (token → token). Add validateMail rules: map keys must cover the From domain ({none,starttls,tls} or {bound,unbound}); reject unknown From.
docs/specs/APP_MANIFEST.md # D3 and docs/specs/SERVICE_PROVISIONING.md # BYO outgoing mail — document the mail.env map and the always-stamp-when-unbound behavior.
internal/lifecycle/mail.go — mailEnvLines (and rewriteEnvMail) take the manifest; after the MALMO_MAIL_* lines, emit each declared app var with its resolved token. Stamp mail-declaring apps in both bound and unbound states (unbound uses the unbound/none→mapped tokens); today rewriteEnvMail appends nothing when unbound — change it to still emit the declared vars.
internal/lifecycle/lifecycle_mail_test.go — cover: bound resolves tls→force_tls and bound→smtp; unbound resolves none→off and unbound→logger and still writes the vars; rebind bound→unbound re-stamps correctly.
- Manifests: add the
mail.env map to catalog/twenty (auto-wire EMAIL_DRIVER) and, in the store repo, apps/vaultwarden (SMTP_SECURITY) — then flip vaultwarden's outbound-mail-enum-mismatch limitation and twenty's row toward full after a live send check.
docs/dev/catalog-import-gaps.md — move mail-enable-enum — twenty to Status: implemented, and add the gap-class to docs/dev/capabilities.yml (since: = brain version, ref: = this PR) so the waiting apps mechanically re-screen (docs/specs/CAPABILITIES.md).
Touch: internal/manifest/manifest.go, internal/lifecycle/mail.go, internal/lifecycle/lifecycle_mail_test.go, catalog/twenty/manifest.yml, docs/specs/APP_MANIFEST.md, docs/specs/SERVICE_PROVISIONING.md, docs/dev/catalog-import-gaps.md, docs/dev/capabilities.yml. (The store repo apps/vaultwarden/{manifest.yml,status.yml} is a coordinated follow-up, not in this repo.)
Done when: a mail-declaring app with a mail.env map boots valid unbound (declared enum vars present with their unbound tokens, no boot-reject) and, once a provider is bound, sends with the app's enum var carrying the mapped token — verified live for both twenty (EMAIL_DRIVER) and vaultwarden (SMTP_SECURITY); mail-enable-enum marked implemented in the ledger + capabilities.yml.
Size: M · Area: backend · Depends on: none
Generalize BYO-mail injection so an app whose mail config is a boot-validated enum (rather than a boolean or a free string) can be auto-wired. Today the brain injects malmo-canonical
MALMO_MAIL_*values and relies on the compose file to adapt them, but composeenvironment:interpolation can only substitute / default (:-) / test-presence (:+) — it cannot remap one enum's tokens onto another's. Two shipped apps are degraded purely by this, and it recurs for any enum-mail app (gap-classmail-enable-enum).Spec / source of truth:
docs/specs/SERVICE_PROVISIONING.md# BYO outgoing mail (MALMO_MAIL_*);docs/specs/APP_MANIFEST.md# D3 (Outgoing mail). Read both end-to-end first.The two failures this retires
EMAIL_DRIVER ∈ {logger, smtp}, boot-rejects empty string. The established gate${MALMO_MAIL_HOST:+smtp}expands to""on an unbound box → container exits 1 (EMAIL_DRIVER must be one of the following values/Config variables validation failed). Twenty shipsdegraded, mail configured manually in-app.SMTP_SECURITY ∈ {off, starttls, force_tls}; the brain injectsMALMO_MAIL_ENCRYPTION ∈ {none, starttls, tls}plus theUSE_TLS/USE_SSLbooleans.none→off,tls→force_tlsis a value remap → impossible in compose, so themail:block is left unwired and email features (email 2FA, new-device alerts, invites) stay off. (Store row:apps/vaultwarden/status.yml, limitationoutbound-mail-enum-mismatch.)Root cause is shared: compose interpolation is the wrong layer for value mapping.
internal/lifecycle/mail.goalready concedes this — it hand-computesMALMO_MAIL_USE_TLS/USE_SSL/DSNin Go because "compose can't derive these from the string." This is the same problem one step further; the fix is to generalize that projection, not add a new layer.The fix
Let the manifest declare, per app-owned mail env var, the token for each malmo state, and have the brain stamp the already-resolved value directly into the app's
.envunder the app's own name — the same direct-stamp conventionconfig:fields already use (noMALMO_*indirection):Two coupled changes make this work, and the second also fixes twenty's actual boot bug:
USE_TLS/USE_SSL/DSNprojections become special cases of it.unboundtokens (e.g.EMAIL_DRIVER=logger,SMTP_SECURITY=off) instead of stripping everyMALMO_MAIL_*line. This is what makes the enable-enum present-and-valid on an unbound box, killing the empty-string boot-reject that today's:+gate causes.Rejected alternatives: (a) inject more spellings from the brain (
MALMO_MAIL_SECURITY_VW…) — app-specific tokens don't belong in the brain and it doesn't scale; (b) a pure-compose trick — genuinely impossible, no ternary/remap in interpolation.Do:
internal/manifest/manifest.go— extend theMailstruct (currently{Optional bool}) withEnv map[string]MailEnvMap, where each entry hasFrom(encryption|bound, extensible) andMap(token → token). AddvalidateMailrules: map keys must cover theFromdomain ({none,starttls,tls}or{bound,unbound}); reject unknownFrom.docs/specs/APP_MANIFEST.md# D3 anddocs/specs/SERVICE_PROVISIONING.md# BYO outgoing mail — document themail.envmap and the always-stamp-when-unbound behavior.internal/lifecycle/mail.go—mailEnvLines(andrewriteEnvMail) take the manifest; after theMALMO_MAIL_*lines, emit each declared app var with its resolved token. Stamp mail-declaring apps in both bound and unbound states (unbound uses theunbound/none→mapped tokens); todayrewriteEnvMailappends nothing when unbound — change it to still emit the declared vars.internal/lifecycle/lifecycle_mail_test.go— cover: bound resolvestls→force_tlsandbound→smtp; unbound resolvesnone→offandunbound→loggerand still writes the vars; rebind bound→unbound re-stamps correctly.mail.envmap tocatalog/twenty(auto-wireEMAIL_DRIVER) and, in the store repo,apps/vaultwarden(SMTP_SECURITY) — then flip vaultwarden'soutbound-mail-enum-mismatchlimitation and twenty's row towardfullafter a live send check.docs/dev/catalog-import-gaps.md— movemail-enable-enum — twentytoStatus: implemented, and add the gap-class todocs/dev/capabilities.yml(since:= brain version,ref:= this PR) so the waiting apps mechanically re-screen (docs/specs/CAPABILITIES.md).Touch:
internal/manifest/manifest.go,internal/lifecycle/mail.go,internal/lifecycle/lifecycle_mail_test.go,catalog/twenty/manifest.yml,docs/specs/APP_MANIFEST.md,docs/specs/SERVICE_PROVISIONING.md,docs/dev/catalog-import-gaps.md,docs/dev/capabilities.yml. (The store repoapps/vaultwarden/{manifest.yml,status.yml}is a coordinated follow-up, not in this repo.)Done when: a mail-declaring app with a
mail.envmap boots valid unbound (declared enum vars present with theirunboundtokens, no boot-reject) and, once a provider is bound, sends with the app's enum var carrying the mapped token — verified live for both twenty (EMAIL_DRIVER) and vaultwarden (SMTP_SECURITY);mail-enable-enummarkedimplementedin the ledger +capabilities.yml.