Phase 7 follow-up: 1-month body retention and actions_log cap (AUD-75)

Implements the AGENTS.md §7 data rule: keep message bodies for one month, and
size-cap the append-only action log.

- internal/store/retention.go: Store.Retain clears messages.body_text older
  than the window (metadata stays) and deletes actions_log rows beyond the
  newest N, in one transaction.
- internal/config: EMAIL_AGENT_RETENTION_DAYS (30) and
  EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS (50000).
- cmd/email-agent: bounded -retain flag for the n8n schedule.
- Tests for body clearing, metadata retention, log cap, and disabled policy.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 10:45:16 +11:00
parent 55a08524b5
commit 17d605dfd5
9 changed files with 380 additions and 5 deletions

View File

@@ -37,6 +37,7 @@ func main() {
summary = flag.Bool("summary", false, "build today's inbox summary, store it, and print it; sends nothing")
summarySend = flag.Bool("summary-send", false, "build today's inbox summary and deliver it by email via Apprise")
notify = flag.Bool("notify", false, "send one test notification to the Hub (POST /notify) and exit")
retain = flag.Bool("retain", false, "enforce retention (AGENTS.md §7): clear old message bodies and cap actions_log, then exit")
)
flag.Parse()
@@ -111,6 +112,14 @@ func main() {
return
}
if *retain {
if err := runRetain(cfg, logger); err != nil {
fmt.Fprintf(os.Stderr, "retain failed: %v\n", err)
os.Exit(1)
}
return
}
if err := run(cfg, logger); err != nil {
logger.Error("fatal", "error", err)
os.Exit(1)
@@ -317,6 +326,44 @@ func runNotify(cfg *config.Config, logger *slog.Logger) error {
return nil
}
// runRetain enforces the AGENTS.md §7 data rules: message bodies older than the
// configured window are cleared (metadata stays), and the append-only
// actions_log is capped. It is a bounded, explicit job for the n8n schedule;
// the HTTP service never runs it on a request. Running it twice is safe.
func runRetain(cfg *config.Config, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
st, err := store.Open(ctx, cfg.DBPath)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer func() { _ = st.Close() }()
policy := store.RetentionPolicy{
BodyMaxAge: time.Duration(cfg.RetentionDays) * 24 * time.Hour,
ActionsLogMaxRows: cfg.ActionsLogMaxRows,
}
res, err := st.Retain(ctx, policy)
if err != nil {
return err
}
if err := st.LogAction(ctx, "retain", "scheduler",
fmt.Sprintf("bodies_cleared=%d actions_deleted=%d retention_days=%d max_rows=%d",
res.BodiesCleared, res.ActionsDeleted, cfg.RetentionDays, cfg.ActionsLogMaxRows)); err != nil {
// Retention already committed; a failed audit row must not fail the job.
logger.Error("log retention action", "error", err)
}
cutoff := "disabled"
if !res.BodyCutoff.IsZero() {
cutoff = res.BodyCutoff.Format("2006-01-02")
}
fmt.Printf("bodies_cleared=%d body_cutoff=%s actions_deleted=%d actions_max_rows=%d\n",
res.BodiesCleared, cutoff, res.ActionsDeleted, cfg.ActionsLogMaxRows)
return nil
}
// probeHealth requests the local health endpoint. It exists so the container
// healthcheck does not need an external HTTP client in the image.
func probeHealth(port int) error {