# 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](/AUD/issues/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:

```json
{
  "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:

```sh
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.
