mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 16:02:03 +00:00
307 lines
14 KiB
Go
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
|
|
}
|