From f0b79e9edd8bbdf00b4d8590efe20488c122aedf Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Mon, 20 Jul 2026 10:24:52 +0900 Subject: [PATCH] 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. --- cmd/felis/api.go | 27 +++ cmd/felis/tui_root.go | 12 + cmd/felis/tui_root_test.go | 26 ++ cmd/felis/tui_smtp.go | 353 ++++++++++++++++++++++++++++ cmd/felis/tui_summary.go | 4 +- internal/api/pgrepo.go | 4 +- internal/config/config.go | 38 +++ internal/config/config_test.go | 50 ++++ internal/mail/mail.go | 152 ++++++++++++ internal/mail/mail_test.go | 41 ++++ internal/platform/workloads.go | 16 ++ internal/platform/workloads_test.go | 17 +- 12 files changed, 730 insertions(+), 10 deletions(-) create mode 100644 cmd/felis/tui_smtp.go create mode 100644 internal/mail/mail.go create mode 100644 internal/mail/mail_test.go diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 5629f60..23d235d 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -16,6 +16,7 @@ import ( "felis.lolicon.best/internal/backupjob" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/config" + "felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/passkey" "felis.lolicon.best/internal/platform" @@ -111,6 +112,31 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers") } + // 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). + var mailer api.OTPMailer + if cfg.SMTP.Host != "" { + passRef := cfg.SMTP.PasswordRef + if passRef == "" { + passRef = platform.SMTPPasswordEnv + } + password := os.Getenv(passRef) + 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, + } + } else { + fmt.Fprintln(stderr, "felis api: [smtp] not configured — email one-time codes are logged, not mailed") + } + // Build subsystem (spec §16): the weak-SA build Job runs in the configured // build namespace and pushes to the internal registry. The build Pod never // holds DB credentials — felis-api owns the PG store and admits scanned @@ -215,6 +241,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { Restorer: restorer, Backuper: backuper, Submissions: submissions, + Mailer: mailer, // The external face is fronted by SessionAuth: it prefers a local session // cookie (minted by the passwordless doors) and otherwise delegates to the // Cloudflare-Access JWT verifier, so both auth models coexist on one face. The diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 161fd1b..a0d83c6 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -313,6 +313,18 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stage = stageStorage return m.adopt(newStorageChooserModel(m.rootDomain, method, prefill)) + case reconfigureSMTPMsg: + // Configuring email after install: the wizard rail deliberately has no SMTP + // step (first-run is mail-less by design), so this is always a summary/status + // detour and returns there when done. + return m.adopt(newSMTPModel(currentSMTPInputs())) + + case smtpResultMsg: + if m.result.alreadySetUp { + return m.showStatus() + } + return m.showSummary() + case goBackMsg: m.stage = stageConnect return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) diff --git a/cmd/felis/tui_root_test.go b/cmd/felis/tui_root_test.go index e73736e..efbf09b 100644 --- a/cmd/felis/tui_root_test.go +++ b/cmd/felis/tui_root_test.go @@ -223,6 +223,32 @@ func TestRootReconfigureStorageReEntersChooser(t *testing.T) { } } +// TestRootReconfigureSMTP locks the post-install "configure email" path: from +// the re-run status screen it opens the SMTP form, and finishing it lands back +// on the status screen (not the first-run summary, which would drop the +// alreadySetUp framing). +func TestRootReconfigureSMTP(t *testing.T) { + m := newTestRoot(true, consoleModeSetup, "") + m = drive(t, m, preflightDoneMsg{}) + if _, ok := m.screen.(*summaryModel); !ok { + t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen) + } + + m = drive(t, m, reconfigureSMTPMsg{}) + if _, ok := m.screen.(*smtpModel); !ok { + t.Fatalf("reconfigure-smtp screen = %T, want *smtpModel", m.screen) + } + + m = drive(t, m, smtpResultMsg{configured: true, detail: "smtp.example.net:587 · from felis@example.net"}) + sum, ok := m.screen.(*summaryModel) + if !ok { + t.Fatalf("after reconfigure-smtp, screen = %T, want *summaryModel", m.screen) + } + if !sum.alreadySetUp { + t.Fatalf("after reconfigure-smtp, summary should still be the alreadySetUp status screen") + } +} + func TestRootRerunLandsOnStatus(t *testing.T) { // adminExists at start of a setup run = re-run: preflight should skip straight // to the "manage in panel" status screen, never touching owner/connect. diff --git a/cmd/felis/tui_smtp.go b/cmd/felis/tui_smtp.go new file mode 100644 index 0000000..a3ccb42 --- /dev/null +++ b/cmd/felis/tui_smtp.go @@ -0,0 +1,353 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "felis.lolicon.best/internal/config" + "felis.lolicon.best/internal/mail" + "felis.lolicon.best/internal/platform" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// ---- Email (SMTP) relay: the post-install "configure email" screen ---- +// +// Bootstrap deliberately has no SMTP (the Owner's address is recorded +// unverified and the passkey is the only pre-SMTP credential), so this screen +// is where a deployment gains real mail: email verification, email-OTP login +// and the op-login mailbox factor all start working once it applies. It is +// reached from the summary/status screen ("e"), mirroring "change storage". + +// smtpInputs is the operator-entered relay coordinates. Only host/port/from/ +// username reach felis.toml; the password goes into the felis-smtp Secret. +type smtpInputs struct { + host string + port string + from string + username string + password string +} + +// reconfigureSMTPMsg is sent from the summary/status screen to open the email +// relay form — the supported way to configure or fix SMTP after install, +// without hand-editing felis.toml and the Secret. +type reconfigureSMTPMsg struct{} + +// smtpResultMsg returns control to the root once the screen is done: applied +// (configured=true, with a recap) or backed out of (configured=false). +type smtpResultMsg struct { + configured bool + detail string +} + +type smtpStep int + +const ( + esForm smtpStep = iota + esWorking + esDone + esError +) + +type smtpApplyMsg struct{ err error } + +// smtpModel drives the email relay form: collect → verify+apply → done/error, +// the storageModel machine with a single form and no chooser. +type smtpModel struct { + step smtpStep + form *huh.Form + sp spinner.Model + err error + in smtpInputs + + width, height int +} + +func newSMTPModel(in smtpInputs) *smtpModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + + if in.port == "" { + in.port = "587" + } + m := &smtpModel{step: esForm, sp: sp, in: in} + m.form = m.build() + return m +} + +func (m *smtpModel) build() *huh.Form { + return m.sized(newFelisForm(huh.NewGroup( + huh.NewNote(). + Title("Email (SMTP)"). + Description("The relay Felis mails one-time codes through — email verification, email login and operator sign-in all need it. The password goes into a Kubernetes Secret; only the other fields are written to felis.toml."), + huh.NewInput(). + Title("SMTP host"). + Description("Your provider's relay, e.g. smtp.gmail.com or smtp.mailgun.org."). + Value(&m.in.host). + Validate(requiredStorageField("SMTP host")), + huh.NewInput(). + Title("Port"). + Description("587 = STARTTLS (most providers) · 465 = implicit TLS."). + Value(&m.in.port). + Validate(validateSMTPPort), + huh.NewInput(). + Title("From address"). + Description("The sender codes are mailed as, e.g. felis@your-domain."). + Value(&m.in.from). + Validate(validateSMTPFrom), + huh.NewInput(). + Title("Username"). + Description("Optional — leave blank for an unauthenticated relay."). + Value(&m.in.username), + huh.NewInput(). + Title("Password"). + Description("Required when a username is set."). + EchoMode(huh.EchoModePassword). + Value(&m.in.password), + ))) +} + +func (m *smtpModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *smtpModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + +func (m *smtpModel) Init() tea.Cmd { return m.form.Init() } + +func (m *smtpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case smtpApplyMsg: + if msg.err != nil { + m.step, m.err = esError, msg.err + return m, nil + } + m.step = esDone + return m, nil + + case spinner.TickMsg: + if m.step == esWorking { + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd + } + return m, nil + + case tea.KeyMsg: + switch m.step { + case esForm: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + return m, smtpEmit(smtpResultMsg{}) + } + case esDone: + switch msg.String() { + case "ctrl+c", "esc", "enter": + return m, smtpEmit(smtpResultMsg{configured: true, detail: smtpDetail(m.in)}) + } + return m, nil + case esError: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.step, m.err = esForm, nil + m.form = m.build() + return m, m.form.Init() + case "enter": + m.step, m.err = esWorking, nil + return m, tea.Batch(m.sp.Tick, m.apply()) + } + return m, nil + case esWorking: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + return m, nil + } + } + + if m.step == esForm && m.form != nil { + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + m.normalizeInputs() + m.step = esWorking + return m, tea.Batch(m.sp.Tick, m.apply()) + case huh.StateAborted: + return m, smtpEmit(smtpResultMsg{}) + } + return m, cmd + } + return m, nil +} + +func smtpEmit(msg smtpResultMsg) tea.Cmd { return func() tea.Msg { return msg } } + +func (m *smtpModel) apply() tea.Cmd { + in := m.in + return func() tea.Msg { + return smtpApplyMsg{err: applySMTPConfig(context.Background(), in)} + } +} + +func (m *smtpModel) normalizeInputs() { + m.in.host = strings.TrimSpace(m.in.host) + m.in.port = strings.TrimSpace(m.in.port) + m.in.from = strings.TrimSpace(m.in.from) + m.in.username = strings.TrimSpace(m.in.username) + m.in.password = strings.TrimSpace(m.in.password) +} + +func (m *smtpModel) View() string { + switch m.step { + case esWorking: + return " " + m.sp.View() + " " + tuiHint.Render("Verifying the relay, saving email settings and rolling the API…") + "\n" + case esDone: + var b strings.Builder + b.WriteString(tuiSuccessBanner("Email configured — codes are now mailed.") + "\n\n") + b.WriteString(tuiInfo("Relay → "+smtpDetail(m.in)) + "\n") + b.WriteString("\n" + tuiAction("enter", "continue")) + return b.String() + case esError: + var b strings.Builder + b.WriteString(tuiErrorBanner("Could not configure email.") + "\n\n") + if m.err != nil { + b.WriteString(tuiHint.Render(m.err.Error()) + "\n") + } + b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit")) + return b.String() + default: + if m.form == nil { + return "" + } + return m.form.View() + } +} + +// arrowNavOK yields the horizontal arrows to the rail except while the form is +// taking text input (where ←/→ move the cursor). +func (m *smtpModel) arrowNavOK() bool { return m.step != esForm } + +// smtpDetail is the one-line relay recap shown on the done screen. +func smtpDetail(in smtpInputs) string { + return in.host + ":" + in.port + " · from " + in.from +} + +func validateSMTPPort(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 || n > 65535 { + return errors.New("port must be a number 1-65535 (587 STARTTLS, 465 implicit TLS)") + } + return nil +} + +func validateSMTPFrom(s string) error { + if !strings.Contains(strings.TrimSpace(s), "@") { + return errors.New("from must be the sender email address") + } + return nil +} + +// currentSMTPInputs reads the relay already recorded in felis.toml so the form +// pre-fills the non-secret fields. The password lives only in the felis-smtp +// Secret and is deliberately never read back — it must be re-entered to change. +// Any read error falls back to a blank form rather than blocking reconfig. +func currentSMTPInputs() smtpInputs { + cfg, err := config.Load(hostSetupConfigPath) + if err != nil || cfg.SMTP.Host == "" { + return smtpInputs{} + } + return smtpInputs{ + host: cfg.SMTP.Host, + port: strconv.Itoa(cfg.SMTP.Port), + from: cfg.SMTP.From, + username: cfg.SMTP.Username, + } +} + +// applySMTPConfig proves the relay works, then persists it and rolls felis-api: +// Ping (connect/STARTTLS/AUTH, no mail sent) → [smtp] into both config files → +// the felis-smtp Secret → the config Secret → rollout. A failed Ping leaves the +// install untouched, so a typo dies at the keyboard, not at a player's OTP. +func applySMTPConfig(ctx context.Context, in smtpInputs) error { + port, err := strconv.Atoi(in.port) + 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 { + 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 + if err := writeConfig(path, cfg); err != nil { + return err + } + } + if err := applySMTPSecret(ctx, in.password); err != nil { + return err + } + if err := applyFelisConfigSecret(ctx); err != nil { + return err + } + if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil { + return err + } + return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s") +} + +// applySMTPSecret creates (or replaces) the felis-smtp Secret the felis-api +// Deployment injects the relay password from. Rendered in-process and piped to +// `kubectl apply` — the password is never a command-line arg, so it never +// appears in the host process table. +func applySMTPSecret(ctx context.Context, password string) error { + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, + ObjectMeta: metav1.ObjectMeta{Name: platform.SMTPSecretName, Namespace: "felis"}, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + platform.SMTPSecretPasswordKey: password, + }, + } + manifest, err := yaml.Marshal(secret) + if err != nil { + return fmt.Errorf("render smtp secret: %w", err) + } + return kubectlWithInput(ctx, manifest, "apply", "-f", "-") +} diff --git a/cmd/felis/tui_summary.go b/cmd/felis/tui_summary.go index a9850b5..51025bc 100644 --- a/cmd/felis/tui_summary.go +++ b/cmd/felis/tui_summary.go @@ -35,6 +35,8 @@ func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return reconfigureConnectMsg{} } case "s", "S": return m, func() tea.Msg { return reconfigureStorageMsg{} } + case "e", "E": + return m, func() tea.Msg { return reconfigureSMTPMsg{} } case "ctrl+c", "esc", "enter", "q": return m, tea.Quit } @@ -78,6 +80,6 @@ func (m *summaryModel) View() string { b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n") } - b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "enter/esc", "exit")) + b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit")) return b.String() } diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index e9eb29d..2627902 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -1863,8 +1863,10 @@ func (p *PGRepo) RedeemMigration(ctx context.Context, targetUserID, codeHash str // merely-asserted address never reaches a session-mintable identity. The // account is passwordless — no password column is read. func (p *PGRepo) UserByEmail(ctx context.Context, email string) (*StaffUser, error) { + // lower() on both sides honors the interface's case-insensitivity contract + // and matches the users_verified_email_unique index (lower(email)). const q = `SELECT id, username, COALESCE(email, ''), role::text, email_verified - FROM users WHERE email = $1 AND email_verified = true` + FROM users WHERE lower(email) = lower($1) AND email_verified = true` var u StaffUser switch err := p.db.QueryRowContext(ctx, q, email).Scan( &u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); { diff --git a/internal/config/config.go b/internal/config/config.go index e0dcfa2..cd8daaa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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() } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 94ea66c..e733455 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 = "felis@example.net" +`)) + 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") + } +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..1e51b0b --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,152 @@ +// Package mail is the SMTP implementation of the api.OTPMailer seam: it +// delivers the email one-time codes the passwordless doors mint (onboarding, +// email login, op-login) through the relay configured in felis.toml [smtp]. +// It is deliberately tiny — one message shape, stdlib net/smtp — because the +// only mail Felis ever sends is a six-digit code. +// +// 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. +package mail + +import ( + "context" + "crypto/tls" + "fmt" + "mime" + "net" + "net/smtp" + "strconv" + "strings" + "time" +) + +// sendTimeout bounds one whole SMTP conversation when the caller's context +// carries no deadline of its own; codes are time-critical (the player is +// staring at a spinner), so a wedged relay must fail fast, not hang a handler. +const sendTimeout = 30 * time.Second + +// SMTP delivers one-time codes through a single configured relay. Fields +// mirror felis.toml [smtp]; Password is the resolved secret (read from the +// env var password_ref names), never the ref itself. +type SMTP struct { + Host string + Port int + From string + Username string + Password string +} + +// SendOTP mails code to email as a small bilingual plain-text message. It is +// the api.OTPMailer implementation felis-api wires when [smtp] is configured. +func (s *SMTP) SendOTP(ctx context.Context, email, code string) error { + c, err := s.connect(ctx) + if err != nil { + return err + } + defer c.Close() + if err := c.Mail(s.From); err != nil { + return fmt.Errorf("smtp: MAIL FROM %s: %w", s.From, err) + } + if err := c.Rcpt(email); err != nil { + return fmt.Errorf("smtp: RCPT TO: %w", err) + } + w, err := c.Data() + if err != nil { + return fmt.Errorf("smtp: DATA: %w", err) + } + if _, err := w.Write(message(s.From, email, code, time.Now())); err != nil { + return fmt.Errorf("smtp: write message: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp: deliver: %w", err) + } + return c.Quit() +} + +// Ping proves the configured relay is reachable and the credentials work +// WITHOUT sending any mail: connect, (STARTTLS,) AUTH, NOOP, QUIT. The setup +// wizard runs it before writing anything, so a typo fails at the keyboard +// instead of at the first code a player is waiting on. +func (s *SMTP) Ping(ctx context.Context) error { + c, err := s.connect(ctx) + if err != nil { + return err + } + defer c.Close() + if err := c.Noop(); err != nil { + return fmt.Errorf("smtp: noop: %w", err) + } + return c.Quit() +} + +// connect dials the relay, upgrades to TLS per the port's posture, and +// authenticates when a username is configured. The whole conversation shares +// one deadline (the context's, else sendTimeout from now). +func (s *SMTP) connect(ctx context.Context) (*smtp.Client, error) { + addr := net.JoinHostPort(s.Host, strconv.Itoa(s.Port)) + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(sendTimeout) + } + dialer := &net.Dialer{Deadline: deadline} + + var conn net.Conn + var err error + if s.Port == 465 { + // Implicit TLS: the socket is TLS from byte zero (smtps submission). + conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: s.Host}}).DialContext(ctx, "tcp", addr) + } else { + conn, err = dialer.DialContext(ctx, "tcp", addr) + } + if err != nil { + return nil, fmt.Errorf("smtp: dial %s: %w", addr, err) + } + _ = conn.SetDeadline(deadline) + + c, err := smtp.NewClient(conn, s.Host) + if err != nil { + conn.Close() + return nil, fmt.Errorf("smtp: handshake %s: %w", addr, err) + } + if s.Port != 465 { + if ok, _ := c.Extension("STARTTLS"); ok { + if err := c.StartTLS(&tls.Config{ServerName: s.Host}); err != nil { + c.Close() + return nil, fmt.Errorf("smtp: starttls: %w", err) + } + } + } + if s.Username != "" { + // PlainAuth refuses an unencrypted connection on its own, so the password + // can never leak to a relay that failed to negotiate TLS above. + if err := c.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil { + c.Close() + return nil, fmt.Errorf("smtp: auth as %s: %w", s.Username, err) + } + } + return c, nil +} + +// message renders the one mail shape Felis sends: RFC 5322 headers (CRLF, the +// subject Q-encoded for its non-ASCII half) over a short bilingual plain-text +// body carrying the code. Split out from SendOTP so the shape is testable +// without a relay. +func message(from, to, code string, now time.Time) []byte { + var b strings.Builder + b.WriteString("From: " + from + "\r\n") + b.WriteString("To: " + to + "\r\n") + b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", "Felis 验证码 · verification code") + "\r\n") + b.WriteString("Date: " + now.Format(time.RFC1123Z) + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("\r\n") + b.WriteString("Your Felis verification code / Felis 验证码:\r\n") + b.WriteString("\r\n") + b.WriteString(" " + code + "\r\n") + b.WriteString("\r\n") + b.WriteString("If you didn't request this, ignore this message. / 若非本人操作,请忽略此邮件。\r\n") + return []byte(b.String()) +} diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go new file mode 100644 index 0000000..e3fb272 --- /dev/null +++ b/internal/mail/mail_test.go @@ -0,0 +1,41 @@ +package mail + +import ( + "strings" + "testing" + "time" +) + +// TestMessageShape pins the one mail Felis sends: CRLF line endings throughout +// (RFC 5322 — a bare LF is how relays mangle or reject a message), the code in +// the body, a Q-encoded subject (it carries non-ASCII), and a blank line +// separating headers from body. +func TestMessageShape(t *testing.T) { + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + msg := string(message("felis@example.net", "player@example.org", "042137", now)) + + if strings.Contains(strings.ReplaceAll(msg, "\r\n", ""), "\n") { + t.Error("message contains a bare LF; every line must end CRLF") + } + headers, body, ok := strings.Cut(msg, "\r\n\r\n") + if !ok { + t.Fatal("message has no blank line between headers and body") + } + for _, want := range []string{ + "From: felis@example.net", + "To: player@example.org", + "Date: Mon, 20 Jul 2026 12:00:00 +0000", + "Content-Type: text/plain; charset=utf-8", + } { + if !strings.Contains(headers, want) { + t.Errorf("headers missing %q:\n%s", want, headers) + } + } + // The subject carries 验证码, so it must be MIME-encoded, never raw UTF-8. + if !strings.Contains(headers, "Subject: =?utf-8?") { + t.Errorf("subject must be Q-encoded, got headers:\n%s", headers) + } + if !strings.Contains(body, "042137") { + t.Errorf("body missing the code:\n%s", body) + } +} diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index c5b3898..d7a1f55 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -103,6 +103,17 @@ const ( UploadsS3AccessKeyEnv = "FELIS_UPLOADS_S3_ACCESS_KEY" UploadsS3SecretKeyEnv = "FELIS_UPLOADS_S3_SECRET_KEY" + // SMTPSecretName is the out-of-band Secret carrying the [smtp] relay password. + // Same red line as the S3 credentials: the setup wizard's "configure email" + // step creates it, felis-api reads it via SMTPPasswordEnv (optionally — a + // mailer-less install has no such Secret and still starts, falling back to + // logging codes), and it is never rendered into the bundle. + SMTPSecretName = "felis-smtp" + SMTPSecretPasswordKey = "password" + // SMTPPasswordEnv is the env var felis-api reads the relay password from; + // [smtp] password_ref defaults to this name. + SMTPPasswordEnv = "FELIS_SMTP_PASSWORD" + // worldsMountPath is where the reaper CronJob mounts the worlds-root (read-only). // It is the default of `felis reaper --worlds-root`; the resolver then reads each // world at /. Single-sourced with cmd/felis/reaper.go. @@ -234,6 +245,11 @@ func APIDeployment(p Params) *appsv1.Deployment { corev1.EnvVar{Name: UploadsS3SecretKeyEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: UploadsS3SecretName}, Key: UploadsS3SecretSecretKey, Optional: optional, }}}, + // The [smtp] relay password, same optional-Secret pattern: absent until the + // setup wizard's "configure email" step creates felis-smtp. + corev1.EnvVar{Name: SMTPPasswordEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: SMTPSecretName}, Key: SMTPSecretPasswordKey, Optional: optional, + }}}, ) container := corev1.Container{ diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go index 1997b48..ddbce60 100644 --- a/internal/platform/workloads_test.go +++ b/internal/platform/workloads_test.go @@ -230,22 +230,23 @@ func TestAPIDeployment_UploadsStorage(t *testing.T) { t.Errorf("uploads mount = %#v, want read-write at %s", m, UploadsLocalPath) } - // S3 backend: both credential env vars come from the Secret (never literals) and - // are OPTIONAL, so a local install with no such Secret still starts. - for _, ev := range []struct{ name, key string }{ - {UploadsS3AccessKeyEnv, UploadsS3SecretAccessKey}, - {UploadsS3SecretKeyEnv, UploadsS3SecretSecretKey}, + // Credential env vars (S3 backend + SMTP relay) come from their Secrets (never + // literals) and are OPTIONAL, so an install without them still starts. + for _, ev := range []struct{ name, secret, key string }{ + {UploadsS3AccessKeyEnv, UploadsS3SecretName, UploadsS3SecretAccessKey}, + {UploadsS3SecretKeyEnv, UploadsS3SecretName, UploadsS3SecretSecretKey}, + {SMTPPasswordEnv, SMTPSecretName, SMTPSecretPasswordKey}, } { e := envVar(c.Env, ev.name) if e == nil || e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { t.Fatalf("%s must be sourced from a secretKeyRef", ev.name) } ref := e.ValueFrom.SecretKeyRef - if ref.Name != UploadsS3SecretName || ref.Key != ev.key { - t.Errorf("%s ref = %s/%s, want %s/%s", ev.name, ref.Name, ref.Key, UploadsS3SecretName, ev.key) + if ref.Name != ev.secret || ref.Key != ev.key { + t.Errorf("%s ref = %s/%s, want %s/%s", ev.name, ref.Name, ref.Key, ev.secret, ev.key) } if ref.Optional == nil || !*ref.Optional { - t.Errorf("%s secretKeyRef must be optional (a local install has no such Secret)", ev.name) + t.Errorf("%s secretKeyRef must be optional (an install without it has no such Secret)", ev.name) } if e.Value != "" { t.Errorf("%s must not carry a literal value", ev.name)