- 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>
390 lines
13 KiB
Go
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)
|
|
)
|