Skip to content

Commit c400ecc

Browse files
lizthegreyclaude
andcommittedFeb 7, 2026
feat: Add escalation engine improvements and admin reply actions
Restructure the escalation engine to produce richer upstream escalation emails with full contact history, peer upstream notification, and domain-specific boilerplate for known problem domains (e.g. Kiwi Farms). Add admin reply actions so admins can act on provider replies: mark a case resolved, trigger immediate escalation when a provider refuses, or ignore an auto-reply that blocked the escalation timer. Changes: - New internal/boilerplate package for domain-specific context in emails - Store: GetEmailChain (recursive CTE), ListAllRepliesByReport, ListEmailRepliesByEmails for batch-fetching reply history - Escalation engine: EscalateNow() for admin-triggered immediate escalation, two-pass upstream resolution for peer notification, composeEscalationBody with contact history chain and boilerplate - Admin: HandleReplyAction (resolve/escalate_now/ignore_autoreply), HandleReportView now shows outgoing emails and their replies - Email composition: domain boilerplate integrated into initial reports - Template: report.html shows email chain with replies and action buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 24ff32b commit c400ecc

File tree

11 files changed

+1114
-64
lines changed

11 files changed

+1114
-64
lines changed
 

‎cmd/wizard/main.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"time"
1515

1616
wizard "github.com/endharassment/reporting-wizard"
17+
"github.com/endharassment/reporting-wizard/internal/boilerplate"
1718
"github.com/endharassment/reporting-wizard/internal/email"
1819
"github.com/endharassment/reporting-wizard/internal/escalation"
1920
"github.com/endharassment/reporting-wizard/internal/infra"
@@ -84,13 +85,19 @@ func main() {
8485
// a local Tor SOCKS proxy is available).
8586
srv.SetSnapshotter(snapshot.NewPlainHTTPSnapshotter())
8687

88+
// Initialize domain boilerplate database.
89+
boilerplateDB := boilerplate.NewDB()
90+
srv.SetBoilerplate(boilerplateDB)
91+
8792
// Start escalation engine.
8893
logger := slog.Default()
8994
abuseContactLookup := &infra.RDAPAbuseContactLookup{
9095
RDAP: infra.NewRDAPClient(),
9196
ASN: infra.NewASNClient(),
9297
}
9398
escalationEngine := escalation.NewEngine(db, abuseContactLookup, cfg.EscalationDays, logger)
99+
escalationEngine.SetBoilerplate(boilerplateDB)
100+
srv.SetEscalator(escalationEngine)
94101
go func() {
95102
if err := escalationEngine.Run(ctx); err != nil && err != context.Canceled {
96103
log.Printf("ERROR: escalation engine: %v", err)

‎internal/admin/admin.go‎

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ type UserFunc func(ctx context.Context) *model.User
2626
// CSRFFunc extracts the CSRF token from a context.
2727
type CSRFFunc func(ctx context.Context) string
2828

29+
// Escalator triggers immediate escalation for an outgoing email.
30+
type Escalator interface {
31+
EscalateNow(ctx context.Context, emailID string) (int, error)
32+
}
33+
2934
// AdminHandler holds dependencies for admin route handlers.
3035
type AdminHandler struct {
3136
store store.Store
@@ -34,6 +39,7 @@ type AdminHandler struct {
3439
templates *template.Template
3540
getUser UserFunc
3641
getCSRF CSRFFunc
42+
escalator Escalator
3743
}
3844

3945
// NewAdminHandler creates an AdminHandler.
@@ -48,6 +54,11 @@ func NewAdminHandler(s store.Store, d *infra.Discovery, emailCfg report.EmailCon
4854
}
4955
}
5056

57+
// SetEscalator configures the escalation engine for immediate escalation actions.
58+
func (h *AdminHandler) SetEscalator(e Escalator) {
59+
h.escalator = e
60+
}
61+
5162
// DashboardCounts holds the counts shown on the admin dashboard.
5263
type DashboardCounts struct {
5364
PendingApproval int
@@ -100,12 +111,16 @@ func (h *AdminHandler) HandleReportView(w http.ResponseWriter, r *http.Request)
100111
}
101112

102113
evidence, _ := h.store.ListEvidenceByReport(r.Context(), reportID)
114+
emails, _ := h.store.ListEmailsByReport(r.Context(), reportID)
115+
repliesByEmail, _ := h.store.ListAllRepliesByReport(r.Context(), reportID)
103116
auditLog, _ := h.store.ListAuditLogByTarget(r.Context(), reportID)
104117

105118
h.render(w, r, "report.html", map[string]interface{}{
106-
"Report": rpt,
107-
"Evidence": evidence,
108-
"AuditLog": auditLog,
119+
"Report": rpt,
120+
"Evidence": evidence,
121+
"Emails": emails,
122+
"RepliesByEmail": repliesByEmail,
123+
"AuditLog": auditLog,
109124
})
110125
}
111126

@@ -425,7 +440,6 @@ func (h *AdminHandler) HandleReportAbuse(w http.ResponseWriter, r *http.Request)
425440
})
426441
}
427442

428-
429443
// HandleSendEmailToUser sends a custom email to the user who filed the report.
430444
func (h *AdminHandler) HandleSendEmailToUser(w http.ResponseWriter, r *http.Request) {
431445
reportID := chi.URLParam(r, "reportID")
@@ -457,6 +471,89 @@ func (h *AdminHandler) HandleSendEmailToUser(w http.ResponseWriter, r *http.Requ
457471
http.Redirect(w, r, fmt.Sprintf("/admin/reports/%s", reportID), http.StatusFound)
458472
}
459473

474+
// HandleReplyAction processes admin actions on email replies.
475+
// Supported actions: resolve, escalate_now, ignore_autoreply.
476+
func (h *AdminHandler) HandleReplyAction(w http.ResponseWriter, r *http.Request) {
477+
emailID := chi.URLParam(r, "emailID")
478+
user := h.getUser(r.Context())
479+
action := r.FormValue("action")
480+
notes := r.FormValue("notes")
481+
482+
email, err := h.store.GetOutgoingEmail(r.Context(), emailID)
483+
if err != nil {
484+
http.Error(w, "Email not found", http.StatusNotFound)
485+
return
486+
}
487+
488+
switch action {
489+
case "resolve":
490+
resolveNotes := "resolved"
491+
if notes != "" {
492+
resolveNotes = "resolved: " + notes
493+
}
494+
email.ResponseNotes = resolveNotes
495+
if err := h.store.UpdateOutgoingEmail(r.Context(), email); err != nil {
496+
log.Printf("ERROR: resolve email: %v", err)
497+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
498+
return
499+
}
500+
501+
// Update report status to resolved.
502+
rpt, err := h.store.GetReport(r.Context(), email.ReportID)
503+
if err == nil {
504+
rpt.Status = model.StatusResolved
505+
rpt.UpdatedAt = time.Now().UTC()
506+
_ = h.store.UpdateReport(r.Context(), rpt)
507+
}
508+
509+
// Cancel pending escalation emails for this report.
510+
reportEmails, _ := h.store.ListEmailsByReport(r.Context(), email.ReportID)
511+
for _, re := range reportEmails {
512+
if re.Status == model.EmailPendingApproval && re.EmailType == model.EmailTypeEscalation {
513+
re.Status = model.EmailRejected
514+
re.ResponseNotes = "cancelled: report resolved"
515+
_ = h.store.UpdateOutgoingEmail(r.Context(), re)
516+
}
517+
}
518+
519+
h.createAuditEntry(r, user.ID, "reply_resolved", emailID,
520+
fmt.Sprintf("Marked email to %s as resolved: %s", email.Recipient, notes))
521+
522+
case "escalate_now":
523+
if h.escalator == nil {
524+
http.Error(w, "Escalation engine not configured", http.StatusInternalServerError)
525+
return
526+
}
527+
528+
created, err := h.escalator.EscalateNow(r.Context(), emailID)
529+
if err != nil {
530+
log.Printf("ERROR: escalate now: %v", err)
531+
http.Error(w, "Escalation failed", http.StatusInternalServerError)
532+
return
533+
}
534+
535+
h.createAuditEntry(r, user.ID, "reply_escalate_now", emailID,
536+
fmt.Sprintf("Immediate escalation triggered for email to %s, %d escalation emails created", email.Recipient, created))
537+
538+
case "ignore_autoreply":
539+
email.ResponseNotes = ""
540+
if err := h.store.UpdateOutgoingEmail(r.Context(), email); err != nil {
541+
log.Printf("ERROR: clear response notes: %v", err)
542+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
543+
return
544+
}
545+
546+
h.createAuditEntry(r, user.ID, "reply_ignore_autoreply", emailID,
547+
fmt.Sprintf("Auto-reply ignored for email to %s, escalation timer resumed", email.Recipient))
548+
549+
default:
550+
http.Error(w, "Invalid action", http.StatusBadRequest)
551+
return
552+
}
553+
554+
http.Redirect(w, r, fmt.Sprintf("/admin/reports/%s", email.ReportID), http.StatusFound)
555+
}
556+
460557
// --- Helpers ---
461558

462559
func (h *AdminHandler) render(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package boilerplate
2+
3+
import "strings"
4+
5+
// DomainInfo holds contextual information about a known problem domain.
6+
type DomainInfo struct {
7+
Domain string // primary exact domain match
8+
Aliases []string // additional exact domain matches
9+
Patterns []string // wildcard suffix patterns, e.g. "*.kiwifarms.*"
10+
DisplayName string // human-friendly name
11+
Summary string // 1-2 sentence description for abuse reports
12+
Context string // longer paragraph with history
13+
KnownASNs []int // ASNs historically associated with this domain
14+
}
15+
16+
// DB holds the collection of known domain entries.
17+
type DB struct {
18+
domains []DomainInfo
19+
}
20+
21+
// NewDB creates a DB populated with built-in domain entries.
22+
func NewDB() *DB {
23+
return &DB{
24+
domains: builtinDomains(),
25+
}
26+
}
27+
28+
// Lookup finds domain info for the given domain.
29+
// It checks exact matches first (Domain and Aliases), then wildcard Patterns.
30+
// Returns nil if no match is found. Matching is case-insensitive.
31+
func (db *DB) Lookup(domain string) *DomainInfo {
32+
domain = strings.ToLower(domain)
33+
34+
// Exact match first.
35+
for i := range db.domains {
36+
if strings.ToLower(db.domains[i].Domain) == domain {
37+
return &db.domains[i]
38+
}
39+
for _, alias := range db.domains[i].Aliases {
40+
if strings.ToLower(alias) == domain {
41+
return &db.domains[i]
42+
}
43+
}
44+
}
45+
46+
// Wildcard pattern match.
47+
for i := range db.domains {
48+
for _, pattern := range db.domains[i].Patterns {
49+
if matchWildcard(strings.ToLower(pattern), domain) {
50+
return &db.domains[i]
51+
}
52+
}
53+
}
54+
55+
return nil
56+
}
57+
58+
// matchWildcard matches a pattern like "*.kiwifarms.*" against a domain.
59+
// Supported wildcards:
60+
// - Leading "*." matches any subdomain prefix (including nested)
61+
// - Trailing ".*" matches any TLD suffix
62+
func matchWildcard(pattern, domain string) bool {
63+
// Handle leading "*."
64+
if strings.HasPrefix(pattern, "*.") {
65+
suffix := pattern[2:] // e.g., "kiwifarms.*"
66+
// Check if suffix also has trailing wildcard
67+
if strings.HasSuffix(suffix, ".*") {
68+
// Pattern like "*.kiwifarms.*" -- domain must contain ".kiwifarms." or start with "kiwifarms."
69+
core := suffix[:len(suffix)-2] // e.g., "kiwifarms"
70+
return strings.Contains(domain, "."+core+".") ||
71+
strings.HasPrefix(domain, core+".") ||
72+
domain == core
73+
}
74+
// Pattern like "*.example.com" -- domain must end with ".example.com" or be exactly "example.com"
75+
return strings.HasSuffix(domain, "."+suffix) || domain == suffix
76+
}
77+
78+
// Handle trailing ".*" only
79+
if strings.HasSuffix(pattern, ".*") {
80+
prefix := pattern[:len(pattern)-2] // e.g., "kiwifarms"
81+
return strings.HasPrefix(domain, prefix+".") || domain == prefix
82+
}
83+
84+
// No wildcards, exact match
85+
return pattern == domain
86+
}
87+
88+
func builtinDomains() []DomainInfo {
89+
return []DomainInfo{
90+
{
91+
Domain: "kiwifarms.net",
92+
Aliases: []string{"kiwifarms.st", "kiwifarms.ru", "kiwifarms.is", "kiwifarms.top", "kiwifarms.cc"},
93+
Patterns: []string{"*.kiwifarms.*"},
94+
DisplayName: "Kiwi Farms",
95+
Summary: "Kiwi Farms is a well-documented harassment and doxxing forum that has been linked to multiple suicides and real-world violence against its targets.",
96+
Context: "Kiwi Farms (formerly known as CWCki Forums) is a website primarily dedicated to " +
97+
"the targeted harassment, doxxing, and stalking of individuals. The site has been " +
98+
"linked to at least three suicides and numerous cases of real-world harassment campaigns. " +
99+
"Multiple infrastructure providers including Cloudflare (September 2022), DDoS-Guard, " +
100+
"and various hosting providers have previously terminated service to this domain. " +
101+
"The site frequently migrates between TLDs and hosting providers to evade enforcement. " +
102+
"We encourage you to review this content under your acceptable use policy.",
103+
KnownASNs: []int{},
104+
},
105+
}
106+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package boilerplate
2+
3+
import "testing"
4+
5+
func TestLookup(t *testing.T) {
6+
db := NewDB()
7+
8+
tests := []struct {
9+
name string
10+
domain string
11+
wantMatch bool
12+
wantName string
13+
}{
14+
{
15+
name: "exact primary domain match",
16+
domain: "kiwifarms.net",
17+
wantMatch: true,
18+
wantName: "Kiwi Farms",
19+
},
20+
{
21+
name: "exact alias match",
22+
domain: "kiwifarms.st",
23+
wantMatch: true,
24+
wantName: "Kiwi Farms",
25+
},
26+
{
27+
name: "case insensitive match",
28+
domain: "KiwiFarms.Net",
29+
wantMatch: true,
30+
wantName: "Kiwi Farms",
31+
},
32+
{
33+
name: "wildcard subdomain match",
34+
domain: "forum.kiwifarms.net",
35+
wantMatch: true,
36+
wantName: "Kiwi Farms",
37+
},
38+
{
39+
name: "wildcard with different TLD",
40+
domain: "kiwifarms.xyz",
41+
wantMatch: true,
42+
wantName: "Kiwi Farms",
43+
},
44+
{
45+
name: "wildcard subdomain with different TLD",
46+
domain: "www.kiwifarms.co",
47+
wantMatch: true,
48+
wantName: "Kiwi Farms",
49+
},
50+
{
51+
name: "nested subdomain match",
52+
domain: "a.b.kiwifarms.net",
53+
wantMatch: true,
54+
wantName: "Kiwi Farms",
55+
},
56+
{
57+
name: "no match for unrelated domain",
58+
domain: "example.com",
59+
wantMatch: false,
60+
},
61+
{
62+
name: "no match for partial name",
63+
domain: "notkiwifarms.net",
64+
wantMatch: false,
65+
},
66+
{
67+
name: "no match for empty domain",
68+
domain: "",
69+
wantMatch: false,
70+
},
71+
}
72+
73+
for _, tt := range tests {
74+
t.Run(tt.name, func(t *testing.T) {
75+
info := db.Lookup(tt.domain)
76+
if tt.wantMatch {
77+
if info == nil {
78+
t.Fatal("expected match, got nil")
79+
}
80+
if info.DisplayName != tt.wantName {
81+
t.Errorf("DisplayName = %q, want %q", info.DisplayName, tt.wantName)
82+
}
83+
if info.Summary == "" {
84+
t.Error("Summary should not be empty")
85+
}
86+
if info.Context == "" {
87+
t.Error("Context should not be empty")
88+
}
89+
} else {
90+
if info != nil {
91+
t.Errorf("expected no match, got %+v", info)
92+
}
93+
}
94+
})
95+
}
96+
}
97+
98+
func TestMatchWildcard(t *testing.T) {
99+
tests := []struct {
100+
name string
101+
pattern string
102+
domain string
103+
want bool
104+
}{
105+
{"leading wildcard match", "*.example.com", "sub.example.com", true},
106+
{"leading wildcard exact", "*.example.com", "example.com", true},
107+
{"leading wildcard no match", "*.example.com", "other.com", false},
108+
{"trailing wildcard match", "example.*", "example.com", true},
109+
{"trailing wildcard match org", "example.*", "example.org", true},
110+
{"trailing wildcard no match", "example.*", "other.com", false},
111+
{"both wildcards match", "*.kiwifarms.*", "forum.kiwifarms.net", true},
112+
{"both wildcards bare domain", "*.kiwifarms.*", "kiwifarms.net", true},
113+
{"both wildcards no match", "*.kiwifarms.*", "example.com", false},
114+
{"exact pattern", "example.com", "example.com", true},
115+
{"exact pattern no match", "example.com", "other.com", false},
116+
{"no false prefix match", "*.kiwifarms.*", "notkiwifarms.net", false},
117+
}
118+
119+
for _, tt := range tests {
120+
t.Run(tt.name, func(t *testing.T) {
121+
got := matchWildcard(tt.pattern, tt.domain)
122+
if got != tt.want {
123+
t.Errorf("matchWildcard(%q, %q) = %v, want %v", tt.pattern, tt.domain, got, tt.want)
124+
}
125+
})
126+
}
127+
}

‎internal/escalation/escalation.go‎

Lines changed: 185 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88
"time"
99

10+
"github.com/endharassment/reporting-wizard/internal/boilerplate"
1011
"github.com/endharassment/reporting-wizard/internal/model"
1112
"github.com/endharassment/reporting-wizard/internal/store"
1213
"github.com/google/uuid"
@@ -17,11 +18,18 @@ type AbuseContactLookup interface {
1718
LookupAbuseContactByASN(ctx context.Context, asn int) (string, error)
1819
}
1920

21+
// upstreamTarget holds a resolved upstream ASN and its abuse contact.
22+
type upstreamTarget struct {
23+
ASN int
24+
AbuseContact string
25+
}
26+
2027
// Engine checks for emails that are due for escalation and creates
2128
// escalation emails to upstream providers.
2229
type Engine struct {
2330
store store.Store
2431
abuseContact AbuseContactLookup
32+
boilerplate *boilerplate.DB
2533
escalationDays int
2634
tickInterval time.Duration
2735
logger *slog.Logger
@@ -38,6 +46,11 @@ func NewEngine(s store.Store, ac AbuseContactLookup, escalationDays int, logger
3846
}
3947
}
4048

49+
// SetBoilerplate configures the domain boilerplate database.
50+
func (e *Engine) SetBoilerplate(db *boilerplate.DB) {
51+
e.boilerplate = db
52+
}
53+
4154
// SetTickInterval overrides the default tick interval (for testing).
4255
func (e *Engine) SetTickInterval(d time.Duration) {
4356
e.tickInterval = d
@@ -63,6 +76,24 @@ func (e *Engine) Run(ctx context.Context) error {
6376
}
6477
}
6578

79+
// EscalateNow immediately triggers escalation for a specific email,
80+
// bypassing the escalation timer. This is used when an admin sees a
81+
// provider refusal and wants to escalate right away.
82+
func (e *Engine) EscalateNow(ctx context.Context, emailID string) (int, error) {
83+
email, err := e.store.GetOutgoingEmail(ctx, emailID)
84+
if err != nil {
85+
return 0, fmt.Errorf("getting email %s: %w", emailID, err)
86+
}
87+
88+
// Clear ResponseNotes so the email is eligible for escalation.
89+
email.ResponseNotes = ""
90+
if err := e.store.UpdateOutgoingEmail(ctx, email); err != nil {
91+
return 0, fmt.Errorf("clearing response_notes for email %s: %w", emailID, err)
92+
}
93+
94+
return e.escalateEmail(ctx, email, time.Now().UTC())
95+
}
96+
6697
// checkAndEscalate finds emails due for escalation and creates escalation
6798
// emails to upstream providers.
6899
func (e *Engine) checkAndEscalate(ctx context.Context) {
@@ -112,6 +143,32 @@ func (e *Engine) escalateEmail(ctx context.Context, email *model.OutgoingEmail,
112143
return 0, fmt.Errorf("listing infra results for report %s: %w", email.ReportID, err)
113144
}
114145

146+
// Get the full email chain for contact history.
147+
chain, err := e.store.GetEmailChain(ctx, email.ID)
148+
if err != nil {
149+
e.logger.Warn("could not fetch email chain, proceeding without history",
150+
"email_id", email.ID, "error", err)
151+
chain = []*model.OutgoingEmail{email}
152+
}
153+
154+
// Batch-fetch replies for all emails in the chain.
155+
chainIDs := make([]string, len(chain))
156+
for i, ce := range chain {
157+
chainIDs[i] = ce.ID
158+
}
159+
repliesByEmail, err := e.store.ListEmailRepliesByEmails(ctx, chainIDs)
160+
if err != nil {
161+
e.logger.Warn("could not fetch chain replies, proceeding without",
162+
"email_id", email.ID, "error", err)
163+
repliesByEmail = make(map[string][]*model.EmailReply)
164+
}
165+
166+
// Look up domain boilerplate.
167+
var domainInfo *boilerplate.DomainInfo
168+
if e.boilerplate != nil {
169+
domainInfo = e.boilerplate.Lookup(report.Domain)
170+
}
171+
115172
// Collect unique upstream ASNs across all infra results.
116173
upstreamASNs := make(map[int]bool)
117174
for _, ir := range infraResults {
@@ -127,54 +184,52 @@ func (e *Engine) escalateEmail(ctx context.Context, email *model.OutgoingEmail,
127184
)
128185
}
129186

130-
created := 0
131-
// Deduplicate abuse contacts to avoid sending multiple emails to the same address.
187+
// First pass: resolve all upstream contacts so we know the full peer set.
188+
var targets []upstreamTarget
132189
seenContacts := make(map[string]bool)
133190

134191
for asn := range upstreamASNs {
135192
abuseContact, err := e.abuseContact.LookupAbuseContactByASN(ctx, asn)
136193
if err != nil {
137194
e.logger.Error("looking up abuse contact for upstream ASN",
138-
"asn", asn,
139-
"email_id", email.ID,
140-
"error", err,
141-
)
195+
"asn", asn, "email_id", email.ID, "error", err)
142196
continue
143197
}
144198
if abuseContact == "" {
145199
e.logger.Warn("no abuse contact found for upstream ASN",
146-
"asn", asn,
147-
"email_id", email.ID,
148-
)
200+
"asn", asn, "email_id", email.ID)
149201
continue
150202
}
151-
152203
if seenContacts[abuseContact] {
153204
continue
154205
}
155206
seenContacts[abuseContact] = true
207+
targets = append(targets, upstreamTarget{ASN: asn, AbuseContact: abuseContact})
208+
}
156209

157-
sentDate := ""
158-
if email.SentAt != nil {
159-
sentDate = email.SentAt.Format("2006-01-02")
160-
} else {
161-
sentDate = email.CreatedAt.Format("2006-01-02")
210+
// Second pass: create escalation emails with full context.
211+
created := 0
212+
for _, target := range targets {
213+
// Build peer list (all other targets in this batch).
214+
var peers []upstreamTarget
215+
for _, other := range targets {
216+
if other.ASN != target.ASN {
217+
peers = append(peers, other)
218+
}
162219
}
163220

164-
days := int(now.Sub(email.CreatedAt).Hours() / 24)
165-
166-
body := composeEscalationBody(report, email, asn, sentDate, days)
167-
subject := fmt.Sprintf("Escalation: Abuse Report for %s (upstream AS%d)", report.Domain, asn)
221+
body := composeEscalationBody(report, chain, repliesByEmail, target.ASN, peers, domainInfo, now)
222+
subject := fmt.Sprintf("Escalation: Abuse Report for %s (upstream AS%d)", report.Domain, target.ASN)
168223

169224
escAfter := now.Add(time.Duration(e.escalationDays) * 24 * time.Hour)
170225

171226
escEmail := &model.OutgoingEmail{
172227
ID: uuid.New().String(),
173228
ReportID: email.ReportID,
174229
ParentEmailID: email.ID,
175-
Recipient: abuseContact,
176-
RecipientOrg: fmt.Sprintf("AS%d", asn),
177-
TargetASN: asn,
230+
Recipient: target.AbuseContact,
231+
RecipientOrg: fmt.Sprintf("AS%d", target.ASN),
232+
TargetASN: target.ASN,
178233
EmailType: model.EmailTypeEscalation,
179234
XARFJson: email.XARFJson,
180235
EmailSubject: subject,
@@ -186,7 +241,7 @@ func (e *Engine) escalateEmail(ctx context.Context, email *model.OutgoingEmail,
186241

187242
if err := e.store.CreateOutgoingEmail(ctx, escEmail); err != nil {
188243
e.logger.Error("creating escalation email",
189-
"upstream_asn", asn,
244+
"upstream_asn", target.ASN,
190245
"email_id", email.ID,
191246
"error", err,
192247
)
@@ -195,8 +250,8 @@ func (e *Engine) escalateEmail(ctx context.Context, email *model.OutgoingEmail,
195250

196251
e.logger.Info("created escalation email",
197252
"escalation_id", escEmail.ID,
198-
"upstream_asn", asn,
199-
"recipient", abuseContact,
253+
"upstream_asn", target.ASN,
254+
"recipient", target.AbuseContact,
200255
"parent_email_id", email.ID,
201256
)
202257
created++
@@ -211,29 +266,119 @@ func (e *Engine) escalateEmail(ctx context.Context, email *model.OutgoingEmail,
211266
return created, nil
212267
}
213268

214-
func composeEscalationBody(report *model.Report, original *model.OutgoingEmail, upstreamASN int, sentDate string, days int) string {
269+
func composeEscalationBody(
270+
report *model.Report,
271+
chain []*model.OutgoingEmail,
272+
repliesByEmail map[string][]*model.EmailReply,
273+
targetASN int,
274+
peers []upstreamTarget,
275+
domainInfo *boilerplate.DomainInfo,
276+
now time.Time,
277+
) string {
215278
var b strings.Builder
216279

217280
b.WriteString("Dear Abuse Team,\n\n")
218-
b.WriteString(fmt.Sprintf(
219-
"Report %s regarding %s was filed with %s (AS%d) on %s. "+
220-
"No action has been taken after %d days. "+
221-
"We are escalating to you as an upstream provider.\n\n",
222-
report.ID, report.Domain, original.Recipient, original.TargetASN, sentDate, days,
223-
))
224-
225-
b.WriteString("Original report details:\n")
226-
b.WriteString(fmt.Sprintf(" Domain: %s\n", report.Domain))
281+
282+
// Identify the downstream provider (first email in the chain, the initial report).
283+
if len(chain) > 0 {
284+
initial := chain[0]
285+
b.WriteString(fmt.Sprintf(
286+
"We are escalating an abuse report regarding %s to you as an upstream "+
287+
"provider of %s (AS%d).\n\n",
288+
report.Domain, initial.RecipientOrg, initial.TargetASN,
289+
))
290+
}
291+
292+
// Contact History section.
293+
if len(chain) > 0 {
294+
b.WriteString("== Contact History ==\n\n")
295+
for i, ce := range chain {
296+
sentDate := "not yet sent"
297+
if ce.SentAt != nil {
298+
sentDate = ce.SentAt.Format("2006-01-02")
299+
}
300+
301+
label := "Initial report"
302+
if ce.EmailType == model.EmailTypeEscalation {
303+
label = "Escalation"
304+
}
305+
306+
b.WriteString(fmt.Sprintf("%d. %s to %s (%s)\n",
307+
i+1, label, ce.Recipient, ce.RecipientOrg))
308+
b.WriteString(fmt.Sprintf(" Sent: %s\n", sentDate))
309+
310+
// Show replies for this email.
311+
replies := repliesByEmail[ce.ID]
312+
if len(replies) > 0 {
313+
for _, r := range replies {
314+
b.WriteString(fmt.Sprintf(" Reply from %s on %s:\n",
315+
r.FromAddress, r.CreatedAt.Format("2006-01-02")))
316+
// Truncate reply body to 500 chars for the escalation email.
317+
body := r.Body
318+
if len(body) > 500 {
319+
body = body[:500] + "..."
320+
}
321+
// Indent each line of the reply.
322+
for _, line := range strings.Split(body, "\n") {
323+
b.WriteString(fmt.Sprintf(" %s\n", line))
324+
}
325+
}
326+
}
327+
328+
// Show status.
329+
if ce.ResponseNotes == "escalated" {
330+
if ce.SentAt != nil {
331+
days := int(now.Sub(*ce.SentAt).Hours() / 24)
332+
b.WriteString(fmt.Sprintf(" Status: No action taken after %d days\n", days))
333+
} else {
334+
b.WriteString(" Status: Escalated\n")
335+
}
336+
} else if ce.ResponseNotes != "" && len(replies) == 0 {
337+
b.WriteString(fmt.Sprintf(" Status: %s\n", ce.ResponseNotes))
338+
} else if len(replies) > 0 {
339+
b.WriteString(" Status: Reply received, no resolution\n")
340+
} else {
341+
b.WriteString(" Status: No response\n")
342+
}
343+
b.WriteString("\n")
344+
}
345+
346+
b.WriteString(fmt.Sprintf("%d. Current escalation to you (AS%d)\n\n",
347+
len(chain)+1, targetASN))
348+
}
349+
350+
// Domain boilerplate section.
351+
if domainInfo != nil {
352+
b.WriteString(fmt.Sprintf("== Context regarding %s ==\n\n", domainInfo.DisplayName))
353+
b.WriteString(domainInfo.Summary)
354+
b.WriteString("\n\n")
355+
b.WriteString(domainInfo.Context)
356+
b.WriteString("\n\n")
357+
}
358+
359+
// Peer escalations section.
360+
if len(peers) > 0 {
361+
b.WriteString("== Peer Escalations ==\n\n")
362+
b.WriteString("This report is also being escalated to:\n")
363+
for _, peer := range peers {
364+
b.WriteString(fmt.Sprintf(" - %s (AS%d)\n", peer.AbuseContact, peer.ASN))
365+
}
366+
b.WriteString("\n")
367+
}
368+
369+
// Original report details.
370+
b.WriteString("== Original Report Details ==\n\n")
371+
b.WriteString(fmt.Sprintf("Domain: %s\n", report.Domain))
227372
if len(report.URLs) > 0 {
228-
b.WriteString(" URLs:\n")
373+
b.WriteString("URLs:\n")
229374
for _, u := range report.URLs {
230-
b.WriteString(fmt.Sprintf(" - %s\n", u))
375+
b.WriteString(fmt.Sprintf(" - %s\n", u))
231376
}
232377
}
233-
b.WriteString(fmt.Sprintf(" Violation: %s\n", report.ViolationType))
234-
b.WriteString(fmt.Sprintf(" Description: %s\n\n", report.Description))
378+
b.WriteString(fmt.Sprintf("Violation: %s\n", report.ViolationType))
379+
b.WriteString(fmt.Sprintf("Description: %s\n\n", report.Description))
235380

236-
b.WriteString("The original machine-readable X-ARF v4 report is attached to this email.\n\n")
381+
b.WriteString("A machine-readable X-ARF v4 report is attached to this email.\n\n")
237382
b.WriteString("We request that you investigate this matter and take appropriate action.\n\n")
238383
b.WriteString("Regards,\n")
239384
b.WriteString("End Network Harassment Inc Reporting Wizard\n")

‎internal/escalation/escalation_test.go‎

Lines changed: 395 additions & 18 deletions
Large diffs are not rendered by default.

‎internal/report/email.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77
"time"
88

9+
"github.com/endharassment/reporting-wizard/internal/boilerplate"
910
"github.com/endharassment/reporting-wizard/internal/model"
1011
"github.com/google/uuid"
1112
"github.com/sendgrid/sendgrid-go"
@@ -21,6 +22,8 @@ type EmailConfig struct {
2122
SandboxMode bool
2223
// SendGridAPIKey is the API key for SendGrid.
2324
SendGridAPIKey string
25+
// Boilerplate provides domain-specific context for known problem domains.
26+
Boilerplate *boilerplate.DB
2427
}
2528

2629
// ComposeEmail builds a model.OutgoingEmail from a report, its infrastructure
@@ -86,6 +89,17 @@ func composeBody(cfg EmailConfig, report *model.Report, infraResults []*model.In
8689
b.WriteString(report.Description)
8790
b.WriteString("\n\n")
8891

92+
// Add domain-specific boilerplate if available.
93+
if cfg.Boilerplate != nil {
94+
if info := cfg.Boilerplate.Lookup(report.Domain); info != nil {
95+
b.WriteString(fmt.Sprintf("Context regarding %s:\n", info.DisplayName))
96+
b.WriteString(info.Summary)
97+
b.WriteString("\n\n")
98+
b.WriteString(info.Context)
99+
b.WriteString("\n\n")
100+
}
101+
}
102+
89103
if len(infraResults) > 0 {
90104
b.WriteString("Infrastructure Details:\n")
91105
for _, ir := range infraResults {

‎internal/server/server.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
"github.com/endharassment/reporting-wizard/internal/admin"
13+
"github.com/endharassment/reporting-wizard/internal/boilerplate"
1314
"github.com/endharassment/reporting-wizard/internal/infra"
1415
"github.com/endharassment/reporting-wizard/internal/report"
1516
"github.com/endharassment/reporting-wizard/internal/store"
@@ -52,6 +53,7 @@ type Server struct {
5253
router chi.Router
5354
staticFS fs.FS
5455
snapshotter Snapshotter
56+
escalator admin.Escalator
5557
}
5658

5759
// NewServer creates a new Server from the given config, store, and filesystem assets.
@@ -168,6 +170,9 @@ func (s *Server) routes() chi.Router {
168170
UserFromContext,
169171
func(ctx context.Context) string { return CSRFTokenFromContext(ctx) },
170172
)
173+
if s.escalator != nil {
174+
ah.SetEscalator(s.escalator)
175+
}
171176

172177
r.Get("/admin", ah.HandleDashboard)
173178
r.Get("/admin/queue", ah.HandleQueue)
@@ -179,6 +184,7 @@ func (s *Server) routes() chi.Router {
179184
r.Get("/admin/emails/{emailID}", ah.HandleEmailPreview)
180185
r.Post("/admin/emails/{emailID}/approve", ah.HandleEmailApprove)
181186
r.Post("/admin/emails/{emailID}/reject", ah.HandleEmailReject)
187+
r.Post("/admin/emails/{emailID}/reply-action", ah.HandleReplyAction)
182188
r.Get("/admin/evidence/{evidenceID}", ah.HandleAdminEvidenceDownload)
183189
r.Get("/admin/users", ah.HandleListUsers)
184190
r.Post("/admin/users/{userID}/ban", ah.HandleBanUser)
@@ -198,6 +204,16 @@ func (s *Server) SetSnapshotter(snap Snapshotter) {
198204
s.snapshotter = snap
199205
}
200206

207+
// SetBoilerplate configures the domain boilerplate database for email composition.
208+
func (s *Server) SetBoilerplate(db *boilerplate.DB) {
209+
s.emailCfg.Boilerplate = db
210+
}
211+
212+
// SetEscalator configures the escalation engine for admin immediate-escalation actions.
213+
func (s *Server) SetEscalator(e admin.Escalator) {
214+
s.escalator = e
215+
}
216+
201217
// Stop cleans up server resources.
202218
func (s *Server) Stop() {
203219
s.rl.Stop()

‎internal/store/sqlite.go‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ func (s *SQLiteStore) GetUserByEmail(ctx context.Context, email string) (*model.
7878
`SELECT id, email, name, is_admin, banned, google_access_token, google_refresh_token, google_token_expiry, created_at FROM users WHERE email = ?`, email))
7979
}
8080

81-
8281
func (s *SQLiteStore) UpdateUser(ctx context.Context, user *model.User) error {
8382
_, err := s.db.ExecContext(ctx,
8483
`UPDATE users SET email = ?, name = ?, is_admin = ?, google_access_token = ?, google_refresh_token = ?, google_token_expiry = ? WHERE id = ?`,
@@ -115,7 +114,6 @@ type scannable interface {
115114
Scan(dest ...interface{}) error
116115
}
117116

118-
119117
func (s *SQLiteStore) CreateUser(ctx context.Context, user *model.User) error {
120118
_, err := s.db.ExecContext(ctx,
121119
`INSERT INTO users (id, email, name, is_admin, banned, google_access_token, google_refresh_token, google_token_expiry, created_at)
@@ -632,6 +630,92 @@ func (s *SQLiteStore) ListEmailRepliesByEmail(ctx context.Context, emailID strin
632630
return replies, rows.Err()
633631
}
634632

633+
func (s *SQLiteStore) GetEmailChain(ctx context.Context, emailID string) ([]*model.OutgoingEmail, error) {
634+
query := `WITH RECURSIVE chain AS (
635+
SELECT id, parent_email_id FROM outgoing_emails WHERE id = ?
636+
UNION ALL
637+
SELECT oe.id, oe.parent_email_id FROM outgoing_emails oe
638+
JOIN chain c ON c.parent_email_id = oe.id
639+
WHERE c.parent_email_id != ''
640+
)
641+
SELECT id, report_id, parent_email_id, recipient, recipient_org, target_asn, email_type,
642+
xarf_json, email_subject, email_body, status, approved_by, approved_at, sent_at,
643+
sendgrid_id, escalate_after, response_notes, created_at
644+
FROM outgoing_emails WHERE id IN (SELECT id FROM chain)
645+
ORDER BY created_at ASC`
646+
647+
rows, err := s.db.QueryContext(ctx, query, emailID)
648+
if err != nil {
649+
return nil, err
650+
}
651+
defer rows.Close()
652+
return s.scanEmails(rows)
653+
}
654+
655+
func (s *SQLiteStore) ListAllRepliesByReport(ctx context.Context, reportID string) (map[string][]*model.EmailReply, error) {
656+
rows, err := s.db.QueryContext(ctx,
657+
`SELECT er.id, er.outgoing_email_id, er.from_address, er.body, er.created_at
658+
FROM email_replies er
659+
JOIN outgoing_emails oe ON er.outgoing_email_id = oe.id
660+
WHERE oe.report_id = ?
661+
ORDER BY er.created_at`, reportID)
662+
if err != nil {
663+
return nil, err
664+
}
665+
defer rows.Close()
666+
667+
result := make(map[string][]*model.EmailReply)
668+
for rows.Next() {
669+
var r model.EmailReply
670+
var createdAt string
671+
err := rows.Scan(&r.ID, &r.OutgoingEmailID, &r.FromAddress, &r.Body, &createdAt)
672+
if err != nil {
673+
return nil, err
674+
}
675+
r.CreatedAt, _ = time.Parse(timeFormat, createdAt)
676+
result[r.OutgoingEmailID] = append(result[r.OutgoingEmailID], &r)
677+
}
678+
return result, rows.Err()
679+
}
680+
681+
func (s *SQLiteStore) ListEmailRepliesByEmails(ctx context.Context, emailIDs []string) (map[string][]*model.EmailReply, error) {
682+
if len(emailIDs) == 0 {
683+
return make(map[string][]*model.EmailReply), nil
684+
}
685+
686+
placeholders := make([]string, len(emailIDs))
687+
args := make([]interface{}, len(emailIDs))
688+
for i, id := range emailIDs {
689+
placeholders[i] = "?"
690+
args[i] = id
691+
}
692+
693+
query := fmt.Sprintf(
694+
`SELECT id, outgoing_email_id, from_address, body, created_at
695+
FROM email_replies WHERE outgoing_email_id IN (%s)
696+
ORDER BY created_at`,
697+
strings.Join(placeholders, ", "))
698+
699+
rows, err := s.db.QueryContext(ctx, query, args...)
700+
if err != nil {
701+
return nil, err
702+
}
703+
defer rows.Close()
704+
705+
result := make(map[string][]*model.EmailReply)
706+
for rows.Next() {
707+
var r model.EmailReply
708+
var createdAt string
709+
err := rows.Scan(&r.ID, &r.OutgoingEmailID, &r.FromAddress, &r.Body, &createdAt)
710+
if err != nil {
711+
return nil, err
712+
}
713+
r.CreatedAt, _ = time.Parse(timeFormat, createdAt)
714+
result[r.OutgoingEmailID] = append(result[r.OutgoingEmailID], &r)
715+
}
716+
return result, rows.Err()
717+
}
718+
635719
// --- Helpers ---
636720

637721
func boolToInt(b bool) int {

‎internal/store/store.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,7 @@ type Store interface {
6060
// Email Replies
6161
CreateEmailReply(ctx context.Context, reply *model.EmailReply) error
6262
ListEmailRepliesByEmail(ctx context.Context, emailID string) ([]*model.EmailReply, error)
63+
GetEmailChain(ctx context.Context, emailID string) ([]*model.OutgoingEmail, error)
64+
ListAllRepliesByReport(ctx context.Context, reportID string) (map[string][]*model.EmailReply, error)
65+
ListEmailRepliesByEmails(ctx context.Context, emailIDs []string) (map[string][]*model.EmailReply, error)
6366
}

‎templates/admin/report.html‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,80 @@ <h2>Evidence</h2>
5151
{{ end }}
5252
</section>
5353

54+
{{ if .Emails }}
55+
<section class="detail-section">
56+
<h2>Outgoing Emails &amp; Replies</h2>
57+
{{ range .Emails }}
58+
<div class="email-entry" id="email-{{ .ID }}">
59+
<h3>
60+
{{ if eq (string .EmailType) "escalation" }}Escalation{{ else }}Initial Report{{ end }}
61+
to {{ .Recipient }} ({{ .RecipientOrg }})
62+
</h3>
63+
<dl>
64+
<dt>Status</dt>
65+
<dd><span class="badge badge-{{ .Status }}">{{ .Status }}</span></dd>
66+
<dt>Created</dt>
67+
<dd><time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ .CreatedAt.Format "Jan 2, 2006 3:04 PM" }}</time></dd>
68+
{{ if .SentAt }}
69+
<dt>Sent</dt>
70+
<dd><time datetime="{{ .SentAt.Format "2006-01-02T15:04:05Z" }}">{{ .SentAt.Format "Jan 2, 2006 3:04 PM" }}</time></dd>
71+
{{ end }}
72+
{{ if .EscalateAfter }}
73+
<dt>Escalate After</dt>
74+
<dd><time datetime="{{ .EscalateAfter.Format "2006-01-02T15:04:05Z" }}">{{ .EscalateAfter.Format "Jan 2, 2006 3:04 PM" }}</time></dd>
75+
{{ end }}
76+
{{ if .ResponseNotes }}
77+
<dt>Response Notes</dt>
78+
<dd>{{ .ResponseNotes }}</dd>
79+
{{ end }}
80+
</dl>
81+
82+
{{ $replies := index $.RepliesByEmail .ID }}
83+
{{ if $replies }}
84+
<div class="replies">
85+
<h4>Replies</h4>
86+
{{ range $replies }}
87+
<div class="reply-entry">
88+
<p class="reply-meta">
89+
From <strong>{{ .FromAddress }}</strong> on
90+
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ .CreatedAt.Format "Jan 2, 2006 3:04 PM" }}</time>
91+
</p>
92+
<pre class="reply-body">{{ .Body }}</pre>
93+
</div>
94+
{{ end }}
95+
</div>
96+
{{ end }}
97+
98+
{{ if eq (string .Status) "sent" }}
99+
<div class="reply-actions">
100+
<form method="post" action="/admin/emails/{{ .ID }}/reply-action" style="display:inline">
101+
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
102+
<input type="hidden" name="action" value="resolve">
103+
<input type="text" name="notes" placeholder="Resolution notes..." style="width:300px">
104+
<button type="submit" class="btn btn-primary btn-sm"
105+
onclick="return confirm('Mark this case as resolved?')">Resolve</button>
106+
</form>
107+
<form method="post" action="/admin/emails/{{ .ID }}/reply-action" style="display:inline">
108+
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
109+
<input type="hidden" name="action" value="escalate_now">
110+
<button type="submit" class="btn btn-danger btn-sm"
111+
onclick="return confirm('Immediately escalate to upstream providers?')">Escalate Now</button>
112+
</form>
113+
{{ if .ResponseNotes }}
114+
<form method="post" action="/admin/emails/{{ .ID }}/reply-action" style="display:inline">
115+
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
116+
<input type="hidden" name="action" value="ignore_autoreply">
117+
<button type="submit" class="btn btn-secondary btn-sm"
118+
onclick="return confirm('Ignore auto-reply and resume escalation timer?')">Ignore Auto-Reply</button>
119+
</form>
120+
{{ end }}
121+
</div>
122+
{{ end }}
123+
</div>
124+
{{ end }}
125+
</section>
126+
{{ end }}
127+
54128
{{ if eq (string .Report.Status) "cloudflare_pending" }}
55129
<section class="detail-section">
56130
<h2>Cloudflare Origin IP</h2>

0 commit comments

Comments
 (0)
Please sign in to comment.