package tasks import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/mailhtml" "github.com/warmbly/warmbly/internal/repository" "github.com/warmbly/warmbly/internal/scheduler" "github.com/warmbly/warmbly/internal/tasks/proto" ) func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx.Error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() // A campaign is one self-perpetuating task, so a pass that fails must not // end it: the reconciler would only notice minutes later, and a failure // that repeats would leave every lead queued between its passes. claimed, handedOff := false, false defer func() { if result != nil && claimed { s.keepChainAfterFailure(task.TaskId, handedOff) } }() // STEP 1: Parse task ID taskID, err := uuid.Parse(task.TaskId) if err != nil { errs.CaptureException(err) return errx.New(errx.BadRequest, "invalid task ID") } executionKey := "campaign:" + taskID.String() executionStatus := "failed" if s.advanced != nil { duplicate, xerr := s.advanced.StartTaskExecution(ctx, taskID, executionKey, map[string]interface{}{ "task_type": "campaign", }) if xerr != nil { return xerr } if duplicate { return nil } defer func() { _ = s.advanced.CompleteTaskExecution(ctx, taskID, executionKey, executionStatus, map[string]interface{}{ "task_type": "campaign", }) }() } // STEP 2: Load task record taskRecord, err := s.taskRepo.GetTask(ctx, taskID) if err != nil { errs.CaptureException(err) return errx.InternalError() } if taskRecord == nil { return errx.ErrNotFound } if taskRecord.Status != "pending" { log.Info(). Str("task_id", taskID.String()). Str("status", taskRecord.Status). Msg("campaign task skipped: task not in pending state") executionStatus = "completed" return nil } // STEP 3: Mark task as active (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil { errs.CaptureException(err) return errx.InternalError() } claimed = true // STEP 4: Load campaign task details campaignTask, err := s.taskRepo.GetCampaignTask(ctx, taskID) if err != nil { errs.CaptureException(err) return errx.InternalError() } if campaignTask == nil { return errx.ErrNotFound } // campaign_tasks nulls its link when the campaign is deleted: the chain // ends here rather than leaving the row claimed as active. if campaignTask.CampaignID == nil { s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") executionStatus = "completed" return nil } // Get campaign progress for task progress events campaignProgress, _ := s.campaignProgressRepo.GetCampaignProgress(ctx, *campaignTask.CampaignID) var totalContacts, totalEmails, processedCount int if campaignProgress != nil { totalContacts = campaignProgress.TotalContacts totalEmails = campaignProgress.TotalContacts * campaignProgress.TotalSequences processedCount = campaignProgress.EmailsSent } // STEP 5: Load campaign campaign, err := s.campaignRepo.GetByID(ctx, *campaignTask.CampaignID) if err != nil { // Deleted between the pending check and here: the chain ends. if errors.Is(err, errx.ErrResourceNotFound) { s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") executionStatus = "completed" return nil } errs.CaptureException(err) return errx.InternalError() } // Check if campaign is still active if campaign.Status != "active" { s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") executionStatus = "completed" return nil // Don't create next task } // The chain woke up. Most wake-ups send nothing (the slot is not due, the // pool is spent, the window is closed), so this is "scheduled", never // "active": the dashboard read the old contact-less "active" as a send in // flight and showed "Sending... Unknown contact" for as long as the // campaign was running. if s.streamingPublisher != nil { s.streamingPublisher.PublishTaskProgress(ctx, s.sendProgress(ctx, campaign, taskID, nil, nil, "scheduled", processedCount, totalEmails, totalContacts)) } // STEP 5.4: Tenancy gate. organization_id is what scopes the entitlement // check below and the recipient suppression check in STEP 7; missing it used // to skip both and send anyway. Fail closed instead — an orgless campaign is // stopped and surfaced, never mailed unchecked. if campaign.OrganizationID == nil { s.haltOrglessCampaign(ctx, campaign.ID, taskID) executionStatus = "completed" return nil } orgID := *campaign.OrganizationID // STEP 5.5: Check if organization can send campaign emails (trial expired, etc.) if s.featureGate != nil { canSend, _ := s.featureGate.CanSendCampaignEmail(ctx, orgID) if !canSend { // Organization cannot send - pause campaign s.campaignRepo.UpdateStatus(ctx, campaign.ID, "paused_trial_expired") s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_trial_expired") executionStatus = "completed" return nil } // Check daily limit limit, _ := s.featureGate.GetDailyEmailLimit(ctx, orgID) if limit >= 0 { sentToday, err := s.campaignProgressRepo.CountEmailsSentTodayByOrganization(ctx, orgID) if err == nil && sentToday >= limit { s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_daily_limit") if s.campaignLogRepo != nil { s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "daily_limit_reached", Message: "Campaign paused for today: organization daily email limit reached", Metadata: map[string]interface{}{ "sent_today": sentToday, "limit": limit, }, }) } // Reschedule to the next day to keep campaign progression alive. nextDay := time.Now().UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(5 * time.Minute) _, _, nextAccountID, calcErr := s.scheduler.CalculateNextCampaignTime(ctx, *campaignTask.CampaignID) if calcErr == nil || errors.Is(calcErr, scheduler.ErrCampaignDeferred) { if err := s.createCampaignTask(ctx, campaign.ID, nextAccountID, nextDay); err != nil { log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to create next campaign task after daily limit") } } executionStatus = "completed" return nil } } } // STEP 6: Calculate next email to send nextTime, nextPair, accountID, err := s.scheduler.CalculateNextCampaignTime(ctx, *campaignTask.CampaignID) if err != nil { if errors.Is(err, scheduler.ErrNoEmailAccounts) { s.autoPauseCampaign(ctx, *campaignTask.CampaignID, taskID, autoPauseReason(err)) executionStatus = "completed" return nil } if errors.Is(err, scheduler.ErrCampaignDeferred) { // A valid contact exists but no eligible mailbox right now (ESP-strict // has no same-provider mailbox, the daily new-lead cap is reached, or // every mailbox has spent its daily budget or is outside its hours). // Reschedule at the deferred slot WITHOUT sending and WITHOUT touching // progress / daily counters / rotation — mirrors the daily-limit path. // This task completes without a send, and completing it must not // spend the mailbox's budget either (issue #306): the budget counts // reserved sends, never bare wake-ups. // Capped: the next-due moment can be days out, and until this chain // wakes nothing re-reads the campaign, so leads imported meanwhile // would sit queued until then. scheduledNext := scheduler.DeferSlot(nextTime) if cerr := s.createCampaignTask(ctx, campaign.ID, accountID, scheduledNext); cerr != nil { log.Warn().Err(cerr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to schedule deferred campaign task") } s.clearIdle(ctx, campaign) s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") executionStatus = "completed" return nil } // Terminal: all emails sent, OR the campaign passed its end date. Both end // the campaign at the "completed" status (the status enum has no separate // "ended"); the reason differs in the activity log. if errors.Is(err, scheduler.ErrCampaignCompleted) || errors.Is(err, scheduler.ErrCampaignEnded) { reason := "Campaign completed: all emails sent" if errors.Is(err, scheduler.ErrCampaignEnded) { reason = "Campaign ended: reached its end date" } // Two counts stand between "nothing was routed" and "the campaign is // finished", and BOTH have to be known before it can be closed: // leads verification refused are never routed (issue #264), and a // lead held with no end reports no next-due moment because there is // none to wake up for (issue #470). Either would otherwise let // "all emails sent" close a campaign that still has work. // // A count that FAILS is not zero. Closing on a database hiccup // writes a claim nothing can walk back, so an unreadable count // leaves the campaign active and the next pass asks again. undeliverable, cerr := s.campaignProgressRepo.CountUndeliverableLeads(ctx, campaign.ID) held, herr := s.campaignProgressRepo.CountHeldLeads(ctx, campaign.ID) if cerr != nil || herr != nil { log.Warn().AnErr("undeliverable", cerr).AnErr("held", herr). Str("campaign_id", campaign.ID.String()). Msg("could not tell a finished campaign from a parked one; leaving it active for the next pass") if taskID != uuid.Nil { s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") } executionStatus = "completed" return nil } if undeliverable > 0 { if errors.Is(err, scheduler.ErrCampaignCompleted) { s.pauseUndeliverable(ctx, campaign.ID, taskID, undeliverable) executionStatus = "completed" return nil } reason = fmt.Sprintf("%s (%d lead(s) skipped: address verification refused them)", reason, undeliverable) } if held > 0 { if errors.Is(err, scheduler.ErrCampaignCompleted) { s.parkHeldLeads(ctx, campaign, taskID, held) executionStatus = "completed" return nil } reason = fmt.Sprintf("%s (%d lead(s) paused)", reason, held) } // A continuous campaign out of leads is waiting, not finished // (issue #336). Only its end date ends it. if errors.Is(err, scheduler.ErrCampaignCompleted) && campaign.Continuous { s.idleCampaign(ctx, campaign, taskID) executionStatus = "completed" return nil } s.campaignRepo.UpdateStatus(ctx, campaign.ID, "completed") if s.campaignLogRepo != nil { s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "completed", Message: reason, }) } // Broadcast live so the dashboard (and the sidebar campaign counters) // flip from "sending" to "finished" without a manual refresh. if s.streamingPublisher != nil { s.streamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{ BaseEvent: pubsub.BaseEvent{ EventType: pubsub.EventCampaignCompleted, UserID: campaign.UserID, }, OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), Name: campaign.Name, Status: "completed", }) } s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") executionStatus = "completed" return nil } // Benign: the campaign was paused/deleted between ticks. Stop this chain // cleanly; a resume re-seeds it. if errors.Is(err, scheduler.ErrCampaignNotActive) { s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") executionStatus = "completed" return nil } // Transient / unknown error (a DB blip bubbled up from the scheduler). Do // NOT silently mark the task completed — that strands the campaign with no // successor. Record the failure for dashboard review, reset the task to // pending, and return 5xx so Cloud Tasks retries (with backoff). The // campaign reconciler is the backstop if retries are ever exhausted. errs.CaptureException(err) s.recordSchedulerFailure(ctx, campaign.ID, "scheduler_error", "Could not compute the next step; retrying", err) // Pulse the dashboard so the failure appears live for the whole team. A // CAMPAIGN_UPDATED with empty status invalidates the campaign logs query // without flipping the campaign's status. if s.streamingPublisher != nil { s.streamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{ BaseEvent: pubsub.BaseEvent{EventType: pubsub.EventCampaignUpdated, UserID: campaign.UserID}, OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), }) } s.retryCampaignTickLater(ctx, taskRecord) executionStatus = "failed" return errx.InternalError() } s.clearIdle(ctx, campaign) // STEP 7: Load contact and sequence contact, xerr := s.contactRepo.GetByID(ctx, nextPair.ContactID) if xerr != nil { return xerr } if s.advanced != nil { suppressed, reason, sxerr := s.advanced.ShouldSuppressRecipient(ctx, orgID, contact.Email) if sxerr != nil { return sxerr } if suppressed { _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_suppressed") if s.campaignLogRepo != nil { _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "suppressed", Message: fmt.Sprintf("Suppressed recipient skipped: %s", contact.Email), Metadata: map[string]interface{}{ "reason": reason, }, }) } _ = s.createCampaignTask(ctx, campaign.ID, accountID, nextTime) executionStatus = "completed" return nil } } // Per-lead hold gate, and a backstop rather than the main defence: routing // already excludes held leads, but a pause (or an out-of-office reply) can // land between that read and this dispatch, and a hold the send raced is // the one thing this feature exists to stop. Re-read where it is committed, // alongside suppression and verification. hold, herr := s.campaignProgressRepo.GetLeadHold(ctx, campaign.ID, contact.ID) if errors.Is(herr, repository.ErrLeadNotInCampaign) { // Removed from the campaign between routing and here. Not an error and // not a hold: there is simply nobody to send to, so skip it the way the // other pre-send gates do and let the chain carry on. _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_suppressed") _ = s.createCampaignTask(ctx, campaign.ID, accountID, nextTime) executionStatus = "completed" return nil } if herr != nil { // Fail closed: an unreadable hold is not an absent one, and sending to // somebody who asked not to be is the failure this gate exists for. errs.CaptureException(herr) s.taskRepo.RecordTaskFailure(ctx, taskID, "Could not read the lead's hold", herr.Error()) s.retryCampaignTickLater(ctx, taskRecord) executionStatus = "failed" return errx.InternalError() } if hold != nil { _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_paused") if s.campaignLogRepo != nil { _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "paused_lead", Message: fmt.Sprintf("Paused lead skipped: %s", contact.Email), Metadata: map[string]interface{}{"reason": hold.Reason, "source": hold.Source}, }) } _ = s.createCampaignTask(ctx, campaign.ID, accountID, nextTime) executionStatus = "completed" return nil } // Pre-send verification gate: drop addresses already known to be invalid // (bad syntax / no MX / 550 RCPT) before a worker sends and earns a hard // bounce. 'invalid' is always dropped; 'risky' is dropped only when the // campaign's "send to risky emails" toggle is off (see the next gate). // 'unknown'/'valid' always send. if contact.VerificationStatus == "invalid" { _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_suppressed") if s.campaignLogRepo != nil { _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "suppressed", Message: fmt.Sprintf("Unverifiable recipient skipped: %s", contact.Email), Metadata: map[string]interface{}{"reason": contact.VerificationReason}, }) } _ = s.createCampaignTask(ctx, campaign.ID, accountID, nextTime) executionStatus = "completed" return nil } // Risky-recipient gate: when "send to risky emails" is off, also drop // addresses verification flagged 'risky' (catch-all / role / low-quality), // which raise bounce risk. Enforces the campaign.RiskyEmails toggle that the // settings UI exposes — without this the toggle is stored but inert. if !campaign.RiskyEmails && contact.VerificationStatus == "risky" { _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_suppressed") if s.campaignLogRepo != nil { _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "suppressed", Message: fmt.Sprintf("Risky recipient skipped (send to risky emails is off): %s", contact.Email), Metadata: map[string]interface{}{"reason": contact.VerificationReason}, }) } _ = s.createCampaignTask(ctx, campaign.ID, accountID, nextTime) executionStatus = "completed" return nil } sequence, err := s.campaignRepo.GetSequenceByID(ctx, nextPair.SequenceID) if err != nil { errs.CaptureException(err) return errx.InternalError() } // STEP 7.6: Non-email nodes (action / wait). These run a control-plane side // effect and route onward WITHOUT sending mail — the render/send block below // is reached only for email nodes. We stamp the node visited so routing // advances past it next tick, then schedule the next campaign tick (now for // instant actions and "end", now+wait for a wait node). An "end" node has no // outgoing connection, so the contact drops out of routing afterwards while // the campaign keeps processing other contacts. if sequence.Kind != "email" { var cfg models.ActionConfig if len(sequence.Action) > 0 { _ = json.Unmarshal(sequence.Action, &cfg) } aerr := s.executeActionNode(ctx, campaign, contact, sequence.ID, &cfg) if aerr != nil { log.Warn().Err(aerr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Str("action", cfg.Type).Msg("Action node execution failed") } resumeAt := nextTime if cfg.Type == "wait" && cfg.WaitMinutes != nil && *cfg.WaitMinutes > 0 { resumeAt = time.Now().UTC().Add(time.Duration(*cfg.WaitMinutes) * time.Minute) } if rerr := s.campaignProgressRepo.RecordEmailSent(ctx, campaign.ID, contact.ID, sequence.ID); rerr != nil { log.Warn().Err(rerr).Str("campaign_id", campaign.ID.String()).Msg("Failed to record action node progress") } if cerr := s.createCampaignTask(ctx, campaign.ID, accountID, resumeAt); cerr != nil { log.Warn().Err(cerr).Str("campaign_id", campaign.ID.String()).Msg("Failed to schedule next task after action node") } if s.campaignLogRepo != nil { // Record the real outcome: a failed or skipped action (e.g. the linked // automation is disabled) must be visible, not logged as if it ran. evt, msg := "action", fmt.Sprintf("Ran '%s' action for %s", cfg.Type, contact.Email) if aerr != nil { evt, msg = "action_skipped", fmt.Sprintf("Action '%s' for %s did not run: %v", cfg.Type, contact.Email, aerr) } _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: evt, Message: msg, }) } s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") executionStatus = "completed" return nil } // Load this step's attachments (campaign-wide files plus the step's own; // metadata only — the worker fetches the bytes from object storage by S3 // key at send time). attachmentRefs := s.campaignAttachmentRefs(ctx, campaign.ID, sequence.ID) // STEP 7.5: Update campaign task with contact_id and sequence_id for tracking // This allows the tracking consumer to find the correct contact/sequence when // processing open/click events from the tracking pixel service if err := s.taskRepo.UpdateCampaignTaskTracking(ctx, taskID, contact.ID, sequence.ID); err != nil { // Log but don't fail - tracking can still work via fallback methods log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to update campaign task tracking") } // stop_on_reply is enforced inside FindRoutedPairs (STEP 6), and it is now // ROUTE-AWARE: a contact who replied is only handed back when their next step // is part of the reply flow (the reply branch's own path). The normal cold // sequence stops there, so there is no longer a blanket "contact has replied, // skip" check here — that would also kill the reply branch's follow-up emails. // STEP 9: Load email account account, xerr := s.emailRepo.GetByID(ctx, accountID) if xerr != nil { return xerr } // STEP 9.25: Stamp the mailbox this send actually goes out from. The chain // seeds a task with the previous tick's pick, and rotation chooses again // here, so tasks.email_account_id (today's budget, the min-gap clock, // rotation's own position, bounce and complaint rates, the contact's // activity feed) has to be corrected before the send (issue #392). Failing // it fails the task like the reservation: a send charged to another mailbox // would let this one past its daily cap. if account.ID != taskRecord.EmailAccountID { if err := s.taskRepo.UpdateTaskEmailAccount(ctx, taskID, account.ID); err != nil { errs.CaptureException(err) s.taskRepo.RecordTaskFailure(ctx, taskID, "Could not record the sending mailbox", err.Error()) s.recordSchedulerFailure(ctx, campaign.ID, "sender_attribution_failed", fmt.Sprintf("Could not record which mailbox is sending to %s; retrying", contact.Email), err) s.retryCampaignTickLater(ctx, taskRecord) executionStatus = "failed" return errx.InternalError() } taskRecord.EmailAccountID = account.ID } // The Reply-To this send is about to carry is the evidence that credits a // reply landing in another workspace mailbox, so it is on the row before // the mail can be answered, and a send that cannot record it waits. It is // written on every attempt: a retry reuses the row and may send without one. if err := s.taskRepo.UpdateTaskReplyTo(ctx, taskID, account.ReplyToHeader()); err != nil { errs.CaptureException(err) s.taskRepo.RecordTaskFailure(ctx, taskID, "Could not record the reply-to address", err.Error()) s.recordSchedulerFailure(ctx, campaign.ID, "reply_to_record_failed", fmt.Sprintf("Could not record where %s's reply should land; retrying", contact.Email), err) s.retryCampaignTickLater(ctx, taskRecord) executionStatus = "failed" return errx.InternalError() } // Who else the email copies, read for an email step before the send is // reserved. Fail closed: copies that cannot be checked against suppression // are not sent, and neither is the email without the copies chosen. copyCC, copyBCC, cerr := s.campaignCopies(ctx, orgID, campaign, contact) if cerr != nil { errs.CaptureException(cerr) s.taskRepo.RecordTaskFailure(ctx, taskID, "Could not read who the email copies", cerr.Error()) s.retryCampaignTickLater(ctx, taskRecord) executionStatus = "failed" return errx.InternalError() } // STEP 9.4: The conversation this step joins. A follow-up is a nudge on the // email the contact already has, not a second cold email, so every step // after their first is threaded onto the last one they received: the // parent's Message-ID becomes In-Reply-To/References, which is what the // RECIPIENT's client threads on, and the parent's provider thread handle // files it in the same conversation in the SENDER's mailbox (issue #472). // A contact's first email has no parent and opens the thread. threadParent := s.threadParent(ctx, campaign.ID, contact.ID, sequence) // STEP 9.5: Resolve {{form_link:...}} markers to per-recipient form URLs // BEFORE templating, so the substituted literal survives the naive // fallback and gets wrapped by click tracking in STEP 11 like any link. rawSubject, rawBodyHTML, rawBodyPlain := sequence.Subject, sequence.BodyHTML, sequence.BodyPlain // A reply carries the conversation's subject, so a threading step does not // have one of its own: it inherits it here, before rendering, so the merge // fields resolve for THIS contact. Empty means the step writes its own. threadSubject := s.threadSubject(ctx, campaign.ID, sequence, threadParent) if threadSubject != "" { rawSubject = threadSubject } s.resolveFormLinks(ctx, orgID, campaign, contact, &rawSubject, &rawBodyHTML, &rawBodyPlain) // STEP 9.75: The recipient's opt-out. The link (when the instance can // mint one) backs the List-Unsubscribe header, the link-mode footer and // any {{.UnsubscribeLink}} the step places by hand; the footer mode comes // from Settings > Sending unless the campaign overrides it. optOut := s.resolveOptOut(ctx, orgID, campaign) var unsubscribeURL string if s.unsubLinks != nil && s.unsubLinks.Enabled() { // On the workspace's own verified tracking domain when it has one, so // the opt-out address sits on the sender's domain like every other link // in the email rather than naming the platform. unsubscribeURL = s.mintUnsubscribeLink(ctx, resolveOptOutOrigin(account, campaign), orgID, campaign.ID, contact.ID) } extra := templateContext(account, unsubscribeURL) // STEP 10: Render email template with contact variables, then expand any // {a|b|c} spintax per-recipient (only real |-groups; literal braces/CSS are // left intact) so each send varies for deliverability. subject := expandSpintax(RenderTemplateWith(rawSubject, *contact, extra)) bodyHTML := expandSpintax(RenderTemplateWith(rawBodyHTML, *contact, extra)) bodyPlain := expandSpintax(RenderTemplateWith(rawBodyPlain, *contact, extra)) // A recorded conversation subject is already rendered: reuse it verbatim. if threadParent != nil && threadParent.SubjectSent && threadSubject != "" { subject = threadSubject } // If no plain text provided, extract from HTML if bodyPlain == "" && bodyHTML != "" { bodyPlain = ExtractPlainTextFromHTML(bodyHTML) } if s.advanced != nil { selection, sxerr := s.advanced.SelectVariant(ctx, orgID, campaign.ID, contact.ID, sequence.ID, subject, bodyHTML, bodyPlain) if sxerr != nil { return sxerr } if selection != nil { // A chosen variant is stored template text, so it goes through the // same render as the step's own copy; the control arm comes back // already rendered, for which this pass is a no-op. // // A threading step is the exception: its subject belongs to the // conversation, not to the arm, so variants on a follow-up vary // the body only. if threadSubject == "" { subject = expandSpintax(RenderTemplateWith(selection.Subject, *contact, extra)) } bodyHTML = expandSpintax(RenderTemplateWith(selection.BodyHTML, *contact, extra)) bodyPlain = expandSpintax(RenderTemplateWith(selection.BodyPlain, *contact, extra)) // A variant may carry HTML only; keep the plain-text alternative. if bodyPlain == "" && bodyHTML != "" { bodyPlain = ExtractPlainTextFromHTML(bodyHTML) } } } // STEP 10.5: Resolve per-recipient AI variables (AI blocks in the body that // generate unique copy for THIS contact). Runs before tracking/signature so // the generated copy gets open/click tracking like any other body text. A // zero-cost no-op when the body has no AI blocks. A generation failure fails // the send (recorded like other send failures) so the task retries with the // same cached output. if s.aiProvider != nil && s.aiCredits != nil { var aerr error subject, bodyHTML, bodyPlain, aerr = s.resolveAIVariables(ctx, campaign, contact, sequence.ID, subject, bodyHTML, bodyPlain) if aerr != nil { s.taskRepo.RecordTaskFailure(ctx, taskID, "AI variable resolution failed", aerr.Error()) if s.campaignLogRepo != nil { s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "email_failed", Message: fmt.Sprintf("Failed to resolve AI variables for %s", contact.Email), Metadata: map[string]interface{}{ "contact_id": contact.ID.String(), "sequence_id": sequence.ID.String(), "error": aerr.Error(), }, }) } s.retryCampaignTickLater(ctx, taskRecord) executionStatus = "failed" return errx.InternalError() } } // Follow-ups thread on exactly this subject; a failed write falls back to the step's. if err := s.taskRepo.UpdateCampaignTaskSubject(ctx, taskID, subject); err != nil { log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()). Msg("Could not record the subject this send carries") } // STEP 10.6: A plain-text campaign ships no HTML part at all. Tracking // below only rewrites HTML, so dropping it here is what makes the // setting's "disables tracking" promise true. if campaign.TextOnly { if bodyPlain == "" && bodyHTML != "" { bodyPlain = ExtractPlainTextFromHTML(bodyHTML) } bodyHTML = "" } // A blank HTML alternative never ships as the part the client prefers. // Shared with the preview and the test send (see dropBlankHTMLPart). bodyHTML = dropBlankHTMLPart(bodyHTML, bodyPlain) // STEP 10.7: A hand-placed {{.UnsubscribeLink}} resolved to the bare signed // URL; give it an anchor so the recipient reads "Unsubscribe" and not the // API address (issue #341). After the plain part was derived, so plain text // keeps the URL it needs, and before tracking, which leaves it alone. bodyHTML = linkifyUnsubscribeURL(bodyHTML, unsubscribeURL, optOut.LinkText) // STEP 10.75: Score the copy the recipient will actually receive, after // merge fields, spintax, A/B and AI blocks have resolved. Advisory: it // warns once per step and never blocks or delays the send. s.warnOnWeakContent(ctx, orgID, campaign.ID, sequence.ID, sequence.Position+1, subject, bodyHTML, bodyPlain, len(attachmentRefs)) // STEP 11: Add tracking on the host resolveTrackingHost picks, and log any // override that was configured but not verified so a customer whose links // are not going through their own domain can see why. trackingDomain, ignored := resolveTrackingHost(config.TrackingHost(), account, campaign) if s.campaignLogRepo != nil { for _, ign := range ignored { s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "tracking_domain_unverified", Message: ign.Message, Metadata: map[string]interface{}{"scope": ign.Scope, "tracking_domain": ign.Domain, "mailbox": account.Email}, }) } } if campaign.OpenTracking && bodyHTML != "" { bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, trackingDomain) } if bodyHTML != "" && (campaign.LinkTracking || campaign.UTMTracking) { linkOpts := LinkTracking{ TaskID: taskID, CampaignID: campaign.ID, TrackingDomain: trackingDomain, Wrap: campaign.LinkTracking, UTM: CampaignUTM(campaign), } tracked, links := TrackLinks(bodyHTML, linkOpts) if len(links) == 0 { bodyHTML = tracked } else if err := s.trackedLinkRepo.CreateBatch(ctx, links); err != nil { // Tracking is a nicety: ship the original working links rather // than tickets that would 404 at the tracking service. UTM tags // need no ticket, so they still go on. log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to store tracked links; sending untracked") bodyHTML, _ = TrackLinks(bodyHTML, LinkTracking{TrackingDomain: linkOpts.TrackingDomain, UTM: linkOpts.UTM}) } else { bodyHTML = tracked } } if campaign.UTMTracking && bodyPlain != "" { bodyPlain = TagPlainTextLinks(bodyPlain, CampaignUTM(campaign), trackingDomain) } // STEP 12: Add signature if account.SignatureSync { if bodyHTML != "" { bodyHTML = AddSignature(bodyHTML, account.SignatureHTML, true) } if bodyPlain != "" { bodyPlain = AddSignature(bodyPlain, account.SignaturePlain, false) } } // STEP 12.5: Opt-out footer, after the signature and after click tracking // so the link is never rewritten into a tracked ticket. bodyHTML, bodyPlain = appendOptOut(bodyHTML, bodyPlain, optOut, unsubscribeURL) // STEP 12.75: Copy any embedded stylesheet onto the elements it matches. // Outlook.com, Yahoo and Gmail's mobile clients drop