Skip to content

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.pyautomated ("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 via create_campaign / update_campaign. Never assign campaign.campaign_mode directly outside this helper.
  • Read it directly: branch on campaign.campaign_mode == CampaignMode.Automated (see recipient_sync.py, campaign_grader.py, weekly_reports.py). SQL filters use campaign_mode = 'automated' / = 'one_off'.
  • API write routes (routes/campaign_routes.py): | Route | Mode semantics | |-------|----------------| | PUT /v1/campaigns/<id> (update_campaign) | campaign_mode is preserve-on-omit — an absent campaign_mode must never silently flip an existing one_off back to automated. | | PUT /v1/campaigns/<id>/mode (update_campaign_mode) | campaign_mode is required — unambiguous, single-purpose. | | PUT /v1/campaigns/<id>/settings (update_campaign_settings) | Does not touch mode; a stray campaign_mode in the settings body is ignored. |

Legacy settings.automated mirror — derived, do not branch on it

  • settings.automated is kept in the JSONB as a read-only mirror of campaign_mode, persisted in lockstep by set_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 — read campaign_mode.
  • Campaign.__init__ has a one-way legacy fallback: a construction dict with no campaign_mode (older fixtures / request bodies) derives the mode from an incoming settings.automated hint (default TrueAutomated). 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 reads campaign_mode. The campaign_mode contract 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_campaign persists CampaignSettings.json()), so any settings key not modeled by CampaignSettings — value overrides, custom profile/QR properties, the legacy automated flag — 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-merge PATCH) is tracked in ENG-3234.