Files
email_agent/internal/server/unsubscribe.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

219 lines
7.0 KiB
Go

// Unsubscribe detection view for Phase 6 (FR-7).
//
// The page lists messages with a detected unsubscribe link, the domain-level
// ignore list, and the domains Sam has recorded as unsubscribed. Every action
// records state; none of them contacts the link. Acting on a real unsubscribe
// link is a manual step that needs Sam's explicit approval (AUD-72).
package server
import (
"net/http"
"net/url"
"strings"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/unsubscribe"
)
type unsubscribeItem struct {
MessageID int64
From string
Subject string
SourceAddr string
Domain string
Links []unsubscribe.Link
Status string
Category string
When string
}
type unsubscribeData struct {
Items []unsubscribeItem
Ignored []store.UnsubscribeRecord
Recorded []store.UnsubscribeRecord
Note string
Error string
CSRFToken string
CandidateCount int
}
// handleUnsubscribe renders the detection list and the ignore/recorded lists.
func (s *Server) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
s.renderUnsubscribe(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error"))
}
func (s *Server) renderUnsubscribe(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) {
ctx := r.Context()
records, err := s.store.ListUnsubscribe(ctx)
if err != nil {
s.logger.Error("list unsubscribe records", "error", err)
http.Error(w, "could not load the unsubscribe list", http.StatusInternalServerError)
return
}
candidates, err := s.store.UnsubscribeCandidates(ctx, 300)
if err != nil {
s.logger.Error("list unsubscribe candidates", "error", err)
http.Error(w, "could not load messages", http.StatusInternalServerError)
return
}
status := make(map[string]string, len(records))
for _, rec := range records {
status[rec.Domain] = rec.Status
}
var items []unsubscribeItem
for _, c := range candidates {
res := unsubscribe.Detect(c.ListUnsubscribe, c.ListUnsubscribePost, c.FromAddr, c.BodyText)
if len(res.Links) == 0 {
continue
}
domain := res.Links[0].Domain
items = append(items, unsubscribeItem{
MessageID: c.MessageID,
From: orDefault(c.FromAddr, "(unknown sender)"),
Subject: orDefault(c.Subject, "(no subject)"),
SourceAddr: c.SourceAddress,
Domain: domain,
Links: res.Links,
Status: status[domain],
Category: c.Category,
When: formatWhen(c.ReceivedAt, c.ReceivedAt),
})
}
data := unsubscribeData{
Items: items,
CSRFToken: s.csrfToken(w, r),
Note: note,
Error: errMsg,
CandidateCount: len(items),
}
for _, rec := range records {
switch rec.Status {
case store.UnsubscribeStatusIgnored:
data.Ignored = append(data.Ignored, rec)
case store.UnsubscribeStatusDone:
data.Recorded = append(data.Recorded, rec)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := s.tmpl.ExecuteTemplate(w, "unsubscribe.html", data); err != nil {
s.logger.Error("render unsubscribe", "error", err)
}
}
// handleUnsubscribeIgnore adds a domain to the ignore list (FR-7).
func (s *Server) handleUnsubscribeIgnore(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 {
s.redirectUnsubscribe(w, r, "", "Could not read the form.")
return
}
domain, ok := cleanDomain(r.FormValue("domain"))
if !ok {
s.redirectUnsubscribe(w, r, "", "Unknown domain.")
return
}
if err := s.store.SetUnsubscribeStatus(r.Context(), domain, store.UnsubscribeStatusIgnored, r.FormValue("url")); err != nil {
s.logger.Error("ignore unsubscribe domain", "domain", domain, "error", err)
s.redirectUnsubscribe(w, r, "", "Could not update the ignore list.")
return
}
s.logAction(r.Context(), "unsubscribe_ignore", "Sam", "domain="+domain)
s.redirectUnsubscribe(w, r, "Added "+domain+" to the ignore list. It is never recommended for unsubscribe.", "")
}
// handleUnsubscribeUnignore removes a domain from the ignore list.
func (s *Server) handleUnsubscribeUnignore(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 {
s.redirectUnsubscribe(w, r, "", "Could not read the form.")
return
}
domain, ok := cleanDomain(r.FormValue("domain"))
if !ok {
s.redirectUnsubscribe(w, r, "", "Unknown domain.")
return
}
if err := s.store.ClearUnsubscribe(r.Context(), domain); err != nil {
s.logger.Error("clear unsubscribe domain", "domain", domain, "error", err)
s.redirectUnsubscribe(w, r, "", "Could not update the list.")
return
}
s.logAction(r.Context(), "unsubscribe_unignore", "Sam", "domain="+domain)
s.redirectUnsubscribe(w, r, "Removed "+domain+" from the unsubscribe list.", "")
}
// handleUnsubscribeMark records that a domain should be unsubscribed from. It
// is a record only: the handler makes no network call and the app never follows
// the link (AUD-72). Following a real link needs Sam's explicit approval.
func (s *Server) handleUnsubscribeMark(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 {
s.redirectUnsubscribe(w, r, "", "Could not read the form.")
return
}
if r.FormValue("confirm") != "yes" {
s.redirectUnsubscribe(w, r, "", "Marking needs confirmation. The app will record it, not follow the link.")
return
}
domain, ok := cleanDomain(r.FormValue("domain"))
if !ok {
s.redirectUnsubscribe(w, r, "", "Unknown domain.")
return
}
if err := s.store.SetUnsubscribeStatus(r.Context(), domain, store.UnsubscribeStatusDone, r.FormValue("url")); err != nil {
s.logger.Error("mark unsubscribe domain", "domain", domain, "error", err)
s.redirectUnsubscribe(w, r, "", "Could not record the unsubscribe.")
return
}
s.logAction(r.Context(), "unsubscribe_marked", "Sam", "domain="+domain+" url="+r.FormValue("url"))
s.redirectUnsubscribe(w, r,
"Recorded "+domain+" for unsubscribe. The app did not contact the link; acting on it is a manual step.", "")
}
// redirectUnsubscribe is POST/redirect/GET so a refresh does not resubmit.
func (s *Server) redirectUnsubscribe(w http.ResponseWriter, r *http.Request, note, errMsg string) {
q := url.Values{}
if note != "" {
q.Set("note", note)
}
if errMsg != "" {
q.Set("error", errMsg)
}
http.Redirect(w, r, "/unsubscribe?"+q.Encode(), http.StatusSeeOther)
}
// cleanDomain validates a domain used as the ignore-list key.
func cleanDomain(raw string) (string, bool) {
domain := strings.ToLower(strings.TrimSpace(raw))
domain = strings.Trim(domain, ".")
if domain == "" || len(domain) > 253 {
return "", false
}
for _, r := range domain {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
default:
return "", false
}
}
if !strings.Contains(domain, ".") {
return "", false
}
return domain, true
}