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

369
internal/server/replies.go Normal file
View File

@@ -0,0 +1,369 @@
// Reply drafting and sending for Phase 5 (FR-8, FR-15).
//
// The flow is: open a message, generate a draft with the LLM (through
// OmniRoute) from a template, edit it, then send it. Sending is always a
// deliberate UI action (AGENTS.md §10): the handler requires a CSRF token and an
// explicit confirmation, and it never auto-sends. A sent reply moves the
// original to its Replied-to/<address> label and is logged (FR-13).
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"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"
)
// ReplyGenerator drafts a reply body. The live implementation is
// *reply.Generator; tests substitute a fake.
type ReplyGenerator interface {
Draft(ctx context.Context, req reply.Request) (body, templateID string, err error)
Configured() bool
Model() string
}
// MailSender sends a reply and can check that it joined the original thread.
// The live implementation is *mailops.Client; tests substitute a fake.
type MailSender interface {
Send(ctx context.Context, msg mailops.OutgoingMessage) error
VerifyThreadUpdated(ctx context.Context, threadID string) (bool, error)
}
// replyData is the template model for the message detail pane.
type replyData struct {
Message store.Message
Category string
Importance string
Action string
Confidence int
Classified bool
HasDraft bool
Draft store.Draft
Templates []reply.Template
TemplateID string
Instruction string
From string
To string
Subject string
CSRFToken string
Note string
Error string
History []store.ActionLogEntry
LLMReady bool
LLMModel string
Sent bool
ThreadUpdated bool
RepliedFolder string
// NoThreadHeader is true when the original message has no stored Message-ID,
// so the reply cannot set In-Reply-To/References.
NoThreadHeader bool
}
// handleMessageDetail renders the detail pane for one message.
func (s *Server) handleMessageDetail(w http.ResponseWriter, r *http.Request) {
id, ok := parseMessageID(r)
if !ok {
http.Error(w, "unknown message", http.StatusBadRequest)
return
}
s.renderMessage(w, r, id, "", "", false, false)
}
// handleDraft generates a reply draft with the LLM and stores it.
func (s *Server) handleDraft(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "could not read the form", http.StatusBadRequest)
return
}
id, ok := parseMessageID(r)
if !ok {
http.Error(w, "unknown message", http.StatusBadRequest)
return
}
if !s.replies.Configured() {
s.renderMessage(w, r, id, "", "Reply drafting is not connected yet: set OMNIROUTE_API_KEY.", false, false)
return
}
ctx := r.Context()
msg, err := s.store.MessageByID(ctx, id)
if err != nil {
s.renderMessage(w, r, id, "", "Could not load that message.", false, false)
return
}
class := s.latestClass(ctx, id)
body, templateID, err := s.replies.Draft(ctx, reply.Request{
Message: msg,
Category: class.Category,
Importance: class.Importance,
Action: class.Action,
TemplateID: r.FormValue("template"),
Instruction: r.FormValue("instruction"),
Signer: "Sam",
})
if err != nil {
s.logger.Warn("draft generation failed", "message_id", id, "error", err)
s.renderMessage(w, r, id, "", "Draft generation failed: "+err.Error(), false, false)
return
}
if _, err := s.store.SaveDraft(ctx, id, body, templateID); err != nil {
s.logger.Error("save draft", "message_id", id, "error", err)
s.renderMessage(w, r, id, "", "Could not save the draft.", false, false)
return
}
s.logMessageAction(ctx, id, "draft", "Sam",
fmt.Sprintf("template=%s model=%s chars=%d", templateID, s.replies.Model(), len(body)))
s.renderMessage(w, r, id,
fmt.Sprintf("Draft generated with the %q template. Edit it, then send.", templateID), "", false, false)
}
// handleSaveDraft stores Sam's edits to a draft.
func (s *Server) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "could not read the form", http.StatusBadRequest)
return
}
id, ok := parseMessageID(r)
if !ok {
http.Error(w, "unknown message", http.StatusBadRequest)
return
}
draftID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("draft_id")), 10, 64)
if err != nil || draftID <= 0 {
s.renderMessage(w, r, id, "", "There is no draft to save.", false, false)
return
}
if err := s.store.UpdateDraftBody(r.Context(), draftID, r.FormValue("body")); err != nil {
s.logger.Error("update draft", "draft_id", draftID, "error", err)
s.renderMessage(w, r, id, "", "Could not save the draft.", false, false)
return
}
s.logMessageAction(r.Context(), id, "draft_edit", "Sam", fmt.Sprintf("draft_id=%d", draftID))
s.renderMessage(w, r, id, "Draft saved.", "", false, false)
}
// handleSendReply sends the (edited) draft. It is the only handler that can send
// mail, it needs the CSRF token and confirm=yes, and it never runs from a
// schedule.
func (s *Server) handleSendReply(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "could not read the form", http.StatusBadRequest)
return
}
id, ok := parseMessageID(r)
if !ok {
http.Error(w, "unknown message", http.StatusBadRequest)
return
}
if r.FormValue("confirm") != "yes" {
s.renderMessage(w, r, id, "", "Sending needs confirmation. Press Send reply again and confirm.", false, false)
return
}
ctx := r.Context()
msg, err := s.store.MessageByID(ctx, id)
if err != nil {
s.renderMessage(w, r, id, "", "Could not load that message.", false, false)
return
}
draftID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("draft_id")), 10, 64)
if err != nil || draftID <= 0 {
s.renderMessage(w, r, id, "", "There is no draft to send.", false, false)
return
}
draft, err := s.store.DraftByID(ctx, draftID)
if err != nil {
s.renderMessage(w, r, id, "", "Could not load the draft.", false, false)
return
}
if draft.Status == store.DraftStatusSent {
s.renderMessage(w, r, id, "", "That draft has already been sent.", false, false)
return
}
// Save any last edits before sending.
body := strings.TrimSpace(r.FormValue("body"))
if body == "" {
body = draft.Body
}
if body == "" {
s.renderMessage(w, r, id, "", "The reply is empty.", false, false)
return
}
if body != draft.Body {
if err := s.store.UpdateDraftBody(ctx, draftID, body); err != nil {
s.logger.Error("update draft before send", "draft_id", draftID, "error", err)
}
}
to := mailops.ReplyTo(msg)
if to == "" {
s.renderMessage(w, r, id, "", "This message has no sender address to reply to.", false, false)
return
}
out := mailops.OutgoingMessage{
To: to,
From: mailops.ReplyIdentity(msg, s.cfg.GmailUser),
Subject: mailops.ReplySubject(msg.Subject),
InReplyTo: msg.MessageIDHeader,
References: msg.MessageIDHeader,
Body: body,
}
if err := s.sender.Send(ctx, out); err != nil {
s.logger.Error("send reply failed", "message_id", id, "to", to, "error", err)
s.renderMessage(w, r, id, "", "Send failed: "+err.Error(), false, false)
return
}
if err := s.store.MarkDraftSent(ctx, draftID); err != nil {
s.logger.Error("mark draft sent", "draft_id", draftID, "error", err)
}
s.logMessageAction(ctx, id, "reply_sent", "Sam",
fmt.Sprintf("to=%s from=%s template=%s", to, out.From, draft.TemplateID))
// File the original under Replied-to/<arrived-at> (FR-9). Best effort: the
// reply is already sent, so a label failure is logged, not fatal.
label, merr := s.actions.Move(ctx, msg, store.FolderReplied)
if merr != nil {
s.logger.Warn("move replied message failed", "message_id", id, "error", merr)
s.logMessageAction(ctx, id, "reply_filed", "agent", "failed="+merr.Error())
} else if serr := s.store.SetMessageLocation(ctx, id, store.FolderReplied, label); serr != nil {
s.logger.Error("record replied location", "message_id", id, "error", serr)
} else {
s.logMessageAction(ctx, id, "move", "Sam",
fmt.Sprintf("to=%s label=%q from=%q", store.FolderReplied, label, msg.Mailbox))
}
// Confirm the thread updated. This is read-only and best effort.
threadUpdated := false
if strings.TrimSpace(msg.ThreadID) != "" {
if ok, verr := s.sender.VerifyThreadUpdated(ctx, msg.ThreadID); verr != nil {
s.logger.Warn("verify thread update failed", "message_id", id, "error", verr)
} else {
threadUpdated = ok
}
}
note := fmt.Sprintf("Reply sent to %s as %s.", to, out.From)
if !threadUpdated && strings.TrimSpace(msg.ThreadID) != "" {
note += " Could not yet confirm the thread update in Gmail; it may take a moment to appear."
}
s.renderMessage(w, r, id, note, "", true, threadUpdated)
}
// renderMessage loads and renders the message detail pane.
func (s *Server) renderMessage(w http.ResponseWriter, r *http.Request, id int64, note, errMsg string, sent, threadUpdated bool) {
ctx := r.Context()
msg, err := s.store.MessageByID(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "message not found", http.StatusNotFound)
return
}
if err != nil {
s.logger.Error("load message detail", "message_id", id, "error", err)
http.Error(w, "could not load message", http.StatusInternalServerError)
return
}
class := s.latestClass(ctx, id)
data := replyData{
Message: msg,
Category: class.Category,
Importance: class.Importance,
Action: class.Action,
Confidence: confidencePercent(class.Confidence),
Classified: class.Category != "",
Templates: reply.Templates,
From: mailops.ReplyIdentity(msg, s.cfg.GmailUser),
To: mailops.ReplyTo(msg),
Subject: mailops.ReplySubject(msg.Subject),
CSRFToken: s.csrfToken(w, r),
Note: note,
Error: errMsg,
LLMReady: s.replies.Configured(),
LLMModel: s.replies.Model(),
Sent: sent,
ThreadUpdated: threadUpdated,
}
if data.Action == "" {
data.Action = "Review"
}
data.NoThreadHeader = strings.TrimSpace(msg.MessageIDHeader) == ""
if d, ok, derr := s.store.LatestDraft(ctx, id); derr != nil {
s.logger.Error("load draft", "message_id", id, "error", derr)
} else if ok {
data.HasDraft = true
data.Draft = d
templateID := d.TemplateID
if templateID == "" {
templateID = reply.TemplateForAction(class.Action).ID
}
data.TemplateID = templateID
}
if history, herr := s.store.MessageActions(ctx, id, 20); herr != nil {
s.logger.Error("load message history", "message_id", id, "error", herr)
} else {
data.History = history
}
if !data.HasDraft {
data.TemplateID = reply.TemplateForAction(class.Action).ID
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "message.html", data); err != nil {
s.logger.Error("render message detail", "error", err)
}
}
// latestClass returns the newest classification for a message.
func (s *Server) latestClass(ctx context.Context, id int64) store.Classification {
classes, err := s.store.LatestClassifications(ctx, []int64{id})
if err != nil {
s.logger.Error("load latest classification", "message_id", id, "error", err)
return store.Classification{}
}
return classes[id]
}
// parseMessageID reads the message id from the query or the posted form.
func parseMessageID(r *http.Request) (int64, bool) {
raw := strings.TrimSpace(r.FormValue("id"))
if raw == "" {
return 0, false
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
// Ensure the live implementations satisfy the interfaces at compile time.
var (
_ ReplyGenerator = (*reply.Generator)(nil)
_ MailSender = (*mailops.Client)(nil)
)

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")
}
}

View File

@@ -21,6 +21,7 @@ import (
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
"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/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
@@ -43,6 +44,8 @@ type Server struct {
fetcher *mailfetch.Fetcher
classifier *classify.Classifier
actions MailActions
replies ReplyGenerator
sender MailSender
}
// New builds a Server. It does not start listening.
@@ -61,6 +64,7 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
return nil, fmt.Errorf("session manager: %w", err)
}
mail := mailops.New(cfg, logger)
s := &Server{
cfg: cfg,
store: st,
@@ -72,7 +76,12 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
),
actions: mailops.New(cfg, logger),
actions: mail,
sender: mail,
replies: reply.NewGenerator(
reply.NewClient(cfg.OmniRouteBaseURL, cfg.OmniRouteAPIKey, cfg.OmniRouteModel, logger),
logger,
),
}
s.http = &http.Server{
Addr: cfg.Addr,
@@ -128,6 +137,12 @@ func (s *Server) Handler() http.Handler {
// Phase 4 (FR-6/FR-9): bulk triage actions (delete, move, mark) and restore.
mux.HandleFunc("POST /actions/bulk", s.requireAuth(s.handleBulkAction))
// Phase 5 (FR-8/FR-15): message detail, reply draft, edit, and send.
mux.HandleFunc("GET /partials/message", s.requireAuth(s.handleMessageDetail))
mux.HandleFunc("POST /actions/draft", s.requireAuth(s.handleDraft))
mux.HandleFunc("POST /actions/draft/save", s.requireAuth(s.handleSaveDraft))
mux.HandleFunc("POST /actions/send", s.requireAuth(s.handleSendReply))
// FR-14: editable category list.
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))