package tasks import ( "context" "fmt" "time" "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/tasks/proto" ) func (s *tasksService) HandleUserEmailTask(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 := "user-email:" + taskID.String() executionStatus := "failed" if s.advanced != nil { duplicate, xerr := s.advanced.StartTaskExecution(ctx, taskID, executionKey, map[string]interface{}{ "task_type": "user_email", }) if xerr != nil { return xerr } if duplicate { return nil } defer func() { _ = s.advanced.CompleteTaskExecution(ctx, taskID, executionKey, executionStatus, map[string]interface{}{ "task_type": "user_email", }) }() } // STEP 2: Load task record taskRecord, err := s.taskRepo.GetTask(ctx, taskID) if err != nil { sentry.CaptureException(err) return errx.InternalError() } if taskRecord == nil { // A queued Cloud Task is firing for a task row that no longer // exists in the DB. Treat as success so Cloud Tasks doesn't // retry — the only way this happens is if the row was // explicitly removed (e.g. account/org wipe) and we don't // want phantom retries piling up. log.Info().Str("task_id", taskID.String()). Msg("user_email skipped: task row missing (returning success)") return nil } // Soft-cancel path. The dashboard "Scheduled" view cancels a // queued send by flipping status to 'cancelled' (no Cloud Tasks // DeleteTask call — that's a paid API per cancel). When the // queued task fires, we see the non-pending status and return // success so Cloud Tasks doesn't retry. Also catches: // - completed (duplicate fire / replay) // - failed / dead_lettered (already terminal) // - active (mid-flight by another worker) if taskRecord.Status != "pending" { log.Info(). Str("task_id", taskID.String()). Str("status", taskRecord.Status). Msg("user_email skipped: task not in pending state") return nil } // STEP 3: Load email_task record (with new columns) emailTask, err := s.taskRepo.GetEmailTask(ctx, taskID) if err != nil { sentry.CaptureException(err) return errx.InternalError() } if emailTask == nil { return errx.ErrNotFound } // STEP 4: Load email account account, xerr := s.emailRepo.GetByID(ctx, taskRecord.EmailAccountID) if xerr != nil { return xerr } // Pre-execution guards. The user may have queued this send // hours/days/weeks ago — re-validate that: // (a) the mailbox is still active (not revoked/inactive/deleted) // (b) the org still has unibox/send access (sub didn't lapse) // If either fails we cancel the task with a clear reason instead // of dispatching to the worker and producing an SMTP failure // downstream. if account.Status != "active" { _ = s.taskRepo.RecordTaskFailure(ctx, taskID, "Mailbox no longer active", "email account status="+string(account.Status)+" at execution time") _ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") log.Info().Str("task_id", taskID.String()).Str("status", string(account.Status)). Msg("user_email cancelled: mailbox not active") return nil } // Tenancy gate: the entitlement check below is org-scoped, so a mailbox with // no workspace would send without it. Fail closed. if account.OrganizationID == nil { sentry.CaptureException(fmt.Errorf("email account %s reached a unibox send with no organization", account.ID)) _ = s.taskRepo.RecordTaskFailure(ctx, taskID, "Mailbox has no workspace", "email account organization_id is NULL at execution time") _ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") log.Error().Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()). Msg("user_email cancelled: mailbox has no organization") return nil } if s.featureGate != nil { canUse, _ := s.featureGate.CanUseUnibox(ctx, *account.OrganizationID) if !canUse { _ = s.taskRepo.RecordTaskFailure(ctx, taskID, "Subscription does not allow sending", "feature gate CanUseUnibox=false at execution time") _ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") log.Info().Str("task_id", taskID.String()). Msg("user_email cancelled: feature gate denied at execution time") return nil } } // STEP 5: Mark task as active (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil { sentry.CaptureException(err) return errx.InternalError() } // STEP 6: Add signature if SignatureSync bodyHTML := emailTask.BodyHTML bodyPlain := emailTask.BodyPlain if account.SignatureSync { if bodyHTML != "" { bodyHTML = AddSignature(bodyHTML, account.SignatureHTML, true) } if bodyPlain != "" { bodyPlain = AddSignature(bodyPlain, account.SignaturePlain, false) } } // STEP 7: Generate Message-ID messageID := generateMessageID(account.Email) // STEP 8: Build InReplyTo string var inReplyTo string if len(emailTask.InReplyTo) > 0 { inReplyTo = emailTask.InReplyTo[0] } // The composer's thread is persisted on the task row; without carrying it // through, every dashboard reply starts a new conversation. var threadID string if emailTask.ThreadID != nil { threadID = *emailTask.ThreadID } // STEP 9: Build EmailMessage and send via worker emailMsg := EmailMessage{ From: account.Email, To: emailTask.To, CC: emailTask.CC, BCC: emailTask.BCC, Subject: emailTask.Subject, BodyHTML: bodyHTML, BodyPlain: bodyPlain, InReplyTo: inReplyTo, MessageID: messageID, ThreadID: threadID, IsWarmup: false, Tracking: nil, } if err := s.emailSender.Send(ctx, taskID, emailMsg, *account); err != nil { s.taskRepo.RecordTaskFailure(ctx, taskID, "Send failed", err.Error()) if s.advanced != nil { _ = s.advanced.CaptureTaskDeadLetter(ctx, taskID, "user_email", map[string]interface{}{ "account_id": account.ID.String(), "to": emailTask.To, }, err.Error(), 1) _ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "dead_lettered") } return nil } // STEP 10: Update task record taskRecord.MessageID = messageID taskRecord.Status = "completed" // STEP 11: Mark task completed (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil { sentry.CaptureException(err) return errx.InternalError() } // STEP 12: Publish events log.Info().Str("task_id", task.TaskId).Str("account_id", account.ID.String()).Msg("user email sent") executionStatus = "completed" return nil }