Phase 5: reply drafts — template/LLM draft, edit, SMTP send (AUD-71)

Implements Phase 5 of AGENTS.md §11 (FR-8, FR-15):

- Message detail pane (GET /partials/message) with body, recommended
  action, reply identity, template picker, editable draft, and history.
- internal/reply: OmniRoute (OpenAI-compatible) chat client and a
  template-based draft generator. All non-JEV models go through
  OmniRoute (§10).
- internal/mailops: SMTP send over smtp.gmail.com:587 with STARTTLS and
  the app password, forcing IPv4 (§9); FR-15 reply identity (send as the
  arrived-at X-Pm-Original-To address, else "Sam Rolfe"); threading via
  In-Reply-To/References; -check-smtp safe verification; and a
  [Gmail]/Sent Mail thread check.
- Migration v5 stores messages.message_id_header and drafts.sent_at;
  mailfetch parses Message-ID.
- Send is user-triggered only: CSRF + UI confirm + confirm=yes guard,
  no auto-send, duplicate-send refused. A sent reply files the original
  under Replied-to/<address> and logs every step (FR-13).
- Tests for reply, mailops SMTP/identity/thread check, store drafts and
  migration v5, and the server draft/edit/send guards.
- docs/phase5-replies.md, README, .env.example, docker-compose updated.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip Builder
2026-10-11 22:05:35 +11:00
parent ae53374ad1
commit 7ca888f0c6
31 changed files with 2556 additions and 42 deletions

View File

@@ -9,16 +9,19 @@ pre-planning material and is not authoritative.
## Status
**Phase 4 — Triage actions.** The Go skeleton, session login, and HTMX
dashboard (Phase 1), real mail over **IMAP with an app password, forced to IPv4**
(Phase 2), **JEV classification** (Phase 3), and now **bulk triage actions**
(Phase 4). The inbox has a select-all toolbar for delete, move, and mark (FR-6),
and a folder view for the recoverable Delete/Saved/Replied-to Gmail labels (FR-9).
A delete moves the message to a Gmail label (never a hard delete) and a row in
the Delete folder can be **restored** to the inbox. Bulk delete needs a UI
confirmation and a message classified Spam needs a second confirmation — spam is
never auto-deleted (§10). Every action is appended to `actions_log` (FR-13). See
[`docs/phase4-triage.md`](docs/phase4-triage.md) for the phase evidence.
**Phase 5 — Reply drafts.** The Go skeleton, session login, and HTMX dashboard
(Phase 1), real mail over **IMAP with an app password, forced to IPv4** (Phase
2), **JEV classification** (Phase 3), **bulk triage actions** (Phase 4), and now
**reply drafts** (Phase 5). Open a message to read it, draft a reply with the LLM
through **OmniRoute** from a template, edit it, and send it over Gmail SMTP
(IPv4 + STARTTLS). A reply is sent **as the address the mail arrived at**
(`X-Pm-Original-To`, FR-15) and returns to the original sender; the reply sets
`In-Reply-To`/`References` so Gmail keeps the thread, and the original is filed
under its `Replied-to/<address>` label. Sending is a deliberate UI action with a
confirmation — there is no auto-send (§10). Every action, including drafts and
sends, is appended to `actions_log` (FR-13). See
[`docs/phase5-replies.md`](docs/phase5-replies.md) for the phase evidence and
[`docs/phase4-triage.md`](docs/phase4-triage.md) for Phase 4.
## Layout
@@ -27,7 +30,8 @@ cmd/email-agent/ service entry point (`-fetch`, `-classify`, `-classify-a
internal/classify/ JEV (TypeSafe System One) client, rule engine, orchestration
internal/config/ environment-driven configuration
internal/mailfetch/ stdlib-only IMAP client, MIME parsing, FR-2 source address
internal/mailops/ Gmail label moves and flag changes for triage actions
internal/mailops/ Gmail label moves and flag changes, and SMTP reply sending
internal/reply/ LLM reply drafts through OmniRoute, reply templates
internal/session/ server-side session store + signed cookie values
internal/store/ SQLite open + schema (internal/store/schema.sql)
internal/server/ HTTP routes, auth middleware, HTMX handlers
@@ -58,6 +62,8 @@ Key variables:
| `EMAIL_AGENT_PASSWORD` | _(empty)_ | Login password for Sam. If unset, `EMAIL_AGENT_SESSION_SECRET` doubles as the password |
| `GMAIL_USER` | _(empty)_ | Gmail account that receives the forwarded mail |
| `GMAIL_APP_PASSWORD` | _(empty)_ | Gmail app password for IMAP/SMTP (no OAuth) |
| `EMAIL_AGENT_SMTP_HOST` | `smtp.gmail.com` | SMTP submission host |
| `EMAIL_AGENT_SMTP_PORT` | `587` | SMTP submission port (STARTTLS, IPv4) |
| `EMAIL_AGENT_IMAP_HOST` | `imap.gmail.com` | IMAP host |
| `EMAIL_AGENT_IMAP_PORT` | `993` | IMAP port (implicit TLS) |
| `EMAIL_AGENT_IMAP_LOOKBACK_DAYS` | `14` | First-scan window |
@@ -67,6 +73,9 @@ Key variables:
| `JEV_MODEL` | `jev-latest` | System One model alias |
| `EMAIL_AGENT_CLASSIFY_CONCURRENCY` | `4` | In-flight JEV requests per run |
| `EMAIL_AGENT_CLASSIFY_MAX_MESSAGES` | `200` | Messages per classify run |
| `OMNIROUTE_BASE_URL` | `http://192.168.20.13:20129/v1` | OmniRoute gateway (all non-JEV models) |
| `OMNIROUTE_API_KEY` | _(empty)_ | OmniRoute API key (required to draft replies) |
| `OMNIROUTE_MODEL` | `auto/best-chat` | Model alias for reply drafts |
## Mail sync (Phase 2)
@@ -144,6 +153,42 @@ or Replied-to folder has a **Restore** button that moves it back to `INBOX`.
Every action appends to `actions_log`: one `move` or `mark` row per message,
plus one `bulk_<action>` summary row.
## Reply drafts (Phase 5)
Opening a message in the inbox loads the detail pane: the body, the recommended
action, the reply identity, a template picker, and the draft. The draft is
generated by an LLM through **OmniRoute** (the only non-JEV model path, §10)
using a built-in template (`quick-ack`, `answer`, `confirm`, `decline`) plus any
note Sam types. Sam edits the draft, then sends it.
The send path uses **Gmail SMTP** (`smtp.gmail.com:587`, STARTTLS) and, like the
IMAP read path, **forces IPv4** (AGENTS.md §9).
- **Reply identity (FR-15):** the `From` header is `Sam Rolfe <address the mail
arrived at>`; when the arrived-at address is unknown it falls back to
`Sam Rolfe <GMAIL_USER>`. The SMTP envelope sender is always the authenticated
Gmail account.
- **Threading:** the reply sets `In-Reply-To` and `References` from the original
`Message-ID` (stored by Phase 5), so Gmail keeps the thread. After a send the
service checks the `[Gmail]/Sent Mail` mailbox for the thread and reports
whether it updated.
- **No auto-send (§10):** sending needs a CSRF token, an explicit UI
confirmation, and the guard field `confirm=yes`. The handler refuses a request
without them and never runs from a schedule. A draft is sent once; a second
attempt is refused.
- **Filing:** after a successful send the original moves to its
`Replied-to/<address>` label (FR-9).
Verify the SMTP path without sending mail:
```sh
# inside the container
email-agent -check-smtp # IPv4 + STARTTLS + app-password login, no message sent
```
A real send is a UI action by Sam; it is never triggered by an agent test without
explicit approval (§10, AGENTS.md §13).
## Run locally
```sh
@@ -178,6 +223,10 @@ Caddy on `.35` proxies `mail.lab.audasmedia.com.au` to this port.
| `POST` | `/actions/classify` | session + CSRF | Classifies pending messages, returns the refreshed list |
| `POST` | `/actions/reclassify` | session + CSRF | Re-classifies every message with the current categories and rules |
| `POST` | `/actions/bulk` | session + CSRF | Bulk delete/move/mark and restore over selected message ids (FR-6/FR-9) |
| `GET` | `/partials/message` | session | HTMX fragment for the message detail, reply draft, and history |
| `POST` | `/actions/draft` | session + CSRF | Generates and stores a reply draft with the LLM (FR-8) |
| `POST` | `/actions/draft/save` | session + CSRF | Saves Sam's edits to a draft |
| `POST` | `/actions/send` | session + CSRF + confirm | Sends a reply over SMTP (FR-8/FR-15); never automatic |
| `GET` | `/categories` | session | Category editor (FR-14) |
| `POST` | `/categories/add` | session + CSRF | Adds a category |
| `POST` | `/categories/rename` | session + CSRF | Renames a category and updates existing classifications |
@@ -198,4 +247,5 @@ and runs on every start. Migration v2 adds `imap_uid` and `imap_uidvalidity` to
`messages`; migration v3 seeds the first category list and a starter rules
version once, so a category removed by the user is not silently re-added;
migration v4 adds `folder` and `mailbox` to `messages` for the Phase 4 triage
folders.
folders; migration v5 adds `messages.message_id_header` (reply threading) and
`drafts.sent_at`.