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
@@ -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); {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <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{
|
||||
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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user