Files
felhom.eu/hub/internal/mailhold/mailhold.go
T

89 lines
3.7 KiB
Go

// Package mailhold is the hub's one mail gate: while the marker file `<data_dir>/MAIL-HOLD` exists, the hub sends
// NO e-mail at all — household or operator, on every path that reaches Resend (the dispatcher's HTTP-API sends, the
// legacy /notify endpoint, the app-mail SMTP relay).
//
// WHY. The 2026-10-09 restore drill (documentation/audits/dooplex-survival-2026-10-09/partD-05-checks.txt) started a
// hub on a restored database, and within a minute it mailed households the pending "kernel notice" mails.
// `notifications.operator_enabled: false` did not stop it, because that switch covers the operator channel only. A
// restored copy is a second hub speaking with the first one's voice; it must start quiet until the operator says so.
// The marker is created (an empty file is enough) before a restored copy's first start.
//
// HELD MAILS ARE DROPPED, NOT QUEUED, and that is deliberate: every mail a restored copy wants to send was decided
// from a snapshot of the past — a kernel notice for a step the real hub already ran or will run, an alarm about a
// state that has since changed. Re-sending them after the hold is lifted would deliver stale news as if it were
// current. After the release, the hub's checkers and boxes produce fresh mails from live state on their own. Each
// dropped mail is logged once (`[WARN] MAIL-HOLD: not sending <kind> to <who>` — never the address, never the body).
//
// The marker is read on every send (one stat of a file in the data dir; no wait on a device the hub does not
// already depend on for its database). A stat error other than "does not exist" counts as HELD: the gate fails
// closed, and the operator pages show the banner, so the state is never invisible.
package mailhold
import (
"errors"
"log"
"os"
"path/filepath"
)
// FileName is the marker's name inside the hub's data directory.
const FileName = "MAIL-HOLD"
// ErrHeld is returned by Check for a mail the hold stopped. Callers treat it as "not sent" (never as sent).
var ErrHeld = errors.New("mail hold: the hub is not sending e-mail (MAIL-HOLD marker present)")
// Hold reads and removes the marker. The zero value and a nil *Hold never hold (tests and wiring without a data dir).
type Hold struct {
Path string
Logger *log.Logger
}
// New returns the hold for a data directory.
func New(dataDir string, logger *log.Logger) *Hold {
return &Hold{Path: filepath.Join(dataDir, FileName), Logger: logger}
}
// Held reports whether the marker is present (fail-closed on an unreadable state).
func (h *Hold) Held() bool {
if h == nil || h.Path == "" {
return false
}
_, err := os.Stat(h.Path)
if err == nil {
return true
}
if errors.Is(err, os.ErrNotExist) {
return false
}
h.logf("[WARN] MAIL-HOLD: cannot read the marker state (%v) — treating mail as HELD", err)
return true
}
// Check is the gate every sender calls immediately before handing a mail to Resend. When held it logs the one WARN
// line for this mail and returns ErrHeld; otherwise nil. kind names the mail ("customer event backup_failed",
// "kernel notice", …); who is a customer ID or "operator" — never an address.
func (h *Hold) Check(kind, who string) error {
if !h.Held() {
return nil
}
h.logf("[WARN] MAIL-HOLD: not sending %s to %s", kind, who)
return ErrHeld
}
// Release removes the marker. Removing a marker that is not there is not an error (the release is idempotent).
func (h *Hold) Release() error {
if h == nil || h.Path == "" {
return nil
}
if err := os.Remove(h.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func (h *Hold) logf(format string, args ...interface{}) {
if h != nil && h.Logger != nil {
h.Logger.Printf(format, args...)
}
}