// 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 }