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.
42 lines
1.3 KiB
Go
42 lines
1.3 KiB
Go
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)
|
|
}
|
|
}
|