Files
warmbly/internal/models/warmup.go
T

307 lines
14 KiB
Go

package models
import (
"time"
"github.com/google/uuid"
)
// WarmupPoolType separates free and paid warmup participation.
type WarmupPoolType string
const (
// WarmupPoolFree is the default pool for unpaid workspaces.
WarmupPoolFree WarmupPoolType = "free"
// WarmupPoolPremium is the pool for paid workspaces.
WarmupPoolPremium WarmupPoolType = "premium"
)
type WarmupToken struct {
Token uuid.UUID `json:"token"`
TaskID uuid.UUID `json:"task_id"`
SenderAccountID uuid.UUID `json:"sender_account_id"`
RecipientAccountID uuid.UUID `json:"recipient_account_id"`
ConversationTheme string `json:"conversation_theme"`
// ContentSource records which content cohort produced this send
// ("static" or "ai") so the A/B harness can compare spam-placement
// rate by cohort. ConversationID points at the cached warmup_conversations
// row when the body came from the AI bank (nil for static content).
ContentSource string `json:"content_source"`
ConversationID *uuid.UUID `json:"conversation_id,omitempty"`
// ConversationTurn is zero for the opening and increments for each reply.
ConversationTurn int `json:"conversation_turn"`
// Subject is what was actually sent, and SentMessageID is the Message-ID
// the provider put on the wire (which is not the one we minted on Graph).
// Both exist so the recipient can still resolve this token when the verify
// header did not survive delivery.
Subject string `json:"subject"`
SentMessageID string `json:"sent_message_id"`
CreatedAt time.Time `json:"created_at"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
// TaskTypeWarmup is the tasks.task_type a warmup send is recorded under. It is
// named here because warmup sends carry a message_id exactly like campaign
// sends, so anything resolving an inbound report back to a send has to be able
// to tell the two apart before it attributes anything to the customer.
const TaskTypeWarmup = "warmup"
// The warmup actions a worker knows how to run. These names travel on the bus,
// so WarmupActionFile keeps its original spelling even though it now files the
// mailbox's own sent copy as well as the copy it received.
const (
WarmupActionFile = "move_to_warmbly"
WarmupActionRescueFromSpam = "remove_from_spam"
WarmupActionMarkRead = "mark_read"
WarmupActionMarkImportant = "mark_important"
WarmupActionStar = "star"
// WarmupActionDelete removes a warmup message the retention window has
// passed on from the mailbox (Trash on Gmail, Deleted Items on Outlook,
// expunged on IMAP) and drops the platform's copy of its body. Published
// by the retention sweep alone, and only for a message it has retired
// first, so the removal the sync then observes is never a strike.
WarmupActionDelete = "delete"
)
// WarmupEmailAction represents actions to perform on a detected warmup email.
//
// For Gmail accounts the worker uses GmailID to issue Users.Messages.Modify
// requests. For IMAP-backed accounts (Outlook + custom SMTP/IMAP) the worker
// needs UID plus the folder to locate the message: MailboxFolder names the
// folder and MailboxUIDValidity says which generation of its UIDs the stored
// UID belongs to, so an action is skipped rather than aimed at whatever
// message inherited the number after a UIDVALIDITY change.
type WarmupEmailAction struct {
UserID uuid.UUID `json:"user_id" avro:"user_id"`
EmailID uuid.UUID `json:"email_id" avro:"email_id"`
GmailID string `json:"gmail_id" avro:"gmail_id"`
UID uint32 `json:"uid" avro:"uid"`
MailboxUIDValidity uint32 `json:"mailbox_uid_validity" avro:"mailbox_uid_validity"`
// MailboxFolder is the source folder's name. Empty on events from
// consumers predating it, where the worker falls back to matching on
// MailboxUIDValidity alone.
MailboxFolder string `json:"mailbox_folder,omitempty" avro:"mailbox_folder"`
// RFCMessageID is the immutable RFC 5322 Message-ID. Graph provider ids
// change when a message is moved (copy+delete), so the worker re-resolves
// the live Graph id from this stable key at action time.
RFCMessageID string `json:"rfc_message_id,omitempty" avro:"rfc_message_id"`
Actions []string `json:"actions" avro:"actions"` // "move_to_warmbly", "mark_read", "remove_from_spam", "mark_important"
// Placement and TargetFolder are where "move_to_warmbly" files the message
// in the customer's own mail client, resolved from the mailbox's settings by
// the control plane (Email.WarmupFiling). An event from a consumer predating
// them carries neither, and the worker falls back to the default folder,
// which is what every mailbox did before the setting existed.
Placement string `json:"placement,omitempty" avro:"placement"`
TargetFolder string `json:"target_folder,omitempty" avro:"target_folder"`
// InternalID is the platform's id for the message, which keys the stored
// body the delete action drops. Empty when the control plane does not
// know it (the sender's own copy of a send), in which case the worker
// resolves it from the provider id it acted on.
InternalID string `json:"internal_id,omitempty" avro:"internal_id"`
// DelaySeconds is retained for wire compatibility but is now always 0: the
// recipient-side "dwell" is owned by the consumer's durable schedule
// (warmup_pending_engagements + the engagement poller), which publishes the
// immediate leg (folder + spam-rescue) now and the delayed leg (read /
// important / star) when due. The worker runs whatever it receives
// immediately. This survives a worker restart, which the old in-process
// timer did not.
DelaySeconds int `json:"delay_seconds,omitempty" avro:"delay_seconds"`
}
// The two warmup pools migration 000156 seeds on every instance, one per type.
var (
WarmupPoolFreeID = uuid.MustParse("77777777-aaaa-0000-0000-000000000001")
WarmupPoolPremiumID = uuid.MustParse("77777777-aaaa-0000-0000-000000000002")
)
// WarmupPoolID resolves a pool type to its seeded pool; false for anything
// that is not a pool type.
func WarmupPoolID(poolType string) (uuid.UUID, bool) {
switch poolType {
case "free":
return WarmupPoolFreeID, true
case "premium":
return WarmupPoolPremiumID, true
}
return uuid.Nil, false
}
// WarmupPoolBorrowsFrom is the tier a thin pool may borrow proven recipients
// from. Only premium borrows, and only free, so nothing unsolicited from the
// free tier reaches a paying inbox on the draw.
func WarmupPoolBorrowsFrom(poolType string) (string, bool) {
if poolType == "premium" {
return "free", true
}
return "", false
}
// WarmupPoolReturnsTo is the tier a proven mailbox may write back into: the
// mirror of the borrow, so a free mailbox only ever calls on a paying inbox
// that wrote to it first. Without this half a thin premium tier sends into
// the free tier and receives nothing (#633).
func WarmupPoolReturnsTo(poolType string) (string, bool) {
if poolType == "free" {
return "premium", true
}
return "", false
}
// WarmupPartnerOrigin says how a candidate came to be in a sender's draw.
type WarmupPartnerOrigin string
const (
// WarmupPartnerOwnTier is a member of the sender's own pool.
WarmupPartnerOwnTier WarmupPartnerOrigin = "own"
// WarmupPartnerBorrowed is a proven free mailbox filling in a thin premium
// tier. Drawn after the sender's own tier.
WarmupPartnerBorrowed WarmupPartnerOrigin = "borrowed"
// WarmupPartnerReturn is a paying mailbox that wrote to this free sender
// recently. Drawn alongside the sender's own tier: returning the visit is
// the pool paying its debt, not a fallback.
WarmupPartnerReturn WarmupPartnerOrigin = "return"
)
// WarmupPartnerCandidate is a recipient the partner selector may draw: a
// member of the sender's tier, one borrowed from the tier it may draw on, or
// one it owes a visit to.
type WarmupPartnerCandidate struct {
ID uuid.UUID
Email string
// OrganizationID lets selection rank outside partners ahead of siblings.
OrganizationID *uuid.UUID
// PoolType is the pool the candidate was drawn from. The health gate is
// pinned to it, never to the sender's pool (#495).
PoolType string
Origin WarmupPartnerOrigin
// Sent7d and Received7d are the candidate's verified warmup sends and
// arrivals over the last seven days, so the draw can favour an inbox that
// gives more than it gets. The inbound cap that keeps a candidate out of
// the set for the day is applied in the repository, before any count or
// sample is taken.
Sent7d int
Received7d int
}
// Borrowed reports whether the candidate was drawn from the tier the sender's
// pool borrows from.
func (c WarmupPartnerCandidate) Borrowed() bool { return c.Origin == WarmupPartnerBorrowed }
// Starvation is how far behind an inbox is on what it sent: 0 for one in
// balance or that sends nothing, 1 for one that has received nothing back.
// The draw multiplies a candidate's weight by it, so the pool's traffic flows
// towards the inboxes that are owed the most.
func (c WarmupPartnerCandidate) Starvation() float64 {
if c.Sent7d <= 0 || c.Received7d >= c.Sent7d {
return 0
}
return float64(c.Sent7d-c.Received7d) / float64(c.Sent7d)
}
type WarmupHealthState string
const (
WarmupHealthHealthy WarmupHealthState = "healthy"
WarmupHealthWatch WarmupHealthState = "watch"
WarmupHealthThrottled WarmupHealthState = "throttled"
WarmupHealthQuarantined WarmupHealthState = "quarantined"
WarmupHealthBlocked WarmupHealthState = "blocked"
)
type WarmupParticipantHealth struct {
PoolID uuid.UUID `json:"pool_id"`
PoolType string `json:"pool_type"`
EmailAccountID uuid.UUID `json:"email_account_id"`
JoinedAt time.Time `json:"joined_at"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
BlockedUntil *time.Time `json:"blocked_until,omitempty"`
BlockedReason *string `json:"blocked_reason,omitempty"`
HealthState WarmupHealthState `json:"health_state"`
LastHealthScore float64 `json:"last_health_score"`
LastHealthReason *string `json:"last_health_reason,omitempty"`
LastHealthEvaluatedAt *time.Time `json:"last_health_evaluated_at,omitempty"`
// Health signals older than this are not counted against the mailbox: its
// join time for a new participant, the #195 fix deploy for one that predates
// it. Keeps the first evaluation from judging a mailbox on a period nothing
// was evaluating.
HealthSignalsFrom time.Time `json:"health_signals_from"`
}
// WarmupBanStatus is the user-facing view of a mailbox's warmup standing,
// returned by GetBanStatus so the dashboard can show why warmup is blocked and
// whether the user can appeal.
type WarmupBanStatus struct {
EmailAccountID uuid.UUID `json:"email_account_id"`
Blocked bool `json:"blocked"`
HealthState string `json:"health_state"`
Reason string `json:"reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
BlockedUntil *time.Time `json:"blocked_until,omitempty"`
CanAppeal bool `json:"can_appeal"`
PendingAppeal bool `json:"pending_appeal"`
}
type WarmupPoolHealthSummary struct {
TotalParticipants int `json:"total_participants"`
ByState map[string]int `json:"by_state"`
// AvgHealthScore averages last_health_score: the severity the bands decided.
AvgHealthScore float64 `json:"avg_health_score"`
AvgSpamPlacement float64 `json:"avg_spam_placement_rate"`
// Keyed by who runs the recipient's mail, the vocabulary routing reads.
SpamPlacementByProvider map[string]int `json:"spam_placement_by_provider"`
BlockedCount int `json:"blocked_count"`
AtRiskCount int `json:"at_risk_count"`
}
// WarmupHealthCounts is the raw count behind each health rate, read in one trip.
type WarmupHealthCounts struct {
SentLast7d int
SpamPlacementsLast7d int
UserComplaintsLast7d int
ComplaintsLast30d int
BouncesLast30d int
DeliveredLast30d int
// Harm this mailbox did to warmup mail it verifiably received, apart
// because a deletion is usually housekeeping and a spam flag never is.
DeletionsLast7d int
SpamFlagsLast7d int
}
type WarmupHealthMetrics struct {
SentLast7d int `json:"sent_last_7d"`
// SpamPlacementsLast7d counts warmup messages that landed in the
// recipient's Junk/Spam folder on delivery. SpamPlacementRate is the
// ratio against SentLast7d.
SpamPlacementsLast7d int `json:"spam_placements_last_7d"`
SpamPlacementRate float64 `json:"spam_placement_rate"`
// UserComplaintsLast7d counts warmup messages the recipient explicitly
// flagged as spam. WarmupComplaintRate is the ratio against SentLast7d.
// This is distinct from external-recipient complaints captured in
// deliverability_events (ComplaintsLast30d / ComplaintRate below).
UserComplaintsLast7d int `json:"user_complaints_last_7d"`
WarmupComplaintRate float64 `json:"warmup_complaint_rate"`
ComplaintsLast30d int `json:"complaints_last_30d"`
DeliveredLast30d int `json:"delivered_last_30d"`
ComplaintRate float64 `json:"complaint_rate"`
BouncesLast30d int `json:"bounces_last_30d"`
BounceRate float64 `json:"bounce_rate"`
// DeletionsLast7d and SpamFlagsLast7d are warmup messages this mailbox
// received and then deleted or flagged as spam. TamperingStrikes weighs
// them: a spam flag counts double, because nobody flags mail by accident.
DeletionsLast7d int `json:"deletions_last_7d"`
SpamFlagsLast7d int `json:"spam_flags_last_7d"`
}
// TamperingStrikes is the weighted harm count the tampering band reads.
func (m *WarmupHealthMetrics) TamperingStrikes() int {
return m.DeletionsLast7d + 2*m.SpamFlagsLast7d
}