feat: fix campaign restart dead end (issue #340): resolve preflight tracking senders through the same explicit/tags/all pool the scheduler uses so a connected mailbox is never reported missing, fetch the launch dialog's step count from the steps endpoint instead of a field the campaign API never returns, turn on Keep running for new leads when a form or an automation feeds a campaign (migration 000131 backfills existing ones) and when a member starts a campaign whose every lead has finished so it goes active and waits for leads instead of answering 400, return waiting_for_leads from the start endpoint for the dialog's success screen, and document the no_leads and no_remaining_leads codes and the new behaviour in the campaigns, forms, automations and API docs

This commit is contained in:
Matthew Meszaros
2026-09-07 02:26:50 -07:00
parent 885a7c2aa3
commit 5d60fe9a21
25 changed files with 380 additions and 85 deletions
+6
View File
@@ -1393,6 +1393,11 @@ func main() {
// Leads tabs refresh through the audit spine.
segmentService.SetEnrolmentAuditor(auditService)
}
// A form that feeds a campaign turns on its "Keep running for new
// leads" setting, like a linked segment does.
if formService != nil {
formService.SetCampaigns(campaignService)
}
if contactService != nil {
contactService.SetCampaignWaker(campaignService)
// The contact drawer's "next action" is a read-only pass through
@@ -1512,6 +1517,7 @@ func main() {
Contacts: contactRepostory,
Orgs: organizationRepository,
ContactSvc: contactService,
Campaigns: campaignService,
})
integrationServiceForHandler.SetPublisher(streamingPublisher)
// AI automation nodes (ai_step / ai_switch) run over the same provider +
+2
View File
@@ -84,6 +84,8 @@ Returned when the request cannot be processed due to invalid syntax.
| `no_contacts` | `POST /contacts/verification` selected no contacts: neither `contacts` nor a `campaign_id` with refused leads |
| `list_bounce_risk` | `POST /campaigns/:id/start` refused the launch on the list's projected bounce rate. Clean or verify the list, or repeat the request with `acknowledge_list_risk: true` |
| `leads_undeliverable` | `POST /campaigns/:id/start` found nothing to send because address verification refused every remaining lead; the campaign is parked at `paused_undeliverable` until they are re-verified or marked deliverable |
| `no_leads` | `POST /campaigns/:id/start` on a campaign with no leads and `continuous` off. Add contacts, or set `continuous` so it starts empty and waits for them |
| `no_remaining_leads` | A platform-initiated restart of a campaign with nothing left to send and `continuous` off found nothing to do; the campaign is `completed` again. A start you request never answers this: it turns `continuous` on and waits |
| `no_organization` | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry |
### 401 Unauthorized
@@ -186,7 +186,7 @@ Create a campaign. Only `name` is required, every other field is optional and ap
| `esp_match_mode` | string | no | `off`, `prefer`, or `strict`. |
| `max_new_leads_per_day` | integer | no | New-lead throttle, `0` is unlimited. |
| `prioritize_new_leads` | boolean | no | Prefer new leads in each send window. |
| `continuous` | boolean | no | Keep running for new leads: out of leads, the campaign stays `active` and waits instead of finishing. Linking a segment turns it on. Default `false`. |
| `continuous` | boolean | no | Keep running for new leads: out of leads, the campaign stays `active` and waits instead of finishing. Linking a segment, a form or an automation that enrols leads turns it on, and so does starting a campaign whose every lead has finished. Default `false`. |
| `tracking_domain` | string | no | Campaign-scoped tracking domain (honored only once verified). |
| `steps` | object[] | no | Initial sequence steps in order (see create sequence input below). They are connected in order: each step routes unconditionally to the next, waiting that step's `wait_after` days. The first step's `wait_after` defaults to `0`, follow-ups to `3`. |
| `variants` | object[] | no | A/B variants for the first step (same shape as create A/B variant). |
@@ -250,7 +250,7 @@ Patch any subset of campaign fields. Omitted fields are left unchanged. The expl
Every field is optional. Scalar fields use nullable pointers, so any field you send is applied. `start_date` and `end_date` additionally accept an explicit `null` to clear the stored date: a null `start_date` means "start now" and a null `end_date` means "run open-ended". Changing any schedule field (`start_date`, `end_date`, `timezone`, `days`, `start_time`, `end_time`, `schedule_windows`) on an active campaign reschedules its next send immediately, so clearing a future start date takes effect right away. Notable fields: `name`, `description`, `status`, `stop_on_reply`, `open_tracking`, `link_tracking`, `text_only`, `daily_limit`, `unsubscribe_header`, `risky_emails`, `cc`, `bcc`, `start_date`, `end_date`, `timezone`, `days`, `start_time`, `end_time`, `schedule_windows`, `email_tags`, `folders`, `contact_order_by`, `contact_order_dir`, `contact_order_field`, `sender_strategy`, `rotation_mode`, `ramp_enabled`, `ramp_start`, `ramp_increment`, `ramp_ceiling`, `esp_match_mode`, `max_new_leads_per_day`, `prioritize_new_leads`, `continuous`, `tracking_domain`, `utm_tracking`, `utm_source`, `utm_medium`, `utm_campaign`.
A campaign with `continuous` set stays `active` when it runs out of leads and carries `idle_since` while it waits; the timestamp clears once it has something to send. A continuous campaign can be started with no leads at all. Adding a lead to a `completed` campaign by any path (this API, a linked segment, an automation) restarts it through the same launch checks as starting it by hand; a refused restart is written to the campaign's activity log.
A campaign with `continuous` set stays `active` when it runs out of leads and carries `idle_since` while it waits; the timestamp clears once it has something to send. A continuous campaign can be started with no leads at all; starting one that is not continuous with no leads answers `400` `no_leads`. Adding a lead to a `completed` campaign by any path (this API, a linked segment, an automation) restarts it through the same launch checks as starting it by hand; a refused restart is written to the campaign's activity log.
```json
{
@@ -719,7 +719,7 @@ The mailbox may be any mailbox of the organization, not only one the caller conn
`POST /campaigns/:id/start`
Start (activate) the campaign so it begins sending real mail. Works from `draft`, any paused status, or `completed` (a campaign closed by a passed end date resumes once the date is extended or cleared; one with nothing left to send re-completes with a `400` explaining why, unless it is `continuous`, in which case it starts and waits for leads with `idle_since` set). **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
Start (activate) the campaign so it begins sending real mail. Works from `draft`, any paused status, or `completed` (a campaign closed by a passed end date resumes once the date is extended or cleared). A campaign with nothing left to send does not finish again: the start turns `continuous` on if it was off, and the campaign goes `active` and waits for leads with `idle_since` set; the switch is written to its activity log. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
@@ -731,9 +731,11 @@ A start refused because every remaining lead was refused by address verification
### Response
```json
{ "status": "started" }
{ "status": "started", "waiting_for_leads": false }
```
`waiting_for_leads` is `true` when the campaign started with nothing left to send and is now `active` with `idle_since` set.
## Stop a campaign
`POST /campaigns/:id/stop`
+2
View File
@@ -99,6 +99,8 @@ Slack, Discord, and webhook actions take an optional message template. Slack and
| --- | --- |
| Create or update contact | Makes a contact from the event's fields, or enriches the one with that email, then tags it and enrols it in a campaign. See [Lead intake](#lead-intake) |
| Add to campaign | Enrols the event's contact in a campaign; a finished campaign restarts through the usual launch checks (a refusal is noted in its activity log and the lead waits), one waiting for leads picks up straight away |
Saving an automation with either of these actions turns on the picked campaign's **Keep running for new leads** setting, so the campaign waits for the next run instead of finishing between them.
| Add / remove a tag | Adds or removes a contact category |
| Label the email | Applies inbox labels to the replied-on conversation |
| Create a task | Assigned to the workspace owner |
+2 -2
View File
@@ -73,7 +73,7 @@ Anything above `50`/day per cold mailbox needs positive reputation signals and a
**Lead flow** throttles new contacts with **Max new leads per day** (`0` for unlimited, up to `1000`). At the cap, follow-ups to in-flight contacts continue and new leads resume tomorrow. You can prioritize new leads over follow-ups, and choose whether to attempt addresses verification flagged risky.
**Keep running for new leads** turns a campaign into an ongoing one: when it runs out of leads it stays **active** and shows **waiting for leads** instead of finishing, then sends the sequence to each lead as they arrive from a linked segment, a form, the API, Zapier, Make, n8n or an automation. Linking a segment turns it on; turn it off in the campaign's preferences to have the campaign finish once every lead is done. A campaign with this on can be started with no leads yet. An end date still ends it.
**Keep running for new leads** turns a campaign into an ongoing one: when it runs out of leads it stays **active** and shows **waiting for leads** instead of finishing, then sends the sequence to each lead as they arrive from a linked segment, a form, the API, Zapier, Make, n8n or an automation. Linking a segment, pointing a form at the campaign, or saving an automation that enrols leads in it turns it on, and so does pressing play on a finished campaign whose every lead is done; turn it off in the campaign's preferences to have the campaign finish once every lead is done. A campaign with this on can be started with no leads yet. An end date still ends it.
### Adding leads
@@ -163,7 +163,7 @@ The play and pause buttons work from the list row or the detail view. Starting m
A campaign can pause itself: **paused, no accounts** when it loses every sender or no sender can send under its settings (a sending behaviour profile with no working days), **paused, trial expired** when a trial ends, **auto-paused** when a guardrail trips, **needs verification** when address verification has refused every remaining lead (the campaign offers to re-verify them or send anyway; see [address verification](/guides/deliverability/#address-verification)), and plain **paused** if it ever loses its workspace, because unsubscribes, bounces and complaints are checked per workspace and cannot be honoured without one. It moves to **finished** once every contact completes the sequence or its end date passes, unless **Keep running for new leads** is on: then it stays active and shows **waiting for leads** until the next lead arrives, and only its end date finishes it. Configured to do so, it also stops following up with a contact the moment they reply.
A finished campaign starts again on its own when a lead is added to it, whether by a linked segment, the API, a form, an integration or an automation, through the same launch checks as pressing play. If a check refuses the restart (the list is a bounce risk, the plan cannot send, too many campaigns are active), the activity log says so and the new leads wait until you fix the cause and press play. Pressing play also works after extending or clearing the end date. If there is genuinely nothing left to send it finishes again immediately with a message saying so.
A finished campaign starts again on its own when a lead is added to it, whether by a linked segment, the API, a form, an integration or an automation, through the same launch checks as pressing play. If a check refuses the restart (the list is a bounce risk, the plan cannot send, too many campaigns are active), the activity log says so and the new leads wait until you fix the cause and press play. Pressing play also works after extending or clearing the end date. If there is genuinely nothing left to send, pressing play turns on **Keep running for new leads** and the campaign stays active, **waiting for leads**, so it never dead-ends on a campaign that only ran out of people to write to; the activity log notes the switch.
## Duplicate and delete
+1 -1
View File
@@ -58,7 +58,7 @@ The **Settings** tab controls what a submission does:
- **Success message** is shown after submitting, or set a **redirect URL** to send the visitor to your own thank-you page instead.
- **Add to categories** files every submitted contact under the categories you pick, for example "Website leads".
- **Add to campaign** enrolls new contacts as leads in the campaign you pick. Sending still follows the campaign's own schedule, limits and windows; a form never causes immediate mail. Turn on the campaign's **Keep running for new leads** so it waits between submissions instead of finishing; a finished campaign restarts when a lead arrives, through the usual launch checks, and a refused restart is noted in its activity log while the lead waits.
- **Add to campaign** enrolls new contacts as leads in the campaign you pick. Sending still follows the campaign's own schedule, limits and windows; a form never causes immediate mail. Picking a campaign turns on its **Keep running for new leads** setting, so it waits between submissions instead of finishing; a campaign that had already finished restarts when a lead arrives, through the usual launch checks, and a refused restart is noted in its activity log while the lead waits.
- **Spam protection** and **allowed embed domains** are covered below.
## Publishing and sharing
+7 -1
View File
@@ -391,7 +391,13 @@ func (h *Handler) StartCampaign(c *gin.Context) {
h.auditOrg(c, models.AuditActionStart, models.AuditEntityCampaign, &campaignID, nil, nil)
}
c.JSON(http.StatusOK, gin.H{"status": "started"})
// waiting_for_leads tells the caller the campaign started with nothing to
// send and is active, waiting, so the dashboard can say so at once.
resp := gin.H{"status": "started", "waiting_for_leads": false}
if campaign, gerr := h.CampaignService.Get(c.Request.Context(), orgID.String(), id); gerr == nil && campaign != nil && campaign.IdleSince != nil {
resp["waiting_for_leads"] = true
}
c.JSON(http.StatusOK, resp)
}
// StopCampaign stops a campaign
+29 -6
View File
@@ -3,6 +3,7 @@ package advanced
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"math/rand"
@@ -10,6 +11,8 @@ import (
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/pkg/emailverify"
@@ -1661,6 +1664,15 @@ func (s *service) ReplayDeadLetter(ctx context.Context, organizationID, deadLett
return nil
}
// capitalize upper-cases the first rune of a validator message for display.
func capitalize(s string) string {
if s == "" {
return s
}
r, n := utf8.DecodeRuneInString(s)
return string(unicode.ToUpper(r)) + s[n:]
}
func (s *service) RunPreflight(ctx context.Context, organizationID, campaignID uuid.UUID) (*models.PreflightReport, *errx.Error) {
campaign, err := s.campaignRepo.GetByID(ctx, campaignID)
if err != nil || campaign == nil {
@@ -1680,13 +1692,21 @@ func (s *service) RunPreflight(ctx context.Context, organizationID, campaignID u
readyErr := s.campaignRepo.ValidateCampaignReady(ctx, campaignID)
if readyErr != nil {
checks = append(checks, models.PreflightCheckResult{
// The validator names the missing piece; a generic list of everything
// that could be missing sends the owner looking in the wrong place.
check := models.PreflightCheckResult{
Key: "campaign_ready",
Passed: false,
Severity: "error",
Message: "Campaign has missing prerequisites (contacts, sequences, or sender accounts).",
Remediation: "Add contacts, sequences, and at least one sender account tag match.",
})
}
var bizErr *errx.Error
if errors.As(readyErr, &bizErr) && bizErr.Message != "" {
check.Message = capitalize(bizErr.Message) + "."
check.Remediation = ""
}
checks = append(checks, check)
recommendations = append(recommendations, "Complete core campaign setup before start.")
} else {
checks = append(checks, models.PreflightCheckResult{
@@ -1755,15 +1775,18 @@ func (s *service) RunPreflight(ctx context.Context, organizationID, campaignID u
}
if settings.Preflight.CheckTrackingDomain && (campaign.OpenTracking || campaign.LinkTracking) {
scope := repository.NewAccountScope(campaign.OrganizationID)
accounts, err := s.emailRepo.GetByTags(ctx, scope, campaign.EmailTags)
// The same pool the scheduler sends from (explicit senders, tags, or
// every active mailbox when neither is picked), so a campaign on the
// "all" fallback is never told it has no senders (issue #340).
pool, err := repository.ResolveCampaignSenderPool(ctx, s.emailRepo, campaign)
accounts := pool.Accounts
if err != nil || len(accounts) == 0 {
checks = append(checks, models.PreflightCheckResult{
Key: "tracking_domain",
Passed: false,
Severity: "error",
Message: "No sender accounts available for tracking validation.",
Remediation: "Attach sender accounts to campaign tags.",
Message: "No active sender mailbox is available to this campaign, so tracking cannot be validated.",
Remediation: "Connect a mailbox, or pick mailboxes or tags for the campaign that have an active one.",
})
recommendations = append(recommendations, "Attach at least one sender account with tracking domain.")
} else {
+60 -6
View File
@@ -152,7 +152,7 @@ func (s *campaignService) rescheduleCampaignWakeup(ctx context.Context, campaign
}
}
}
_ = s.enqueueCampaignWakeup(ctx, campaignID)
_ = s.enqueueCampaignWakeup(ctx, campaignID, false)
}
func (s *campaignService) Delete(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.Campaign, *errx.Error) {
@@ -514,7 +514,11 @@ func (s *campaignService) StartCampaign(ctx context.Context, orgID uuid.UUID, ca
return errx.InternalError()
}
if xerr := s.enqueueCampaignWakeup(ctx, cID); xerr != nil {
// A member pressing play on a campaign with nothing left to send means
// "run it for the leads to come", never "finish it again": the campaign
// goes active and waits (issue #340). A platform-initiated restart keeps
// the campaign's own setting.
if xerr := s.enqueueCampaignWakeup(ctx, cID, !opts.Automatic); xerr != nil {
return xerr
}
@@ -613,7 +617,7 @@ func (s *campaignService) WakeCampaigns(ctx context.Context, orgID uuid.UUID, ca
for i := range pending {
_ = s.taskRepo.DeleteTask(ctx, pending[i].ID)
}
_ = s.enqueueCampaignWakeup(ctx, id)
_ = s.enqueueCampaignWakeup(ctx, id, false)
}
}
@@ -649,6 +653,45 @@ func (s *campaignService) restartForNewLeads(ctx context.Context, orgID uuid.UUI
})
}
// ContinuousOnEventType is the activity log entry written when "Keep running
// for new leads" is turned on by a lead source rather than by hand.
const ContinuousOnEventType = "continuous_on"
// KeepRunning implements the interface comment on CampaignService.
func (s *campaignService) KeepRunning(ctx context.Context, orgID, campaignID uuid.UUID, reason string) *errx.Error {
transitioned, err := s.campaignRepository.KeepRunning(ctx, orgID, campaignID)
if err != nil {
if errors.Is(err, errx.ErrResourceNotFound) {
return errx.ErrNotFound
}
sentry.CaptureException(err)
return errx.InternalError()
}
if !transitioned {
return nil
}
if s.campaignLogRepo != nil {
s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{
CampaignID: campaignID,
EventType: ContinuousOnEventType,
Message: "Keep running for new leads turned on: " + reason + ". Out of leads, the campaign waits instead of finishing; turn it off in the campaign's preferences.",
Metadata: map[string]interface{}{"reason": reason},
})
}
if s.streamingPublisher != nil {
campaign, gerr := s.campaignRepository.GetByID(ctx, campaignID)
if gerr == nil && campaign != nil {
s.streamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{
BaseEvent: pubsub.BaseEvent{EventType: pubsub.EventCampaignUpdated, UserID: campaign.UserID},
OrgID: modelOrgID(campaign.OrganizationID),
CampaignID: campaignID.String(),
Name: campaign.Name,
})
}
}
return nil
}
// idleContinuousCampaign keeps a continuous campaign active with nothing to
// send: it waits for leads. Logged and broadcast on the transition only.
func (s *campaignService) idleContinuousCampaign(ctx context.Context, campaign *models.Campaign) {
@@ -674,7 +717,10 @@ func (s *campaignService) idleContinuousCampaign(ctx context.Context, campaign *
}
}
func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID uuid.UUID) *errx.Error {
// enqueueCampaignWakeup seeds the campaign's send chain. keepRunningIfEmpty
// turns on "Keep running for new leads" instead of finishing the campaign
// when there is nothing to send, for a start a member asked for.
func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID uuid.UUID, keepRunningIfEmpty bool) *errx.Error {
if s.scheduler == nil || s.tasksClient == nil || s.taskRepo == nil {
return nil
}
@@ -711,12 +757,20 @@ func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID
}
}
// A continuous campaign starts with nothing to send and waits.
if c, gerr := s.campaignRepository.GetByID(ctx, campaignID); gerr == nil && c != nil && c.Continuous {
c, gerr := s.campaignRepository.GetByID(ctx, campaignID)
if gerr == nil && c != nil && !c.Continuous && keepRunningIfEmpty && c.OrganizationID != nil {
if xerr := s.KeepRunning(ctx, *c.OrganizationID, c.ID,
"the campaign was started with every lead finished, so it waits for new ones instead of finishing again"); xerr == nil {
c.Continuous = true
}
}
if gerr == nil && c != nil && c.Continuous {
s.idleContinuousCampaign(ctx, c)
return nil
}
_ = s.campaignRepository.UpdateStatusWithLock(ctx, campaignID, "completed")
return errx.New(errx.BadRequest, "campaign has no remaining contacts to send")
return errx.NewWithIdentifier(errx.BadRequest, "no_remaining_leads",
"campaign has no remaining contacts to send; turn on Keep running for new leads to keep it active for leads that arrive later")
case errors.Is(err, scheduler.ErrCampaignEnded):
_ = s.campaignRepository.UpdateStatusWithLock(ctx, campaignID, "completed")
return errx.New(errx.BadRequest, "campaign is past its end date; extend or clear the end date to keep sending")
+7
View File
@@ -56,6 +56,13 @@ type CampaignService interface {
// the caller — the lead was still added.
WakeCampaigns(ctx context.Context, orgID uuid.UUID, campaignIDs []string)
// KeepRunning turns on the campaign's "Keep running for new leads" setting
// because a live lead source (a form, an automation) now feeds it, the
// same way linking a segment does. reason is written to the campaign's
// activity log on the transition; a campaign already continuous is left
// alone. Returns ErrNotFound when the campaign is not the organization's.
KeepRunning(ctx context.Context, orgID, campaignID uuid.UUID, reason string) *errx.Error
// Explicit sender pool (feature 1).
ListCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string) ([]models.CampaignSender, *errx.Error)
ReplaceCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error)
+22
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/geo"
@@ -74,6 +75,7 @@ type Service interface {
SetLinks(r repository.FormLinkRepository)
SetEvents(r repository.FormEventRepository)
SetDomains(d OrgStore)
SetCampaigns(k CampaignKeeper)
}
// ContactReader is the slice of the contact repository the link paths need.
@@ -123,8 +125,15 @@ type SubmitResult struct {
RedirectURL string `json:"redirect_url,omitempty"`
}
// CampaignKeeper turns on a campaign's "Keep running for new leads" setting
// when a form starts feeding it. Implemented by the campaign service.
type CampaignKeeper interface {
KeepRunning(ctx context.Context, orgID, campaignID uuid.UUID, reason string) *errx.Error
}
type service struct {
repo repository.FormRepository
campaigns CampaignKeeper
links repository.FormLinkRepository
events repository.FormEventRepository
domains OrgStore
@@ -149,6 +158,7 @@ func (s *service) SetGeo(g *geo.Client) { s.geo = g }
func (s *service) SetLinks(r repository.FormLinkRepository) { s.links = r }
func (s *service) SetEvents(r repository.FormEventRepository) { s.events = r }
func (s *service) SetDomains(d OrgStore) { s.domains = d }
func (s *service) SetCampaigns(k CampaignKeeper) { s.campaigns = k }
// formTrendDays is the sparkline window on the forms list.
const formTrendDays = 14
@@ -250,6 +260,18 @@ func (s *service) Update(ctx context.Context, orgID, id uuid.UUID, in *models.Fo
if in.CampaignID.Set {
f.CampaignID = in.CampaignID.Value
}
// A form is a live lead source, so the campaign it feeds must wait for
// leads instead of finishing between submissions (issue #340), exactly as
// a linked segment does. Doing it before the write also proves the
// campaign is this organization's.
if in.CampaignID.Set && in.CampaignID.Value != nil && s.campaigns != nil {
if xerr := s.campaigns.KeepRunning(ctx, orgID, *in.CampaignID.Value, "the form \""+f.Name+"\" adds its leads to this campaign"); xerr != nil {
if xerr.Code == errx.ErrNotFound.Code {
return nil, errx.New(errx.BadRequest, "campaign not found")
}
log.Warn().Str("form_id", id.String()).Str("campaign_id", in.CampaignID.Value.String()).Msg("could not turn on keep running for the form's campaign")
}
}
if in.CategoryIDs != nil {
f.CategoryIDs = *in.CategoryIDs
}
@@ -8,6 +8,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/models"
)
@@ -180,6 +181,9 @@ type NativeActions interface {
UpsertContact(ctx context.Context, orgID, actorID uuid.UUID, in models.AddContact) (*models.Contact, error)
// AddToCampaign enrols an existing contact in a campaign and wakes it.
AddToCampaign(ctx context.Context, orgID, actorID, contactID, campaignID uuid.UUID) error
// KeepCampaignRunning turns on a campaign's "Keep running for new leads"
// setting because an automation now feeds it leads.
KeepCampaignRunning(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error
// ListCategories / CreateCategory / ListPipelines back the AI agent step's
// argument-based tools: the model picks a tag/label/pipeline by name and the
@@ -303,6 +307,43 @@ func buildUpsertContact(a models.Automation, cfg nativeActionConfig, data map[st
return in, nil
}
// fedCampaignIDs lists the campaigns an automation enrols leads in: every
// add-to-campaign node and every create-or-update-contact node with a campaign.
func fedCampaignIDs(a *models.Automation) []uuid.UUID {
seen := map[uuid.UUID]bool{}
out := []uuid.UUID{}
for _, n := range a.Graph.Nodes {
if n.Type != "action" {
continue
}
if n.Action != models.IntegrationActionAddToCampaign && n.Action != models.IntegrationActionUpsertContact {
continue
}
id, err := uuid.Parse(strings.TrimSpace(parseNativeConfig(n.Config).CampaignID))
if err != nil || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
// keepFedCampaignsRunning follows an automation save: a campaign an automation
// feeds must wait for leads instead of finishing between runs, exactly as a
// linked segment or a form does. Best effort; the save already happened.
func (s *service) keepFedCampaignsRunning(ctx context.Context, a *models.Automation) {
if s.native == nil || a == nil {
return
}
for _, id := range fedCampaignIDs(a) {
reason := "the automation \"" + a.Name + "\" adds its leads to this campaign"
if err := s.native.KeepCampaignRunning(ctx, a.OrganizationID, id, reason); err != nil {
log.Warn().Err(err).Str("automation_id", a.ID.String()).Str("campaign_id", id.String()).Msg("could not turn on keep running for the automation's campaign")
}
}
}
// uuidStrings keeps the entries of a saved id list that parse, trimmed.
func uuidStrings(ids []string) []string {
out := make([]string, 0, len(ids))
+2
View File
@@ -567,6 +567,7 @@ func (s *service) CreateAutomation(ctx context.Context, orgID uuid.UUID, w model
if err := s.repo.CreateAutomation(ctx, a); err != nil {
return nil, err
}
s.keepFedCampaignsRunning(ctx, a)
return s.GetAutomation(ctx, orgID, a.ID)
}
@@ -595,6 +596,7 @@ func (s *service) UpdateAutomation(ctx context.Context, orgID, id uuid.UUID, w m
if err := s.repo.UpdateAutomation(ctx, a); err != nil {
return nil, err
}
s.keepFedCampaignsRunning(ctx, a)
return s.GetAutomation(ctx, orgID, id)
}
+22
View File
@@ -8,6 +8,7 @@ import (
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/app/contact"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
@@ -26,6 +27,9 @@ type Adapter struct {
// upsert runs the plan check, wakes campaigns and fires contact.created,
// which a bare repository write would not.
ContactSvc contact.ContactService
// Campaigns flips "Keep running for new leads" on a campaign an
// automation feeds, so it waits for leads instead of finishing.
Campaigns CampaignKeeper
}
func (a Adapter) ResolveContact(ctx context.Context, orgID uuid.UUID, contactID, email string) (*models.Contact, error) {
@@ -129,6 +133,24 @@ func (a Adapter) UpsertContact(ctx context.Context, orgID, actorID uuid.UUID, in
return &created[0], nil
}
// CampaignKeeper is the campaign service's "Keep running for new leads" switch.
type CampaignKeeper interface {
KeepRunning(ctx context.Context, orgID, campaignID uuid.UUID, reason string) *errx.Error
}
// KeepCampaignRunning turns on "Keep running for new leads" on a campaign an
// automation feeds. A campaign that is not the organization's is reported,
// never touched.
func (a Adapter) KeepCampaignRunning(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error {
if a.Campaigns == nil {
return fmt.Errorf("campaign settings are not available")
}
if xerr := a.Campaigns.KeepRunning(ctx, orgID, campaignID, reason); xerr != nil {
return xerr
}
return nil
}
// AddToCampaign enrols an existing contact in a campaign through the bulk
// edit path, which also wakes the campaign's parked send chain.
func (a Adapter) AddToCampaign(ctx context.Context, orgID, actorID, contactID, campaignID uuid.UUID) error {
@@ -0,0 +1,3 @@
-- One-way data backfill; nothing to reverse (a campaign may have been made
-- continuous by hand since). No-op.
SELECT 1;
@@ -0,0 +1,17 @@
-- A campaign fed by a live lead source waits for leads instead of finishing
-- (issue #340). Segments were covered by 000130; forms and automations that
-- enrol leads are the same case.
UPDATE public.campaigns SET continuous = true
WHERE NOT continuous
AND id IN (SELECT campaign_id FROM public.forms WHERE campaign_id IS NOT NULL);
UPDATE public.campaigns SET continuous = true
WHERE NOT continuous
AND id IN (
SELECT (n->'config'->>'campaign_id')::uuid
FROM public.automations a,
jsonb_array_elements(a.graph->'nodes') n
WHERE n->>'type' = 'action'
AND n->>'action' IN ('warmbly.add_to_campaign', 'warmbly.upsert_contact')
AND n->'config'->>'campaign_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
);
+62
View File
@@ -0,0 +1,62 @@
package repository
import (
"context"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// CampaignSenderPool is the set of mailboxes a campaign sends from, resolved
// the one way every caller must agree on: the explicit campaign_senders pool
// and the tag-resolved mailboxes united, and when the campaign selects
// neither, every active mailbox in its organization ("all").
type CampaignSenderPool struct {
// Accounts is the union, in explicit-pool-first order, without duplicates.
Accounts []models.Email
// Explicit is the campaign_senders pool with its rotation metadata.
Explicit []CampaignSenderAccount
}
// ResolveCampaignSenderPool resolves a campaign's mailboxes. The scheduler and
// the pre-send checks both go through it, so a check can never refuse a pool
// the scheduler would happily send from (issue #340: a campaign on the "all"
// fallback was told it had no sender accounts).
//
// Tenancy is the campaign's organization, never its owner: a user in two
// organizations must not have A's campaign pick up B's mailbox. A campaign
// with no organization resolves to no mailboxes.
func ResolveCampaignSenderPool(ctx context.Context, repo EmailRepository, campaign *models.Campaign) (CampaignSenderPool, *errx.Error) {
pool := CampaignSenderPool{Accounts: []models.Email{}}
scope := NewAccountScope(campaign.OrganizationID)
explicit, err := repo.GetByCampaignSenders(ctx, scope, campaign.ID)
if err != nil {
return pool, err
}
pool.Explicit = explicit
seen := map[string]bool{}
for _, snd := range explicit {
pool.Accounts = append(pool.Accounts, snd.Account)
seen[snd.Account.ID.String()] = true
}
if len(campaign.EmailTags) > 0 {
tagged, err := repo.GetByTags(ctx, scope, campaign.EmailTags)
if err != nil {
return pool, err
}
for _, acct := range tagged {
if !seen[acct.ID.String()] {
pool.Accounts = append(pool.Accounts, acct)
seen[acct.ID.String()] = true
}
}
}
if len(explicit) == 0 && len(campaign.EmailTags) == 0 {
all, err := repo.GetAllActiveInScope(ctx, scope)
if err != nil {
return pool, err
}
pool.Accounts = all
}
return pool, nil
}
+32 -1
View File
@@ -112,6 +112,10 @@ type CampaignRepository interface {
MarkIdle(ctx context.Context, campaignID uuid.UUID) (bool, error)
// ClearIdle removes the idle mark once there is something to send.
ClearIdle(ctx context.Context, campaignID uuid.UUID) error
// KeepRunning turns on continuous for one of the organization's campaigns.
// Returns true only on the transition; ErrResourceNotFound when the
// campaign is not the organization's.
KeepRunning(ctx context.Context, orgID, campaignID uuid.UUID) (bool, error)
// ── Campaign-scoped tracking domain (feature 5) ─────────────────────
// SetCampaignTrackingDomainVerified flips the verified flag / timestamp on
@@ -1571,7 +1575,8 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign
return err
}
if contactCount == 0 && !continuous {
return errx.New(errx.BadRequest, "campaign must have at least one contact")
return errx.NewWithIdentifier(errx.BadRequest, "no_leads",
"campaign has no leads yet; add contacts, or turn on Keep running for new leads to start it empty and send as they arrive")
}
// Sender pool (unified): valid if it has any enabled explicit sender OR any
@@ -2081,6 +2086,32 @@ func (r *campaignRepository) MarkIdle(ctx context.Context, campaignID uuid.UUID)
return tag.RowsAffected() > 0, nil
}
// KeepRunning implements the interface comment: it flips continuous on, scoped
// to the organization so a foreign campaign id cannot be reached.
func (r *campaignRepository) KeepRunning(ctx context.Context, orgID, campaignID uuid.UUID) (bool, error) {
var continuous bool
err := r.DB.QueryRow(ctx, `
SELECT continuous FROM campaigns WHERE id = $1 AND organization_id = $2
`, campaignID, orgID).Scan(&continuous)
if errors.Is(err, pgx.ErrNoRows) {
return false, errx.ErrResourceNotFound
}
if err != nil {
return false, err
}
if continuous {
return false, nil
}
tag, err := r.DB.Exec(ctx, `
UPDATE campaigns SET continuous = true, updated_at = NOW()
WHERE id = $1 AND organization_id = $2 AND NOT continuous
`, campaignID, orgID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// ClearIdle ends the wait once the campaign has something to send again.
func (r *campaignRepository) ClearIdle(ctx context.Context, campaignID uuid.UUID) error {
_, err := r.DB.Exec(ctx, `
+9 -45
View File
@@ -133,33 +133,16 @@ type senderMeta struct {
hasMeta bool
}
// campaignSenders resolves the campaign's sending mailboxes. Explicit strategy
// uses the campaign_senders pool (carrying per-sender rotation metadata); tags
// strategy keeps the existing tag-based resolution. An empty explicit pool
// falls back to tags so a misconfigured campaign still sends.
// campaignSenders resolves the campaign's sending mailboxes through the shared
// resolver (explicit pool united with tags, "all" when neither is selected),
// keeping the explicit pool's rotation metadata for the rotation modes.
func (s *schedulerService) campaignSenders(ctx context.Context, campaign *models.Campaign) ([]models.Email, map[uuid.UUID]senderMeta, error) {
campaignID := campaign.ID
accounts := []models.Email{}
senderMetaByID := map[uuid.UUID]senderMeta{}
seen := map[uuid.UUID]bool{}
// UNION of the explicit campaign_senders pool and the tag-resolved mailboxes
// (one dropdown picks both — they're no longer mutually exclusive). When the
// campaign selects NEITHER tags nor explicit accounts, it sends from ALL of
// the active mailboxes in the campaign's tenant ("all").
//
// Tenancy is the campaign's organization, never its owner: a user who belongs
// to two organizations must not have organization A's campaign pick up an
// organization B mailbox and burn B's reputation, caps and warmup state. A
// campaign with no organization resolves to no mailboxes, the same way the
// campaign task halts it rather than sending unchecked.
scope := repository.NewAccountScope(campaign.OrganizationID)
senders, serr := s.emailRepo.GetByCampaignSenders(ctx, scope, campaignID)
if serr != nil {
return nil, nil, serr
pool, err := repository.ResolveCampaignSenderPool(ctx, s.emailRepo, campaign)
if err != nil {
return nil, nil, err
}
for _, snd := range senders {
accounts = append(accounts, snd.Account)
seen[snd.Account.ID] = true
senderMetaByID := make(map[uuid.UUID]senderMeta, len(pool.Explicit))
for _, snd := range pool.Explicit {
senderMetaByID[snd.Account.ID] = senderMeta{
weight: snd.Weight,
rotationPosition: snd.RotationPosition,
@@ -167,26 +150,7 @@ func (s *schedulerService) campaignSenders(ctx context.Context, campaign *models
hasMeta: true,
}
}
if len(campaign.EmailTags) > 0 {
tagAccounts, terr := s.emailRepo.GetByTags(ctx, scope, campaign.EmailTags)
if terr != nil {
return nil, nil, terr
}
for _, ta := range tagAccounts {
if !seen[ta.ID] {
accounts = append(accounts, ta)
seen[ta.ID] = true
}
}
}
if len(senders) == 0 && len(campaign.EmailTags) == 0 {
allAccts, aerr := s.emailRepo.GetAllActiveInScope(ctx, scope)
if aerr != nil {
return nil, nil, aerr
}
accounts = allAccts
}
return accounts, senderMetaByID, nil
return pool.Accounts, senderMetaByID, nil
}
// placeCampaignSend runs every hard constraint and pacing rule on one
@@ -2788,7 +2788,7 @@ function NativeActionConfig({
/>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
Enrols the contact this event is about (by <code>contact_id</code> or <code>contact_email</code>). Sending still runs through the campaign&apos;s mailboxes, daily caps and spacing.
Enrols the contact this event is about (by <code>contact_id</code> or <code>contact_email</code>). Sending still runs through the campaign&apos;s mailboxes, daily caps and spacing, and the campaign keeps running for new leads instead of finishing between runs.
</p>
</div>
)}
@@ -2942,7 +2942,7 @@ function UpsertContactFields({
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
A blank value never erases what the contact already has. The written contact becomes this event&apos;s contact, so the
steps after it (tag, task, deal) act on it.
steps after it (tag, task, deal) act on it. A campaign picked here keeps running for new leads instead of finishing between runs.
</p>
</div>
);
@@ -23,9 +23,13 @@ import {
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { useQuery } from "@tanstack/react-query";
import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
import usePreflight from "@/lib/api/hooks/app/campaigns/usePreflight";
import getSequences from "@/lib/api/client/app/campaigns/sequences/getSequences";
import { preflightFailures } from "@/lib/api/models/app/campaigns/Preflight";
import { isIdleCampaign } from "@/components/app/campaigns/status";
import type { StartCampaignResult } from "@/lib/api/client/app/campaigns/startCampaign";
type Phase = "idle" | "launching" | "done";
@@ -95,14 +99,24 @@ export default function LaunchCampaignDialog({
// The backend refused the launch on projected bounce rate. The member can
// take the risk explicitly (a list verified elsewhere), once they read why.
const [riskBlocked, setRiskBlocked] = React.useState(false);
// The start answered that the campaign is active but waiting for leads.
const [waiting, setWaiting] = React.useState(false);
const timer = React.useRef<number | null>(null);
// The list passes a campaign whose `sequences` is null (the list endpoint
// omits them), so fetch the full record to show an accurate step count and
// The list passes a trimmed campaign, so fetch the full record for accurate
// settings. Falls back to the passed campaign until it resolves.
const full = useCampaign(campaign?.id ?? "");
const c = full.data ?? campaign;
// Steps live on their own endpoint (the campaign record never carries
// them), under the same key the sequence editor uses so the cache is shared.
const steps = useQuery({
queryKey: ["campaigns", campaign?.id ?? "", "sequences"],
queryFn: () => getSequences(campaign?.id ?? ""),
enabled: !!campaign,
});
const stepCount = steps.data?.length;
// The platform's own pre-send checks, run fresh each time the dialog opens.
// Advisory: a failure is shown, never used to disable Launch, because the
// backend owns what actually blocks a start.
@@ -115,6 +129,7 @@ export default function LaunchCampaignDialog({
setPhase("idle");
setError(null);
setRiskBlocked(false);
setWaiting(false);
}
}, [campaign]);
@@ -139,7 +154,10 @@ export default function LaunchCampaignDialog({
setError(null);
setPhase("launching");
try {
await onConfirm(campaign.id, acknowledge ? { acknowledge_list_risk: true } : undefined);
const res = (await onConfirm(campaign.id, acknowledge ? { acknowledge_list_risk: true } : undefined)) as
| StartCampaignResult
| undefined;
setWaiting(!!res?.waiting_for_leads);
setPhase("done");
timer.current = window.setTimeout(onClose, 1200);
} catch (e) {
@@ -189,10 +207,12 @@ export default function LaunchCampaignDialog({
<CheckIcon className="w-6 h-6" strokeWidth={2.6} />
</motion.div>
<p className="mt-3 text-[14px] font-semibold text-slate-900">
You're live
{waiting || isIdleCampaign(c) ? "Running, waiting for leads" : "You're live"}
</p>
<p className="mt-0.5 text-[12px] text-slate-500 truncate max-w-full">
{c.name} is now sending
{waiting || isIdleCampaign(c)
? `${c.name} sends to leads as they arrive`
: `${c.name} is now sending`}
</p>
</motion.div>
) : (
@@ -242,11 +262,11 @@ export default function LaunchCampaignDialog({
icon={<ListChecksIcon className="w-4 h-4" />}
label="Steps"
value={
full.isLoading && !c.sequences
? ""
: c.sequences
? `${c.sequences.length} step${c.sequences.length === 1 ? "" : "s"}`
: "0 steps"
steps.isError
? "Unavailable"
: stepCount === undefined
? "…"
: `${stepCount} step${stepCount === 1 ? "" : "s"}`
}
/>
</div>
@@ -307,6 +327,11 @@ export default function LaunchCampaignDialog({
<p className="px-5 pt-3 text-[11.5px] text-slate-500 leading-relaxed">
Sending begins immediately, paced to the schedule and your
mailbox guardrails. You can pause anytime.
{c.continuous
? " Out of leads, the campaign stays active and waits for new ones."
: c.status === "completed"
? " If every lead has already finished, the campaign stays active and waits for new ones."
: ""}
</p>
{error && (
@@ -270,7 +270,7 @@ export function LeadFlowSection({
/>
<SettingRow
title="Keep running for new leads"
description="Out of leads, the campaign stays active and waits instead of finishing, so leads from a linked segment, the API or an automation keep getting the sequence. Linking a segment turns this on."
description="Out of leads, the campaign stays active and waits instead of finishing, so leads from a linked segment, a form, the API or an automation keep getting the sequence. Linking a segment, a form or an automation to this campaign turns it on, and so does starting the campaign once every lead has finished."
control={
<Toggle
id="campaign-pref-continuous"
@@ -105,7 +105,7 @@ export default function SettingsPanel({
/>
<p className="text-[11px] text-slate-500 mt-1">
New contacts join this campaign as leads. Sending still follows the campaign's own schedule and
limits.
limits. The campaign keeps running for new leads instead of finishing between submissions.
</p>
</div>
</Section>
@@ -5,8 +5,15 @@ export interface StartCampaignOptions {
acknowledge_list_risk?: boolean;
}
export default async function startCampaign(id: string, options?: StartCampaignOptions): Promise<void> {
return await Request<void>({
export interface StartCampaignResult {
status: string;
// The campaign started with nothing left to send and is active, waiting
// for leads (Keep running for new leads is on).
waiting_for_leads?: boolean;
}
export default async function startCampaign(id: string, options?: StartCampaignOptions): Promise<StartCampaignResult> {
return await Request<StartCampaignResult>({
method: "POST",
url: `/campaigns/${id}/start`,
data: options ?? {},
@@ -1,5 +1,3 @@
import type Sequence from "./sequences/Sequence";
export type CampaignKind = "sequence" | "one_time";
export default interface Campaign {
@@ -104,7 +102,6 @@ export default interface Campaign {
// Extra
analytics: null;
sequences: Sequence[] | null;
}
// One sending window within a day, in minutes since local midnight (end > start).