Unverified Commit f0b79e9e authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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
(87279a13), 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.
parent 7860152f
Loading
Loading
Loading
Loading
+27 −0
Changes for cmd/felis/api.go: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+12 −0
Changes for cmd/felis/tui_root.go: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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))
+26 −0
Changes for cmd/felis/tui_root_test.go: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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) {
	// adminExists at start of a setup run = re-run: preflight should skip straight
	// to the "manage in panel" status screen, never touching owner/connect.

cmd/felis/tui_smtp.go

0 → 100644
+353 −0
Changes for cmd/felis/tui_smtp.go: 353 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
Changes for cmd/felis/tui_summary.go: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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()
}
Loading