Files
warmbly/internal/pkg/arf/arf.go
T
Matthew Meszaros 1b8c51ad06 feat: read spam complaints and domain-auth refusals, the last two open delivery signals (#232)
* feat: close the two halves of the delivery-signal loop that were still open, complaints and domain-auth refusals: a spam complaint arrives as mail long after the send succeeded, and nothing read those reports, so the strongest negative signal a sender gets never reached the complaint rate, the suppression list or the breaker; internal/pkg/arf parses RFC 5965 feedback reports worker-side alongside the existing DSN path, takes the LAST Message-ID because the reported mail's headers follow the report's own, and records only abuse-type reports so a not-spam report cannot be inverted into a complaint; separately a receiving server refusing mail because the SENDING DOMAIN failed its authentication (5.7.515, 5.7.26) was classified as SERVER_UNREACHABLE and retried forever, and it is now its own hard code that blames the domain rather than the address, leaves the recipient unsuppressed, and brings that domain's DNS re-check forward so the sweep confirms the verdict the send gate acts on

* feat: make the complaint path actually reachable, and stop it trusting the report: selectTextParts only ever picked text/plain and text/html, so an RFC 5965 report's message/feedback-report and message/rfc822 parts never reached the worker and both Feedback-Type and the reported Message-ID were invisible, which would have left this feature inert and has been quietly weakening DSN parsing too; report parts are now selected as plain text; the complainer is taken from the RESOLVED SEND rather than the report body, because a report is unauthenticated mail anyone able to reach the mailbox could forge and honouring the address it names would let a forger suppress a contact the send never went to; Original-Mail-From is no longer read as the complainer since that address is the sender; and a domain-auth refusal now releases the reservation instead of spending one of the lead's attempts, because the recipient received nothing and the problem is the mailbox's domain, so another mailbox in the pool picks the lead up

* feat: fix the same missing-parts gap in the Gmail adapter, and say plainly that Microsoft Graph cannot see reports at all: goog.extractBody took only text/plain and text/html exactly as the IMAP path did, so a feedback report synced from Gmail was as invisible as one synced over IMAP, and the tests that would have caught either called the parser directly rather than going through the adapter where it actually broke; the new tests exercise that seam, and Graph returns one rendered body with no parts so reports there are undetectable without a MIME fetch that is not built, which the docs now state rather than implying full coverage
2026-08-28 11:02:45 -07:00

93 lines
3.6 KiB
Go

// Package arf parses Abuse Reporting Format messages (RFC 5965), the feedback
// loop report a mailbox provider sends when a recipient presses "spam".
//
// A complaint is the strongest negative signal a sender gets, and unlike a
// bounce it never arrives synchronously: it comes back as mail. Like the dsn
// package this only PARSES; resolving and suppressing stay control-plane.
package arf
import (
"regexp"
"strings"
)
// Report is the extracted result of parsing a feedback report.
type Report struct {
// IsComplaint is true only for an abuse-type report. Other feedback types
// (fraud, not-spam, opt-out) are deliberately excluded: acting on a
// "not-spam" report as if it were a complaint would be backwards.
IsComplaint bool
// OriginalMessageID is the Message-ID of the reported outbound message,
// which is how the complaint resolves to a campaign send.
OriginalMessageID string
// ComplainedRecipient is the address the report names, when the provider
// disclosed it. Advisory only: the caller suppresses the contact the
// resolved send actually went to, never an address a report asserts.
ComplainedRecipient string
// UserAgent is the reporting provider, kept for the event record.
UserAgent string
}
var (
reFeedbackType = regexp.MustCompile(`(?im)^\s*Feedback-Type:\s*([a-z-]+)`)
reMessageID = regexp.MustCompile(`(?im)^\s*Message-ID:\s*<([^>\s]+)>`)
// Original-Rcpt-To only. Original-Mail-From is the SENDER, and reading it
// as the complainer would suppress the customer's own address.
reOriginalRcpt = regexp.MustCompile(`(?im)^\s*Original-Rcpt-To:\s*<?([^\s<>]+@[^\s<>]+?)>?\s*$`)
reUserAgent = regexp.MustCompile(`(?im)^\s*User-Agent:\s*(.+)$`)
)
// reportMarkers identify a feedback report in the envelope.
var reportMarkers = []string{"message/feedback-report", "report-type=feedback-report"}
// senderMarkers are the addresses providers send feedback loops from.
var senderMarkers = []string{
"abuse@", "feedback@", "fbl@", "scomp@", "staff@hotmail.com",
"complaints@", "abusedesk@",
}
// Detect reports whether an inbound message looks like a feedback report, from
// the cheap envelope signals only. Callers gate the full Parse on this.
func Detect(from, subject, contentType string) bool {
ct := strings.ToLower(contentType)
for _, m := range reportMarkers {
if strings.Contains(ct, m) {
return true
}
}
f := strings.ToLower(from)
for _, m := range senderMarkers {
if strings.Contains(f, m) {
return true
}
}
s := strings.ToLower(subject)
// The subject providers use for the report itself.
return strings.Contains(s, "abuse report") || strings.Contains(s, "complaint about message") ||
strings.Contains(s, "email feedback report")
}
// Parse extracts complaint details from the report body. Safe on unrelated
// bodies: fields stay empty and IsComplaint stays false.
//
// The Message-ID taken is the LAST one in the body. An ARF message carries the
// reported mail's own headers in its third part, after the machine-readable
// part, so the last Message-ID is the reported message rather than the report.
func Parse(body string) Report {
var r Report
if m := reFeedbackType.FindStringSubmatch(body); m != nil {
r.IsComplaint = strings.EqualFold(strings.TrimSpace(m[1]), "abuse")
}
if m := reOriginalRcpt.FindStringSubmatch(body); m != nil {
r.ComplainedRecipient = strings.TrimSpace(m[1])
}
if m := reUserAgent.FindStringSubmatch(body); m != nil {
r.UserAgent = strings.TrimSpace(m[1])
}
if all := reMessageID.FindAllStringSubmatch(body, -1); len(all) > 0 {
r.OriginalMessageID = strings.TrimSpace(all[len(all)-1][1])
}
return r
}