12 KiB
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.
/askcalls onlystore.CountByCategoryandstore.MessagesByCategory. It never writes, including noactions_logrow (FR-13 is for actions, not reads). Proven live in §2.3 and by the unit testTestAskWritesNothing. - 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: nullplus a reason. The service does not track read/unread, so it says so rather than inventing a number. - Token required. With no
HUB_TOKENconfigured the endpoints return503; a wrong token returns401. No project trusts an unsigned request. - Topics deliberately omit
unread(seerouting.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/summarybuilds 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.goaddsCountByCategory,MessagesByCategory, and summary CRUD over the existingsummariestable. No schema migration was needed; the table exists since v1.internal/apprisedelivers the summary by email through the house Apprise API (POST {APPRISE_URL}/notify/{APPRISE_KEY}, or the stateless/notifyform withAPPRISE_URLS). SetAPPRISE_TOKENif 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 /reportsis 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>andHUB_PROJECTS=email_agent=http://192.168.20.13:8790were added to~/.config/environment.d/10-secrets.conf(mode 600, backup10-secrets.conf.bak.aud73-*). The Hub mounts this file read-only and readsHUB_PROJECTSfrom it.- The
hubcontainer was restarted to pick up the registration. It came backhealthy;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
git revert a349b42 && docker compose up -d --build.- No schema change to undo. The
summariestable rows and theHUB_TOKENsecret are left in place; remove the token from the secrets file if the Hub API must be disabled. - The Reports screen,
/capabilities, and/askdisappear with the revert. The Hub registration now exists; removeHUB_PROJECTSandHUB_PROJECT_EMAIL_AGENT_TOKENfrom the secrets file and restarthubto 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
/notifyreceiver does not exist yet in Hub v1, so the outbound path is verified against a stub, not the live Hub. routing.mdstill labelsemail_agenta placeholder (see §2.6). The live Hub routes correctly from/capabilitiesregardless.
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:
- Real summary email — sent through the house Apprise API on
.35; the delivery is recorded in §2.4. - Hub registration — the shared token and
HUB_PROJECTSare 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.