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.
153 lines
5.2 KiB
Go
153 lines
5.2 KiB
Go
// 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())
|
|
}
|