mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
576 lines
18 KiB
Go
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")
|
|
}
|
|
}
|