# Phase 5 — Reply drafts (templates, LLM draft, edit, send) Status: **built, tested, and deployed. The one real test send is awaiting Sam's explicit approval** (issue interaction `080c500d-5968-49ad-9c40-0b2d3f445cc0` on [AUD-71](/AUD/issues/AUD-71)). Parent [AUD-68](/AUD/issues/AUD-68). Phase 5 of AGENTS.md §11. **DoD:** a reply is drafted, edited, and sent, and the thread updates. Every part of the DoD except the real send is verified below. The real send needs Sam's approval per the phase rules and AGENTS.md §10; it is the only step that cannot be tested without sending mail. ## 1. What was built - **Message detail pane (FR-8).** Selecting a message opens the body, the recommended action, the reply identity, a template picker, and the draft. It is an HTMX fragment (`GET /partials/message`) rendered into `#message-detail`. - **Template-based LLM draft (FR-8).** `internal/reply` calls the **OmniRoute** gateway's OpenAI-compatible `/chat/completions` endpoint (the only non-JEV model path, §10). Four built-in templates — `quick-ack`, `answer`, `confirm`, `decline` — shape the prompt; the action default is `answer` for a `reply` recommendation and `quick-ack` otherwise. Sam can add a free-text note for the model. The generated body is stored in `drafts`. - **Edit then send.** Sam edits the draft in a textarea, can **Save draft**, then **Send reply**. Sending is a deliberate UI action: the handler requires a CSRF token, the UI confirmation, and the guard field `confirm=yes`. There is **no auto-send** and no scheduled send (§10). - **Reply identity (FR-15).** `From:` is `"Sam Rolfe"
` (`X-Pm-Original-To`). When that is unknown it falls back to `"Sam Rolfe" `. The SMTP envelope sender is always the authenticated Gmail account. `To:` is the original sender. - **Threading.** The reply sets `In-Reply-To` and `References` from the original `Message-ID`, which Phase 5 now stores (`messages.message_id_header`, migration v5). After a send the service checks `[Gmail]/Sent Mail` for the original Gmail thread id and reports whether the thread updated. - **SMTP send, IPv4 forced (§9).** `internal/mailops` sends over `smtp.gmail.com:587` with STARTTLS and the app password, dialling `tcp4` exactly like the IMAP read path. - **Filing (FR-9).** After a successful send the original message moves to its `Replied-to/
` label. - **FR-13 logging.** `draft`, `draft_edit`, `reply_sent`, the `move` to the Replied-to label, and the summary rows are appended to `actions_log`. - **Duplicate guard.** A draft that is already `sent` cannot be sent again. New environment variables (all optional; defaults shown): `EMAIL_AGENT_SMTP_HOST` (`smtp.gmail.com`), `EMAIL_AGENT_SMTP_PORT` (`587`), `OMNIROUTE_BASE_URL` (`http://192.168.20.13:20129/v1`), `OMNIROUTE_MODEL` (`auto/best-chat`). `OMNIROUTE_API_KEY` and the Gmail credentials are the only secrets. ## 2. DoD evidence ### 2.1 Automated tests `gofmt -l` clean, `go vet ./...` clean, `go test ./...` green (in `golang:1.25-alpine`). New coverage: | Package | Tests | |---|---| | `internal/reply` | chat-completions request shape (auth, `x-opencode-session`, model, non-streaming); not-configured; HTTP error; template default by action; prompt includes template/instruction/classification/message; fence and `Subject:` stripping; empty reply rejected | | `internal/mailops` | FR-15 identity (arrived-at vs Gmail fallback); `ReplyTo` parsing; `Re:` prefixing; RFC822 headers (From/To/Subject/In-Reply-To/References/Message-ID, CRLF); `Send` without credentials; `dialSMTP4` reaches an IPv4 listener; `VerifyThreadUpdated` selects `[Gmail]/Sent Mail` and searches the thread | | `internal/store` | draft round trip (save/edit/mark sent); latest-draft miss; `MessageByID` returns the Message-ID; **migration v5 on a hand-built v4 database** adds `messages.message_id_header` and `drafts.sent_at` | | `internal/server` | detail pane renders the reply identity and body; detail requires auth; draft requires CSRF; draft is generated and stored; edit saves; send requires confirmation; send calls the sender with the right To/From/Subject/threading headers, marks the draft sent, moves the message to Replied-to, and verifies the thread; already-sent draft refused; missing Message-ID warns | A fake LLM client (an `httptest` server) and fake sender/generator are used, so the suite needs no network and sends no mail. ### 2.2 Live, deployed (schema v5, `email-agent` recreated on `.13`) | Check | Result | |---|---| | `GET /health` | `200 {"status":"ok","env":"production","database":"ok"}` | | Container log | `database ready … schema_version: 5` (migration v5 ran in place on the Phase 1–4 volume) | | `docker exec email-agent email-agent -check-smtp` | `smtp check ok: ipv4 + starttls + auth (no mail sent)` — Gmail submission path works over IPv4 with the app password | | OmniRoute `POST /v1/chat/completions` (model `auto/best-chat`) | `200`; a probe returned `ok` | | Login over public HTTPS (`https://mail.lab.audasmedia.com.au`) | `303`, session + CSRF cookies | | `POST /actions/draft` for message `96` | `200`; OmniRoute produced `Got it.` and the draft was stored | | `POST /actions/draft/save` | `200`; the edited body persisted | | `POST /actions/send` **without** `confirm=yes` | `200`; refused with "Sending needs confirmation"; **no mail sent** | | `POST /actions/send` **without** a CSRF token | `403` | The draft → edit path is therefore verified end to end over the public HTTPS URL, and the send path is verified up to (but not including) DATA. ### 2.3 Gmail "Send mail as" verification (AGENTS.md §13 "Remaining") AGENTS.md records send-as as "reported as already set up in Gmail … not verifiable from the sent folder". Gmail exposes no read-only alias list over IMAP/SMTP, so the only reliable check is a real send: Gmail accepts the `From` header only when the address is a registered send-as alias, and rejects it otherwise during DATA. The SMTP connection, STARTTLS upgrade, and app-password authentication are verified (`-check-smtp`). The `From`-header/send-as check is **pending the approved test send**. ### 2.4 Draft generated, edited, and stored (screenshot of the live data) Draft `2` for message `99` ("Test email", arrived at `hello@where-woof.com`), generated through the live UI path and then edited: ``` Thanks, got it. This came through to hello@where-woof.com. Since it's a test, no action needed on my side. If you need anything specific, let me know. Sam ``` ### 2.5 Thread update after a sent reply Pending the approved test send. The plan is to reply to message `99` as `"Sam Rolfe" ` to `samuelrolfe@gmail.com` (Sam himself — no third party), then confirm the thread by finding the original Gmail thread id in `[Gmail]/Sent Mail` (`X-GM-THRID` search). The exact message and draft are on the pending approval card. ## 3. Configuration `OMNIROUTE_API_KEY` and `GMAIL_USER`/`GMAIL_APP_PASSWORD` come from `~/.config/environment.d/10-secrets.conf` and are passed in by `docker-compose`. No secret value is committed. The other Phase 5 settings have working defaults. ## 4. Deployment On `.13`, from the repo, with the host secrets in scope: ```sh set -a; . ~/.config/environment.d/10-secrets.conf; set +a docker compose build docker compose up -d # migration v5 runs on start; data volume kept docker exec email-agent email-agent -check-smtp # safe: no mail sent ``` ## 5. Rollback 1. `git revert ` and `docker compose up -d --build`. 2. Migration v5 is additive (`messages.message_id_header`, `drafts.sent_at`); the Phase 4 binary ignores the new columns. 3. A sent reply cannot be unsent. Gmail's Sent copy and the thread stay; the `Replied-to/
` label can be removed in Gmail by hand. ## 6. Not done / deferred - **One real send + thread confirmation** — awaiting Sam's approval (the pending interaction above). This is the last DoD item. - A **template editor** (user-defined templates) is not in the Phase 5 requirements; templates are built in. The Phase 6 rules editor still feeds the classifier. - **1-month body retention** (§7) is still not implemented. - Rich-text or HTML replies are out of scope; replies are plain text.