Skip to content

Mail: manifest-declared value maps for enum-style SMTP config (retire mail-enable-enum gap) #302

Description

@onel

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:

  1. 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.
  2. 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.gomailEnvLines (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.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions