mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-07 08:02:08 +00:00
585 lines
24 KiB
Go
585 lines
24 KiB
Go
package models
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TimeInterval is a sending window within a single day, expressed in minutes
|
|
// since local midnight. End is exclusive-ish and must be > Start, <= 1440.
|
|
type TimeInterval struct {
|
|
Start int `json:"start"`
|
|
End int `json:"end"`
|
|
}
|
|
|
|
// ScheduleWindows is a campaign's per-day sending schedule, indexed by
|
|
// time.Weekday (0=Sunday .. 6=Saturday). A nil/empty day means "no sending that
|
|
// day". When non-empty it is the authoritative schedule and supersedes the
|
|
// legacy Days/StartTime/EndTime fields. Persisted as a jsonb array-of-7.
|
|
type ScheduleWindows [7][]TimeInterval
|
|
|
|
// IsEmpty reports whether no day carries any interval (treated as "unset").
|
|
func (w ScheduleWindows) IsEmpty() bool {
|
|
for _, day := range w {
|
|
if len(day) > 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// DaySpan returns the earliest start and latest end across a weekday's
|
|
// intervals (ok=false when that day has none). Used for even send distribution.
|
|
func (w ScheduleWindows) DaySpan(weekday int) (start, end int, ok bool) {
|
|
if weekday < 0 || weekday > 6 || len(w[weekday]) == 0 {
|
|
return 0, 0, false
|
|
}
|
|
start, end = w[weekday][0].Start, w[weekday][0].End
|
|
for _, iv := range w[weekday][1:] {
|
|
if iv.Start < start {
|
|
start = iv.Start
|
|
}
|
|
if iv.End > end {
|
|
end = iv.End
|
|
}
|
|
}
|
|
return start, end, true
|
|
}
|
|
|
|
// Value implements driver.Valuer — marshals to jsonb, or NULL when empty (so an
|
|
// empty schedule reverts to the legacy day/time derivation).
|
|
func (w ScheduleWindows) Value() (driver.Value, error) {
|
|
if w.IsEmpty() {
|
|
return nil, nil
|
|
}
|
|
return json.Marshal(w)
|
|
}
|
|
|
|
// Scan implements sql.Scanner — reads the jsonb column (NULL → empty).
|
|
func (w *ScheduleWindows) Scan(src any) error {
|
|
if src == nil {
|
|
*w = ScheduleWindows{}
|
|
return nil
|
|
}
|
|
var b []byte
|
|
switch v := src.(type) {
|
|
case []byte:
|
|
b = v
|
|
case string:
|
|
b = []byte(v)
|
|
default:
|
|
return fmt.Errorf("ScheduleWindows: unsupported scan type %T", src)
|
|
}
|
|
if len(b) == 0 {
|
|
*w = ScheduleWindows{}
|
|
return nil
|
|
}
|
|
var parsed ScheduleWindows
|
|
if err := json.Unmarshal(b, &parsed); err != nil {
|
|
return err
|
|
}
|
|
*w = parsed
|
|
return nil
|
|
}
|
|
|
|
type Campaign struct {
|
|
ID uuid.UUID `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
|
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
|
|
StopOnReply bool `json:"stop_on_reply"`
|
|
OpenTracking bool `json:"open_tracking"`
|
|
LinkTracking bool `json:"link_tracking"`
|
|
TextOnly bool `json:"text_only"`
|
|
DailyLimit int `json:"daily_limit"`
|
|
UnsubscribeHeader bool `json:"unsubscribe_header"`
|
|
RiskyEmails bool `json:"risky_emails"`
|
|
// UnsubscribeMode is the in-body opt-out: inherit | text | link | off.
|
|
UnsubscribeMode string `json:"unsubscribe_mode"`
|
|
|
|
CC []string `json:"cc"`
|
|
BCC []string `json:"bcc"`
|
|
|
|
StartDate *time.Time `json:"start_date"`
|
|
EndDate *time.Time `json:"end_date"`
|
|
// Timezone is the zone the schedule is read in. Empty means the campaign
|
|
// follows the workspace timezone; EffectiveTimezone is the zone in use
|
|
// either way, resolved on read.
|
|
Timezone string `json:"timezone"`
|
|
EffectiveTimezone string `json:"effective_timezone"`
|
|
Days uint8 `json:"days"`
|
|
StartTime string `json:"start_time"`
|
|
EndTime string `json:"end_time"`
|
|
|
|
// ScheduleWindows, when non-empty, is the authoritative per-day sending
|
|
// schedule (supersedes Days/StartTime/EndTime). Indexed by time.Weekday.
|
|
ScheduleWindows ScheduleWindows `json:"schedule_windows"`
|
|
|
|
EmailTags []string `json:"email_tags"`
|
|
Folders []string `json:"folders"`
|
|
|
|
ContactOrderBy string `json:"contact_order_by"`
|
|
ContactOrderDir string `json:"contact_order_dir"`
|
|
ContactOrderField *string `json:"contact_order_field,omitempty"`
|
|
|
|
// Sending-account selection. SenderStrategy is "tags" (default — accounts
|
|
// resolved from EmailTags) or "explicit" (the campaign_senders list).
|
|
// RotationMode picks how volume spreads across the chosen mailboxes.
|
|
SenderStrategy string `json:"sender_strategy"`
|
|
RotationMode string `json:"rotation_mode"`
|
|
Senders []CampaignSender `json:"senders,omitempty"` // loaded on demand, not in the base SELECT
|
|
|
|
// Per-campaign daily ramp-up. Applied only via min() against the per-mailbox
|
|
// cap, so it can never raise volume above the cold cap. RampLevel/RampLevelDate
|
|
// are server-managed (persisted across pause/resume).
|
|
RampEnabled bool `json:"ramp_enabled"`
|
|
RampStart int `json:"ramp_start"`
|
|
RampIncrement int `json:"ramp_increment"`
|
|
RampCeiling int `json:"ramp_ceiling"`
|
|
RampLevel int `json:"ramp_level"`
|
|
RampLevelDate *time.Time `json:"ramp_level_date,omitempty"`
|
|
|
|
// ESP/provider matching: off | prefer | strict.
|
|
ESPMatchMode string `json:"esp_match_mode"`
|
|
|
|
// New-lead throttle. MaxNewLeadsPerDay 0 = unlimited (current behavior).
|
|
MaxNewLeadsPerDay int `json:"max_new_leads_per_day"`
|
|
PrioritizeNewLeads bool `json:"prioritize_new_leads"`
|
|
|
|
// EntryDelayMinutes holds a contact's FIRST email back for this long after
|
|
// they entered the campaign. 0 means the first email is due immediately.
|
|
EntryDelayMinutes int `json:"entry_delay_minutes"`
|
|
|
|
// Continuous keeps the campaign active when it runs out of leads: it waits
|
|
// for more instead of finishing. IdleSince is set while it waits.
|
|
Continuous bool `json:"continuous"`
|
|
IdleSince *time.Time `json:"idle_since,omitempty"`
|
|
|
|
// Auto-pause guardrails. Rates are evaluated over a rolling window and the
|
|
// campaign is paused the moment a band is breached, rather than waiting for
|
|
// a mailbox provider to react first. A rate threshold of 0 disables that
|
|
// rule; GuardrailMinSample keeps a small sample from tripping anything.
|
|
//
|
|
// Bounce and complaint rates are CEILINGS (pause when at or above); the
|
|
// reply rate is a FLOOR (pause when below), because a campaign at volume
|
|
// that nobody answers is spending sender reputation for nothing.
|
|
GuardrailEnabled bool `json:"guardrail_enabled"`
|
|
GuardrailBounceRateMax float64 `json:"guardrail_bounce_rate_max"`
|
|
GuardrailComplaintRateMax float64 `json:"guardrail_complaint_rate_max"`
|
|
GuardrailReplyRateMin float64 `json:"guardrail_reply_rate_min"`
|
|
GuardrailMinSample int `json:"guardrail_min_sample"`
|
|
GuardrailWindowDays int `json:"guardrail_window_days"`
|
|
GuardrailTrippedAt *time.Time `json:"guardrail_tripped_at,omitempty"`
|
|
GuardrailReason string `json:"guardrail_reason,omitempty"`
|
|
|
|
// Campaign-scoped tracking-domain override. Honored only when verified;
|
|
// otherwise falls back to the mailbox/default domain.
|
|
TrackingDomain string `json:"tracking_domain"`
|
|
TrackingDomainVerified bool `json:"tracking_domain_verified"`
|
|
TrackingDomainVerifiedAt *time.Time `json:"tracking_domain_verified_at,omitempty"`
|
|
|
|
// Automatic UTM tagging of every link in the email body. Empty source,
|
|
// medium and campaign values mean the defaults ("warmbly", "email", the
|
|
// campaign name); utm_content is always the link's own text.
|
|
UTMTracking bool `json:"utm_tracking"`
|
|
UTMSource string `json:"utm_source"`
|
|
UTMMedium string `json:"utm_medium"`
|
|
UTMCampaign string `json:"utm_campaign"`
|
|
|
|
LastStatusChangeAt *time.Time `json:"last_status_change_at,omitempty"`
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ClockTimezone is the IANA zone the campaign's schedule is read in: its own
|
|
// when set, else the workspace's as resolved on read, else UTC.
|
|
func (c *Campaign) ClockTimezone() string {
|
|
if c.Timezone != "" {
|
|
return c.Timezone
|
|
}
|
|
if c.EffectiveTimezone != "" {
|
|
return c.EffectiveTimezone
|
|
}
|
|
return "UTC"
|
|
}
|
|
|
|
// CampaignSender is one mailbox in an explicit-strategy campaign's sender pool.
|
|
type CampaignSender struct {
|
|
EmailAccountID uuid.UUID `json:"email_account_id"`
|
|
Weight int `json:"weight"`
|
|
LastSentAt *time.Time `json:"last_sent_at,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// CampaignSenderInput is the write shape for PUT /campaigns/:id/senders.
|
|
type CampaignSenderInput struct {
|
|
EmailAccountID uuid.UUID `json:"email_account_id"`
|
|
Weight *int `json:"weight,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
|
|
type MiniCampaign struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type CampaignsResult struct {
|
|
Data []Campaign `json:"data"`
|
|
Pagination Pagination `json:"pagination"`
|
|
}
|
|
|
|
// CampaignsOverview backs the campaigns browser sidebar: status-bucket
|
|
// counts plus per-folder totals for the org. Paused sums every paused_*
|
|
// variant.
|
|
type CampaignsOverview struct {
|
|
Total int64 `json:"total"`
|
|
Active int64 `json:"active"`
|
|
Paused int64 `json:"paused"`
|
|
Draft int64 `json:"draft"`
|
|
Completed int64 `json:"completed"`
|
|
Folders []CampaignFolderCount `json:"folders"`
|
|
}
|
|
|
|
// CampaignEstimate is the request for POST /campaigns-estimate: how many
|
|
// contacts a set of segments resolves to and how long a sender pool needs
|
|
// to reach them under the per-mailbox caps. Nothing is written.
|
|
type CampaignEstimate struct {
|
|
SegmentIDs []string `json:"segment_ids"`
|
|
EmailTagIDs []string `json:"email_tag_ids,omitempty"`
|
|
DailyLimit *int `json:"daily_limit,omitempty"`
|
|
Days *uint8 `json:"days,omitempty"`
|
|
Timezone *string `json:"timezone,omitempty"`
|
|
StartDate *time.Time `json:"start_date,omitempty"`
|
|
// StartTime and EndTime are the daily sending window ("HH:MM"); absent
|
|
// means the scheduler's default window.
|
|
StartTime *string `json:"start_time,omitempty"`
|
|
EndTime *string `json:"end_time,omitempty"`
|
|
// StepWaits is each follow-up's wait_after in days, in order. Empty is a
|
|
// single email.
|
|
StepWaits []int `json:"step_waits,omitempty"`
|
|
// CampaignID projects a saved campaign, filling anything not sent from it.
|
|
CampaignID *string `json:"campaign_id,omitempty"`
|
|
}
|
|
|
|
// CampaignEstimateStepsMax bounds StepWaits, the follow-ups one estimate simulates.
|
|
const CampaignEstimateStepsMax = 30
|
|
|
|
// CampaignEstimateResult is the projection. DailyCapacity is the pool's
|
|
// per-day ceiling under the campaign limit today; RemainingToday subtracts
|
|
// what the mailboxes already sent today. SendingDays is how many sending days
|
|
// the audience needs and EstimatedFinishAt the calendar day the last send
|
|
// lands on, both nil when the pool has no capacity or the horizon is passed.
|
|
type CampaignEstimateResult struct {
|
|
Recipients int `json:"recipients"`
|
|
Mailboxes int `json:"mailboxes"`
|
|
DailyCapacity int `json:"daily_capacity"`
|
|
RemainingToday int `json:"remaining_today"`
|
|
SendingDays *int `json:"sending_days"`
|
|
EstimatedFinishAt *time.Time `json:"estimated_finish_at"`
|
|
|
|
// Steps is how many emails each contact receives; TotalSends is
|
|
// recipients times steps, assuming nobody replies or unsubscribes.
|
|
Steps int `json:"steps"`
|
|
TotalSends int `json:"total_sends"`
|
|
// FirstTouchFinishAt is the day the last contact gets their first email.
|
|
FirstTouchFinishAt *time.Time `json:"first_touch_finish_at"`
|
|
// SteadyCapacity is the pool's sending-day capacity once every mailbox
|
|
// has graduated from warmup; FullCapacityAt is the first day it gets
|
|
// there, nil when it already has or never does inside the horizon.
|
|
SteadyCapacity int `json:"steady_capacity"`
|
|
FullCapacityAt *time.Time `json:"full_capacity_at"`
|
|
// Ramping counts mailboxes still climbing their warmup graduation
|
|
// ceiling; Held counts mailboxes that contribute nothing today.
|
|
Ramping int `json:"ramping"`
|
|
Held int `json:"held"`
|
|
// Warmup is the warmup mail the pool keeps sending alongside.
|
|
Warmup CampaignEstimateWarmup `json:"warmup"`
|
|
// OtherCampaignsPerDay is what the pool's mailboxes already send for
|
|
// other campaigns on an average recent day; it shares their caps.
|
|
OtherCampaignsPerDay int `json:"other_campaigns_per_day"`
|
|
// Bottleneck is the clamp that costs the most sends on the first
|
|
// sending day, empty when the mailboxes' own caps are the limit.
|
|
Bottleneck string `json:"bottleneck"`
|
|
// Timeline is the projection day by day from the start, at most
|
|
// CampaignEstimateTimelineMax days.
|
|
Timeline []CampaignEstimateDay `json:"timeline"`
|
|
// Senders is the pool mailbox by mailbox, at most CampaignEstimateSendersMax.
|
|
Senders []CampaignEstimateSender `json:"senders"`
|
|
}
|
|
|
|
const (
|
|
CampaignEstimateTimelineMax = 120
|
|
CampaignEstimateSendersMax = 200
|
|
)
|
|
|
|
// Bottleneck values of a campaign estimate.
|
|
const (
|
|
EstimateBottleneckCampaignLimit = "campaign_limit"
|
|
EstimateBottleneckGraduation = "warmup_graduation"
|
|
EstimateBottleneckSpacing = "spacing"
|
|
EstimateBottleneckOtherCampaigns = "other_campaigns"
|
|
EstimateBottleneckHealth = "health"
|
|
EstimateBottleneckHeld = "held"
|
|
EstimateBottleneckWorkspaceRisk = "workspace_risk"
|
|
EstimateBottleneckOrgDailyLimit = "org_daily_limit"
|
|
EstimateBottleneckSendingBehavior = "sending_behavior"
|
|
)
|
|
|
|
// Sender states of a campaign estimate.
|
|
const (
|
|
EstimateSenderReady = "ready"
|
|
EstimateSenderRamping = "ramping"
|
|
EstimateSenderThrottled = "throttled"
|
|
EstimateSenderHealthHold = "health_hold"
|
|
EstimateSenderDomainAuth = "domain_auth"
|
|
EstimateSenderResting = "resting"
|
|
EstimateSenderNoWorker = "no_worker"
|
|
)
|
|
|
|
// CampaignEstimateWarmup is the warmup traffic running beside the campaign.
|
|
// A mailbox backing a live campaign warms at a reduced volume, and every
|
|
// warmup email takes a slot on the same spacing clock as a campaign send.
|
|
type CampaignEstimateWarmup struct {
|
|
Mailboxes int `json:"mailboxes"`
|
|
PerDay int `json:"per_day"`
|
|
}
|
|
|
|
// CampaignEstimateDay is one calendar day of the projection.
|
|
type CampaignEstimateDay struct {
|
|
Date string `json:"date"`
|
|
SendingDay bool `json:"sending_day"`
|
|
Capacity int `json:"capacity"`
|
|
Sends int `json:"sends"`
|
|
FirstEmails int `json:"first_emails"`
|
|
FollowUps int `json:"follow_ups"`
|
|
Warmup int `json:"warmup"`
|
|
}
|
|
|
|
// CampaignEstimateSender is one mailbox's part in the projection.
|
|
type CampaignEstimateSender struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Email string `json:"email"`
|
|
Provider string `json:"provider"`
|
|
State string `json:"state"`
|
|
FirstDayCap int `json:"first_day_cap"`
|
|
SteadyCap int `json:"steady_cap"`
|
|
WarmupPerDay int `json:"warmup_per_day"`
|
|
FullCapAt *time.Time `json:"full_cap_at"`
|
|
}
|
|
|
|
type CampaignFolderCount struct {
|
|
FolderID uuid.UUID `json:"folder_id"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
type UpdateCampaign struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Status *string `json:"status,omitempty"`
|
|
|
|
StopOnReply *bool `json:"stop_on_reply"`
|
|
OpenTracking *bool `json:"open_tracking"`
|
|
LinkTracking *bool `json:"link_tracking"`
|
|
TextOnly *bool `json:"text_only"`
|
|
DailyLimit *int `json:"daily_limit"`
|
|
UnsubscribeHeader *bool `json:"unsubscribe_header"`
|
|
RiskyEmails *bool `json:"risky_emails"`
|
|
UnsubscribeMode *string `json:"unsubscribe_mode"`
|
|
|
|
CC []string `json:"cc"`
|
|
BCC []string `json:"bcc"`
|
|
|
|
// Absent leaves the stored date untouched; an explicit null clears it
|
|
// ("start now" / "no end date"), matching the validation error's promise.
|
|
StartDate NullableTime `json:"start_date"`
|
|
EndDate NullableTime `json:"end_date"`
|
|
Timezone *string `json:"timezone"`
|
|
Days *uint8 `json:"days"`
|
|
StartTime *string `json:"start_time"`
|
|
EndTime *string `json:"end_time"`
|
|
|
|
// Authoritative per-day schedule. When sent, supersedes Days/StartTime/EndTime.
|
|
ScheduleWindows *ScheduleWindows `json:"schedule_windows,omitempty"`
|
|
|
|
EmailTags []string `json:"email_tags"`
|
|
Folders []string `json:"folders"`
|
|
|
|
ContactOrderBy *string `json:"contact_order_by"`
|
|
ContactOrderDir *string `json:"contact_order_dir"`
|
|
ContactOrderField *string `json:"contact_order_field"`
|
|
|
|
// Net-new send controls. The explicit sender LIST is edited via
|
|
// PUT /campaigns/:id/senders; only the strategy/mode toggles ride PATCH.
|
|
SenderStrategy *string `json:"sender_strategy,omitempty"`
|
|
RotationMode *string `json:"rotation_mode,omitempty"`
|
|
|
|
RampEnabled *bool `json:"ramp_enabled,omitempty"`
|
|
RampStart *int `json:"ramp_start,omitempty"`
|
|
RampIncrement *int `json:"ramp_increment,omitempty"`
|
|
RampCeiling *int `json:"ramp_ceiling,omitempty"`
|
|
|
|
ESPMatchMode *string `json:"esp_match_mode,omitempty"`
|
|
MaxNewLeadsPerDay *int `json:"max_new_leads_per_day,omitempty"`
|
|
PrioritizeNewLeads *bool `json:"prioritize_new_leads,omitempty"`
|
|
EntryDelayMinutes *int `json:"entry_delay_minutes,omitempty"`
|
|
Continuous *bool `json:"continuous,omitempty"`
|
|
TrackingDomain *string `json:"tracking_domain,omitempty"`
|
|
|
|
UTMTracking *bool `json:"utm_tracking,omitempty"`
|
|
UTMSource *string `json:"utm_source,omitempty"`
|
|
UTMMedium *string `json:"utm_medium,omitempty"`
|
|
UTMCampaign *string `json:"utm_campaign,omitempty"`
|
|
|
|
// Auto-pause guardrails. GuardrailTrippedAt/Reason are server-owned and
|
|
// are cleared when the campaign is started again, so they are not settable
|
|
// here.
|
|
GuardrailEnabled *bool `json:"guardrail_enabled,omitempty"`
|
|
GuardrailBounceRateMax *float64 `json:"guardrail_bounce_rate_max,omitempty"`
|
|
GuardrailComplaintRateMax *float64 `json:"guardrail_complaint_rate_max,omitempty"`
|
|
GuardrailReplyRateMin *float64 `json:"guardrail_reply_rate_min,omitempty"`
|
|
GuardrailMinSample *int `json:"guardrail_min_sample,omitempty"`
|
|
GuardrailWindowDays *int `json:"guardrail_window_days,omitempty"`
|
|
}
|
|
|
|
// TouchesSchedule reports whether the patch changes anything the campaign's
|
|
// next wakeup time is computed from.
|
|
func (u *UpdateCampaign) TouchesSchedule() bool {
|
|
return u.StartDate.Set || u.EndDate.Set || u.Timezone != nil || u.Days != nil ||
|
|
u.StartTime != nil || u.EndTime != nil || u.ScheduleWindows != nil ||
|
|
u.EntryDelayMinutes != nil
|
|
}
|
|
|
|
// CreateCampaign is the payload accepted by POST /campaigns. Name is required;
|
|
// every other field is optional and only applied if the caller sent a non-nil
|
|
// value. The wizard sends everything at once; the simple modal can still send
|
|
// just {name, description} and get sane defaults.
|
|
type CreateCampaign struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
// Sending rules / tracking
|
|
StopOnReply *bool `json:"stop_on_reply,omitempty"`
|
|
OpenTracking *bool `json:"open_tracking,omitempty"`
|
|
LinkTracking *bool `json:"link_tracking,omitempty"`
|
|
TextOnly *bool `json:"text_only,omitempty"`
|
|
DailyLimit *int `json:"daily_limit,omitempty"`
|
|
UnsubscribeHeader *bool `json:"unsubscribe_header,omitempty"`
|
|
RiskyEmails *bool `json:"risky_emails,omitempty"`
|
|
UnsubscribeMode *string `json:"unsubscribe_mode,omitempty"`
|
|
|
|
CC []string `json:"cc,omitempty"`
|
|
BCC []string `json:"bcc,omitempty"`
|
|
|
|
// Schedule
|
|
StartDate *time.Time `json:"start_date,omitempty"`
|
|
EndDate *time.Time `json:"end_date,omitempty"`
|
|
Timezone *string `json:"timezone,omitempty"`
|
|
Days *uint8 `json:"days,omitempty"`
|
|
StartTime *string `json:"start_time,omitempty"`
|
|
EndTime *string `json:"end_time,omitempty"`
|
|
|
|
// Authoritative per-day schedule. When sent, supersedes Days/StartTime/EndTime.
|
|
ScheduleWindows *ScheduleWindows `json:"schedule_windows,omitempty"`
|
|
|
|
// Sender pool — accepts UUIDs already created by the user.
|
|
EmailTagIDs []string `json:"email_tag_ids,omitempty"`
|
|
FolderIDs []string `json:"folder_ids,omitempty"`
|
|
|
|
// Sending-account selection + rotation (net-new). When sender_strategy is
|
|
// "explicit", Senders is the mailbox pool; otherwise EmailTagIDs are used.
|
|
SenderStrategy *string `json:"sender_strategy,omitempty"`
|
|
RotationMode *string `json:"rotation_mode,omitempty"`
|
|
Senders []CampaignSenderInput `json:"senders,omitempty"`
|
|
|
|
// Per-campaign daily ramp-up (net-new). ramp_level is server-owned.
|
|
RampEnabled *bool `json:"ramp_enabled,omitempty"`
|
|
RampStart *int `json:"ramp_start,omitempty"`
|
|
RampIncrement *int `json:"ramp_increment,omitempty"`
|
|
RampCeiling *int `json:"ramp_ceiling,omitempty"`
|
|
|
|
// ESP/provider matching + new-lead throttle + tracking-domain override.
|
|
ESPMatchMode *string `json:"esp_match_mode,omitempty"`
|
|
MaxNewLeadsPerDay *int `json:"max_new_leads_per_day,omitempty"`
|
|
PrioritizeNewLeads *bool `json:"prioritize_new_leads,omitempty"`
|
|
EntryDelayMinutes *int `json:"entry_delay_minutes,omitempty"`
|
|
Continuous *bool `json:"continuous,omitempty"`
|
|
TrackingDomain *string `json:"tracking_domain,omitempty"`
|
|
|
|
// Automatic UTM tagging (off unless sent). Empty values keep the defaults.
|
|
UTMTracking *bool `json:"utm_tracking,omitempty"`
|
|
UTMSource *string `json:"utm_source,omitempty"`
|
|
UTMMedium *string `json:"utm_medium,omitempty"`
|
|
UTMCampaign *string `json:"utm_campaign,omitempty"`
|
|
|
|
// Initial sequences (in order) — caller can also create them after.
|
|
Sequences []CreateSequenceInput `json:"steps,omitempty"`
|
|
|
|
// A/B variants for the first sequence — useful for "create + test" in one shot.
|
|
Variants []CreateCampaignABVariantRequest `json:"variants,omitempty"`
|
|
|
|
// Advanced overrides (bounce/intent/dashboard/etc) — see AdvancedOutreachSettings.
|
|
AdvancedOverrides *AdvancedOutreachSettings `json:"advanced_overrides,omitempty"`
|
|
}
|
|
|
|
// CreateSequenceInput is one step in a sequence. Used during initial campaign
|
|
// creation; matches UpdateSequence shape so the wizard can reuse the editor.
|
|
type CreateSequenceInput struct {
|
|
Name string `json:"name"`
|
|
Subject string `json:"subject"`
|
|
BodyPlain string `json:"body_plain"`
|
|
BodyHTML string `json:"body_html"`
|
|
BodySync *bool `json:"body_sync,omitempty"`
|
|
BodyCode *bool `json:"body_code,omitempty"`
|
|
WaitAfter *int `json:"wait_after,omitempty"`
|
|
// ThreadReply defaults to true: a step written here is a follow-up and
|
|
// belongs in the conversation the first email started.
|
|
ThreadReply *bool `json:"thread_reply,omitempty"`
|
|
}
|
|
|
|
// ThreadReplyDefaults decides, for a sequence written in one shot, which steps
|
|
// reply in the conversation the steps before them opened, for a caller that
|
|
// did not say. Steps given in one request are a linear sequence, so a
|
|
// follow-up is a reply; a step carrying a subject that is not the
|
|
// conversation's was written to start a new one, which is what the wizard's
|
|
// blank-subject follow-up has always meant and what migration 000154 applied
|
|
// to the steps that already existed.
|
|
//
|
|
// Without it, an integration that has always posted a distinct subject per
|
|
// step would suddenly have every follow-up sent under the first one's.
|
|
//
|
|
// The comparison is against the CONVERSATION's subject, not the previous
|
|
// step's: a blank follow-up in between inherits rather than replaces it, so
|
|
// the step after it is still continuing the same conversation.
|
|
func ThreadReplyDefaults(steps []CreateSequenceInput) []bool {
|
|
out := make([]bool, len(steps))
|
|
conversation := ""
|
|
for i := range steps {
|
|
own := strings.TrimSpace(steps[i].Subject)
|
|
out[i] = own == "" || conversation == "" || own == conversation
|
|
if own != "" {
|
|
conversation = own
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// StartCampaignOptions qualifies a start request.
|
|
type StartCampaignOptions struct {
|
|
// AcknowledgeListRisk launches past the bounce-risk gate: the member has
|
|
// read the projection and takes the risk (the list may have been verified
|
|
// elsewhere).
|
|
AcknowledgeListRisk bool `json:"acknowledge_list_risk"`
|
|
// Automatic marks a start the platform initiated (a resume after
|
|
// verification), which skips the member-facing cooldown.
|
|
Automatic bool `json:"-"`
|
|
}
|