docs/retention.md covers the 30-day body window, the actions_log row cap, the
audit-row ordering, configuration, unit + deployed verification, deployment on
.13 (commit 453d8a2), scheduling, and rollback. Phase 7's deferred note now
points at the completed work.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
8.1 KiB
Retention — one month of bodies, a capped action log
Status: complete. Built, tested, and deployed on .13 (own container,
port 8790). This closes the AGENTS.md §7 data-model rule that was still
outstanding after Phase 7 (AUD-73). Parent
AUD-68. Code commits 17d605d…d9f8d34.
DoD: a stored message older than 30 days has its body cleared and its
metadata stays; actions_log is size-capped; tests green; deployed on .13.
— met (§4, §5).
1. What was built
AGENTS.md §7 says:
Retention: keep message bodies for one month. Logs are append-only and size-capped.
The bounded -retain job enforces both halves.
| Piece | Where | Behaviour |
|---|---|---|
Store.Retain / Store.RetainWithAudit |
internal/store/retention.go |
One transaction. Blanks messages.body_text for messages received before the cutoff, deletes the oldest actions_log rows beyond the cap. |
| Config | internal/config/config.go |
EMAIL_AGENT_RETENTION_DAYS (default 30), EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS (default 50000). |
| CLI | cmd/email-agent/main.go |
-retain opens the store, runs one pass, prints a one-line result, exits. |
| Tests | internal/store/retention_test.go, internal/config/config_test.go |
Body clearing, metadata retention, log cap (with and without the audit row), disabled policy, config defaults/overrides. |
2. Decisions
- The window is 30 days from
received_at.received_atis the time the service stored the message (internal/mailfetchsets it during the IMAP scan), which is the same clock the rest of the app uses. It is not theDateheader, which a sender controls. - Clear the body, keep the metadata.
body_textis set toNULL. Sender, recipient, subject, source address (X-Pm-Original-To), classification, folder, Gmail label, and all timestamps stay. A cleared body is much smaller than a deleted row and keeps the inbox history truthful. The UI already renders an empty body (COALESCE(body_text, '')), so nothing else changes. NULL, not''. The update predicate isbody_text IS NOT NULL AND body_text <> '', so a second pass is a true no-op and never re-counts a cleared row. The test asserts this.- The cap is a row count, not an age. AGENTS.md says "size-capped", and the
log is append-only, so the job keeps the newest
Nrows by monotonicidand deletes the rest. The defaultN=50000holds a long triage history while keeping the SQLite file modest. - Retention is logged, and the audit row counts against the cap. The job
writes one
retainrow toactions_log(FR-13: every action is logged). That row is written inside the same transaction and before the cap, so after a passactions_logholds at mostNrows including the audit row. Without that ordering the table would sit atN+1between runs. - One transaction. Body clearing and the cap commit together, so a partial pass never leaves the database mixed. Both statements are idempotent.
- Bounded and explicit. The job is a CLI flag, like
-fetchand-classify. The HTTP server never runs retention on a request, so no page load can start a slow write. - Re-fetch is harmless. If an old message is re-fetched inside the lookback
window,
UpsertMessagerestores its body; the next retention pass clears it again. The stored metadata is never lost.
3. Configuration
| Variable | Default | Purpose |
|---|---|---|
EMAIL_AGENT_RETENTION_DAYS |
30 |
Body window in days. |
EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS |
50000 |
Newest actions_log rows kept. |
Both are passed through by docker-compose.yml and listed in .env.example.
A non-positive value disables that half of the pass, which is useful for a
diagnostic run.
4. Verification
4.1 Unit tests (green)
go test ./internal/store/ ./internal/config/ — all pass. The retention tests:
TestRetainClearsOldBodiesKeepsMetadata— a 45-day-old body becomesNULL; a 2-day-old body is untouched;MessageByIDstill returns the subject and source address; a second pass clears 0.TestRetainCapsActionsLog— 5 rows with a cap of 2 leaves the 2 newest; a smaller table is untouched.TestRetainWithAuditCountsItsOwnRow— 5 rows + 1 audit with a cap of 3 leaves exactly 3 rows, newest is theretainrow.TestRetainDisabledPolicyIsNoOp— a zero policy changes nothing.TestRetentionDefaultsAndOverrides— config defaults and overrides.
4.2 Deployed binary, seeded scratch database
Run with the deployed image (email-agent:latest, commit d9f8d34) against a
scratch database that held one 45-day-old and one 2-day-old message and 10
action rows, with the cap set to 5:
$ email-agent -retain # EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS=5
bodies_cleared=1 body_cutoff=2026-09-12 actions_deleted=7 actions_max_rows=5
# message rows afterwards
message: ('retention-fresh', 'Fresh mail', 'fresh body content', 'hello@where-woof.com')
message: ('retention-old', 'Old forwarded mail', NULL, 'hello@where-woof.com')
actions_log rows after cap: 5
newest action: ('retain', 'retention_days=30 max_rows=5 bodies_cleared=1', 'scheduler')
The old body is NULL; its subject and source address are intact; the fresh
body is untouched; actions_log is exactly at the cap, including the audit row.
4.3 Live deployment, production database
$ docker exec email-agent email-agent -retain
bodies_cleared=0 body_cutoff=2026-09-12 actions_deleted=0 actions_max_rows=50000
bodies_cleared=0 is correct: the oldest stored message was received on
2026-10-11, well inside the 30-day window. The same command was run twice and
the database is unchanged, which shows the job is idempotent. The live database
holds 103 messages (102 with a body) and one retain audit row per run:
audit: ('2026-10-12 23:44:28', 'scheduler', 'retention_days=30 max_rows=50000 bodies_cleared=0')
schema_version: 6
No schema change was needed; retention operates on existing columns, so the
live database stayed at schema_version: 6.
5. Deployment
On .13, from the repo, with the host secrets in scope:
set -a; . ~/.config/environment.d/10-secrets.conf; set +a
EMAIL_AGENT_VERSION=retention EMAIL_AGENT_COMMIT=$(git rev-parse --short HEAD) \
docker compose build
docker compose up -d
https://mail.lab.audasmedia.com.au/health → 200, reporting
version: retention, commit: d9f8d34. The container is the project's own
email-agent on port 8790; no other service changed.
6. Scheduling
The job is a bounded one-shot, so the n8n schedule (FR-1) can call it the same
way it calls -fetch and -classify, for example once a day:
docker exec email-agent email-agent -retain
No n8n workflow was added in this change. The build spec's own wording allows
either an n8n step or a bounded CLI flag ("Run it on the existing n8n
schedule, or add a bounded CLI flag"), and the flag is the deployable unit. Note
that no email_agent workflow exists in n8n yet, so wiring the schedule is a
separate task that touches the shared n8n service.
7. Rollback
git revert <docs-commit> d9f8d34 3766ce1 17d605d && docker compose up -d --build.- No schema change to undo. Bodies already cleared are not recoverable from the
database; a later
-fetchrestores the body of any message still inside the IMAP lookback window. - The action-log cap only deletes rows; there is no undo for a trimmed log.
8. Not done / notes
- Reclassification after a clear. A cleared body is unavailable to
-classify-alland to the LLM reply draft. That is the intended trade-off of the one-month rule; the message keeps its existing classification. Run classification before retention if a full reclassification is needed. - The n8n schedule is not wired (§6). This is a shared-service change and was not made here.
- The audit row detail records
retention_days,max_rows, andbodies_cleared;actions_deletedis not in the detail because the row is written before the cap runs. The CLI output and theactions_logcount show the deleted number.