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>
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"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
|
||||
@@ -63,6 +64,11 @@ type replyData struct {
|
||||
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
|
||||
@@ -109,6 +115,9 @@ func (s *Server) handleDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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,
|
||||
@@ -116,6 +125,7 @@ func (s *Server) handleDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Action: class.Action,
|
||||
TemplateID: r.FormValue("template"),
|
||||
Instruction: r.FormValue("instruction"),
|
||||
Rules: rulesText,
|
||||
Signer: "Sam",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -313,6 +323,16 @@ func (s *Server) renderMessage(w http.ResponseWriter, r *http.Request, id int64,
|
||||
}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user