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:
2026-10-11 22:24:38 +11:00
parent 7c66e4ad31
commit 00bb0e05c4
24 changed files with 2121 additions and 42 deletions

View File

@@ -18,6 +18,10 @@ const maxPromptBodyChars = 4000
// maxDraftChars bounds the generated body before it is stored.
const maxDraftChars = 8000
// maxRulesChars bounds how much of the user's rules document is sent to the
// model (FR-11: the LLM reads the rules too).
const maxRulesChars = 2000
// Request is one draft request.
type Request struct {
// Message is the mail being replied to.
@@ -31,6 +35,9 @@ type Request struct {
// Instruction is optional extra guidance Sam typed (for example "say the
// invoice is paid"). It is appended to the template instruction.
Instruction string
// Rules is the current rules document from the editor (FR-11). The model
// reads it so reply style and handling follow the same rules as triage.
Rules string
// Signer is the name used for the sign-off (default "Sam").
Signer string
}
@@ -107,6 +114,11 @@ func BuildPrompt(req Request, tmpl Template) (system, user string) {
b.WriteString(extra)
b.WriteString("\n")
}
if rules := strings.TrimSpace(req.Rules); rules != "" {
b.WriteString("USER RULES (follow when they apply):\n")
b.WriteString(truncate(rules, maxRulesChars))
b.WriteString("\n")
}
if cat := strings.TrimSpace(req.Category); cat != "" {
b.WriteString("CLASSIFICATION: category=")
b.WriteString(cat)