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:
flyemoji committed 2026-07-20 10:24:52 +09:00
1 parent 7860152f57
commit f0b79e9edd
12 files changed
+730 -10

No files matched your search

+27
View File
@@ -16,6 +16,7 @@ import (
"felis.lolicon.best/internal/backupjob" "felis.lolicon.best/internal/backupjob"
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/panel"
"felis.lolicon.best/internal/passkey" "felis.lolicon.best/internal/passkey"
"felis.lolicon.best/internal/platform" "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") 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 subsystem (spec §16): the weak-SA build Job runs in the configured
// build namespace and pushes to the internal registry. The build Pod never // build namespace and pushes to the internal registry. The build Pod never
// holds DB credentials — felis-api owns the PG store and admits scanned // 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, Restorer: restorer,
Backuper: backuper, Backuper: backuper,
Submissions: submissions, Submissions: submissions,
Mailer: mailer,
// The external face is fronted by SessionAuth: it prefers a local session // The external face is fronted by SessionAuth: it prefers a local session
// cookie (minted by the passwordless doors) and otherwise delegates to the // cookie (minted by the passwordless doors) and otherwise delegates to the
// Cloudflare-Access JWT verifier, so both auth models coexist on one face. The // Cloudflare-Access JWT verifier, so both auth models coexist on one face. The
+12
View File
@@ -313,6 +313,18 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stage = stageStorage m.stage = stageStorage
return m.adopt(newStorageChooserModel(m.rootDomain, method, prefill)) 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: case goBackMsg:
m.stage = stageConnect m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
+26
View File
@@ -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 [email protected]"})
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) { func TestRootRerunLandsOnStatus(t *testing.T) {
// adminExists at start of a setup run = re-run: preflight should skip straight // adminExists at start of a setup run = re-run: preflight should skip straight
// to the "manage in panel" status screen, never touching owner/connect. // to the "manage in panel" status screen, never touching owner/connect.
+353
View File
@@ -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", "-")
}
+3 -1
View File
@@ -35,6 +35,8 @@ func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, func() tea.Msg { return reconfigureConnectMsg{} } return m, func() tea.Msg { return reconfigureConnectMsg{} }
case "s", "S": case "s", "S":
return m, func() tea.Msg { return reconfigureStorageMsg{} } return m, func() tea.Msg { return reconfigureStorageMsg{} }
case "e", "E":
return m, func() tea.Msg { return reconfigureSMTPMsg{} }
case "ctrl+c", "esc", "enter", "q": case "ctrl+c", "esc", "enter", "q":
return m, tea.Quit 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(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() return b.String()
} }
+3 -1
View File
@@ -1863,8 +1863,10 @@ func (p *PGRepo) RedeemMigration(ctx context.Context, targetUserID, codeHash str
// merely-asserted address never reaches a session-mintable identity. The // merely-asserted address never reaches a session-mintable identity. The
// account is passwordless — no password column is read. // account is passwordless — no password column is read.
func (p *PGRepo) UserByEmail(ctx context.Context, email string) (*StaffUser, error) { 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 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 var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, email).Scan( switch err := p.db.QueryRowContext(ctx, q, email).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); { &u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); {
+38
View File
@@ -21,6 +21,7 @@ type Config struct {
K8s K8sConfig `toml:"k8s"` K8s K8sConfig `toml:"k8s"`
Registry RegistryConfig `toml:"registry"` Registry RegistryConfig `toml:"registry"`
Archive ArchiveConfig `toml:"archive"` Archive ArchiveConfig `toml:"archive"`
SMTP SMTPConfig `toml:"smtp"`
// AuthSources is the [[auth_source]] array-of-tables: the third-party Yggdrasil // AuthSources is the [[auth_source]] array-of-tables: the third-party Yggdrasil
// roots the Felis-nano hasJoined multiplexer federates over, in priority order // 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 // (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"` 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. // ServerConfig is the [server] table.
type ServerConfig struct { type ServerConfig struct {
Listen string `toml:"listen"` Listen string `toml:"listen"`
@@ -179,6 +200,9 @@ const (
// so this base only has to be a sensible, parseable prefix (see the §16 build // 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). // subsystem and the internal/submit package doc for the lane's provenance).
defaultUserUploadsContext = "s3://felis-user-uploads" 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 // 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 == "" { if c.Registry.UserUploadsContext == "" {
c.Registry.UserUploadsContext = defaultUserUploadsContext 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 // 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, "://") { 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) 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() return c.validateAuthSources()
} }
+50
View File
@@ -405,3 +405,53 @@ url = "postgres://felis@db/felis"
t.Fatal("expected error for unknown key") 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")
}
}
+152
View File
@@ -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())
}
+41
View File
@@ -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("[email protected]", "[email protected]", "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: [email protected]",
"To: [email protected]",
"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)
}
}
+16
View File
@@ -103,6 +103,17 @@ const (
UploadsS3AccessKeyEnv = "FELIS_UPLOADS_S3_ACCESS_KEY" UploadsS3AccessKeyEnv = "FELIS_UPLOADS_S3_ACCESS_KEY"
UploadsS3SecretKeyEnv = "FELIS_UPLOADS_S3_SECRET_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). // 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 // It is the default of `felis reaper --worlds-root`; the resolver then reads each
// world at <worldsMountPath>/<pvc>. Single-sourced with cmd/felis/reaper.go. // world at <worldsMountPath>/<pvc>. 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{ corev1.EnvVar{Name: UploadsS3SecretKeyEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: UploadsS3SecretName}, Key: UploadsS3SecretSecretKey, Optional: optional, 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{ container := corev1.Container{
+9 -8
View File
@@ -230,22 +230,23 @@ func TestAPIDeployment_UploadsStorage(t *testing.T) {
t.Errorf("uploads mount = %#v, want read-write at %s", m, UploadsLocalPath) 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 // Credential env vars (S3 backend + SMTP relay) come from their Secrets (never
// are OPTIONAL, so a local install with no such Secret still starts. // literals) and are OPTIONAL, so an install without them still starts.
for _, ev := range []struct{ name, key string }{ for _, ev := range []struct{ name, secret, key string }{
{UploadsS3AccessKeyEnv, UploadsS3SecretAccessKey}, {UploadsS3AccessKeyEnv, UploadsS3SecretName, UploadsS3SecretAccessKey},
{UploadsS3SecretKeyEnv, UploadsS3SecretSecretKey}, {UploadsS3SecretKeyEnv, UploadsS3SecretName, UploadsS3SecretSecretKey},
{SMTPPasswordEnv, SMTPSecretName, SMTPSecretPasswordKey},
} { } {
e := envVar(c.Env, ev.name) e := envVar(c.Env, ev.name)
if e == nil || e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { if e == nil || e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil {
t.Fatalf("%s must be sourced from a secretKeyRef", ev.name) t.Fatalf("%s must be sourced from a secretKeyRef", ev.name)
} }
ref := e.ValueFrom.SecretKeyRef ref := e.ValueFrom.SecretKeyRef
if ref.Name != UploadsS3SecretName || ref.Key != 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, UploadsS3SecretName, 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 { 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 != "" { if e.Value != "" {
t.Errorf("%s must not carry a literal value", ev.name) t.Errorf("%s must not carry a literal value", ev.name)