Campaign¶
States¶
graph LR
Pending --> Active
Pending --> Paused
Active --> Completed
Active --> Paused
Active --> Error
Paused --> Active
Scheduled --> Completed
Scheduled --> Error
Archived
Error
classDef starting fill:#96d0ff;
classDef ending fill:#ffd6d6;
classDef static fill:#dccbff;
classDef paused fill:#fff2a8;
class Pending starting;
class Completed,Archived,Error ending;
class Paused paused;
Pending¶
Campaign will stage recipients for send, but not proof nor mail. Recipients stay at Pending and are not processed for actual mailing.
Active¶
Campaign will actually trigger sends to recipients. Process Pending recipients and handle the full send workflow. Most automated campaigns remain in this state until turned off. One-off campaigns are in this state for the duration of the send and then transition to completed.
Completed¶
Campaign will no longer sync nor send to recipients. Used when there are no more Pending recipients or when the campaign has finished its intended run.
Archived¶
Treated as a deleted campaign; will no longer show up to users. Set by scripts/campaign_archiver.py. Campaigns can be set to archived when there are no more recipients.
Scheduled¶
Campaign has been scheduled to mail at a certain date. Sends and billing will be processed, but it will be handed to the postal service at a designated date. This allows for pre-processing while controlling the actual mail delivery timing.
Paused¶
No longer sync recipients nor send mail until a given resumption date. If paused, settings.resume_date will be used to determine when to resume sending. This provides temporary suspension of campaign activity while preserving the ability to restart.
[!CAUTION] Scheduled vs Paused — choosing the right one
Paused is generally the preferred mechanism for planning go-live sends in the future. It fully suspends recipient sync and other pipeline activity until the resume date, making it ideal for "set it up now, launch later" workflows.
Scheduled is designed for pre-sending — it allows sends and billing to process ahead of time while controlling the actual postal handoff date. This is useful when printers may be overloaded and require pre-processing of volume before the mail date.
Rule of thumb: if you want to delay everything until a go-live date, use Paused. If you want the pipeline to run now but control when mail hits the postal service, use Scheduled.
Error¶
Campaign has encountered an error and will no longer sync nor send to recipients. This is a terminal state that requires manual intervention to resolve the underlying issue before the campaign can be reactivated.
Transitions¶
Enum: CampaignStatus in app/core/models/campaigns.py
| From | To | Trigger | File |
|---|---|---|---|
| Any | Paused | pause_campaign() via API |
methods/campaigns/status_handlers.py |
| Paused | Active | resume_paused_campaign_if_needed() — resume_date passes |
methods/campaigns/status_handlers.py |
| Pending | Active | Manual API call PUT /v1/campaigns/<id>/status |
routes/campaign_routes.py |
| Active | Scheduled | Manual API call | routes/campaign_routes.py |
| Scheduled | Completed | complete_scheduled_campaign_if_needed() — mail date passes, no mailable recipients |
methods/campaigns/status_handlers.py |
| Active | Completed | complete_all_campaigns_in_org() — org churn/disable |
methods/organizations.py |
| Active | Completed | mark_campaign_complete() — final recipient processed |
methods/runner.py |
| Any (non-Completed) | Archived | Admin script action | scripts/campaign_archiver.py |
| Completed | Active | API call (blocked for legacy-rendered campaigns) | routes/campaign_routes.py |
Mode¶
Enum: CampaignMode in app/core/models/campaigns.py — automated ("automated") | one_off ("one_off").
campaigns.campaign_mode is a top-level column and the sole canonical source of truth for a campaign's operating mode. It is the first-class replacement for the overloaded legacy settings.automated boolean.
| Mode | Behavior |
|---|---|
automated |
Continuously syncs recipients from the integration provider and runs open-endedly (a "flow"). Carries a settings.flow_trigger. |
one_off |
A single discrete send from a manual/one-time list (a "campaign"). Only syncs while status = Pending. |
Reading & writing¶
- Single write chokepoint:
set_campaign_mode(campaign, mode)sets the canonical column in memory only; callers persist viacreate_campaign/update_campaign. Never assigncampaign.campaign_modedirectly outside this helper. - Read it directly: branch on
campaign.campaign_mode == CampaignMode.Automated(seerecipient_sync.py,campaign_grader.py,weekly_reports.py). SQL filters usecampaign_mode = 'automated'/= 'one_off'. - API write routes (
routes/campaign_routes.py): | Route | Mode semantics | |-------|----------------| |PUT /v1/campaigns/<id>(update_campaign) |campaign_modeis preserve-on-omit — an absentcampaign_modemust never silently flip an existingone_offback toautomated. | |PUT /v1/campaigns/<id>/mode(update_campaign_mode) |campaign_modeis required — unambiguous, single-purpose. | |PUT /v1/campaigns/<id>/settings(update_campaign_settings) | Does not touch mode; a straycampaign_modein the settings body is ignored. |
Legacy settings.automated mirror — derived, do not branch on it¶
settings.automatedis kept in the JSONB as a read-only mirror ofcampaign_mode, persisted in lockstep byset_campaign_mode(CampaignSettings.json()emits it, and the chokepoint re-derives it on every write). It exists only so direct-JSONB consumers (Retool, analytics, ad-hoc SQL) that haven't migrated keep reading correct values. Never branch on it in Python — readcampaign_mode.Campaign.__init__has a one-way legacy fallback: a construction dict with nocampaign_mode(older fixtures / request bodies) derives the mode from an incomingsettings.automatedhint (defaultTrue→Automated). This only reads the incoming dict.- Retiring the mirror (drop from
json(), stop persisting, strip from existing rows) is a separate ticket, gated on confirming every out-of-band consumer readscampaign_mode. Thecampaign_modecontract work (ENG-3208) deliberately keeps the mirror in lockstep rather than removing it, so this rollout stays non-destructive.
[!CAUTION] The settings JSONB is written as a whole-blob replace (
update_campaignpersistsCampaignSettings.json()), so any settings key not modeled byCampaignSettings— value overrides, custom profile/QR properties, the legacyautomatedflag — is dropped on every PUT, and omitted fields reset to their payload defaults rather than preserving stored values. Re-thinking this contract (likely a partial-mergePATCH) is tracked in ENG-3234.