feat(mail): deliver email one-time codes over SMTP and add the setup email screen
Felis never actually sent mail: OTP codes for onboarding, email login and
op-login were only written to the felis-api log behind a "demo has no SMTP"
limitation, and the Settings/SMTP flow those comments promised was never
built. Combined with the bootstrap Owner's address being recorded unverified
(87279a1), op-login start always took the anti-enumeration neutral branch and
minted a fake request_id, so the in-game approve inevitably answered "No
pending operator sign-in with that code".
Give the codes a real delivery path, configured in felis.toml rather than a
web settings page so config keeps a single source of truth:
- config: new [smtp] table (host, port defaulting to 587, from, username,
password_ref). Validation requires a plausible from address and a sane
port; the password itself never enters the config file.
- internal/mail (new): stdlib net/smtp mailer implementing the api.OTPMailer
seam. Port 465 dials implicit TLS, other ports upgrade via STARTTLS when
advertised; AUTH only when a username is configured (PlainAuth itself
refuses plaintext, so the password cannot leak to a TLS-less relay).
Ping() proves reachability and credentials without sending mail. The
message shape (CRLF, Q-encoded bilingual subject) is pinned by test.
- platform: felis-smtp Secret constants and an optional FELIS_SMTP_PASSWORD
env var on the felis-api Deployment, mirroring felis-uploads-s3.
- cmd/felis api: construct the real mailer when [smtp] is configured; keep
the log fallback otherwise and say so at startup. Warn when a username is
set but the credentials env is empty.
- setup TUI: "e" on the summary/status screen opens the email form (host,
port, from, optional auth). Apply order: Ping preflight, [smtp] into both
host and pod config files, felis-smtp Secret piped to kubectl via stdin,
config Secret, felis-api rollout. A failed preflight leaves the install
untouched. SMTP is deliberately not a wizard rail step: first-run stays
mail-less by design, and the passkey minted at onboarding is the pre-SMTP
owner credential.
Also make PGRepo.UserByEmail match case-insensitively (lower(email) =
lower($1)), honoring the interface contract and the users_verified_email_
unique partial index; the fake repo already matched with EqualFold.
Existing installs need the felis-api Deployment manifest re-applied (e.g. a
bootstrap re-run) before the new env var exists; a rollout restart alone
cannot add it.
This commit is contained in:
12 files changed
+730
-10
No files matched your search
@@ -21,6 +21,7 @@ type Config struct {
|
||||
K8s K8sConfig `toml:"k8s"`
|
||||
Registry RegistryConfig `toml:"registry"`
|
||||
Archive ArchiveConfig `toml:"archive"`
|
||||
SMTP SMTPConfig `toml:"smtp"`
|
||||
// AuthSources is the [[auth_source]] array-of-tables: the third-party Yggdrasil
|
||||
// roots the Felis-nano hasJoined multiplexer federates over, in priority order
|
||||
// (config order = priority, so array-of-tables not a map — a map would lose order
|
||||
@@ -47,6 +48,26 @@ type AuthSourceConfig struct {
|
||||
URL string `toml:"url"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// felis.toml. The setup wizard's "configure email" step creates the felis-smtp
|
||||
// Secret the deployment injects that variable from.
|
||||
type SMTPConfig struct {
|
||||
Host string `toml:"host"`
|
||||
// Port defaults to 587 (STARTTLS submission). 465 selects implicit TLS.
|
||||
Port int `toml:"port"`
|
||||
// From is the envelope/header sender address the codes are mailed as.
|
||||
From string `toml:"from"`
|
||||
// Username is the AUTH identity; empty means the relay needs no AUTH.
|
||||
Username string `toml:"username"`
|
||||
PasswordRef string `toml:"password_ref"`
|
||||
}
|
||||
|
||||
// ServerConfig is the [server] table.
|
||||
type ServerConfig struct {
|
||||
Listen string `toml:"listen"`
|
||||
@@ -179,6 +200,9 @@ const (
|
||||
// so this base only has to be a sensible, parseable prefix (see the §16 build
|
||||
// subsystem and the internal/submit package doc for the lane's provenance).
|
||||
defaultUserUploadsContext = "s3://felis-user-uploads"
|
||||
// defaultSMTPPort is the STARTTLS submission port; applied only when [smtp]
|
||||
// host is set (a portless [smtp] block with no host stays fully zero).
|
||||
defaultSMTPPort = 587
|
||||
)
|
||||
|
||||
// decodeConfig reads a felis.toml and rejects unknown keys (typos surface as errors
|
||||
@@ -251,6 +275,9 @@ func (c *Config) applyDefaults() {
|
||||
if c.Registry.UserUploadsContext == "" {
|
||||
c.Registry.UserUploadsContext = defaultUserUploadsContext
|
||||
}
|
||||
if c.SMTP.Host != "" && c.SMTP.Port == 0 {
|
||||
c.SMTP.Port = defaultSMTPPort
|
||||
}
|
||||
}
|
||||
|
||||
// Validate enforces the mandatory fields (spec §24: database.url is 强制) and
|
||||
@@ -288,6 +315,17 @@ func (c *Config) Validate() error {
|
||||
if c.Registry.URL != "" && strings.Contains(c.Registry.URL, "://") {
|
||||
return fmt.Errorf("config: [registry] url %q must be a bare host[:port] with no scheme (e.g. registry.felis.svc:5000); a scheme breaks the user-modpack build lane's derived push target", c.Registry.URL)
|
||||
}
|
||||
// [smtp] is optional as a whole, but once a host is named the block must be
|
||||
// deliverable: a From address (relays reject MAIL FROM:<>) and a sane port.
|
||||
// Fail at load, not at the first OTP a player is waiting on.
|
||||
if c.SMTP.Host != "" {
|
||||
if !strings.Contains(c.SMTP.From, "@") {
|
||||
return fmt.Errorf("config: [smtp] from %q must be the sender email address codes are mailed as", c.SMTP.From)
|
||||
}
|
||||
if c.SMTP.Port < 1 || c.SMTP.Port > 65535 {
|
||||
return fmt.Errorf("config: [smtp] port %d must be 1-65535 (587 STARTTLS, 465 implicit TLS)", c.SMTP.Port)
|
||||
}
|
||||
}
|
||||
return c.validateAuthSources()
|
||||
}
|
||||
|
||||
|
||||
@@ -405,3 +405,53 @@ url = "postgres://felis@db/felis"
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadSMTPDefaultsPort pins the [smtp] contract: a host with no port gets the
|
||||
// 587 STARTTLS default, and an absent [smtp] block stays fully zero (no mailer).
|
||||
func TestLoadSMTPDefaultsPort(t *testing.T) {
|
||||
cfg, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
[smtp]
|
||||
host = "smtp.example.net"
|
||||
from = "[email protected]"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.SMTP.Port != 587 {
|
||||
t.Errorf("smtp port = %d, want the 587 default", cfg.SMTP.Port)
|
||||
}
|
||||
|
||||
cfg, err = config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load without [smtp]: %v", err)
|
||||
}
|
||||
if cfg.SMTP.Host != "" || cfg.SMTP.Port != 0 {
|
||||
t.Errorf("absent [smtp] must stay zero, got %+v", cfg.SMTP)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestLoadRejectsSMTPWithoutFrom(t *testing.T) {
|
||||
_, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
[smtp]
|
||||
host = "smtp.example.net"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when [smtp] host is set without a from address")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user