Files
Felis/internal/mail/mail.go
T
Lemon-miaow 8e7c7bbf24 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.
2026-09-23 03:47:19 +08:00

240 lines
9.6 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 — 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
// 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 := s.deliver(c, email, message(s.From, email, code, time.Now())); err != nil {
return err
}
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
// setup wizard runs it before writing anything, so a bad relay fails at the
// keyboard instead of at the first code a player is waiting on.
//
// It really does send that one message, and it has to: a probe that stops at
// NOOP (or even at MAIL FROM) proves nothing about deliverability, because
// relays which validate sender identity answer MAIL FROM with an unconditional
// 250 and defer the verdict to end-of-DATA. Fastmail does exactly that, and a
// NOOP-only Ping green-lit a From on a domain the account could not send as —
// every OTP after it died at w.Close() with the wizard reporting success.
//
// What it does NOT prove is that the relay will let this From reach anyone
// else. The self-test is addressed to From, which is a mailbox inside the
// relay account, and a relay that gates sender identity at end-of-DATA gates
// it on the way OUT: Fastmail answers 250 for [email protected] → the
// account's own mailbox and 551 5.7.1 "Not authorised to send from this
// header address" for that same From → any external recipient. Only the
// account's exact authorized identity passes the second one — another
// local-part on the same domain is refused too. So a green Ping means
// connect/TLS/AUTH/message-shape are good; the operator still has to have
// authorized From as a sending identity with their provider, and the first
// player OTP is what proves they did. Addressing the self-test elsewhere
// would not fix this — the only mailbox an operator can check is usually
// inside the account as well — so the honest move is to say so here rather
// than to buy false confidence with a bigger probe.
func (s *SMTP) Ping(ctx context.Context) error {
c, err := s.connect(ctx)
if err != nil {
return err
}
defer c.Close()
if err := s.deliver(c, s.From, selfTest(s.From, time.Now())); err != nil {
return err
}
return c.Quit()
}
// deliver runs one MAIL FROM → RCPT TO → DATA transaction on an established
// client. SendOTP and Ping share it so the wizard's check exercises the exact
// path a player's code takes — a check that skips a step is a check that can
// pass while the real send fails.
func (s *SMTP) deliver(c *smtp.Client, to string, msg []byte) error {
if err := c.Mail(s.From); err != nil {
return fmt.Errorf("smtp: MAIL FROM %s: %w", s.From, err)
}
if err := c.Rcpt(to); 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(msg); err != nil {
return fmt.Errorf("smtp: write message: %w", err)
}
// End-of-DATA is where a relay renders its real verdict on the sender, so
// this error names From: "550 …" here almost always means the account is
// not allowed to send as that address.
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: relay refused mail from %s: %w", s.From, err)
}
return nil
}
// 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
}
// headers renders the RFC 5322 header block every Felis message shares: CRLF
// throughout, the subject Q-encoded because both subjects carry non-ASCII, and
// the blank line that ends the block.
func headers(from, to, subject string, now time.Time) string {
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + to + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\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")
return b.String()
}
// message renders the one mail players receive: 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(headers(from, to, "Felis 验证码 · verification code", now))
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())
}
// selfTest renders the message Ping delivers to the sender itself. It carries
// no code and says why it arrived, so an operator who finds it in the inbox
// reads it as the wizard's proof of delivery rather than a stray OTP.
func selfTest(from string, now time.Time) []byte {
var b strings.Builder
b.WriteString(headers(from, from, "Felis SMTP 自检 · relay self-test", now))
b.WriteString("Felis accepted this relay because it accepted this message.\r\n")
b.WriteString("Felis 已确认该邮件中继可用:本邮件即为投递证明。\r\n")
b.WriteString("\r\n")
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)
}