Make SMTP configuration optional (email-disabled instance) #172
Labels
No labels
kind
bug
kind
enhancement
wayfinder
grilling
wayfinder
map
wayfinder
prototype
wayfinder
research
wayfinder
task
workflow
needs-info
workflow
needs-triage
workflow
ready-for-agent
workflow
ready-for-human
workflow
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rosa/vernier#172
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem Statement
Vernier refuses to boot without SMTP.
VERNIER_SMTP_HOSTandVERNIER_SMTP_SENDERare 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
configuration, so that I can run my instance without inventing a fake SMTP host.
VERNIER_SMTP_*variable and have theinstance start cleanly, so that a minimal single-user deployment needs no email
infrastructure.
to fail fast at startup with a clear message, so that I never boot into a mailer
that silently cannot send.
today, so that upgrading does not change my existing configured deployment.
warning that passkey recovery is unavailable and losing all passkeys is
unrecoverable, so that I understand and accept the lockout risk.
VERNIER_SMTP_*variable names, sothat making SMTP optional does not force me to rewrite my deployment config.
emails users already stored to remain intact (just inert), so that disabling
email does not corrupt or drop existing account data.
working, so that I can enable email on an instance that began without it.
normally, so that the absence of email does not block me from creating an
account.
account, so that it is already stored and ready to confirm if the operator
later enables email.
dispatched (and no confirmation minted), so that the system does not create
artifacts it can never deliver.
to be absent from the login surface, so that I am not offered a recovery path
that cannot work.
Not Found, so that a bookmarked or guessed recovery URL does not present a
broken flow.
to confirm it through the ordinary path once email is enabled, so that I can
verify my address without special migration steps.
every previously-stored address, so that stale addresses are not mailed
unexpectedly.
SMTP is absent, so that no email jobs accumulate that can never be processed.
enable_background_jobsto keep its current meaningindependent of SMTP presence, so that media and reconciler workers still run on
an email-disabled instance.
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.
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>onConfig(incrates/config).SmtpConfigcarries thehost, 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_TLSnames. The nested struct mustdeserialize from these flat names.
RISK/OPEN QUESTION: the
configcrate's env-var flattening for a nestedOption<struct>may want a separator (e.g.VERNIER_SMTP__HOST). Preserve thesingle-underscore names (via
#[serde(flatten)]or explicit renames). If thecrate cannot cleanly deserialize a nested
Optionwhile 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.
Configvalidation rejects a partial SMTPblock 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::validatepattern that already checksbase_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. Thedomain's
accountsandpasskeyservices keep requiring a realMailerand aresimply not invoked to send when email is disabled. The
Mailerport isunchanged; 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 andreturn 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_confirmationat 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 ofenable_background_jobs.enable_background_jobskeeps 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!thatnames 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/serverintegration harness (test_serverincrates/server/tests/common/mod.rs). Add one email-disabled variant oftest_serverthat buildsAppStatewithConfig.smtp = None. Do NOT swap in amock mailer; the capability is derived in
AppState, so the disabled variant justomits the SMTP block and the real
ApalisMaileris simply never called. Throughthis seam, using existing helpers:
GET /recoverandPOST /recoverreturn 404 on an email-disabled instance.register(app, user, email)succeeds, the user's email is stored, andemail_confirmations_for(pool, user_id)(the existing DB-count helper atpasskey_test.rs:134) is 0 — no confirmation minted or enqueued.registration_enqueues_an_email_confirmationtest(
passkey_test.rs:146, asserts the count is 1) stays green on the defaultemail-enabled
test_serverand serves as the contrast case.Prior art:
passkey_test.rs(recovery flow,email_confirmations_for),user_handlers_test.rs(confirmation handler tests), and the wholetest_serverharness.Secondary seam —
crates/configunit tests for fail-fast partial-configvalidation. 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 forbase_url/bind_address(currently untested — thisadds the first
crates/configtests).Not given its own seam — worker registration (the Q7 wiring). Skipping the
three mailer workers when SMTP is absent lives in
main.rs, which theintegration 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; thebehavior follows structurally from "no SMTP config ⇒ no
SmtpMailerto build."Keep the seam count at two.
Out of Scope
reset, etc.) for email-disabled instances. Losing all passkeys is accepted as
unrecoverable; this spec only makes the risk explicit.
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.
Mailerport, the SMTP transport, or the retry/backoff policy of themailer jobs.
VERNIER_SMTP_*environment variable (only permitted if the configcrate forces it, and only after a separate decision).
Further Notes
User.emailisOption<Email>andemail_confirmed_atisOption. Making SMTP optional doesnot require domain-model changes for the "no email" case — the work is config,
web-layer capability, route mounting, and worker wiring.
decisions and links from ADR 0014 (passkeys as the sole login factor) as its
direct consequence.
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.