feat: expand deliverability metrics

This commit is contained in:
Matthew Meszaros
2026-06-08 12:03:23 +02:00
parent 5f7ac801ba
commit ad7c87acca
4 changed files with 320 additions and 0 deletions
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_deliverability_events_task;
@@ -0,0 +1,6 @@
-- Supports the deliverability dashboard's per-mailbox breakdown, which joins
-- deliverability_events to tasks on task_id. Partial (task_id IS NOT NULL) keeps
-- it small since open/click/some events carry no task_id.
CREATE INDEX IF NOT EXISTS idx_deliverability_events_task
ON deliverability_events (task_id)
WHERE task_id IS NOT NULL;
+82
View File
@@ -259,6 +259,88 @@ type DeliverabilityDashboard struct {
IntentOOO int `json:"intent_out_of_office"`
IntentQuestion int `json:"intent_question"`
IntentNeutral int `json:"intent_neutral"`
// Computed rates (percent, 0-100; 0 when no sends in the window). EmailsSent
// is the count of completed campaign sends in the window (rate denominator).
EmailsSent int `json:"emails_sent"`
BounceRate float64 `json:"bounce_rate"`
ComplaintRate float64 `json:"complaint_rate"`
OpenRate float64 `json:"open_rate"`
ClickRate float64 `json:"click_rate"`
ReplyRate float64 `json:"reply_rate"`
// Seed inbox-placement (nil when there are no seed samples in the window).
SpamPlacementRate *float64 `json:"spam_placement_rate,omitempty"`
InboxPlacementRate *float64 `json:"inbox_placement_rate,omitempty"`
PlacementSamples int `json:"placement_samples"`
// Overall health band for the org window (from the documented thresholds).
Band string `json:"band"`
Timeseries []DeliverabilityDailyPoint `json:"timeseries"`
ByMailbox []MailboxDeliverability `json:"by_mailbox"`
ByCampaign []CampaignDeliverability `json:"by_campaign"`
}
// DeliverabilityDailyPoint is one UTC day in the deliverability timeseries.
type DeliverabilityDailyPoint struct {
Date string `json:"date"` // YYYY-MM-DD (UTC)
Sent int `json:"sent"`
Bounces int `json:"bounces"`
Complaints int `json:"complaints"`
Opens int `json:"opens"`
Clicks int `json:"clicks"`
Replies int `json:"replies"`
Unsubscribes int `json:"unsubscribes"`
}
// MailboxDeliverability is one mailbox's bounce/complaint posture in the window.
type MailboxDeliverability struct {
EmailAccountID uuid.UUID `json:"email_account_id"`
Email string `json:"email"`
Sent int `json:"sent"`
Bounces int `json:"bounces"`
Complaints int `json:"complaints"`
BounceRate float64 `json:"bounce_rate"`
ComplaintRate float64 `json:"complaint_rate"`
Band string `json:"band"`
}
// CampaignDeliverability is one campaign's bounce/complaint posture in the window.
type CampaignDeliverability struct {
CampaignID uuid.UUID `json:"campaign_id"`
Name string `json:"name"`
Sent int `json:"sent"`
Bounces int `json:"bounces"`
Complaints int `json:"complaints"`
BounceRate float64 `json:"bounce_rate"`
ComplaintRate float64 `json:"complaint_rate"`
Band string `json:"band"`
}
// DeliverabilityBand maps bounce%/complaint%/spam-placement% to a health band
// using the documented shared-pool thresholds (see CLAUDE.md). Rates are on the
// percent scale (0-100), so complaintRate>=0.03 means 0.03% (3 basis points),
// bounceRate>=10 means 10%. Worst band wins.
func DeliverabilityBand(bounceRate, complaintRate, spamRate float64) string {
switch {
case complaintRate >= 0.30 || bounceRate >= 10 || spamRate >= 40:
return "blocked"
case complaintRate >= 0.10 || bounceRate >= 5 || spamRate >= 20:
return "quarantine"
case complaintRate >= 0.03 || spamRate >= 10:
return "warning"
default:
return "healthy"
}
}
// Rate is a divide-by-zero-safe percentage (count/total*100).
func Rate(count, total int) float64 {
if total <= 0 {
return 0
}
return float64(count) / float64(total) * 100
}
type ABVariantStats struct {
+231
View File
@@ -516,9 +516,240 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
return nil, err
}
// Rate denominator: completed campaign sends in the window. Best-effort — a
// rate/breakdown query failing must not break the (already-built) core rollup.
sentQuery := `
SELECT COUNT(*) FROM tasks t
JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.organization_id = $1 AND t.task_type = 'campaign' AND t.status = 'completed'
AND t.completed_at >= $2 AND t.completed_at <= $3`
_ = r.db.QueryRow(ctx, sentQuery, organizationID, from, to).Scan(&out.EmailsSent)
out.BounceRate = models.Rate(out.BounceCount, out.EmailsSent)
out.ComplaintRate = models.Rate(out.ComplaintCount, out.EmailsSent)
out.OpenRate = models.Rate(out.OpenCount, out.EmailsSent)
out.ClickRate = models.Rate(out.ClickCount, out.EmailsSent)
out.ReplyRate = models.Rate(out.ReplyCount, out.EmailsSent)
out.Timeseries = r.deliverabilityTimeseries(ctx, organizationID, from, to)
out.ByCampaign = r.deliverabilityByCampaign(ctx, organizationID, from, to)
out.ByMailbox = r.deliverabilityByMailbox(ctx, organizationID, from, to)
// Seed inbox-placement (optional — nil rates when there are no seed samples).
var spam, inbox, placementTotal int
placementQuery := `
SELECT pr.folder, COUNT(*)
FROM placement_results pr JOIN placement_tests pt ON pt.id = pr.test_id
WHERE pt.organization_id = $1 AND pr.detected_at >= $2 AND pr.detected_at <= $3 AND pr.folder <> 'pending'
GROUP BY pr.folder`
if rows, perr := r.db.Query(ctx, placementQuery, organizationID, from, to); perr == nil {
for rows.Next() {
var folder string
var n int
if rows.Scan(&folder, &n) == nil {
placementTotal += n
switch folder {
case "spam":
spam = n
case "inbox":
inbox = n
}
}
}
rows.Close()
}
out.PlacementSamples = placementTotal
spamRate := 0.0
if placementTotal > 0 {
sr := models.Rate(spam, placementTotal)
ir := models.Rate(inbox, placementTotal)
out.SpamPlacementRate = &sr
out.InboxPlacementRate = &ir
spamRate = sr
}
out.Band = models.DeliverabilityBand(out.BounceRate, out.ComplaintRate, spamRate)
return out, nil
}
// deliverabilityTimeseries returns gap-filled UTC daily points for the window.
func (r *advancedOutreachRepository) deliverabilityTimeseries(ctx context.Context, orgID uuid.UUID, from, to time.Time) []models.DeliverabilityDailyPoint {
byDay := map[string]*models.DeliverabilityDailyPoint{}
// Bucket by UTC calendar day explicitly (created_at::date alone uses the DB
// session timezone) so the SQL days line up with the Go gap-fill loop below.
evQ := `
SELECT (created_at AT TIME ZONE 'UTC')::date,
COUNT(*) FILTER (WHERE event_type='bounce'),
COUNT(*) FILTER (WHERE event_type='complaint'),
COUNT(*) FILTER (WHERE event_type='open'),
COUNT(*) FILTER (WHERE event_type='click'),
COUNT(*) FILTER (WHERE event_type='reply'),
COUNT(*) FILTER (WHERE event_type='unsubscribe')
FROM deliverability_events
WHERE organization_id=$1 AND created_at >= $2 AND created_at <= $3
GROUP BY 1`
if rows, err := r.db.Query(ctx, evQ, orgID, from, to); err == nil {
for rows.Next() {
var d time.Time
var b, c, o, cl, rep, u int
if rows.Scan(&d, &b, &c, &o, &cl, &rep, &u) == nil {
key := d.Format("2006-01-02")
byDay[key] = &models.DeliverabilityDailyPoint{Date: key, Bounces: b, Complaints: c, Opens: o, Clicks: cl, Replies: rep, Unsubscribes: u}
}
}
rows.Close()
}
sentQ := `
SELECT (t.completed_at AT TIME ZONE 'UTC')::date, COUNT(*)
FROM tasks t JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.organization_id=$1 AND t.task_type='campaign' AND t.status='completed'
AND t.completed_at >= $2 AND t.completed_at <= $3
GROUP BY 1`
if rows, err := r.db.Query(ctx, sentQ, orgID, from, to); err == nil {
for rows.Next() {
var d time.Time
var s int
if rows.Scan(&d, &s) == nil {
key := d.Format("2006-01-02")
if p := byDay[key]; p != nil {
p.Sent = s
} else {
byDay[key] = &models.DeliverabilityDailyPoint{Date: key, Sent: s}
}
}
}
rows.Close()
}
out := []models.DeliverabilityDailyPoint{}
for d := from.UTC().Truncate(24 * time.Hour); !d.After(to.UTC()); d = d.Add(24 * time.Hour) {
key := d.Format("2006-01-02")
if p := byDay[key]; p != nil {
out = append(out, *p)
} else {
out = append(out, models.DeliverabilityDailyPoint{Date: key})
}
}
return out
}
// deliverabilityByCampaign ranks campaigns by bounce+complaint in the window,
// with the per-campaign sent denominator from campaign_contact_progress.
func (r *advancedOutreachRepository) deliverabilityByCampaign(ctx context.Context, orgID uuid.UUID, from, to time.Time) []models.CampaignDeliverability {
out := []models.CampaignDeliverability{}
q := `
SELECT de.campaign_id, c.name,
COUNT(*) FILTER (WHERE de.event_type='bounce'),
COUNT(*) FILTER (WHERE de.event_type='complaint')
FROM deliverability_events de JOIN campaigns c ON c.id = de.campaign_id
WHERE de.organization_id=$1 AND de.created_at >= $2 AND de.created_at <= $3 AND de.campaign_id IS NOT NULL
GROUP BY de.campaign_id, c.name
ORDER BY (COUNT(*) FILTER (WHERE de.event_type='bounce') + COUNT(*) FILTER (WHERE de.event_type='complaint')) DESC
LIMIT 20`
rows, err := r.db.Query(ctx, q, orgID, from, to)
if err != nil {
return out
}
type rec struct {
id uuid.UUID
name string
b, c int
}
items := []rec{}
for rows.Next() {
var x rec
if rows.Scan(&x.id, &x.name, &x.b, &x.c) == nil {
items = append(items, x)
}
}
rows.Close()
if len(items) == 0 {
return out
}
sent := map[uuid.UUID]int{}
sq := `
SELECT ccp.campaign_id, COUNT(*)
FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id
WHERE c.organization_id=$1 AND ccp.sent_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3
GROUP BY ccp.campaign_id`
if srows, serr := r.db.Query(ctx, sq, orgID, from, to); serr == nil {
for srows.Next() {
var id uuid.UUID
var n int
if srows.Scan(&id, &n) == nil {
sent[id] = n
}
}
srows.Close()
}
for _, x := range items {
s := sent[x.id]
br := models.Rate(x.b, s)
cr := models.Rate(x.c, s)
out = append(out, models.CampaignDeliverability{CampaignID: x.id, Name: x.name, Sent: s, Bounces: x.b, Complaints: x.c, BounceRate: br, ComplaintRate: cr, Band: models.DeliverabilityBand(br, cr, 0)})
}
return out
}
// deliverabilityByMailbox ranks mailboxes by bounce+complaint in the window,
// with the per-mailbox sent denominator from completed campaign tasks.
func (r *advancedOutreachRepository) deliverabilityByMailbox(ctx context.Context, orgID uuid.UUID, from, to time.Time) []models.MailboxDeliverability {
out := []models.MailboxDeliverability{}
q := `
SELECT ea.id, ea.email,
COUNT(*) FILTER (WHERE de.event_type='bounce'),
COUNT(*) FILTER (WHERE de.event_type='complaint')
FROM deliverability_events de
JOIN tasks t ON t.id = de.task_id
JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE de.organization_id=$1 AND de.created_at >= $2 AND de.created_at <= $3 AND de.task_id IS NOT NULL
GROUP BY ea.id, ea.email
ORDER BY (COUNT(*) FILTER (WHERE de.event_type='bounce') + COUNT(*) FILTER (WHERE de.event_type='complaint')) DESC
LIMIT 50`
rows, err := r.db.Query(ctx, q, orgID, from, to)
if err != nil {
return out
}
type rec struct {
id uuid.UUID
email string
b, c int
}
items := []rec{}
for rows.Next() {
var x rec
if rows.Scan(&x.id, &x.email, &x.b, &x.c) == nil {
items = append(items, x)
}
}
rows.Close()
if len(items) == 0 {
return out
}
sent := map[uuid.UUID]int{}
sq := `
SELECT t.email_account_id, COUNT(*)
FROM tasks t JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.organization_id=$1 AND t.task_type='campaign' AND t.status='completed'
AND t.completed_at >= $2 AND t.completed_at <= $3
GROUP BY t.email_account_id`
if srows, serr := r.db.Query(ctx, sq, orgID, from, to); serr == nil {
for srows.Next() {
var id uuid.UUID
var n int
if srows.Scan(&id, &n) == nil {
sent[id] = n
}
}
srows.Close()
}
for _, x := range items {
s := sent[x.id]
br := models.Rate(x.b, s)
cr := models.Rate(x.c, s)
out = append(out, models.MailboxDeliverability{EmailAccountID: x.id, Email: x.email, Sent: s, Bounces: x.b, Complaints: x.c, BounceRate: br, ComplaintRate: cr, Band: models.DeliverabilityBand(br, cr, 0)})
}
return out
}
// executionLockTTL is the maximum time an in_progress execution lock is considered valid.
// If a lock has been in_progress longer than this, it is treated as expired (worker crash).
const executionLockTTL = 5 * time.Minute