Check SPF, DMARC, MTA-STS and DNS blocklists per sending domain; count delivery outcomes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 16:56:57 -06:00
1 parent b46e0813fe
commit 3a86254cb6
6 files changed
+864

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
package blacktip
import (
"sort"
"sync"
"time"
"git.veillax.com/Veillax/reef/model"
)
// Delivery outcomes: every mail transaction the egress handles ends delivered,
// deferred (a 4xx the sender will retry) or failed (a 5xx). They are counted in
// hourly buckets for the last day, so the dashboard can show a delivery rate
// and say which recipient domains are refusing mail and why.
const (
outcomeWindow = 24 * time.Hour
outcomeRecent = 10 // refusals kept for the dashboard to explain
)
type outcomeBucket struct {
hour time.Time
byDomain map[string]model.OutcomeCount
}
type outcomeLog struct {
mu sync.Mutex
buckets map[string][]*outcomeBucket // by mail-egress ID, oldest first
recent map[string][]model.OutcomeFailure
}
// recordOutcome counts one finished transaction (or a refusal along the way).
func (b *Blacktip) recordOutcome(egress, domain string, code int, text string) {
b.outcomes.add(egress, domain, code, text, time.Now())
}
func (o *outcomeLog) add(egress, domain string, code int, text string, now time.Time) {
o.mu.Lock()
defer o.mu.Unlock()
if o.buckets == nil {
o.buckets, o.recent = map[string][]*outcomeBucket{}, map[string][]model.OutcomeFailure{}
}
hour := now.Truncate(time.Hour)
bs := o.buckets[egress]
if len(bs) == 0 || !bs[len(bs)-1].hour.Equal(hour) {
bs = append(bs, &outcomeBucket{hour: hour, byDomain: map[string]model.OutcomeCount{}})
}
cut := 0
for cut < len(bs) && now.Sub(bs[cut].hour) >= outcomeWindow {
cut++
}
bs = bs[cut:]
o.buckets[egress] = bs
c := bs[len(bs)-1].byDomain[domain]
switch {
case code >= 200 && code < 300:
c.Delivered++
case code >= 400 && code < 500:
c.Deferred++
default:
c.Failed++
}
bs[len(bs)-1].byDomain[domain] = c
if code >= 300 {
r := append([]model.OutcomeFailure{{Time: now, Domain: domain, Code: code, Text: text}}, o.recent[egress]...)
if len(r) > outcomeRecent {
r = r[:outcomeRecent]
}
o.recent[egress] = r
}
}
// summary totals the last day for one egress, or nil if it handled no mail.
func (o *outcomeLog) summary(egress string, now time.Time) *model.MailOutcomes {
o.mu.Lock()
defer o.mu.Unlock()
out := &model.MailOutcomes{ByDomain: map[string]model.OutcomeCount{}}
for _, bk := range o.buckets[egress] {
if now.Sub(bk.hour) >= outcomeWindow {
continue
}
if out.Since.IsZero() || bk.hour.Before(out.Since) {
out.Since = bk.hour
}
for d, c := range bk.byDomain {
t := out.ByDomain[d]
t.Delivered, t.Deferred, t.Failed = t.Delivered+c.Delivered, t.Deferred+c.Deferred, t.Failed+c.Failed
out.ByDomain[d] = t
out.Delivered, out.Deferred, out.Failed = out.Delivered+c.Delivered, out.Deferred+c.Deferred, out.Failed+c.Failed
}
}
if out.Delivered+out.Deferred+out.Failed == 0 {
return nil
}
for _, f := range o.recent[egress] {
if now.Sub(f.Time) < outcomeWindow {
out.Recent = append(out.Recent, f)
}
}
sort.SliceStable(out.Recent, func(i, j int) bool { return out.Recent[i].Time.After(out.Recent[j].Time) })
return out
}