fix(api): 未配置 SMTP 时发码门统一 503 mail_unavailable 且不再把验证码写日志,非本机中继默认强制 STARTTLS(require_tls)

This commit is contained in:
Lemon-miaow committed 2026-09-25 17:25:05 +08:00
1 parent d93c1b6913
commit 7d82402c18
27 files changed
+529 -87

No files matched your search

+23 -2
View File
@@ -59,8 +59,8 @@ type AuthSourceConfig struct {
// 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
// OPTIONAL — an empty host means "no mailer": every door that mails a code
// answers 503 mail_unavailable and sign-in is by passkey only. 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
@@ -79,6 +79,27 @@ type SMTPConfig struct {
// so a flood cannot spend the relay's quota and get the account suspended.
// 0 means DefaultMailPerHour. Size it to the relay's own limit.
MaxPerHour int `toml:"max_per_hour"`
// RequireTLS refuses to send through a relay on a port other than 465 that
// does not offer STARTTLS. Unset, it is on for every relay except one on
// this host (see TLSRequired). A code sent in the clear can be read by
// anyone on the path, and a relay's STARTTLS offer can be stripped by
// anyone who can rewrite the conversation.
RequireTLS *bool `toml:"require_tls,omitempty"`
}
// TLSRequired reports whether mail may go to this relay only over TLS: the
// explicit require_tls when set, otherwise true unless the relay is this
// host (localhost or a loopback address), where the path never leaves the
// machine.
func (c SMTPConfig) TLSRequired() bool {
if c.RequireTLS != nil {
return *c.RequireTLS
}
if strings.EqualFold(c.Host, "localhost") {
return false
}
ip := net.ParseIP(c.Host)
return ip == nil || !ip.IsLoopback()
}
// DefaultMailPerHour is the install-wide mail cap when smtp.max_per_hour is
+40
View File
@@ -598,6 +598,46 @@ url = "postgres://felis@db/felis"
}
}
// TestSMTPRequireTLS pins when mail may go out in the clear: only to a relay
// on this host unless require_tls says otherwise, and an explicit value wins
// in both directions. The key is read from felis.toml, not only set in code.
func TestSMTPRequireTLS(t *testing.T) {
load := func(smtp string) config.SMTPConfig {
t.Helper()
cfg, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[smtp]
from = "[email protected]"
`+smtp))
if err != nil {
t.Fatalf("Load: %v", err)
}
return cfg.SMTP
}
cases := []struct {
smtp string
want bool
}{
{`host = "smtp.example.net"`, true},
{`host = "10.0.0.5"`, true},
{`host = "localhost"`, false},
{`host = "LocalHost"`, false},
{`host = "127.0.0.1"`, false},
{`host = "127.0.0.53"`, false},
{`host = "::1"`, false},
{"host = \"smtp.example.net\"\nrequire_tls = false", false},
{"host = \"127.0.0.1\"\nrequire_tls = true", true},
}
for _, c := range cases {
if got := load(c.smtp).TLSRequired(); got != c.want {
t.Errorf("%q: TLSRequired = %v, want %v", c.smtp, got, c.want)
}
}
}
// 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.