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

@@ -0,0 +1,300 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/reply"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
type fakeGenerator struct {
body string
templateID string
err error
calls int
lastReq reply.Request
}
func (f *fakeGenerator) Draft(_ context.Context, req reply.Request) (string, string, error) {
f.calls++
f.lastReq = req
if f.err != nil {
return "", "", f.err
}
return f.body, f.templateID, nil
}
func (f *fakeGenerator) Configured() bool { return true }
func (f *fakeGenerator) Model() string { return "fake-model" }
type fakeSender struct {
sent []mailops.OutgoingMessage
err error
threadUpdated bool
verifyErr error
verifyCalls int
}
func (f *fakeSender) Send(_ context.Context, msg mailops.OutgoingMessage) error {
if f.err != nil {
return f.err
}
f.sent = append(f.sent, msg)
return nil
}
func (f *fakeSender) VerifyThreadUpdated(_ context.Context, _ string) (bool, error) {
f.verifyCalls++
return f.threadUpdated, f.verifyErr
}
func newReplyServer(t *testing.T) (*Server, *fakeActions, *fakeGenerator, *fakeSender) {
t.Helper()
srv, fa := newActionsServer(t)
fg := &fakeGenerator{body: "Thanks, Saturday works.\n\nSam", templateID: "confirm"}
fs := &fakeSender{threadUpdated: true}
srv.replies = fg
srv.sender = fs
return srv, fa, fg, fs
}
func seedReplyable(t *testing.T, srv *Server, gmailID, source, messageID string) int64 {
t.Helper()
ctx := context.Background()
id, err := srv.store.UpsertMessage(ctx, &store.Message{
GmailID: gmailID,
ThreadID: "1234567890",
MessageIDHeader: messageID,
FromAddr: "Customer Name <cust@example.test>",
ToAddr: source,
Subject: "Booking for Saturday",
BodyText: "Can we book Saturday at 10?",
SourceAddress: source,
})
if err != nil {
t.Fatalf("seed message: %v", err)
}
if _, err := srv.store.SaveClassification(ctx, store.Classification{
MessageID: id, Category: "Action", Importance: "high", Action: "reply", Confidence: 0.8,
}); err != nil {
t.Fatalf("seed classification: %v", err)
}
return id
}
func TestMessageDetailRendersReplyIdentity(t *testing.T) {
srv, _, _, _ := newReplyServer(t)
session, _, _ := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
rec := getWithSession(t, srv, "/partials/message?id="+itoa(id), session)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"hello@where-woof.com",
"Sam Rolfe",
"cust@example.test",
"Re: Booking for Saturday",
"Can we book Saturday at 10?",
"Draft reply",
} {
if !strings.Contains(body, want) {
t.Errorf("detail pane missing %q", want)
}
}
}
func TestMessageDetailRequiresAuth(t *testing.T) {
srv, _, _, _ := newReplyServer(t)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
req := httptest.NewRequest(http.MethodGet, "/partials/message?id="+itoa(id), nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
}
}
func TestDraftRequiresCSRF(t *testing.T) {
srv, _, fg, _ := newReplyServer(t)
session, _, _ := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
form := url.Values{"id": {itoa(id)}, "template": {"quick-ack"}}
rec := postForm(t, srv, "/actions/draft", session, nil, form)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if fg.calls != 0 {
t.Errorf("generator called %d times, want 0", fg.calls)
}
}
func TestDraftGeneratesAndSaves(t *testing.T) {
srv, _, fg, _ := newReplyServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
form := url.Values{"csrf": {csrf}, "id": {itoa(id)}, "template": {"confirm"}, "instruction": {"10am works"}}
rec := postForm(t, srv, "/actions/draft", session, csrfCookie, form)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if fg.calls != 1 {
t.Fatalf("generator calls = %d, want 1", fg.calls)
}
if fg.lastReq.Instruction != "10am works" || fg.lastReq.TemplateID != "confirm" {
t.Errorf("generator request = %+v", fg.lastReq)
}
if !strings.Contains(rec.Body.String(), "Thanks, Saturday works.") {
t.Errorf("response does not show the generated draft")
}
d, ok, err := srv.store.LatestDraft(context.Background(), id)
if err != nil || !ok {
t.Fatalf("LatestDraft() ok=%v err=%v", ok, err)
}
if d.TemplateID != "confirm" || d.Status != store.DraftStatusDraft {
t.Errorf("stored draft = %+v", d)
}
}
func TestSaveDraftEditsBody(t *testing.T) {
srv, _, _, _ := newReplyServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
d, err := srv.store.SaveDraft(context.Background(), id, "original", "quick-ack")
if err != nil {
t.Fatalf("SaveDraft() error = %v", err)
}
form := url.Values{"csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)}, "body": {"edited by Sam"}}
rec := postForm(t, srv, "/actions/draft/save", session, csrfCookie, form)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
got, _, err := srv.store.LatestDraft(context.Background(), id)
if err != nil {
t.Fatalf("LatestDraft() error = %v", err)
}
if got.Body != "edited by Sam" {
t.Errorf("body = %q, want the edit", got.Body)
}
}
func TestSendRequiresConfirmation(t *testing.T) {
srv, _, _, fs := newReplyServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
d, _ := srv.store.SaveDraft(context.Background(), id, "body", "quick-ack")
form := url.Values{"csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)}, "body": {"body"}}
rec := postForm(t, srv, "/actions/send", session, csrfCookie, form)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if len(fs.sent) != 0 {
t.Fatalf("sent %d messages without confirmation", len(fs.sent))
}
if !strings.Contains(rec.Body.String(), "needs confirmation") {
t.Errorf("body did not explain the confirmation requirement")
}
}
func TestSendReplySendsFilesAndVerifiesThread(t *testing.T) {
srv, fa, _, fs := newReplyServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
d, _ := srv.store.SaveDraft(context.Background(), id, "Saturday works.\n\nSam", "confirm")
form := url.Values{
"csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)},
"body": {"Saturday works.\n\nSam"}, "confirm": {"yes"},
}
rec := postForm(t, srv, "/actions/send", session, csrfCookie, form)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if len(fs.sent) != 1 {
t.Fatalf("sent = %d, want 1", len(fs.sent))
}
out := fs.sent[0]
if out.To != "cust@example.test" {
t.Errorf("To = %q", out.To)
}
if out.From != `"Sam Rolfe" <hello@where-woof.com>` {
t.Errorf("From = %q", out.From)
}
if out.Subject != "Re: Booking for Saturday" {
t.Errorf("Subject = %q", out.Subject)
}
if out.InReplyTo != "<orig-1@mail.example>" || out.References != "<orig-1@mail.example>" {
t.Errorf("threading headers = %q / %q", out.InReplyTo, out.References)
}
if fs.verifyCalls != 1 {
t.Errorf("verifyCalls = %d, want 1", fs.verifyCalls)
}
if !strings.Contains(rec.Body.String(), "Reply sent") {
t.Errorf("response does not confirm the send")
}
// The draft is marked sent and the message is filed under Replied-to.
got, _, err := srv.store.LatestDraft(context.Background(), id)
if err != nil {
t.Fatalf("LatestDraft() error = %v", err)
}
if got.Status != store.DraftStatusSent {
t.Errorf("draft status = %q, want sent", got.Status)
}
if fa.moveCount() != 1 {
t.Errorf("moves = %d, want 1 (file the original)", fa.moveCount())
}
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
if len(msgs) != 1 || msgs[0].Folder != store.FolderReplied {
t.Errorf("message = %+v, want folder replied", msgs)
}
}
func TestSendReplyAlreadySentRefused(t *testing.T) {
srv, _, _, fs := newReplyServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "<orig-1@mail.example>")
d, _ := srv.store.SaveDraft(context.Background(), id, "body", "quick-ack")
if err := srv.store.MarkDraftSent(context.Background(), d.ID); err != nil {
t.Fatalf("MarkDraftSent() error = %v", err)
}
form := url.Values{
"csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)},
"body": {"body"}, "confirm": {"yes"},
}
rec := postForm(t, srv, "/actions/send", session, csrfCookie, form)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if len(fs.sent) != 0 {
t.Fatalf("sent = %d, want 0 for an already-sent draft", len(fs.sent))
}
if !strings.Contains(rec.Body.String(), "already been sent") {
t.Errorf("body did not explain the duplicate guard")
}
}
func TestSendReplyNotesMissingThreadHeader(t *testing.T) {
srv, _, _, _ := newReplyServer(t)
session, _, _ := login(t, srv)
id := seedReplyable(t, srv, "1", "hello@where-woof.com", "")
rec := getWithSession(t, srv, "/partials/message?id="+itoa(id), session)
if !strings.Contains(rec.Body.String(), "cannot set threading headers") {
t.Errorf("detail pane did not warn about the missing Message-ID")
}
}