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.
2229type 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).
4255func (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.
6899func (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 " )
0 commit comments