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

@@ -466,3 +466,68 @@ a:hover { text-decoration: underline; }
@media (max-width: 759px) {
.reply-actions .btn { flex: 1 1 auto; }
}
/* Phase 6: rules editor (FR-4, FR-11) and unsubscribe detection (FR-7) */
.rules-panel, .unsub-panel { margin-top: var(--space-md); }
.rules-help, .unsub-help { font: var(--type-body-sm); margin-bottom: var(--space-md); max-width: 78ch; }
.rules-help code { font: var(--type-caption); background: var(--surface-2); padding: 1px 4px; border-radius: 4px; }
.rules-errors { margin: var(--space-xs) 0 0; padding-left: var(--space-md); font: var(--type-body-sm); }
.rules-form { display: grid; gap: var(--space-sm); }
.rules-textarea {
width: 100%;
min-height: 340px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
.rules-actions, .rules-reclassify { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.rules-reclassify { margin-top: var(--space-sm); }
.rules-versions { display: flex; flex-direction: column; }
.rules-version {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--hairline);
}
.rules-version:last-child { border-bottom: 0; }
.rules-version-main { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; flex-wrap: wrap; }
.rules-version-preview { font: var(--type-caption); color: var(--ink-secondary); overflow-wrap: anywhere; }
.unsub-list { display: flex; flex-direction: column; }
.unsub-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--hairline);
}
.unsub-row:last-child { border-bottom: 0; }
.unsub-main { display: grid; gap: 2px; min-width: 0; flex: 1 1 auto; }
.unsub-subject { font: var(--type-body-sm); color: var(--ink); overflow-wrap: anywhere; }
.unsub-links { display: grid; gap: 2px; margin-top: var(--space-xs); }
.unsub-link { display: flex; align-items: center; gap: var(--space-xs); flex-wrap: wrap; }
.unsub-source { font: var(--type-caption); color: var(--ink-faint); }
.unsub-url { font: var(--type-caption); color: var(--ink-secondary); overflow-wrap: anywhere; }
.unsub-domain { font: var(--type-caption); color: var(--ink-faint); margin-top: 2px; }
.unsub-actions { display: flex; align-items: center; gap: var(--space-xs); flex-wrap: wrap; flex: 0 0 auto; }
.unsub-detected {
border: 1px solid var(--hairline);
border-radius: 4px;
padding: var(--space-sm);
margin-bottom: var(--space-md);
display: grid;
gap: var(--space-xs);
}
.unsub-detected h3 { font: var(--type-eyebrow); text-transform: uppercase; letter-spacing: .125px; color: var(--ink-faint); margin: 0; }
.unsub-link-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); }
.unsub-link-list li { display: flex; align-items: center; gap: var(--space-xs); flex-wrap: wrap; font: var(--type-caption); color: var(--ink-secondary); }
@media (max-width: 759px) {
.unsub-row { flex-direction: column; align-items: stretch; }
.rules-version { flex-direction: column; align-items: stretch; }
}