Files
warmbly/internal/tasks/campaign_task.go
T

576 lines
18 KiB
Go

package tasks
import (
"context"
"errors"
"fmt"
"time"
"github.com/getsentry/sentry-go"
"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/repository"
"github.com/warmbly/warmbly/internal/scheduler"
"github.com/warmbly/warmbly/internal/tasks/proto"
)
func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// STEP 1: Parse task ID
taskID, err := uuid.Parse(task.TaskId)
if err != nil {
sentry.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 {
sentry.CaptureException(err)
return errx.InternalError()
}
if taskRecord == nil {
return errx.ErrNotFound
}
// STEP 3: Mark task as active (with advisory lock)
if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
// STEP 4: Load campaign task details
campaignTask, err := s.taskRepo.GetCampaignTask(ctx, taskID)
if err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
if campaignTask == nil || campaignTask.CampaignID == nil {
return errx.ErrNotFound
}
// Get campaign progress for task progress events
campaignProgress, _ := s.campaignProgressRepo.GetCampaignProgress(ctx, *campaignTask.CampaignID)
var totalContacts, processedCount int
if campaignProgress != nil {
totalContacts = campaignProgress.TotalContacts
processedCount = campaignProgress.EmailsSent
}
// STEP 5: Load campaign
campaign, err := s.campaignRepo.GetByID(ctx, *campaignTask.CampaignID)
if err != nil {
sentry.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
}
// Publish task started progress event
if s.streamingPublisher != nil {
progress := 0
if totalContacts > 0 {
progress = (processedCount * 100) / totalContacts
}
s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{
BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID},
CampaignID: campaign.ID.String(),
TaskID: taskID.String(),
Status: "active",
Progress: progress,
TotalContacts: totalContacts,
ProcessedCount: processedCount,
})
}
// STEP 5.5: Check if organization can send campaign emails (trial expired, etc.)
if s.featureGate != nil && campaign.OrganizationID != nil {
canSend, _ := s.featureGate.CanSendCampaignEmail(ctx, *campaign.OrganizationID)
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, *campaign.OrganizationID)
if limit >= 0 {
sentToday, err := s.campaignProgressRepo.CountEmailsSentTodayByOrganization(ctx, *campaign.OrganizationID)
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 {
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)
executionStatus = "completed"
return nil
}
if errors.Is(err, scheduler.ErrCampaignCompleted) {
s.campaignRepo.UpdateStatus(ctx, campaign.ID, "completed")
if s.campaignLogRepo != nil {
s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{
CampaignID: campaign.ID,
EventType: "completed",
Message: "Campaign completed: all emails sent",
})
}
}
s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed")
executionStatus = "completed"
return nil
}
// STEP 7: Load contact and sequence
contact, xerr := s.contactRepo.GetByID(ctx, nextPair.ContactID)
if xerr != nil {
return xerr
}
if s.advanced != nil && campaign.OrganizationID != nil {
suppressed, reason, sxerr := s.advanced.ShouldSuppressRecipient(ctx, *campaign.OrganizationID, 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
}
}
sequence, err := s.campaignRepo.GetSequenceByID(ctx, nextPair.SequenceID)
if err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
// 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")
}
// STEP 8: Check stop_on_reply
if campaign.StopOnReply {
hasReplied, err := s.campaignProgressRepo.CheckContactHasReplied(ctx, contact.ID, campaign.ID)
if err == nil && hasReplied {
// Contact has replied, skip
s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed")
// Create next task anyway for next contact
s.createCampaignTask(ctx, campaign.ID, accountID, nextTime)
executionStatus = "completed"
return nil
}
}
// STEP 9: Load email account
account, xerr := s.emailRepo.GetByID(ctx, accountID)
if xerr != nil {
return xerr
}
// STEP 10: Render email template with contact variables
subject := RenderTemplate(sequence.Subject, *contact)
bodyHTML := RenderTemplate(sequence.BodyHTML, *contact)
bodyPlain := RenderTemplate(sequence.BodyPlain, *contact)
// If no plain text provided, extract from HTML
if bodyPlain == "" && bodyHTML != "" {
bodyPlain = ExtractPlainTextFromHTML(bodyHTML)
}
if s.advanced != nil && campaign.OrganizationID != nil {
selection, sxerr := s.advanced.SelectVariant(ctx, *campaign.OrganizationID, campaign.ID, contact.ID, subject, bodyHTML, bodyPlain)
if sxerr != nil {
return sxerr
}
if selection != nil {
subject = selection.Subject
bodyHTML = selection.BodyHTML
bodyPlain = selection.BodyPlain
}
}
// STEP 11: Add tracking
if campaign.OpenTracking && bodyHTML != "" {
bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, account.TrackingDomain)
}
if campaign.LinkTracking && bodyHTML != "" {
bodyHTML = WrapLinksForTracking(bodyHTML, taskID, account.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 13: Encrypt email content
userUUID, err := uuid.Parse(account.UserID)
if err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
cipher, err := s.cipherService.Cipher(ctx, userUUID)
if err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
_, err = cipher.Encrypt(ctx, subject)
if err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
_, err = cipher.Encrypt(ctx, bodyHTML)
if err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
// STEP 14: Generate Message-ID
messageID := generateMessageID(account.Email)
// STEP 15: Build tracking info
var tracking *models.TrackingInfo
if campaign.OpenTracking || campaign.LinkTracking {
tracking = &models.TrackingInfo{
OpenTracking: campaign.OpenTracking,
LinkTracking: campaign.LinkTracking,
TrackingDomain: account.TrackingDomain,
}
}
// STEP 15.5: Generate List-Unsubscribe URL if enabled
var unsubscribeURL string
if campaign.UnsubscribeHeader {
unsubscribeURL = fmt.Sprintf("https://%s/unsubscribe?cid=%s&rid=%s",
config.Domain, campaign.ID.String(), contact.ID.String())
}
// STEP 16: Send email to worker via Kafka
emailMsg := EmailMessage{
From: account.Email,
To: []string{contact.Email},
CC: campaign.CC,
BCC: campaign.BCC,
Subject: subject,
BodyHTML: bodyHTML,
BodyPlain: bodyPlain,
MessageID: messageID,
IsWarmup: false,
Tracking: tracking,
UserID: userUUID,
UnsubscribeURL: unsubscribeURL,
}
if err := s.emailSender.Send(ctx, taskID, emailMsg, *account); err != nil {
// Failed to send to worker, record failure
s.taskRepo.RecordTaskFailure(ctx, taskID, "Send failed", err.Error())
if s.campaignLogRepo != nil {
s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{
CampaignID: campaign.ID,
EventType: "email_failed",
Message: fmt.Sprintf("Failed to send to %s", contact.Email),
Metadata: map[string]interface{}{
"contact_id": contact.ID.String(),
"error": err.Error(),
},
})
}
// Publish task failure to Pub/Sub
if s.streamingPublisher != nil {
s.streamingPublisher.PublishTaskStatus(ctx, campaign.UserID, taskID, pubsub.EventTaskFailed, "Failed to send email", map[string]string{
"campaign_id": campaign.ID.String(),
"contact_id": contact.ID.String(),
"error": err.Error(),
})
// Publish detailed task progress event for failure
progress := 0
if totalContacts > 0 {
progress = (processedCount * 100) / totalContacts
}
contactName := contact.FirstName
if contact.LastName != "" {
contactName = contactName + " " + contact.LastName
}
s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{
BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID},
CampaignID: campaign.ID.String(),
TaskID: taskID.String(),
Status: "failed",
ContactID: contact.ID.String(),
ContactEmail: contact.Email,
ContactName: contactName,
SequenceID: sequence.ID.String(),
SequenceName: sequence.Name,
Progress: progress,
TotalContacts: totalContacts,
ProcessedCount: processedCount,
})
}
if s.advanced != nil {
_ = s.advanced.CaptureTaskDeadLetter(ctx, taskID, "campaign", map[string]interface{}{
"campaign_id": campaign.ID.String(),
"contact_id": contact.ID.String(),
"email": contact.Email,
}, err.Error(), 1)
_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "dead_lettered")
}
return nil
}
// STEP 16: Store sent email metadata (encrypted) in database
// Note: Full email stored in Cassandra by email sync service
taskRecord.MessageID = messageID
taskRecord.Status = "completed"
// STEP 17: Update campaign progress
if err := s.campaignProgressRepo.RecordEmailSent(ctx, campaign.ID, contact.ID, sequence.ID); err != nil {
// Log but don't fail
log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to record email sent")
}
// Publish campaign progress summary to Pub/Sub for real-time dashboard updates
if s.streamingPublisher != nil {
if progress, pErr := s.campaignProgressRepo.GetCampaignProgress(ctx, campaign.ID); pErr == nil && progress != nil {
s.streamingPublisher.PublishCampaignProgress(ctx, campaign.UserID, campaign.ID, progress)
}
}
// Log email sent
if s.campaignLogRepo != nil {
s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{
CampaignID: campaign.ID,
EventType: "email_sent",
Message: fmt.Sprintf("Email sent to %s", contact.Email),
Metadata: map[string]interface{}{
"contact_id": contact.ID.String(),
"sequence_id": sequence.ID.String(),
"account_id": account.ID.String(),
},
})
}
// STEP 18: Mark task as completed (with advisory lock)
if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil {
sentry.CaptureException(err)
return errx.InternalError()
}
// Publish task completion to Pub/Sub
if s.streamingPublisher != nil {
s.streamingPublisher.PublishTaskStatus(ctx, campaign.UserID, taskID, pubsub.EventTaskCompleted, "Email sent successfully", map[string]string{
"campaign_id": campaign.ID.String(),
"contact_id": contact.ID.String(),
})
// Publish detailed task progress event
newProcessedCount := processedCount + 1
progress := 0
if totalContacts > 0 {
progress = (newProcessedCount * 100) / totalContacts
}
contactName := contact.FirstName
if contact.LastName != "" {
contactName = contactName + " " + contact.LastName
}
// Get sequence index
sequences, _ := s.campaignRepo.GetSequencesByCampaignID(ctx, campaign.ID)
seqIndex := 0
for i, seq := range sequences {
if seq.ID == sequence.ID {
seqIndex = i + 1
break
}
}
s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{
BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID},
CampaignID: campaign.ID.String(),
TaskID: taskID.String(),
Status: "completed",
ContactID: contact.ID.String(),
ContactEmail: contact.Email,
ContactName: contactName,
SequenceID: sequence.ID.String(),
SequenceName: sequence.Name,
SequenceIndex: seqIndex,
Progress: progress,
TotalContacts: totalContacts,
ProcessedCount: newProcessedCount,
})
}
// STEP 19: Publish events to Kafka
s.publishEmailSentEvent(ctx, taskRecord, account, campaign, contact, sequence)
// STEP 20: Create next campaign task
scheduledNext := nextTime
if s.advanced != nil && campaign.OrganizationID != nil {
if optimized, xerr := s.advanced.OptimizeSendTime(ctx, *campaign.OrganizationID, contact, nextTime); xerr == nil {
scheduledNext = optimized
}
}
if err := s.createCampaignTask(ctx, campaign.ID, account.ID, scheduledNext); err != nil {
// Log but don't fail the current task
log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to create next campaign task")
}
executionStatus = "completed"
return nil
}
// autoPauseCampaign pauses a campaign when no active email accounts are available.
// Uses advisory lock to prevent concurrent auto-pause from multiple tasks.
func (s *tasksService) autoPauseCampaign(ctx context.Context, campaignID, taskID uuid.UUID) {
s.campaignRepo.UpdateStatusWithLock(ctx, campaignID, "paused_no_accounts")
s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed")
if s.campaignLogRepo != nil {
s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{
CampaignID: campaignID,
EventType: "auto_paused",
Message: "Campaign auto-paused: no active email accounts available",
})
}
}
// createCampaignTask creates a new campaign task in GCP Cloud Tasks
func (s *tasksService) createCampaignTask(ctx context.Context, campaignID, accountID uuid.UUID, scheduleTime time.Time) error {
// Create task in database with advisory lock
newTaskID := uuid.New()
newTask := &Task{
ID: newTaskID,
TaskType: "campaign",
EmailAccountID: accountID,
Status: "pending",
ScheduledAt: &scheduleTime,
}
campaignTask := &CampaignTask{
TaskID: newTaskID,
CampaignID: &campaignID,
}
if err := s.taskRepo.CreateTaskWithLock(ctx, newTask, campaignTask); err != nil {
return err
}
// Create GCP Cloud Task
processTask := &proto.ProcessTask{
TaskId: newTaskID.String(),
}
cloudTaskName, err := s.tasksClient.CreateTask(ctx, processTask, scheduleTime)
if err != nil {
return err
}
// Update task with cloud task name
if err := s.taskRepo.UpdateTaskScheduledAt(ctx, newTaskID, scheduleTime, cloudTaskName); err != nil {
return err
}
return nil
}
// publishEmailSentEvent publishes email sent event to Kafka
func (s *tasksService) publishEmailSentEvent(
ctx context.Context,
task *Task,
account *Email,
campaign *Campaign,
contact *Contact,
sequence *Sequence,
) {
if s.eventsPublisher == nil {
return
}
if err := s.eventsPublisher.PublishEmailSent(ctx, task, account, campaign, contact, sequence); err != nil {
log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", task.ID.String()).Msg("Failed to publish email sent event")
}
}