Files
email_agent/docs/phase7-summary-hub.md

12 KiB
Raw Blame History

Phase 7 — Summary and Hub

Status: complete. Built, tested, deployed on .13. Both approval-gated steps are done: the daily summary was delivered by email through the house Apprise API, and email_agent is registered with the running Hub. Parent AUD-68. Phase 7 of AGENTS.md §11. Code commit a349b42, docs commit 9f5e600 plus this completion update on top.

DoD: the Hub asks "anything important in my inbox?" and gets a correct short answer. — met end to end. The live Hub routed the question to email_agent and returned the correct short answer (§2.6).

1. What was built

1.1 Hub API (FR-12)

The project side of the frozen contract (central_ai_llm_master/docs/ask-contract.md) lives in internal/hub and internal/server/hub.go.

Endpoint Auth Behaviour
GET /capabilities Authorization: Bearer $HUB_TOKEN {"project":"email_agent","topics":["email","inbox","mail","message"],"people":["sam"]}
POST /ask Authorization: Bearer $HUB_TOKEN A short, speakable answer or {"answer":null,"reason":…}
  • Read-only. /ask calls only store.CountByCategory and store.MessagesByCategory. It never writes, including no actions_log row (FR-13 is for actions, not reads). Proven live in §2.3 and by the unit test TestAskWritesNothing.
  • No LLM, fast. The answerer is deterministic and answers from SQLite in milliseconds. The 3-second contract budget is never at risk.
  • Never guesses. An unanswerable question returns answer: null plus a reason. The service does not track read/unread, so it says so rather than inventing a number.
  • Token required. With no HUB_TOKEN configured the endpoints return 503; a wrong token returns 401. No project trusts an unsigned request.
  • Topics deliberately omit unread (see routing.md), because the service has no read-state store.

Intents answered: important / urgent / action, spam, newsletter / unsubscribe, inbox count, and a one-sentence overview / summary.

1.2 Outbound notify (ask-contract "Notify (project to Hub)")

internal/hub/notify.go posts to {HUB_BASE_URL}/notify with {"from","level","message","person"} and the project bearer token. It is the one Hub call this service makes. The Hub's /notify receiver is not part of its frozen v1 routes (docs/hub-api.md §12), so the call is best-effort and a failure is logged, never blocking local work. The CLI -notify exercises it.

1.3 Daily summary (FR-10)

  • internal/summary builds a deterministic plain-text summary: inbox total, per-category counts, the "needs attention" list, newsletter count, and spam count (with the "recommend, never auto-delete" wording from AGENTS.md §10.5).
  • internal/store/summary.go adds CountByCategory, MessagesByCategory, and summary CRUD over the existing summaries table. No schema migration was needed; the table exists since v1.
  • internal/apprise delivers the summary by email through the house Apprise API (POST {APPRISE_URL}/notify/{APPRISE_KEY}, or the stateless /notify form with APPRISE_URLS). Set APPRISE_TOKEN if the API is protected.
  • Delivery happens in exactly two explicit paths: the CLI -summary-send (for the daily n8n schedule) and the Reports screen's Send now button. Building a summary never sends mail.
  • GET /reports is the Reports screen (AGENTS.md §5): build today's summary, send it, and delete a stored report. All mutations are CSRF-protected POSTs.

1.4 CLI

Flag Effect
-summary Build today's summary, store it, print it. Sends nothing.
-summary-send Build, store, and deliver by email via Apprise.
-notify Send one notification to the Hub (POST /notify).

The n8n schedule (FR-1) runs -summary-send once a day; the summary job is a separate bounded process, like -fetch and -classify.

2. DoD evidence

2.1 Real /ask round-trip

Against the deployed container on .13 (commit a349b42), sending the exact ProjectClient request shape the Hub uses (content-type: application/json, Authorization: Bearer …):

POST /ask
{"question":"anything important in my inbox?","asker":"sam"}

200 {"answer":"20 emails need your attention, including \"Security alert\" from Google.",
     "data":{"count":20,"messages":[ … ]},"expires":"2026-10-11T11:48:01Z"}

The count is correct: the live inbox held 102 messages, of which **19 Action

  • 1 Important = 20** needed attention. An overview question returns "Your inbox has 102 emails: 20 need attention and 40 are newsletters."

GET /capabilities:

{"project":"email_agent","topics":["email","inbox","mail","message"],"people":["sam"]}

Auth checks: no token → 401; wrong token → 401; correct token → 200. An unanswerable question returns {"answer":null,"reason":"I do not track read or unread mail"}.

2.2 /notify path

email-agent -notify against a local stub implementing the contract captured:

{
  "path": "/notify",
  "auth": "Bearer <project token>",
  "body": {
    "from": "email_agent",
    "level": "info",
    "message": "Email Agent notify path check.",
    "person": "sam"
  }
}

This matches the NotifyRequest shape in ask-contract.md exactly.

2.3 /ask is read-only

Five /ask calls (important, count, spam, unknown, overview) were issued against the deployed service. The /data SQLite database was copied before and after and compared row-by-row:

table before after delta
messages 103 103 +0
classifications 403 403 +0
actions_log 428 428 +0
summaries 0 0 +0
rules 2 2 +0
unsubscribe 0 0 +0
drafts 2 2 +0

No table changed. The unit test TestAskWritesNothing asserts the same property for the message, classification, action-log, and summary counts.

2.4 Daily-summary-by-email path

docker exec email-agent email-agent -summary produced and stored the summary (row id=1, date 2026-10-11, 1110 bytes, delivered_to empty at build time):

Email Agent — daily summary for 2026-10-11

Inbox: 102 message(s)
  Newsletter   40
  Auto-reply   34
  Action       19
  Info          7
  Important     1
  Spam          1

Needs attention: 20
  - Security alert — Google [Action/high]
  - …

Newsletters: 40
Spam (recommend, never auto-delete): 1

-summary-send with no Apprise key stops before sending: summary failed: apprise is not configured: set APPRISE_URL and APPRISE_KEY (or APPRISE_URLS).

Real send (approved). Sam approved one real summary email and the house Apprise destination. APPRISE_URLS on the container was set to the shared mailto destination, then -summary-send ran once:

summary delivered id=1 to=samuelrolfe@gmail.com

Evidence: the stored row reports delivered_to=samuelrolfe@gmail.com, and the Apprise container on .35 logged the delivery and answered 200:

2026-10-11 07:16:20,853 [INFO] apprise: Applying Google Mail Defaults
2026-10-11 07:16:25,505 [INFO] apprise: Sent Email to samuelrolfe@gmail.com
2026-10-11 07:16:25,806 [INFO] django: NOTIFY -  - Delivered Stateless Notification(s)
192.168.20.13 - - [11/Oct/2026:12:16:25 +0000] "POST /notify HTTP/1.1" 200 104 "-" "Go-http-client/1.1"

The recipient was EMAIL_AGENT_SUMMARY_TO (samuelrolfe@gmail.com). No other summary has been sent.

2.5 Automated tests

gofmt -l clean, go vet ./... clean, go test ./... green (in golang:1.25-alpine). New coverage:

Package Tests
internal/hub important/empty/spam/newsletter/count/summary/unknown intents, person and asker checks, unread refusal, read errors, sender-name extraction, default capabilities, notify payload/auth/error/no-op
internal/apprise key mode, stateless URL mode, auth header, error status, Configured
internal/summary build counts and ordering, text rendering, empty inbox
internal/store category counts, multi-category importance ordering, summary upsert/get/list/delete/delivery
internal/config Hub/Apprise defaults, HubConfigured, AppriseConfigured, URL-list parsing
internal/server capabilities auth + shape, ask auth, ask answer, ask writes nothing, reports page auth, generate stores without sending, send delivers through Apprise, CSRF

2.6 Live Hub registration and end-to-end round-trip

Sam approved registering this project with the running Hub. The changes are host configuration only (no image change):

  • HUB_PROJECT_EMAIL_AGENT_TOKEN=<HUB_TOKEN> and HUB_PROJECTS=email_agent=http://192.168.20.13:8790 were added to ~/.config/environment.d/10-secrets.conf (mode 600, backup 10-secrets.conf.bak.aud73-*). The Hub mounts this file read-only and reads HUB_PROJECTS from it.
  • The hub container was restarted to pick up the registration. It came back healthy; GET /health → 200.

End-to-end proof, against the live Hub on .13 (POST /ask with the console door token and source: "console"):

POST http://192.168.20.13:8788/ask
{"text":"anything important in my inbox?","asker":"sam","source":"console"}

200 {"status":"ok",
     "answer":"20 emails need your attention, including \"Security alert\" from Google.",
     "shape":"text",
     "route":{"kind":"project","project":"email_agent"}}

The Hub chose email_agent, and the answer matches the project's own /ask. The project endpoint is still token-protected: no token or a wrong token returns 401 (§2.1).

Note: central_ai_llm_master/routing.md still lists email_agent with the "placeholder — not built" status. Routing works regardless, because the Hub reads live /capabilities first. Updating that human-owned row to active is a cosmetic follow-up in the Hub repo, outside this phase's change set.

3. Configuration

New environment variables (names only; values live in ~/.config/environment.d/10-secrets.conf on .13 and are never committed):

Variable Purpose
HUB_TOKEN Shared bearer token for /capabilities and /ask. Also the token sent to the Hub's /notify.
HUB_BASE_URL Hub base URL for outbound notify (default http://192.168.20.13:8788).
HUB_NOTIFY_TIMEOUT Notify call timeout (default 5s).
APPRISE_URL Apprise API base (default http://192.168.20.35:8210).
APPRISE_KEY Stored Apprise configuration key.
APPRISE_URLS Stateless Apprise destinations (used when no key).
APPRISE_TOKEN Optional bearer token for a protected Apprise API.
EMAIL_AGENT_SUMMARY_TO Summary recipient (default samuelrolfe@gmail.com).

A HUB_TOKEN was generated and written to the secrets file on .13 (mode 600, backup 10-secrets.conf.bak.aud73-*). The value is not in this repo.

4. 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=phase7 EMAIL_AGENT_COMMIT=$(git rev-parse --short HEAD) \
  docker compose build
docker compose up -d

The migration-free change started in place on the Phase 1–6 volume (schema_version: 6). https://mail.lab.audasmedia.com.au/health → 200.

5. Rollback

  1. git revert a349b42 && docker compose up -d --build.
  2. No schema change to undo. The summaries table rows and the HUB_TOKEN secret are left in place; remove the token from the secrets file if the Hub API must be disabled.
  3. The Reports screen, /capabilities, and /ask disappear with the revert. The Hub registration now exists; remove HUB_PROJECTS and HUB_PROJECT_EMAIL_AGENT_TOKEN from the secrets file and restart hub to undo it.

6. Not done / deferred

  • 1-month body retention (AGENTS.md §7) is still not implemented. It is a data-model rule, not part of the Phase 7 DoD; it remains project backlog.
  • The Hub's /notify receiver does not exist yet in Hub v1, so the outbound path is verified against a stub, not the live Hub.
  • routing.md still labels email_agent a placeholder (see §2.6). The live Hub routes correctly from /capabilities regardless.

7. Approvals and execution

Both approval-gated steps were approved by Sam (interaction 73a5494e-e054-4cf9-aa9e-7b8a17295002): approve-house for one real summary email and wire-now for the Hub registration. Both are now executed:

  1. Real summary email — sent through the house Apprise API on .35; the delivery is recorded in §2.4.
  2. Hub registration — the shared token and HUB_PROJECTS are in the host secrets file and the Hub was restarted; the end-to-end round-trip is recorded in §2.6.

The routing.md status row is the only cosmetic leftover; see §2.6 and §6.