// Package mailhold is the hub's one mail gate: while the marker file `/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 to ` — 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...) } }