Make SMTP configuration optional (email-disabled instance) #172

Open
opened 2026-08-10 03:14:27 +00:00 by rosa · 0 comments
Owner

Problem Statement

Vernier refuses to boot without SMTP. VERNIER_SMTP_HOST and VERNIER_SMTP_SENDER
are mandatory config, so an operator who has no mail relay — or who simply does
not want their single-user instance sending email — cannot run Vernier at all.
They are forced to invent a throwaway SMTP host just to satisfy startup, even
when no email will ever be sent.

Solution

Make the whole SMTP block optional. An operator may start Vernier with no SMTP
configuration at all; this yields an email-disabled instance. On such an
instance the two flows that need email — email confirmation and passkey
recovery — are simply unavailable, and everything else works normally. Email is
a single instance capability: it is present exactly when SMTP is configured, and
absent otherwise, with no separate toggle to drift out of sync.

Because passkey recovery is the only account-recovery channel, an email-disabled
instance warns loudly at boot that losing every passkey means permanent lockout.
The tradeoff is the operator's to make.

See ADR 0022 (docs/adr/0022-smtp-is-optional-email-is-one-instance-capability.md)
and the "Email-disabled instance" glossary term in CONTEXT.md.

User Stories

  1. As an operator without a mail relay, I want Vernier to boot with no SMTP
    configuration, so that I can run my instance without inventing a fake SMTP host.
  2. As an operator, I want to omit every VERNIER_SMTP_* variable and have the
    instance start cleanly, so that a minimal single-user deployment needs no email
    infrastructure.
  3. As an operator, I want a half-configured SMTP block (e.g. a host but no sender)
    to fail fast at startup with a clear message, so that I never boot into a mailer
    that silently cannot send.
  4. As an operator, I want a complete SMTP block to keep working exactly as it does
    today, so that upgrading does not change my existing configured deployment.
  5. As an operator running an email-disabled instance, I want a prominent startup
    warning that passkey recovery is unavailable and losing all passkeys is
    unrecoverable, so that I understand and accept the lockout risk.
  6. As an operator, I want to keep my existing VERNIER_SMTP_* variable names, so
    that making SMTP optional does not force me to rewrite my deployment config.
  7. As an operator who previously ran with SMTP and later removed it, I want any
    emails users already stored to remain intact (just inert), so that disabling
    email does not corrupt or drop existing account data.
  8. As an operator, I want to add SMTP configuration later and have email start
    working, so that I can enable email on an instance that began without it.
  9. As a user on an email-disabled instance, I want registration to succeed
    normally, so that the absence of email does not block me from creating an
    account.
  10. As a user on an email-disabled instance, I want to still set an email on my
    account, so that it is already stored and ready to confirm if the operator
    later enables email.
  11. As a user on an email-disabled instance, I want no confirmation email to be
    dispatched (and no confirmation minted), so that the system does not create
    artifacts it can never deliver.
  12. As a user on an email-disabled instance, I want the "recover access" affordance
    to be absent from the login surface, so that I am not offered a recovery path
    that cannot work.
  13. As a user on an email-disabled instance, I want the recovery routes to return
    Not Found, so that a bookmarked or guessed recovery URL does not present a
    broken flow.
  14. As a user whose email was stored while the instance was email-disabled, I want
    to confirm it through the ordinary path once email is enabled, so that I can
    verify my address without special migration steps.
  15. As an operator enabling email later, I want no burst of confirmation emails to
    every previously-stored address, so that stale addresses are not mailed
    unexpectedly.
  16. As an operator, I want the mailer background workers to not be registered when
    SMTP is absent, so that no email jobs accumulate that can never be processed.
  17. As an operator, I want enable_background_jobs to keep its current meaning
    independent of SMTP presence, so that media and reconciler workers still run on
    an email-disabled instance.
  18. As an operator on a complete SMTP deployment, I want passkey recovery,
    registration confirmation, and the recovery affordance to behave exactly as
    they do today, so that enabling email is a no-op relative to current behavior.
  19. As a maintainer, I want the "email-disabled instance" concept captured in the
    glossary and an ADR, so that the decision and its lockout tradeoff are recorded
    for future work.

Implementation Decisions

  • Config shape. The SMTP settings collapse into a single nested
    Option<SmtpConfig> on Config (in crates/config). SmtpConfig carries the
    host, sender, and the already-optional username/password/port/tls fields. The
    block is either wholly present (a complete sender + host) or wholly absent;
    half-filled is not a representable end state — it is rejected (see validation).

  • Preserve env-var names. Operators keep the existing VERNIER_SMTP_HOST,
    VERNIER_SMTP_SENDER, VERNIER_SMTP_USERNAME, VERNIER_SMTP_PASSWORD,
    VERNIER_SMTP_PORT, VERNIER_SMTP_TLS names. The nested struct must
    deserialize from these flat names.
    RISK/OPEN QUESTION: the config crate's env-var flattening for a nested
    Option<struct> may want a separator (e.g. VERNIER_SMTP__HOST). Preserve the
    single-underscore names (via #[serde(flatten)] or explicit renames). If the
    crate cannot cleanly deserialize a nested Option while keeping those names,
    stop and raise it before renaming any operator-facing variable — that would be a
    breaking change requiring a separate decision.

  • Fail fast on partial config. Config validation rejects a partial SMTP
    block at startup (a host without a sender, or any SMTP field present without the
    required host+sender), with a clear message. Fully-absent is valid
    (email-disabled); fully-present is valid. This extends the existing
    Config::validate pattern that already checks base_url / bind_address.

  • Capability lives in the web layer. The email capability is derived once from
    config presence and held in AppState. It is not threaded into the domain. The
    domain's accounts and passkey services keep requiring a real Mailer and are
    simply not invoked to send when email is disabled. The Mailer port is
    unchanged; no Option<Mailer> and no null-object mailer.

  • Route mounting. When email is disabled, the passkey-recovery routes
    (GET /recover, POST /recover, POST /recover/verify) are not mounted and
    return Not Found. The login surface omits the "recover access" affordance. Being
    explicit leaks nothing: "email is off" is a whole-instance fact, not a per-account
    one, so the enumeration-resistance that governs the enabled recovery flow does not
    apply here.

  • Confirmation send is skipped, not faked. On registration/email-set while
    disabled, the web layer does not call accounts::Service::send_email_confirmation
    at all. No confirmation row is minted and no job is enqueued. The user's email is
    still stored (unconfirmed).

  • Email is still stored when disabled. Setting an email on an email-disabled
    instance succeeds and persists; it stays unconfirmed and inert. Pre-existing
    stored emails on an instance whose SMTP was later removed are untouched. Nothing
    auto-confirms or backfills; confirmation happens later through the ordinary
    user-initiated path once email is enabled.

  • Worker wiring is independent of enable_background_jobs. When SMTP is absent,
    SmtpMailer::new() is not built and the three mailer workers
    (vernier-email-confirmation, vernier-passkey-recovery,
    vernier-passkey-recovery-initiate) are not registered — regardless of
    enable_background_jobs. enable_background_jobs keeps its current meaning
    (gates the worker Monitor as a whole, including media/reconciler workers). This
    also removes the previous wart where SMTP was required even when background jobs
    were turned off.

  • Boot warning. An email-disabled boot emits a prominent tracing::warn! that
    names the consequence: passkey recovery is unavailable and losing all passkeys
    locks the account out permanently.

  • Docs. ADR 0022 and the CONTEXT.md "Email-disabled instance" glossary term are
    already written as part of the design and should be kept in sync with the final
    implementation.

Testing Decisions

Good tests here assert externally observable behavior — HTTP status codes,
persisted rows, whether a confirmation was minted — never internal wiring. There
are two seams; the ideal of one is not reachable because startup validation lives
below the HTTP surface.

  • Primary seam — the crates/server integration harness (test_server in
    crates/server/tests/common/mod.rs). Add one email-disabled variant of
    test_server that builds AppState with Config.smtp = None. Do NOT swap in a
    mock mailer; the capability is derived in AppState, so the disabled variant just
    omits the SMTP block and the real ApalisMailer is simply never called. Through
    this seam, using existing helpers:

    • GET /recover and POST /recover return 404 on an email-disabled instance.
    • register(app, user, email) succeeds, the user's email is stored, and
      email_confirmations_for(pool, user_id) (the existing DB-count helper at
      passkey_test.rs:134) is 0 — no confirmation minted or enqueued.
    • The existing registration_enqueues_an_email_confirmation test
      (passkey_test.rs:146, asserts the count is 1) stays green on the default
      email-enabled test_server and serves as the contrast case.
      Prior art: passkey_test.rs (recovery flow, email_confirmations_for),
      user_handlers_test.rs (confirmation handler tests), and the whole
      test_server harness.
  • Secondary seam — crates/config unit tests for fail-fast partial-config
    validation. This is pre-boot and has no HTTP surface, so it cannot be reached
    through the integration seam. Assert: fully-absent SMTP → Ok; complete block →
    Ok; partial block (host without sender) → Err. Prior art: the existing
    validate() behavior for base_url / bind_address (currently untested — this
    adds the first crates/config tests).

  • Not given its own seam — worker registration (the Q7 wiring). Skipping the
    three mailer workers when SMTP is absent lives in main.rs, which the
    integration harness does not exercise (tests run with enable_background_jobs = false, so no Monitor runs). Adding a seam there costs more than it is worth; the
    behavior follows structurally from "no SMTP config ⇒ no SmtpMailer to build."
    Keep the seam count at two.

Out of Scope

  • Any alternative account-recovery channel (recovery codes, operator-assisted
    reset, etc.) for email-disabled instances. Losing all passkeys is accepted as
    unrecoverable; this spec only makes the risk explicit.
  • A dedicated "resend confirmation" UI affordance. Confirming a previously-stored
    email once SMTP is enabled goes through the ordinary user-initiated path; if that
    path does not already reach an already-stored email, wiring a resend affordance is
    a follow-up, not part of this spec.
  • Automatic backfill of confirmations when email is enabled (explicitly rejected).
  • Changing the Mailer port, the SMTP transport, or the retry/backoff policy of the
    mailer jobs.
  • Renaming any VERNIER_SMTP_* environment variable (only permitted if the config
    crate forces it, and only after a separate decision).

Further Notes

  • The domain already tolerates users without email: User.email is
    Option<Email> and email_confirmed_at is Option. Making SMTP optional does
    not require domain-model changes for the "no email" case — the work is config,
    web-layer capability, route mounting, and worker wiring.
  • The design was settled through a grilling session; ADR 0022 records all nine
    decisions and links from ADR 0014 (passkeys as the sole login factor) as its
    direct consequence.
  • Verify during implementation that the ordinary email-set path invoked when email
    is enabled reaches an already-stored (previously inert) email so User Story 14
    holds; if it does not, note it for the resend-affordance follow-up.
## Problem Statement Vernier refuses to boot without SMTP. `VERNIER_SMTP_HOST` and `VERNIER_SMTP_SENDER` are mandatory config, so an operator who has no mail relay — or who simply does not want their single-user instance sending email — cannot run Vernier at all. They are forced to invent a throwaway SMTP host just to satisfy startup, even when no email will ever be sent. ## Solution Make the whole SMTP block optional. An operator may start Vernier with no SMTP configuration at all; this yields an **email-disabled instance**. On such an instance the two flows that need email — email confirmation and passkey recovery — are simply unavailable, and everything else works normally. Email is a single instance capability: it is present exactly when SMTP is configured, and absent otherwise, with no separate toggle to drift out of sync. Because passkey recovery is the only account-recovery channel, an email-disabled instance warns loudly at boot that losing every passkey means permanent lockout. The tradeoff is the operator's to make. See ADR 0022 (`docs/adr/0022-smtp-is-optional-email-is-one-instance-capability.md`) and the "Email-disabled instance" glossary term in CONTEXT.md. ## User Stories 1. As an operator without a mail relay, I want Vernier to boot with no SMTP configuration, so that I can run my instance without inventing a fake SMTP host. 2. As an operator, I want to omit every `VERNIER_SMTP_*` variable and have the instance start cleanly, so that a minimal single-user deployment needs no email infrastructure. 3. As an operator, I want a half-configured SMTP block (e.g. a host but no sender) to fail fast at startup with a clear message, so that I never boot into a mailer that silently cannot send. 4. As an operator, I want a complete SMTP block to keep working exactly as it does today, so that upgrading does not change my existing configured deployment. 5. As an operator running an email-disabled instance, I want a prominent startup warning that passkey recovery is unavailable and losing all passkeys is unrecoverable, so that I understand and accept the lockout risk. 6. As an operator, I want to keep my existing `VERNIER_SMTP_*` variable names, so that making SMTP optional does not force me to rewrite my deployment config. 7. As an operator who previously ran with SMTP and later removed it, I want any emails users already stored to remain intact (just inert), so that disabling email does not corrupt or drop existing account data. 8. As an operator, I want to add SMTP configuration later and have email start working, so that I can enable email on an instance that began without it. 9. As a user on an email-disabled instance, I want registration to succeed normally, so that the absence of email does not block me from creating an account. 10. As a user on an email-disabled instance, I want to still set an email on my account, so that it is already stored and ready to confirm if the operator later enables email. 11. As a user on an email-disabled instance, I want no confirmation email to be dispatched (and no confirmation minted), so that the system does not create artifacts it can never deliver. 12. As a user on an email-disabled instance, I want the "recover access" affordance to be absent from the login surface, so that I am not offered a recovery path that cannot work. 13. As a user on an email-disabled instance, I want the recovery routes to return Not Found, so that a bookmarked or guessed recovery URL does not present a broken flow. 14. As a user whose email was stored while the instance was email-disabled, I want to confirm it through the ordinary path once email is enabled, so that I can verify my address without special migration steps. 15. As an operator enabling email later, I want no burst of confirmation emails to every previously-stored address, so that stale addresses are not mailed unexpectedly. 16. As an operator, I want the mailer background workers to not be registered when SMTP is absent, so that no email jobs accumulate that can never be processed. 17. As an operator, I want `enable_background_jobs` to keep its current meaning independent of SMTP presence, so that media and reconciler workers still run on an email-disabled instance. 18. As an operator on a complete SMTP deployment, I want passkey recovery, registration confirmation, and the recovery affordance to behave exactly as they do today, so that enabling email is a no-op relative to current behavior. 19. As a maintainer, I want the "email-disabled instance" concept captured in the glossary and an ADR, so that the decision and its lockout tradeoff are recorded for future work. ## Implementation Decisions - **Config shape.** The SMTP settings collapse into a single nested `Option<SmtpConfig>` on `Config` (in `crates/config`). `SmtpConfig` carries the host, sender, and the already-optional username/password/port/tls fields. The block is either wholly present (a complete sender + host) or wholly absent; half-filled is not a representable end state — it is rejected (see validation). - **Preserve env-var names.** Operators keep the existing `VERNIER_SMTP_HOST`, `VERNIER_SMTP_SENDER`, `VERNIER_SMTP_USERNAME`, `VERNIER_SMTP_PASSWORD`, `VERNIER_SMTP_PORT`, `VERNIER_SMTP_TLS` names. The nested struct must deserialize from these flat names. RISK/OPEN QUESTION: the `config` crate's env-var flattening for a nested `Option<struct>` may want a separator (e.g. `VERNIER_SMTP__HOST`). Preserve the single-underscore names (via `#[serde(flatten)]` or explicit renames). If the crate cannot cleanly deserialize a nested `Option` while keeping those names, stop and raise it before renaming any operator-facing variable — that would be a breaking change requiring a separate decision. - **Fail fast on partial config.** `Config` validation rejects a partial SMTP block at startup (a host without a sender, or any SMTP field present without the required host+sender), with a clear message. Fully-absent is valid (email-disabled); fully-present is valid. This extends the existing `Config::validate` pattern that already checks `base_url` / `bind_address`. - **Capability lives in the web layer.** The email capability is derived once from config presence and held in `AppState`. It is not threaded into the domain. The domain's `accounts` and `passkey` services keep requiring a real `Mailer` and are simply not invoked to send when email is disabled. The `Mailer` port is unchanged; no `Option<Mailer>` and no null-object mailer. - **Route mounting.** When email is disabled, the passkey-recovery routes (`GET /recover`, `POST /recover`, `POST /recover/verify`) are not mounted and return Not Found. The login surface omits the "recover access" affordance. Being explicit leaks nothing: "email is off" is a whole-instance fact, not a per-account one, so the enumeration-resistance that governs the enabled recovery flow does not apply here. - **Confirmation send is skipped, not faked.** On registration/email-set while disabled, the web layer does not call `accounts::Service::send_email_confirmation` at all. No confirmation row is minted and no job is enqueued. The user's email is still stored (unconfirmed). - **Email is still stored when disabled.** Setting an email on an email-disabled instance succeeds and persists; it stays unconfirmed and inert. Pre-existing stored emails on an instance whose SMTP was later removed are untouched. Nothing auto-confirms or backfills; confirmation happens later through the ordinary user-initiated path once email is enabled. - **Worker wiring is independent of `enable_background_jobs`.** When SMTP is absent, `SmtpMailer::new()` is not built and the three mailer workers (`vernier-email-confirmation`, `vernier-passkey-recovery`, `vernier-passkey-recovery-initiate`) are not registered — regardless of `enable_background_jobs`. `enable_background_jobs` keeps its current meaning (gates the worker Monitor as a whole, including media/reconciler workers). This also removes the previous wart where SMTP was required even when background jobs were turned off. - **Boot warning.** An email-disabled boot emits a prominent `tracing::warn!` that names the consequence: passkey recovery is unavailable and losing all passkeys locks the account out permanently. - **Docs.** ADR 0022 and the CONTEXT.md "Email-disabled instance" glossary term are already written as part of the design and should be kept in sync with the final implementation. ## Testing Decisions Good tests here assert externally observable behavior — HTTP status codes, persisted rows, whether a confirmation was minted — never internal wiring. There are two seams; the ideal of one is not reachable because startup validation lives below the HTTP surface. - **Primary seam — the `crates/server` integration harness** (`test_server` in `crates/server/tests/common/mod.rs`). Add one email-disabled variant of `test_server` that builds `AppState` with `Config.smtp = None`. Do NOT swap in a mock mailer; the capability is derived in `AppState`, so the disabled variant just omits the SMTP block and the real `ApalisMailer` is simply never called. Through this seam, using existing helpers: - `GET /recover` and `POST /recover` return **404** on an email-disabled instance. - `register(app, user, email)` **succeeds**, the user's email is stored, and `email_confirmations_for(pool, user_id)` (the existing DB-count helper at `passkey_test.rs:134`) is **0** — no confirmation minted or enqueued. - The existing `registration_enqueues_an_email_confirmation` test (`passkey_test.rs:146`, asserts the count is 1) stays green on the default email-*enabled* `test_server` and serves as the contrast case. Prior art: `passkey_test.rs` (recovery flow, `email_confirmations_for`), `user_handlers_test.rs` (confirmation handler tests), and the whole `test_server` harness. - **Secondary seam — `crates/config` unit tests** for fail-fast partial-config validation. This is pre-boot and has no HTTP surface, so it cannot be reached through the integration seam. Assert: fully-absent SMTP → Ok; complete block → Ok; partial block (host without sender) → Err. Prior art: the existing `validate()` behavior for `base_url` / `bind_address` (currently untested — this adds the first `crates/config` tests). - **Not given its own seam — worker registration (the Q7 wiring).** Skipping the three mailer workers when SMTP is absent lives in `main.rs`, which the integration harness does not exercise (tests run with `enable_background_jobs = false`, so no Monitor runs). Adding a seam there costs more than it is worth; the behavior follows structurally from "no SMTP config ⇒ no `SmtpMailer` to build." Keep the seam count at two. ## Out of Scope - Any alternative account-recovery channel (recovery codes, operator-assisted reset, etc.) for email-disabled instances. Losing all passkeys is accepted as unrecoverable; this spec only makes the risk explicit. - A dedicated "resend confirmation" UI affordance. Confirming a previously-stored email once SMTP is enabled goes through the ordinary user-initiated path; if that path does not already reach an already-stored email, wiring a resend affordance is a follow-up, not part of this spec. - Automatic backfill of confirmations when email is enabled (explicitly rejected). - Changing the `Mailer` port, the SMTP transport, or the retry/backoff policy of the mailer jobs. - Renaming any `VERNIER_SMTP_*` environment variable (only permitted if the config crate forces it, and only after a separate decision). ## Further Notes - The domain already tolerates users without email: `User.email` is `Option<Email>` and `email_confirmed_at` is `Option`. Making SMTP optional does not require domain-model changes for the "no email" case — the work is config, web-layer capability, route mounting, and worker wiring. - The design was settled through a grilling session; ADR 0022 records all nine decisions and links from ADR 0014 (passkeys as the sole login factor) as its direct consequence. - Verify during implementation that the ordinary email-set path invoked when email is enabled reaches an already-stored (previously inert) email so User Story 14 holds; if it does not, note it for the resend-affordance follow-up.
rosa added this to the v0.1 milestone 2026-08-12 03:32:42 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rosa/vernier#172
No description provided.