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:
1 parent
b46e0813fe
commit
3a86254cb6
6 files changed
+864
No files matched your search
+102
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user