package models import ( "time" "github.com/google/uuid" ) // Human sending behaviour. A mailbox that sends exactly 40 emails a day, every // day, starting at 09:00:00 and spaced exactly 600s apart is describing itself // as a machine in its own Received headers. These settings give each mailbox a // range to roll inside, and the roll happens once per local day so the mailbox // behaves like one person having one workday rather than a process re-deciding // its schedule every few minutes. // // Every minute-of-day value below is minutes since LOCAL midnight in the // mailbox's own timezone (email_accounts.timezone). const ( BehaviorDailyLimitMinDefault = 30 BehaviorDailyLimitMaxDefault = 45 BehaviorDailyLimitFloor = 1 BehaviorDailyLimitCeiling = 500 // Hourly ceiling for cold sends. The default band is deliberately wider // than daily_limit / working-hours so the hourly cap shapes bursts without // making the daily target unreachable. BehaviorHourlyLimitMinDefault = 5 BehaviorHourlyLimitMaxDefault = 9 BehaviorHourlyLimitFloor = 1 BehaviorHourlyLimitCeiling = 200 BehaviorGapMinSecondsDefault = 90 BehaviorGapMaxSecondsDefault = 420 BehaviorGapFloor = 30 BehaviorGapCeiling = 86400 // 09:03-09:27 and 17:18-17:56. BehaviorWorkStartMinDefault = 543 BehaviorWorkStartMaxDefault = 567 BehaviorWorkEndMinDefault = 1038 BehaviorWorkEndMaxDefault = 1076 // Lunch starts somewhere in 12:00-13:30 and runs 30-60 minutes. BehaviorLunchEarliestDefault = 720 BehaviorLunchLatestDefault = 810 BehaviorLunchMinMinutesDefault = 30 BehaviorLunchMaxMinutesDefault = 60 BehaviorLunchMaxMinutesCeiling = 240 // Monday-indexed bitmask, bit 0 = Monday. 31 = Mon-Fri. BehaviorWeekdaysDefault = 31 BehaviorWeekdaysAll = 127 // MinutesPerDay is the exclusive upper bound for a minute-of-day value. MinutesPerDay = 1440 ) // SendingBehavior is one mailbox's behaviour profile: the ranges, not the // rolled values. Disabled profiles are still stored so a customer can tune the // ranges before switching it on. type SendingBehavior struct { EmailAccountID uuid.UUID `json:"email_account_id"` Enabled bool `json:"enabled"` DailyLimitMin int `json:"daily_limit_min"` DailyLimitMax int `json:"daily_limit_max"` HourlyLimitMin int `json:"hourly_limit_min"` HourlyLimitMax int `json:"hourly_limit_max"` GapMinSeconds int `json:"gap_min_seconds"` GapMaxSeconds int `json:"gap_max_seconds"` WorkStartMin int `json:"work_start_min"` WorkStartMax int `json:"work_start_max"` WorkEndMin int `json:"work_end_min"` WorkEndMax int `json:"work_end_max"` LunchEnabled bool `json:"lunch_enabled"` LunchEarliest int `json:"lunch_earliest"` LunchLatest int `json:"lunch_latest"` LunchMinMinutes int `json:"lunch_min_minutes"` LunchMaxMinutes int `json:"lunch_max_minutes"` // Monday-indexed bitmask (bit 0 = Monday .. bit 6 = Sunday), matching // campaigns.days and the dashboard's Monday-first week grid. Weekdays int `json:"weekdays"` // Timezone is a read-only echo of the mailbox's timezone, so a client // rendering the profile does not have to fetch the mailbox as well. Timezone string `json:"timezone,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // DefaultSendingBehavior is the profile a mailbox gets before anyone edits it: // switched off, with ranges that describe an ordinary weekday sender. func DefaultSendingBehavior(accountID uuid.UUID) SendingBehavior { return SendingBehavior{ EmailAccountID: accountID, Enabled: false, DailyLimitMin: BehaviorDailyLimitMinDefault, DailyLimitMax: BehaviorDailyLimitMaxDefault, HourlyLimitMin: BehaviorHourlyLimitMinDefault, HourlyLimitMax: BehaviorHourlyLimitMaxDefault, GapMinSeconds: BehaviorGapMinSecondsDefault, GapMaxSeconds: BehaviorGapMaxSecondsDefault, WorkStartMin: BehaviorWorkStartMinDefault, WorkStartMax: BehaviorWorkStartMaxDefault, WorkEndMin: BehaviorWorkEndMinDefault, WorkEndMax: BehaviorWorkEndMaxDefault, LunchEnabled: true, LunchEarliest: BehaviorLunchEarliestDefault, LunchLatest: BehaviorLunchLatestDefault, LunchMinMinutes: BehaviorLunchMinMinutesDefault, LunchMaxMinutes: BehaviorLunchMaxMinutesDefault, Weekdays: BehaviorWeekdaysDefault, } } // WorksOn reports whether the profile sends on the given weekday. The stored // mask is Monday-indexed while time.Weekday is Sunday-indexed, so the mapping // is explicit here and nowhere else. func (b SendingBehavior) WorksOn(wd time.Weekday) bool { return b.Weekdays&(1< BehaviorDailyLimitCeiling { return fail("daily_limit", "must be between 1 and 500 emails per day") } if b.DailyLimitMin > b.DailyLimitMax { return fail("daily_limit", "minimum cannot be above maximum") } if b.HourlyLimitMin < BehaviorHourlyLimitFloor || b.HourlyLimitMax > BehaviorHourlyLimitCeiling { return fail("hourly_limit", "must be between 1 and 200 emails per hour") } if b.HourlyLimitMin > b.HourlyLimitMax { return fail("hourly_limit", "minimum cannot be above maximum") } if b.GapMinSeconds < BehaviorGapFloor || b.GapMaxSeconds > BehaviorGapCeiling { return fail("gap_seconds", "must be between 30 seconds and 24 hours") } if b.GapMinSeconds > b.GapMaxSeconds { return fail("gap_seconds", "minimum cannot be above maximum") } if b.WorkStartMin < 0 || b.WorkStartMax >= MinutesPerDay || b.WorkStartMin > b.WorkStartMax { return fail("work_start", "must be a valid time range within the day") } if b.WorkEndMin < 0 || b.WorkEndMax >= MinutesPerDay || b.WorkEndMin > b.WorkEndMax { return fail("work_end", "must be a valid time range within the day") } // The latest start must still precede the earliest end, or some rolls // would produce a workday that ends before it begins. if b.WorkStartMax >= b.WorkEndMin { return fail("work_end", "the workday must end after the latest possible start") } if b.LunchEarliest < 0 || b.LunchLatest >= MinutesPerDay || b.LunchEarliest > b.LunchLatest { return fail("lunch_window", "must be a valid time range within the day") } if b.LunchMinMinutes < 0 || b.LunchMaxMinutes > BehaviorLunchMaxMinutesCeiling || b.LunchMinMinutes > b.LunchMaxMinutes { return fail("lunch_length", "must be between 0 and 240 minutes") } if b.LunchEnabled { // A break outside the workday is silently ignored by the planner, // which reads as "lunch is on but nothing happens". Reject it instead. if b.LunchEarliest < b.WorkStartMax || b.LunchLatest+b.LunchMaxMinutes > b.WorkEndMin { return fail("lunch_window", "the break must fit inside the shortest possible workday") } } if b.Weekdays < 0 || b.Weekdays > BehaviorWeekdaysAll { return fail("weekdays", "must be a bitmask of Monday..Sunday") } if b.Enabled && b.Weekdays == 0 { return fail("weekdays", "pick at least one sending day") } return nil } // DailyPlan is the workday a mailbox actually rolled for one local date. It is // written once and never updated, so every scheduling pass through the day // reads the same numbers. type DailyPlan struct { EmailAccountID uuid.UUID `json:"email_account_id"` // PlanDate is the local calendar date in Timezone, as YYYY-MM-DD. PlanDate string `json:"plan_date"` Timezone string `json:"timezone"` IsWorkingDay bool `json:"is_working_day"` DailyLimit int `json:"daily_limit"` HourlyLimit int `json:"hourly_limit"` WorkStartMinute int `json:"work_start_minute"` WorkEndMinute int `json:"work_end_minute"` LunchStartMinute *int `json:"lunch_start_minute"` LunchEndMinute *int `json:"lunch_end_minute"` GapMinSeconds int `json:"gap_min_seconds"` GapMaxSeconds int `json:"gap_max_seconds"` CreatedAt time.Time `json:"created_at"` } // HasLunch reports whether this day carries a break. func (p DailyPlan) HasLunch() bool { return p.LunchStartMinute != nil && p.LunchEndMinute != nil } // WorkingMinutes is the length of the day's sending window with the break // removed. Used to pace sends across the day. func (p DailyPlan) WorkingMinutes() int { if !p.IsWorkingDay { return 0 } total := p.WorkEndMinute - p.WorkStartMinute if p.HasLunch() { total -= *p.LunchEndMinute - *p.LunchStartMinute } if total < 0 { return 0 } return total } // Contains reports whether a minute-of-day falls inside the working window and // outside the break. func (p DailyPlan) Contains(minute int) bool { if !p.IsWorkingDay || minute < p.WorkStartMinute || minute >= p.WorkEndMinute { return false } if p.HasLunch() && minute >= *p.LunchStartMinute && minute < *p.LunchEndMinute { return false } return true } // NextOpenMinute returns the first sending minute at or after `minute` within // this day, and false when the day is over (or is not a working day). A minute // inside the break jumps to the end of the break. func (p DailyPlan) NextOpenMinute(minute int) (int, bool) { if !p.IsWorkingDay { return 0, false } if minute < p.WorkStartMinute { minute = p.WorkStartMinute } if p.HasLunch() && minute >= *p.LunchStartMinute && minute < *p.LunchEndMinute { minute = *p.LunchEndMinute } if minute >= p.WorkEndMinute { return 0, false } return minute, true } // DailyPlanView is the plan plus the derived numbers the dashboard shows, so // the client never re-implements the window maths. type DailyPlanView struct { DailyPlan // SentToday is completed cold sends from this mailbox on this local date. SentToday int `json:"sent_today"` // RemainingToday is what the plan still allows, floored at zero. RemainingToday int `json:"remaining_today"` // Behavior is the profile the plan was rolled from. Behavior SendingBehavior `json:"behavior"` }