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:
218
internal/server/unsubscribe.go
Normal file
218
internal/server/unsubscribe.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user