fix(reaper): deliver pre-reap warnings for real — and never fake a delivery
The §18 warning path had no delivery channel at all: no Warner implementation existed, `felis reaper` passed nil, and maybeWarn still stamped warned_3d_at/ warned_1d_at and counted `warned=N`. So every owned server was silently reaped 15 days after its last join with no notice, and the operator's only feedback said warnings were sent. Two changes close that: - Honest stamps: warned_* now records a DELIVERED notice. A nil Warner logs `warning suppressed — no warner wired` and does NOT stamp; a delivery error logs and retries on the next daily run (bounded by the warning window). The stamps are no longer burned by notices nobody received. - A real channel: mail.SendNotice (the second and last message shape the mail package sends) plus a mailWarner that resolves the owner's VERIFIED email and mails the notice through the configured [smtp] relay. `felis reaper` wires it when [smtp] is set (same password_ref convention as felis-api) and prints exactly what happens when it is not. Plumbing so the in-cluster CronJob can actually reach the relay: the reaper pod gets the optional FELIS_SMTP_PASSWORD env (same Secret as felis-api), and the "configure email" screen now refreshes the minecraft-namespace mirrors of felis-smtp AND felis-config (a secretKeyRef is namespace-local, and the config mirror is what carries [smtp] into the reaper's own config). `felis setup`'s replica list gains felis-smtp for fresh installs. Tests: the delivered/retried/suppressed matrix in internal/reaper (the old "stamp advances on failure" contract is deliberately replaced), the notice message shape, the warner's resolve/send/failure paths, and the CronJob's optional-secret env. docs/troubleshooting.md §10 now states the real semantics.
This commit is contained in:
12 files changed
+375
-27
No files matched your search
+31
-2
@@ -1,8 +1,9 @@
|
||||
// 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.
|
||||
// It is deliberately tiny — two message shapes, stdlib net/smtp — because the
|
||||
// only mail Felis ever sends is a six-digit code plus the reaper's pre-deletion
|
||||
// notice (SendNotice).
|
||||
//
|
||||
// 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
|
||||
@@ -53,6 +54,23 @@ func (s *SMTP) SendOTP(ctx context.Context, email, code string) error {
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// SendNotice mails one operator-composed notice to email — the reaper's
|
||||
// pre-deletion warning is its only caller. Subject and body are the caller's;
|
||||
// the body is CRLF-normalized so a multi-line string renders as one text/plain
|
||||
// message. Delivery errors surface exactly like SendOTP's, so the caller can
|
||||
// retry on its own cadence.
|
||||
func (s *SMTP) SendNotice(ctx context.Context, email, subject, body string) error {
|
||||
c, err := s.connect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
if err := s.deliver(c, email, notice(s.From, email, subject, body, time.Now())); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Ping proves the configured relay will actually ACCEPT mail from this sender,
|
||||
// by running a complete transaction — connect, (STARTTLS,) AUTH, MAIL FROM,
|
||||
// RCPT TO, DATA — and delivering a short self-test message to From itself. The
|
||||
@@ -208,3 +226,14 @@ func selfTest(from string, now time.Time) []byte {
|
||||
b.WriteString("Sent by `felis setup` when the SMTP relay was configured. / 由 `felis setup` 配置 SMTP 时发出。\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// notice renders an operator notice: the shared header block plus the caller's
|
||||
// body, CRLF-normalized so every line obeys RFC 5322 regardless of which line
|
||||
// endings the caller's format string produced.
|
||||
func notice(from, to, subject, body string, now time.Time) []byte {
|
||||
body = strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")
|
||||
if !strings.HasSuffix(body, "\r\n") {
|
||||
body += "\r\n"
|
||||
}
|
||||
return []byte(headers(from, to, subject, now) + body)
|
||||
}
|
||||
@@ -58,6 +58,33 @@ func TestSelfTestCarriesNoCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoticeShape pins the second message shape — the reaper's pre-deletion
|
||||
// warning: CRLF throughout even when the caller's body used bare LFs, a
|
||||
// Q-encoded subject when it carries non-ASCII, and the caller's text rendered
|
||||
// verbatim between the header block and the wire.
|
||||
func TestNoticeShape(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
msg := string(notice("[email protected]", "[email protected]",
|
||||
"Felis: 服务器 survival 将回收", "line one\nline two\n", now))
|
||||
|
||||
if strings.Contains(strings.ReplaceAll(msg, "\r\n", ""), "\n") {
|
||||
t.Error("notice contains a bare LF; every line must end CRLF")
|
||||
}
|
||||
headers, body, ok := strings.Cut(msg, "\r\n\r\n")
|
||||
if !ok {
|
||||
t.Fatal("notice has no blank line between headers and body")
|
||||
}
|
||||
if !strings.Contains(headers, "To: [email protected]") {
|
||||
t.Errorf("headers missing To:\n%s", headers)
|
||||
}
|
||||
if !strings.Contains(headers, "Subject: =?utf-8?") {
|
||||
t.Errorf("non-ASCII subject must be Q-encoded:\n%s", headers)
|
||||
}
|
||||
if !strings.Contains(body, "line one\r\nline two\r\n") {
|
||||
t.Errorf("body must be CRLF-normalized verbatim text:\n%q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRelay speaks just enough SMTP for net/smtp, answering 250 to MAIL FROM
|
||||
// and RCPT TO but dataVerdict at end-of-DATA. That split is the entire point:
|
||||
// relays which validate sender identity (Fastmail among them) accept MAIL FROM
|
||||
|
||||
Reference in new issue
Block a user