diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 0305a7c..302d036 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -140,7 +140,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { // Email one-time codes go through the [smtp] relay when one is configured; the // password is read from the env var password_ref names (default SMTPPasswordEnv, // injected from the felis-smtp Secret). No [smtp] host ⇒ mailer stays nil and - // deliverOTP logs each code server-side (the pre-SMTP bootstrap posture). + // every door that mails a code answers 503 mail_unavailable: a code that is + // not mailed is never written anywhere else either. var mailer api.OTPMailer if cfg.SMTP.Host != "" { passRef := cfg.SMTP.PasswordRef @@ -151,15 +152,12 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { if cfg.SMTP.Username != "" && password == "" { fmt.Fprintf(stderr, "felis api: warning: [smtp] username is set but credentials env %s is empty — OTP sends will fail AUTH\n", passRef) } - mailer = &mail.SMTP{ - Host: cfg.SMTP.Host, - Port: cfg.SMTP.Port, - From: cfg.SMTP.From, - Username: cfg.SMTP.Username, - Password: password, + mailer = smtpRelay(cfg.SMTP, password) + if !cfg.SMTP.TLSRequired() { + fmt.Fprintf(stderr, "felis api: warning: [smtp] %s may be sent codes without TLS (require_tls off or a relay on this host)\n", cfg.SMTP.Host) } } else { - fmt.Fprintln(stderr, "felis api: [smtp] not configured — email one-time codes are logged, not mailed") + fmt.Fprintln(stderr, "felis api: [smtp] not configured — email sign-in and verification are off (503 mail_unavailable); sign in with a passkey, or run felis setup to add a relay") } // Build subsystem (spec §16): the weak-SA build Job runs in the configured @@ -824,3 +822,17 @@ func internalCallerTokens(getenv func(string) string) (api.CallerTokens, error) } return api.NewCallerTokens(tokens) } + +// smtpRelay is the relay [smtp] names, with the resolved password and the TLS +// posture config.SMTPConfig.TLSRequired picks. felis api, the reaper and the +// watchdog all send through it, so none can drift to a weaker posture. +func smtpRelay(c config.SMTPConfig, password string) *mail.SMTP { + return &mail.SMTP{ + Host: c.Host, + Port: c.Port, + From: c.From, + Username: c.Username, + Password: password, + RequireTLS: c.TLSRequired(), + } +} diff --git a/cmd/felis/reaper.go b/cmd/felis/reaper.go index 302adcd..a900f44 100644 --- a/cmd/felis/reaper.go +++ b/cmd/felis/reaper.go @@ -16,7 +16,6 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/backup" "felis.lolicon.best/internal/config" - "felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/reaper" corev1 "k8s.io/api/core/v1" @@ -126,13 +125,7 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int { } return email, nil }, - notifier: &mail.SMTP{ - Host: cfg.SMTP.Host, - Port: cfg.SMTP.Port, - From: cfg.SMTP.From, - Username: cfg.SMTP.Username, - Password: password, - }, + notifier: smtpRelay(cfg.SMTP, password), } } else { fmt.Fprintln(stderr, "felis reaper: [smtp] not configured — pre-reap warnings are logged and NOT marked sent") diff --git a/cmd/felis/tui_smtp.go b/cmd/felis/tui_smtp.go index e852e70..a00dd17 100644 --- a/cmd/felis/tui_smtp.go +++ b/cmd/felis/tui_smtp.go @@ -9,7 +9,6 @@ import ( "strings" "felis.lolicon.best/internal/config" - "felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/platform" "github.com/charmbracelet/bubbles/spinner" @@ -311,24 +310,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error { if err != nil { return fmt.Errorf("port %q is not a number", in.port) } - relay := &mail.SMTP{Host: in.host, Port: port, From: in.from, Username: in.username, Password: in.password} - if err := relay.Ping(ctx); err != nil { + var prev config.SMTPConfig + if cur, err := config.Load(hostSetupConfigPath); err == nil { + prev = cur.SMTP + } + // Ping under the posture felis api will send with, so a relay without + // STARTTLS is turned down here rather than at a player's first code. + if err := smtpRelay(setupSMTPConfig(in, port, prev), in.password).Ping(ctx); err != nil { return err } - smtpCfg := config.SMTPConfig{ - Host: in.host, - Port: port, - From: in.from, - Username: in.username, - PasswordRef: platform.SMTPPasswordEnv, - } for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} { cfg, err := config.Load(path) if err != nil { return err } - cfg.SMTP = smtpCfg + cfg.SMTP = setupSMTPConfig(in, port, cfg.SMTP) if err := writeConfig(path, cfg); err != nil { return err } @@ -351,6 +348,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error { return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s") } +// setupSMTPConfig is the [smtp] block this screen writes: the relay it just +// proved, plus the keys only an operator sets by hand (require_tls, +// max_per_hour), carried over from the block it replaces so reconfiguring the +// relay does not quietly reset them. +func setupSMTPConfig(in smtpInputs, port int, prev config.SMTPConfig) config.SMTPConfig { + return config.SMTPConfig{ + Host: in.host, + Port: port, + From: in.from, + Username: in.username, + PasswordRef: platform.SMTPPasswordEnv, + MaxPerHour: prev.MaxPerHour, + RequireTLS: prev.RequireTLS, + } +} + // smtpSecretManifest renders the felis-smtp Secret for the given namespace, the // one the receiving Deployment/CronJob resolves its secretKeyRef against (felis // for felis-api, the workload namespace for the reaper's mirror). The namespace diff --git a/cmd/felis/tui_smtp_test.go b/cmd/felis/tui_smtp_test.go index 97c78de..f1a5b8f 100644 --- a/cmd/felis/tui_smtp_test.go +++ b/cmd/felis/tui_smtp_test.go @@ -1,9 +1,12 @@ package main import ( + "reflect" "strings" "testing" + "felis.lolicon.best/internal/config" + "felis.lolicon.best/internal/mail" "sigs.k8s.io/yaml" ) @@ -35,3 +38,39 @@ func TestSMTPSecretManifestCarriesTargetNamespace(t *testing.T) { } } } + +// TestSMTPRelayCarriesTLSPosture: the relay felis api, the reaper and the +// watchdog send through refuses plaintext for a remote host and allows it for +// one on this host, as [smtp] says. +func TestSMTPRelayCarriesTLSPosture(t *testing.T) { + remote := smtpRelay(config.SMTPConfig{Host: "smtp.example.net", Port: 587, From: "felis@example.net", Username: "felis"}, "pw") + want := &mail.SMTP{Host: "smtp.example.net", Port: 587, From: "felis@example.net", Username: "felis", Password: "pw", RequireTLS: true} + if !reflect.DeepEqual(remote, want) { + t.Errorf("remote relay = %+v, want %+v", remote, want) + } + if local := smtpRelay(config.SMTPConfig{Host: "127.0.0.1", Port: 25, From: "felis@example.net"}, ""); local.RequireTLS { + t.Error("a relay on this host must not require TLS by default") + } +} + +// TestSetupSMTPConfigKeepsHandSetKeys: re-running the email screen replaces the +// relay but keeps require_tls and max_per_hour, which only an operator sets. +func TestSetupSMTPConfigKeepsHandSetKeys(t *testing.T) { + off := false + prev := config.SMTPConfig{Host: "old.example.net", Port: 25, From: "old@example.net", MaxPerHour: 500, RequireTLS: &off} + in := smtpInputs{host: "smtp.example.net", from: "felis@example.net", username: "felis"} + got := setupSMTPConfig(in, 465, prev) + if got.Host != "smtp.example.net" || got.Port != 465 || got.From != "felis@example.net" || + got.Username != "felis" || got.PasswordRef != "FELIS_SMTP_PASSWORD" { + t.Errorf("relay fields = %+v", got) + } + if got.MaxPerHour != 500 { + t.Errorf("max_per_hour = %d, want 500", got.MaxPerHour) + } + if got.RequireTLS == nil || *got.RequireTLS { + t.Errorf("require_tls = %v, want the operator's false", got.RequireTLS) + } + if fresh := setupSMTPConfig(in, 587, config.SMTPConfig{}); fresh.RequireTLS != nil || fresh.MaxPerHour != 0 { + t.Errorf("first setup = %+v, want require_tls and max_per_hour unset", fresh) + } +} diff --git a/cmd/felis/watchdog.go b/cmd/felis/watchdog.go index bfaa665..59426cf 100644 --- a/cmd/felis/watchdog.go +++ b/cmd/felis/watchdog.go @@ -13,7 +13,6 @@ import ( "time" "felis.lolicon.best/internal/config" - "felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/offsite" "felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/store" @@ -245,7 +244,7 @@ func sendAlert(ctx context.Context, cfg *config.Config, state *watchdog.State, s if ref := cfg.SMTP.PasswordRef; ref != "" && os.Getenv(ref) != "" { password = os.Getenv(ref) } - relay := &mail.SMTP{Host: cfg.SMTP.Host, Port: cfg.SMTP.Port, From: cfg.SMTP.From, Username: cfg.SMTP.Username, Password: password} + relay := smtpRelay(cfg.SMTP, password) var errs []error for _, to := range state.Recipients { if err := relay.SendNotice(ctx, to, subject, body); err != nil { diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index b9110c7..f97c445 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -350,6 +350,7 @@ listen = "0.0.0.0:8080" from = "felis@example.net" username = "relay-user" password_ref = "smtp-password" + require_tls = false # Third-party Yggdrasil sources federated by the hasJoined multiplexer. Mojang is # always the code-owned identity anchor (premium-first), prepended in Go; sources here @@ -367,6 +368,7 @@ out="$(run_smtp "$smtp_dir")" expect "a configured [smtp] relay is carried" 'host = "mail.example"' "$out" expect "its port survives the carry" 'port = 587' "$out" expect "its credentials reference survives" 'password_ref = "smtp-password"' "$out" +expect "an operator's require_tls survives" 'require_tls = false' "$out" case "$out" in *"#"*) echo "FAIL: the carry hoards comment lines:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;; diff --git a/docs/deferred-seams.md b/docs/deferred-seams.md index b920c9b..d441a0a 100644 --- a/docs/deferred-seams.md +++ b/docs/deferred-seams.md @@ -116,12 +116,11 @@ worth revisiting. ## Wired since the marker was written -- `internal/api/handlers_email_otp.go:54,223,227` and `internal/api/api.go:84` — - SMTP shipped on - 2026-07-20 (`internal/mail`, wired at `cmd/felis/api.go:264`). The nil-`Mailer` - branch that logs the code server-side is a runtime fallback for an install with no - `[smtp]` section, not an unbuilt feature. The comments are accurate; the reading - "Felis cannot send mail" is not. +- `internal/api/handlers_email_otp.go` and `internal/api/api.go` — SMTP shipped on + 2026-07-20 (`internal/mail`, wired in `cmd/felis/api.go`). An install with no + `[smtp]` section leaves the `Mailer` nil, and every door that mails a code answers + 503 `mail_unavailable`; codes are never logged. The reading "Felis cannot send + mail" is stale. - `internal/config/config.go:117` — was stale. It described the upload transport as a deferred integration after both backends had shipped (`LocalContextStore`, `S3ContextStore`, selected in `cmd/felis/api.go` by the shape of the configured diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 74390f1..1babe4c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -192,6 +192,15 @@ components: content: application/json: schema: { $ref: '#/components/schemas/Error' } + MailUnavailable: + description: > + This install has no [smtp] relay (code mail_unavailable), so no code was minted + or sent. The public doors answer it before resolving the address, so it is the + same for every address. Sign in with a passkey, or have the operator configure + email with felis setup. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } RateLimited: description: > This client address called the public sign-in doors faster than the per-address @@ -2162,7 +2171,10 @@ paths: '200': description: >- The login methods available for the address, in a deterministic order - (passkey before email_otp). An empty array means no verified account. + (passkey before email_otp). email_otp is offered only when the install has + a mail relay, passkey only when a verifier is wired and the account has a + credential. An empty array means no verified account, or none of its methods + is available on this install. content: application/json: schema: @@ -2546,6 +2558,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '502': $ref: '#/components/responses/MailUndeliverable' + '503': + $ref: '#/components/responses/MailUnavailable' /api/v1/auth/email/verify: post: @@ -2673,6 +2687,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '502': $ref: '#/components/responses/MailUndeliverable' + '503': + $ref: '#/components/responses/MailUnavailable' /api/v1/auth/op-login/status/{id}: get: @@ -4113,7 +4129,7 @@ paths: email: { type: string, format: email } responses: '202': - description: Code minted and dispatched (or logged server-side when no mailer is wired). + description: Code minted and mailed. content: application/json: schema: @@ -4142,6 +4158,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '502': $ref: '#/components/responses/MailUndeliverable' + '503': + $ref: '#/components/responses/MailUnavailable' /api/v1/account/email/verify: post: @@ -4540,6 +4558,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '502': $ref: '#/components/responses/MailUndeliverable' + '503': + $ref: '#/components/responses/MailUnavailable' /api/v1/account/reauth/email/verify: post: @@ -4757,6 +4777,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '502': $ref: '#/components/responses/MailUndeliverable' + '503': + $ref: '#/components/responses/MailUnavailable' /api/v1/account/migrate/confirm/otp/verify: post: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index deddb21..8ca96b3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1947,11 +1947,12 @@ Skips the pre-migration snapshot (`migrate up -no-backup`). The installer warns loudly when it is set. Use it only when the snapshot cannot work and you have another backup, e.g. an external database newer than the host's `pg_dump`. -## 17. Sign-in refused with 429, mail budget, account code locks, failed sign-ins +## 17. Sign-in refused: 429 limits, no mail relay, account code locks, failed sign-ins The public sign-in doors (`/api/v1/auth/*` except logout and the op-login status poll) have three limits of their own. Each answers 429 with a -`Retry-After` header and a distinct error code. +`Retry-After` header and a distinct error code. The doors that mail a code +also answer 503 `mail_unavailable` on an install with no mail relay. ### `rate_limited`: one address called the doors too often @@ -1991,6 +1992,33 @@ raise `max_per_hour` to what your relay allows. (`felis_mail_total{result="failed"}`, 502 `mail_undeliverable` to the caller). The relay's reason is in the `felis-api` log. +### `mail_unavailable`: no mail relay + +With no `[smtp]` section every door that mails a code answers 503 +`mail_unavailable` before minting one: email sign-in, op.console sign-in, +email verification, and the email step-up for sensitive changes and +migration. The public doors answer before looking up the address, so every +address gets the same reply. Sign-in is by passkey only, and a verified email +stops counting as a way into the account (it is no longer offered as a +re-verification factor). Codes are never logged: `felis api` says at start +`[smtp] not configured`. Run `felis setup` and configure email to open the +doors. + +### Relay refused for lacking TLS + +A relay on port 465 is spoken to over TLS from the first byte. On any other +port Felis upgrades with STARTTLS, and when the relay does not offer it the +send fails with `smtp: : does not offer STARTTLS` (502 +`mail_undeliverable` to the caller, the full text in the `felis-api` log, and +the same error on the `felis setup` email screen). Without TLS anyone on the +path reads the codes, and anyone who can rewrite the conversation can strip +the STARTTLS offer, so this is the default for every relay except one on this +host (`localhost`, `127.0.0.0/8`, `::1`). Use port 465 or a relay that offers +STARTTLS. For a relay you reach over a link you trust, set +`require_tls = false` under `[smtp]` in `/etc/felis/felis.toml` and +`/etc/felis/felis.pod.toml`; installer re-runs and the setup email screen keep +it. `felis api` warns at start whenever codes may go out without TLS. + ### `otp_account_locked`: ten wrong codes in 24 hours Ten wrong email codes for one account within 24 hours, counted across every diff --git a/internal/api/api.go b/internal/api/api.go index 24d9d6b..32838ae 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -101,10 +101,10 @@ type API struct { Submissions SubmissionService // Mailer delivers player email one-time codes (spec §B2 onboarding). It is - // optional: when nil the email-OTP start route mints and persists the code but - // logs it server-side instead of mailing it (a KNOWN-LIMITATION — the demo has no - // SMTP), so the verify flow is still exercised end-to-end. Production wires a real - // sender. The code is never returned to the client on either path. + // optional: when nil (no [smtp] relay) every door that mails a code answers 503 + // mail_unavailable before minting one, auth options stops offering email_otp, + // and a verified email stops counting as a reauth factor. The code is never + // returned to the client or logged. Mailer OTPMailer // Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6 diff --git a/internal/api/handlers_auth_email_test.go b/internal/api/handlers_auth_email_test.go index d922d43..9ebef00 100644 --- a/internal/api/handlers_auth_email_test.go +++ b/internal/api/handlers_auth_email_test.go @@ -815,3 +815,33 @@ func TestDeadAccountsCannotLogInOrKeepSessions(t *testing.T) { t.Error("refused redeem consumed the code; re-enabling the account must stay retryable within TTL") } } + +// TestPublicMailDoorsWithoutRelay: with no [smtp] relay both public doors that mail +// a code answer 503 mail_unavailable before the address is looked up, so a known +// address, a staff address and an unknown one get the same answer, no code or +// op-login request is minted, and no cooldown is spent for when a relay is added. +func TestPublicMailDoorsWithoutRelay(t *testing.T) { + api, repo, _ := seedLoginEmailAPI(t) + repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Email: "op@example.net", Role: "admin", EmailVerified: true} + api.Mailer = nil + eh := api.ExternalHandler() + + for _, door := range []string{"/api/v1/auth/email/start", "/api/v1/auth/op-login/start"} { + for _, email := range []string{"player@example.net", "op@example.net", "ghost@example.net"} { + w := do(eh, "POST", door, `{"email":"`+email+`"}`, jsonHeader) + code, msg := errEnvelope(t, w) + if w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" || + msg != "this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email" { + t.Errorf("%s %s = %d %s, want 503 mail_unavailable", door, email, w.Code, w.Body.String()) + } + } + } + if len(repo.otps) != 0 || len(repo.opLogins) != 0 { + t.Fatalf("refused starts minted %d codes and %d op-login requests", len(repo.otps), len(repo.opLogins)) + } + + api.Mailer = &captureMailer{} + if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"player@example.net"}`, jsonHeader); w.Code != http.StatusAccepted { + t.Fatalf("start once a relay is wired = %d (%s), want 202", w.Code, w.Body.String()) + } +} diff --git a/internal/api/handlers_auth_options.go b/internal/api/handlers_auth_options.go index a96c19d..66a0742 100644 --- a/internal/api/handlers_auth_options.go +++ b/internal/api/handlers_auth_options.go @@ -14,8 +14,9 @@ import ( // It is the deliberate counter-slice to the anti-enumeration login doors // (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an // address has an account precisely because THIS endpoint is the one sanctioned place -// existence is revealed. An empty methods array means "no (verified) account". That -// makes it a mass-enumeration surface by design — an accepted product decision, the +// existence is revealed. An empty methods array means "no (verified) account, or +// none of its methods is available on this install". That makes it a +// mass-enumeration surface by design — an accepted product decision, the // same one the email door's header records. The handler sends no mail and mutates // nothing, so a per-recipient cooldown would merely block a legitimate retry; what // bounds enumeration is the per-client-address token bucket shared by every public @@ -87,8 +88,12 @@ func (a *API) handleAuthOptions(w http.ResponseWriter, r *http.Request) { } } // Email-OTP login works for any resolved verified account (UserByEmail resolves only - // email_verified rows), so it is always on offer. - methods = append(methods, "email_otp") + // email_verified rows), so it is on offer whenever a relay can mail the code; with + // none the email door answers 503 mail_unavailable, so it is left out like an + // unwired passkey verifier. + if a.Mailer != nil { + methods = append(methods, "email_otp") + } writeJSON(w, http.StatusOK, map[string]any{"methods": methods}) } diff --git a/internal/api/handlers_auth_options_test.go b/internal/api/handlers_auth_options_test.go index 43208a6..be89587 100644 --- a/internal/api/handlers_auth_options_test.go +++ b/internal/api/handlers_auth_options_test.go @@ -22,7 +22,8 @@ import ( // door would immediately 503. // seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player -// (u1) and a verified staff account (a1), and a passkey verifier wired by default. +// (u1) and a verified staff account (a1), and a passkey verifier and a mail relay wired +// by default. // Callers seed passkey credentials per-test to set the credential state. func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) { t.Helper() @@ -32,6 +33,7 @@ func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) { repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "boss@example.net", Role: "admin", EmailVerified: true} api := newTestAPI(repo, newFakeCluster()) api.Passkey = &fakePasskeyVerifier{} + api.Mailer = &captureMailer{} return api, repo } @@ -128,6 +130,22 @@ func TestAuthOptionsDoesNotRevealStaffness(t *testing.T) { } } +// TestAuthOptionsEmailRequiresMailRelay: with no [smtp] relay the email door answers +// 503 mail_unavailable, so options leaves email_otp out; an account with a passkey is +// still offered it, and one without is offered nothing. +func TestAuthOptionsEmailRequiresMailRelay(t *testing.T) { + api, repo := seedAuthOptionsAPI(t) + api.Mailer = nil + repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "a1", CredentialID: "c-a1", PublicKey: "k", CreatedAt: frozenNow} + eh := api.ExternalHandler() + if w := do(eh, "POST", authOptionsPath, `{"email":"boss@example.net"}`, jsonHeader); w.Body.String() != `{"methods":["passkey"]}`+"\n" { + t.Errorf("passkey account body = %q, want only passkey", w.Body.String()) + } + if w := do(eh, "POST", authOptionsPath, `{"email":"player@example.net"}`, jsonHeader); w.Body.String() != `{"methods":[]}`+"\n" { + t.Errorf("email-only account body = %q, want no methods", w.Body.String()) + } +} + // TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but // no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in // that state, so options must NOT advertise passkey — it would be a dead offer. diff --git a/internal/api/handlers_email_otp.go b/internal/api/handlers_email_otp.go index b2148ae..0ca7bb7 100644 --- a/internal/api/handlers_email_otp.go +++ b/internal/api/handlers_email_otp.go @@ -68,10 +68,9 @@ const ( otpLiveLoginCodes = 3 ) -// OTPMailer delivers a one-time code to an email address. It is a seam, not a -// dependency: the demo ships without SMTP, so a nil Mailer logs the code -// server-side instead of mailing it (a KNOWN-LIMITATION, never a code returned to -// the client). Production wires a real sender. +// OTPMailer delivers a one-time code to an email address. felis api wires the +// [smtp] relay (internal/mail); with none configured it stays nil and every door +// that mails a code answers 503 mail_unavailable before minting one. type OTPMailer interface { SendOTP(ctx context.Context, email, code string) error } @@ -140,6 +139,12 @@ func (a *API) handleEmailOTPStart(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required")) return } + // No relay (or a spent budget) is said before asking for a re-verification + // the player could not then use. + if err := a.checkMailBudget(); err != nil { + writeError(w, r, err) + return + } // Gate the start: the verify only redeems a code minted here. if !a.requireReauth(w, r, p) { return @@ -274,16 +279,17 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email}) } -// deliverOTP hands the code to the configured Mailer, or — when none is wired (the -// demo) — logs it server-side as a KNOWN-LIMITATION. The code is logged ONLY in the -// no-mailer fallback and ONLY to the server log; it is never put in an HTTP response. +// deliverOTP hands the code to the configured Mailer. The code goes nowhere +// else: never into a response and never into a log, since anyone who can read +// the API's logs could otherwise sign in as any player. The doors refuse a +// relay-less install before minting (checkMailBudget); the nil check here only +// keeps a future caller that skips that check from minting a code no one gets. // // Every real send spends one token of the install-wide mail budget (mailGate); // a spent budget is a 429 mail_rate_limited and nothing reaches the relay. func (a *API) deliverOTP(ctx context.Context, email, code string) error { if a.Mailer == nil { - log.Printf("email-otp: no Mailer configured; code for %s is %s (KNOWN-LIMITATION: demo has no SMTP)", email, code) - return nil + return errMailUnavailable() } if ok, wait := a.mailGate().take(mailGateKey); !ok { metrics.MailTotal.WithLabelValues("otp", "throttled").Inc() diff --git a/internal/api/handlers_email_otp_test.go b/internal/api/handlers_email_otp_test.go index 1d8fc1d..48ce6c8 100644 --- a/internal/api/handlers_email_otp_test.go +++ b/internal/api/handlers_email_otp_test.go @@ -139,15 +139,15 @@ func TestWithRecoverLogsPanicStack(t *testing.T) { } } -// TestEmailOTPStartValidation covers the mint-side input gate and the no-mailer -// fallback (the demo path): a malformed address never mints, and a nil Mailer still -// persists a code (logged server-side) so the verify flow stays exercisable. +// TestEmailOTPStartValidation covers the mint-side input gate and the no-relay +// refusal: a malformed address never mints, and with no Mailer the start answers +// 503 mail_unavailable without minting, so no code exists to leak anywhere. func TestEmailOTPStartValidation(t *testing.T) { user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} mk := func(repo *fakeRepo) http.Handler { api := newTestAPI(repo, newFakeCluster()) api.External = staticExternal{p: user} - return api.ExternalHandler() // no Mailer wired → demo fallback + return api.ExternalHandler() // no Mailer wired } bad := map[string]string{ @@ -174,16 +174,50 @@ func TestEmailOTPStartValidation(t *testing.T) { }) } - t.Run("no mailer still persists a code (demo fallback)", func(t *testing.T) { + t.Run("no mailer refuses without minting", func(t *testing.T) { repo := newFakeRepo() w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"player@example.net"}`, nil) - if w.Code != http.StatusAccepted { - t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) + code, msg := errEnvelope(t, w) + if w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" { + t.Fatalf("code = %d %s, want 503 mail_unavailable", w.Code, w.Body.String()) } - if len(repo.otps) != 1 { - t.Fatalf("want exactly 1 persisted code, got %d", len(repo.otps)) + if msg != "this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email" { + t.Errorf("message = %q", msg) + } + if len(repo.otps) != 0 { + t.Fatalf("a refused start minted %d codes", len(repo.otps)) } }) + + // No relay is said before a re-verification the player could not use. + t.Run("no mailer is said before reauth", func(t *testing.T) { + repo := newFakeRepo() + repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u1", CredentialID: "c-p", CreatedAt: frozenNow} + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: &Principal{UserID: "u1", Email: "u1@example.net", Role: "user", ViaSession: true}} + w := do(api.ExternalHandler(), "POST", "/api/v1/account/email/start", `{"email":"player@example.net"}`, nil) + if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" { + t.Fatalf("code = %d %s, want 503 mail_unavailable", w.Code, w.Body.String()) + } + }) +} + +// TestDeliverOTPWithoutMailer: a caller that reaches deliverOTP with no relay gets +// the 503, and the code is written nowhere, the log included. +func TestDeliverOTPWithoutMailer(t *testing.T) { + var logged strings.Builder + old := log.Writer() + log.SetOutput(&logged) + t.Cleanup(func() { log.SetOutput(old) }) + api := newTestAPI(newFakeRepo(), newFakeCluster()) + err := api.deliverOTP(context.Background(), "player@example.net", "042137") + var ae *apiError + if !errors.As(err, &ae) || ae.status != http.StatusServiceUnavailable || ae.code != "mail_unavailable" { + t.Fatalf("deliverOTP = %v, want 503 mail_unavailable", err) + } + if strings.Contains(logged.String(), "042137") { + t.Errorf("the code reached the log: %s", logged.String()) + } } // TestEmailOTPStartRateLimited closes the email-bomb vector: handleEmailOTPStart is diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 6f0b601..54524a2 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -212,12 +212,21 @@ func errMailRateLimited(wait time.Duration) *apiError { "this server is sending too much mail right now; try again shortly").retryAfter(wait) } -// checkMailBudget is the public doors' pre-resolution check: it refuses every -// address alike while the budget is spent, so the refusal says nothing about -// whether the address has an account. +// errMailUnavailable answers a door that would mail a code on an install with +// no [smtp] relay. The code is never minted, so it cannot turn up anywhere. +func errMailUnavailable() *apiError { + return newError(http.StatusServiceUnavailable, "mail_unavailable", + "this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email") +} + +// checkMailBudget runs before a door mints a code: with no relay it refuses +// with mail_unavailable, and while the install-wide budget is spent with +// mail_rate_limited. The public doors call it before resolving the address, so +// either refusal is the same for every address and says nothing about whether +// it has an account. func (a *API) checkMailBudget() error { if a.Mailer == nil { - return nil + return errMailUnavailable() } if ok, wait := a.mailGate().peek(mailGateKey); !ok { metrics.MailTotal.WithLabelValues("otp", "throttled").Inc() diff --git a/internal/api/reauth.go b/internal/api/reauth.go index f4153ec..971267a 100644 --- a/internal/api/reauth.go +++ b/internal/api/reauth.go @@ -74,12 +74,18 @@ func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) { if hasPasskey { st.Factors = append(st.Factors, reauthFactorPasskey) } - if staffRole(p.Role) { - st.Factors = append(st.Factors, reauthFactorSignIn) - } else if p.EmailVerified { - st.Factors = append(st.Factors, reauthFactorEmail) + // A verified email is a way in only while a relay can mail it a code: with + // none, the email and op-login doors answer 503 mail_unavailable, so it is + // neither a factor to offer nor a door to guard. + emailWayIn := p.EmailVerified && a.Mailer != nil + if emailWayIn { + if staffRole(p.Role) { + st.Factors = append(st.Factors, reauthFactorSignIn) + } else { + st.Factors = append(st.Factors, reauthFactorEmail) + } } - if !hasPasskey && !p.EmailVerified { + if !hasPasskey && !emailWayIn { // Nothing to protect yet: the session is the account's only way in. return st, nil } @@ -342,6 +348,10 @@ func (a *API) finishStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Pri // address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown // so the step-up doors never perturb each other's throttle. func (a *API) startStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, keyPrefix, auditAction string) { + if err := a.checkMailBudget(); err != nil { + writeError(w, r, err) + return + } if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil { writeError(w, r, err) return diff --git a/internal/api/reauth_test.go b/internal/api/reauth_test.go index 9446248..d01dc33 100644 --- a/internal/api/reauth_test.go +++ b/internal/api/reauth_test.go @@ -202,6 +202,7 @@ func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusB func TestReauthStatusNamesTheFactors(t *testing.T) { f := reauthFixture(t) + f.api.Mailer = &captureMailer{} f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow} f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow} @@ -228,6 +229,30 @@ func TestReauthStatusNamesTheFactors(t *testing.T) { } } +// TestReauthWithoutMailRelay: with no [smtp] relay a verified email is no way in +// (the email and op-login doors answer 503), so it is neither offered as a factor +// nor guarded; a passkey still is, and the email start door says why it cannot help. +func TestReauthWithoutMailRelay(t *testing.T) { + f := reauthFixture(t) + f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow} + + steve := getReauthStatus(t, f, laptopTok) + if steve.Needed || len(steve.Factors) != 0 { + t.Fatalf("email-only player status = %+v, want not needed and no factors", steve) + } + pam := getReauthStatus(t, f, opTok) + if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey" { + t.Fatalf("operator with a passkey status = %+v, want needed with passkey only", pam) + } + w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok)) + if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" { + t.Fatalf("email start = %d %s, want 503 mail_unavailable", w.Code, w.Body.String()) + } + if n := len(f.repo.otps); n != 0 { + t.Errorf("a refused start minted %d codes", n) + } +} + func TestReauthByEmailCode(t *testing.T) { f := reauthFixture(t) mailer := &captureMailer{} diff --git a/internal/config/config.go b/internal/config/config.go index b0c4828..e1e53d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,8 +59,8 @@ type AuthSourceConfig struct { // SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers // email one-time codes through (onboarding, email login, op-login). It is -// OPTIONAL — an empty host means "no mailer", and felis-api falls back to -// logging each code server-side (the pre-SMTP bootstrap posture). Only the +// OPTIONAL — an empty host means "no mailer": every door that mails a code +// answers 503 mail_unavailable and sign-in is by passkey only. Only the // coordinates live here; the password follows the tree's credential rule // (ArchiveS3Config, RegistryS3Config): PasswordRef NAMES the environment // variable felis-api reads it from — the secret itself is never written into @@ -79,6 +79,27 @@ type SMTPConfig struct { // so a flood cannot spend the relay's quota and get the account suspended. // 0 means DefaultMailPerHour. Size it to the relay's own limit. MaxPerHour int `toml:"max_per_hour"` + // RequireTLS refuses to send through a relay on a port other than 465 that + // does not offer STARTTLS. Unset, it is on for every relay except one on + // this host (see TLSRequired). A code sent in the clear can be read by + // anyone on the path, and a relay's STARTTLS offer can be stripped by + // anyone who can rewrite the conversation. + RequireTLS *bool `toml:"require_tls,omitempty"` +} + +// TLSRequired reports whether mail may go to this relay only over TLS: the +// explicit require_tls when set, otherwise true unless the relay is this +// host (localhost or a loopback address), where the path never leaves the +// machine. +func (c SMTPConfig) TLSRequired() bool { + if c.RequireTLS != nil { + return *c.RequireTLS + } + if strings.EqualFold(c.Host, "localhost") { + return false + } + ip := net.ParseIP(c.Host) + return ip == nil || !ip.IsLoopback() } // DefaultMailPerHour is the install-wide mail cap when smtp.max_per_hour is diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a35f0fb..a9e6293 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -598,6 +598,46 @@ url = "postgres://felis@db/felis" } } +// TestSMTPRequireTLS pins when mail may go out in the clear: only to a relay +// on this host unless require_tls says otherwise, and an explicit value wins +// in both directions. The key is read from felis.toml, not only set in code. +func TestSMTPRequireTLS(t *testing.T) { + load := func(smtp string) config.SMTPConfig { + t.Helper() + cfg, err := config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[smtp] +from = "felis@example.net" +`+smtp)) + if err != nil { + t.Fatalf("Load: %v", err) + } + return cfg.SMTP + } + cases := []struct { + smtp string + want bool + }{ + {`host = "smtp.example.net"`, true}, + {`host = "10.0.0.5"`, true}, + {`host = "localhost"`, false}, + {`host = "LocalHost"`, false}, + {`host = "127.0.0.1"`, false}, + {`host = "127.0.0.53"`, false}, + {`host = "::1"`, false}, + {"host = \"smtp.example.net\"\nrequire_tls = false", false}, + {"host = \"127.0.0.1\"\nrequire_tls = true", true}, + } + for _, c := range cases { + if got := load(c.smtp).TLSRequired(); got != c.want { + t.Errorf("%q: TLSRequired = %v, want %v", c.smtp, got, c.want) + } + } +} + // TestLoadRejectsSMTPWithoutFrom guards the deliverability rule: naming a relay // host commits the block to being sendable, so a missing/invalid From fails at // load rather than at the first OTP a player is waiting on. diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 8a10beb..2cd1159 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -6,10 +6,12 @@ // notice (SendNotice). // // TLS posture: port 465 dials implicit TLS; any other port dials plaintext and -// upgrades via STARTTLS when the relay advertises it. AUTH is attempted only -// when a username is configured, and net/smtp's PlainAuth itself refuses to -// send credentials over an unencrypted connection — a relay that offers no -// TLS can carry unauthenticated mail but can never be handed the password. +// upgrades via STARTTLS. With RequireTLS set (the default for any relay not on +// this host, config.SMTPConfig.TLSRequired) a relay that does not offer +// STARTTLS is refused before a single address or code is sent, so a relay +// without TLS, or a path that strips the offer, fails loudly. AUTH is +// attempted only when a username is configured, and net/smtp's PlainAuth +// itself refuses to send credentials over an unencrypted connection. package mail import ( @@ -38,6 +40,9 @@ type SMTP struct { From string Username string Password string + // RequireTLS refuses a relay on a port other than 465 that does not offer + // STARTTLS. Callers set it from config.SMTPConfig.TLSRequired. + RequireTLS bool } // SendOTP mails code to email as a small bilingual plain-text message. It is @@ -172,6 +177,10 @@ func (s *SMTP) connect(ctx context.Context) (*smtp.Client, error) { c.Close() return nil, fmt.Errorf("smtp: starttls: %w", err) } + } else if s.RequireTLS { + c.Close() + return nil, fmt.Errorf("smtp: %s does not offer STARTTLS, so mail to it would cross the network unencrypted; "+ + "use port 465 or a relay with STARTTLS, or set [smtp] require_tls = false for a relay you reach over a trusted link", addr) } } if s.Username != "" { diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go index 935e2f6..6802e13 100644 --- a/internal/mail/mail_test.go +++ b/internal/mail/mail_test.go @@ -7,6 +7,7 @@ import ( "net" "strconv" "strings" + "sync" "testing" "time" ) @@ -91,6 +92,34 @@ func TestNoticeShape(t *testing.T) { // unconditionally and only render their verdict after the message body, so a // probe stopping short of end-of-DATA reports a green relay that cannot send. func fakeRelay(t *testing.T, dataVerdict string) (string, int) { + t.Helper() + return startRelay(t, &relayOpts{dataVerdict: dataVerdict}) +} + +// relayOpts shapes a fake relay: its end-of-DATA verdict, whether its EHLO +// offers STARTTLS (it cannot complete one: it answers 220 and hangs up), and +// the commands it received, in order. +type relayOpts struct { + dataVerdict string + starttls bool + + mu sync.Mutex + seen []string +} + +func (o *relayOpts) record(cmd string) { + o.mu.Lock() + defer o.mu.Unlock() + o.seen = append(o.seen, cmd) +} + +func (o *relayOpts) commands() []string { + o.mu.Lock() + defer o.mu.Unlock() + return append([]string(nil), o.seen...) +} + +func startRelay(t *testing.T, opts *relayOpts) (string, int) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -103,7 +132,7 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) { if err != nil { return } - go serveFakeRelay(conn, dataVerdict) + go serveFakeRelay(conn, opts) } }() host, portStr, err := net.SplitHostPort(ln.Addr().String()) @@ -117,18 +146,28 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) { return host, port } -func serveFakeRelay(conn net.Conn, dataVerdict string) { +func serveFakeRelay(conn net.Conn, opts *relayOpts) { defer conn.Close() br := bufio.NewReader(conn) io.WriteString(conn, "220 fake ESMTP\r\n") for { line, err := br.ReadString('\n') if err != nil { + opts.record("") return } - switch cmd := strings.ToUpper(strings.TrimSpace(line)); { + cmd := strings.ToUpper(strings.TrimSpace(line)) + if f := strings.Fields(cmd); len(f) > 0 { + opts.record(f[0]) + } + switch { + case strings.HasPrefix(cmd, "EHLO") && opts.starttls: + io.WriteString(conn, "250-fake\r\n250 STARTTLS\r\n") case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): io.WriteString(conn, "250 fake\r\n") + case strings.HasPrefix(cmd, "STARTTLS"): + io.WriteString(conn, "220 ready\r\n") + return case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"): io.WriteString(conn, "250 2.1.0 Ok\r\n") case strings.HasPrefix(cmd, "DATA"): @@ -142,7 +181,7 @@ func serveFakeRelay(conn net.Conn, dataVerdict string) { break } } - io.WriteString(conn, dataVerdict+"\r\n") + io.WriteString(conn, opts.dataVerdict+"\r\n") case strings.HasPrefix(cmd, "QUIT"): io.WriteString(conn, "221 bye\r\n") return @@ -199,3 +238,66 @@ func TestSendOTPSurfacesEndOfDataRefusal(t *testing.T) { t.Errorf("want the relay's refusal surfaced, got: %v", err) } } + +// TestRequireTLSRefusesPlaintextRelay: with RequireTLS a relay that offers no +// STARTTLS gets nothing past EHLO — no sender, no recipient, no code — and the +// operator is told which relay and which knob. +func TestRequireTLSRefusesPlaintextRelay(t *testing.T) { + relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"} + host, port := startRelay(t, relay) + s := &SMTP{Host: host, Port: port, From: "noreply@example.net", RequireTLS: true} + + err := s.SendOTP(context.Background(), "player@example.org", "042137") + if err == nil { + t.Fatal("SendOTP sent a code through a relay without STARTTLS") + } + for _, want := range []string{"does not offer STARTTLS", host + ":" + strconv.Itoa(port), "require_tls = false"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } + // The client hangs up instead of leaving the connection to the garbage + // collector; the relay sees that shortly after SendOTP returns. + deadline := time.Now().Add(2 * time.Second) + for len(relay.commands()) < 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := strings.Join(relay.commands(), " "); got != "EHLO " { + t.Errorf("relay received %q, want EHLO and then the connection closed", got) + } + if err := s.Ping(context.Background()); err == nil || !strings.Contains(err.Error(), "does not offer STARTTLS") { + t.Errorf("Ping = %v, want the STARTTLS refusal", err) + } +} + +// TestPlaintextRelayAllowedWithoutRequireTLS: require_tls = false (or a relay +// on this host) keeps the old opportunistic posture and the code is delivered. +func TestPlaintextRelayAllowedWithoutRequireTLS(t *testing.T) { + relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"} + host, port := startRelay(t, relay) + s := &SMTP{Host: host, Port: port, From: "noreply@example.net"} + + if err := s.SendOTP(context.Background(), "player@example.org", "042137"); err != nil { + t.Fatalf("SendOTP: %v", err) + } + if got := strings.Join(relay.commands(), " "); got != "EHLO MAIL RCPT DATA QUIT" { + t.Errorf("relay received %q, want EHLO MAIL RCPT DATA QUIT", got) + } +} + +// TestRequireTLSTakesAnOfferedStartTLS: when the relay does offer STARTTLS the +// client goes for it rather than refusing; this fake then hangs up mid-upgrade, +// so the failure is the upgrade's own. +func TestRequireTLSTakesAnOfferedStartTLS(t *testing.T) { + relay := &relayOpts{dataVerdict: "250 2.0.0 Ok", starttls: true} + host, port := startRelay(t, relay) + s := &SMTP{Host: host, Port: port, From: "noreply@example.net", RequireTLS: true} + + err := s.SendOTP(context.Background(), "player@example.org", "042137") + if err == nil || !strings.HasPrefix(err.Error(), "smtp: starttls:") { + t.Fatalf("SendOTP = %v, want the STARTTLS upgrade failure", err) + } + if got := strings.Join(relay.commands(), " "); got != "EHLO STARTTLS" { + t.Errorf("relay received %q, want EHLO STARTTLS", got) + } +} diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 21434fd..0afd55a 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -50,6 +50,7 @@ "bad_idle_stop": "Idle stop must be between 1 minute and 24 hours, or Never.", "email_taken": "That email is already verified on another account — sign in with it or use another address.", "mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.", + "mail_unavailable": "This server can't send email codes because no mail relay is set up. Sign in with a passkey, or ask the server operator to configure email.", "bad_path": "That path is invalid — use a relative path inside the world directory.", "too_large": "That is larger than the size limit.", "files_timeout": "The file operation timed out — try again shortly.", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 021ce07..b5ed1d7 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -50,6 +50,7 @@ "bad_idle_stop": "空闲停服时长须在 1 分钟到 24 小时之间,或选择“从不”。", "email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。", "mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。", + "mail_unavailable": "服务器没有配置邮件服务,发不出验证码。请改用 Passkey 登录,或请管理员配置邮件。", "bad_path": "路径不合法——请使用世界目录内的相对路径。", "too_large": "内容超出大小限制,无法处理。", "files_timeout": "文件操作超时——请稍后重试。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index cb09d9b..4bcf99f 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -402,6 +402,13 @@ describe("api access-control wire shapes", () => { ); }); + it("says email codes are off when the install has no mail relay", async () => { + const { humanizeError } = await import("./api"); + expect(humanizeError({ status: 503, code: "mail_unavailable" })).toBe( + "This server can't send email codes because no mail relay is set up. Sign in with a passkey, or ask the server operator to configure email.", + ); + }); + it("maps the backup rationing codes to their own copy", async () => { const { humanizeError } = await import("./api"); expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index f16e67f..53dd11f 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -941,6 +941,9 @@ export function humanizeError(e: unknown): string { return t("email_taken"); case "mail_undeliverable": return t("mail_undeliverable"); + // No [smtp] relay at all: every door that mails a code refuses before minting. + case "mail_unavailable": + return t("mail_unavailable"); // File editor (spec §7): path/size refusals from the sandboxed job, plus the // subsystem being unwired. case "bad_path": diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 57548cc..9ebeb95 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -2488,6 +2488,15 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; + /** @description This install has no [smtp] relay (code mail_unavailable), so no code was minted or sent. The public doors answer it before resolving the address, so it is the same for every address. Sign in with a passkey, or have the operator configure email with felis setup. */ + MailUnavailable: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description This client address called the public sign-in doors faster than the per-address limit allows (code rate_limited); Retry-After gives the seconds until the next call is admitted. The address is the visitor header the install's edge writes ([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else the TCP peer; IPv6 clients share one limit per /64. */ RateLimited: { headers: { @@ -3963,7 +3972,7 @@ export interface operations { }; }; responses: { - /** @description The login methods available for the address, in a deterministic order (passkey before email_otp). An empty array means no verified account. */ + /** @description The login methods available for the address, in a deterministic order (passkey before email_otp). email_otp is offered only when the install has a mail relay, passkey only when a verifier is wired and the account has a credential. An empty array means no verified account, or none of its methods is available on this install. */ 200: { headers: { [name: string]: unknown; @@ -4364,6 +4373,7 @@ export interface operations { }; }; 502: components["responses"]["MailUndeliverable"]; + 503: components["responses"]["MailUnavailable"]; }; }; loginEmailVerify: { @@ -4492,6 +4502,7 @@ export interface operations { }; }; 502: components["responses"]["MailUndeliverable"]; + 503: components["responses"]["MailUnavailable"]; }; }; opLoginStatus: { @@ -5960,7 +5971,7 @@ export interface operations { }; }; responses: { - /** @description Code minted and dispatched (or logged server-side when no mailer is wired). */ + /** @description Code minted and mailed. */ 202: { headers: { [name: string]: unknown; @@ -5995,6 +6006,7 @@ export interface operations { }; }; 502: components["responses"]["MailUndeliverable"]; + 503: components["responses"]["MailUnavailable"]; }; }; emailOtpVerify: { @@ -6388,6 +6400,7 @@ export interface operations { }; }; 502: components["responses"]["MailUndeliverable"]; + 503: components["responses"]["MailUnavailable"]; }; }; reauthEmailVerify: { @@ -6614,6 +6627,7 @@ export interface operations { }; }; 502: components["responses"]["MailUndeliverable"]; + 503: components["responses"]["MailUnavailable"]; }; }; migrateConfirmOtpVerify: {