- 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>
219 lines
7.0 KiB
Go
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
|
|
}
|