Files
email_agent/internal/server/replies.go
Sam Rolfe 00bb0e05c4 Phase 6: rules editor + unsubscribe detection (AUD-72)
- FR-11 rules editor: plain-text area at /rules, saved and versioned
  (SaveRules appends a version; restore re-saves an old version), parse
  errors surfaced, one-click re-classify. The reply model now reads the
  rules document too (FR-11).
- FR-7 unsubscribe: parse and store List-Unsubscribe / List-Unsubscribe-Post
  (migration v6), detect email and web links (internal/unsubscribe), a
  domain-level ignore list, and an explicit record of what was marked. No
  code path contacts an unsubscribe link; acting on one needs Sam's approval.
- Migration v6 is additive; unsubscribe gains url/updated_at.
- Tests: detector, parser, store migration/records/candidates, rules editor,
  unsubscribe handlers, message-detail rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 22:27:08 +11:00

390 lines
13 KiB
Go

// 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"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/unsubscribe"
)
// 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
// Phase 6 (FR-7): detected unsubscribe links for this message, and the
// domain's status on the ignore/recorded list.
UnsubLinks []unsubscribe.Link
UnsubStatus 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)
// FR-11: the reply model reads the current rules document too.
rulesText, _, _ := s.store.CurrentRules(ctx)
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"),
Rules: rulesText,
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) == ""
// FR-7: detect unsubscribe links and look up the domain's list status.
if res := unsubscribe.Detect(msg.ListUnsubscribe, msg.ListUnsubscribePost, msg.FromAddr, msg.BodyText); len(res.Links) > 0 {
data.UnsubLinks = res.Links
if rec, ok, rerr := s.store.UnsubscribeByDomain(ctx, res.Links[0].Domain); rerr != nil {
s.logger.Error("load unsubscribe status", "message_id", id, "error", rerr)
} else if ok {
data.UnsubStatus = rec.Status
}
}
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)
)