mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
312 lines
12 KiB
Go
312 lines
12 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/app/advanced"
|
|
"github.com/warmbly/warmbly/internal/app/cipher"
|
|
"github.com/warmbly/warmbly/internal/app/credits"
|
|
"github.com/warmbly/warmbly/internal/app/feature"
|
|
"github.com/warmbly/warmbly/internal/app/unsublink"
|
|
warmupapp "github.com/warmbly/warmbly/internal/app/warmup"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/events"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/generation"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
"github.com/warmbly/warmbly/internal/scheduler"
|
|
"github.com/warmbly/warmbly/internal/tasks/proto"
|
|
"github.com/warmbly/warmbly/internal/tasksched"
|
|
)
|
|
|
|
// Type aliases for repository types
|
|
type (
|
|
Task = repository.Task
|
|
CampaignTask = repository.CampaignTask
|
|
WarmupTask = repository.WarmupTask
|
|
ContactSequencePair = repository.ContactSequencePair
|
|
)
|
|
|
|
// Type aliases for model types
|
|
type (
|
|
Email = models.Email
|
|
Contact = models.Contact
|
|
Campaign = models.Campaign
|
|
Sequence = models.Sequence
|
|
)
|
|
|
|
type TasksService interface {
|
|
// HandleTask routes a due task by its row's task_type. It is the single
|
|
// entrypoint for both schedulers: the local poller calls it in-process, and
|
|
// the Cloud Tasks webhook forwards to it. All enqueues share one dispatch,
|
|
// so the type is read from the row, not inferred from any route.
|
|
HandleTask(task *proto.ProcessTask) *errx.Error
|
|
HandleCampaignTask(task *proto.ProcessTask) *errx.Error
|
|
HandleEmailTask(task *proto.ProcessTask) *errx.Error
|
|
HandleUserEmailTask(task *proto.ProcessTask) *errx.Error
|
|
|
|
// Test email and preview support. Both are org-scoped: the mailbox has to
|
|
// belong to orgID, and the contact (optional) is resolved by the caller.
|
|
SendTestEmail(ctx context.Context, orgID uuid.UUID, accountID uuid.UUID, recipient string, campaign *models.Campaign, sequence *models.Sequence, contact *models.Contact) *errx.Error
|
|
PreviewEmail(ctx context.Context, orgID uuid.UUID, in EmailPreviewInput) *EmailPreview
|
|
GetCampaignSequences(ctx context.Context, campaignID uuid.UUID) ([]models.Sequence, error)
|
|
|
|
// Warmup scheduling lifecycle
|
|
EnsureWarmupScheduled(ctx context.Context, accountID uuid.UUID) error
|
|
StartWarmupReconciler(ctx context.Context, interval time.Duration)
|
|
|
|
// StartCampaignReconciler re-seeds active campaigns whose self-perpetuating
|
|
// task chain died (swallowed enqueue / crash between ticks). Campaigns have
|
|
// no other bootstrap once started, so this is the stall backstop.
|
|
StartCampaignReconciler(ctx context.Context, interval time.Duration)
|
|
|
|
// SetAI wires the LLM provider + credit ledger the campaign "switch" sequence
|
|
// steps run over (mirrors integration.Service.SetAI). Nil provider leaves
|
|
// AI steps returning a clean "not available".
|
|
SetAI(p generation.Provider, c credits.CreditService)
|
|
|
|
// SetAISearch wires the optional web-search backend the switch step's
|
|
// web-search capability uses (nil = capability silently unavailable).
|
|
SetAISearch(sc generation.SearchClient)
|
|
|
|
// SetAITools wires the web-tool source so research-mode AI variables can run
|
|
// a bounded web-research agent at send time (nil = research degrades to a
|
|
// single completion with one optional web search).
|
|
SetAITools(src AIToolSource)
|
|
|
|
// SetDomainAuthPolicy wires the sending-domain authentication gate, which
|
|
// stops warmup sends from a domain that has been failing SPF/DMARC past
|
|
// the operator's grace window. Nil leaves the state observe-only.
|
|
SetDomainAuthPolicy(p DomainAuthPolicy)
|
|
// SetUnsubscribeLinks wires the signer behind per-recipient unsubscribe
|
|
// links (the List-Unsubscribe header and the link-mode footer).
|
|
SetUnsubscribeLinks(signer *unsublink.Signer)
|
|
// SetCloudLink wires the self-hosted side of the warmup pool link: a
|
|
// mailbox the cloud warms gets no local warmup chain.
|
|
SetCloudLink(r CloudLinkReader)
|
|
|
|
// SetFormLinks wires the minter that resolves {{form_link:...}} markers
|
|
// into per-recipient form URLs. Nil-safe: markers drop with a warning.
|
|
SetFormLinks(m FormLinkMinter)
|
|
}
|
|
|
|
// CloudLinkReader reports whether Warmbly Cloud warms a mailbox.
|
|
type CloudLinkReader interface {
|
|
IsEnrolled(ctx context.Context, accountID uuid.UUID) bool
|
|
}
|
|
|
|
// AIToolSource yields the read-only web tools (search_web, fetch_url) a
|
|
// research-mode AI variable agent runs over, bound to an org. Defined here (not
|
|
// imported from aitools) so the tasks package stays free of the aitools import
|
|
// cycle; *aitools.Registry satisfies it structurally via its WebResearchTools
|
|
// method.
|
|
type AIToolSource interface {
|
|
WebResearchTools(orgID uuid.UUID) []generation.ToolDef
|
|
}
|
|
|
|
// AutomationRunner launches an automation graph by id. It's satisfied
|
|
// structurally by integration.Service, so tasks needs no import of the
|
|
// integration package (mirrors the advanced.EventDispatcher wiring pattern).
|
|
type AutomationRunner interface {
|
|
RunAutomationByID(ctx context.Context, orgID, automationID uuid.UUID, data map[string]any) error
|
|
}
|
|
|
|
type tasksService struct {
|
|
// Infrastructure
|
|
tasksClient tasksched.Scheduler
|
|
generationClient *generation.GenerationClient
|
|
streamingPublisher *pubsub.StreamingPublisher
|
|
eventsPublisher events.Publisher
|
|
|
|
// Services
|
|
scheduler scheduler.SchedulerService
|
|
cipherService cipher.CipherService
|
|
emailSender EmailSender
|
|
featureGate feature.FeatureGateService
|
|
advanced advanced.Service
|
|
warmupHealth warmupapp.Service
|
|
|
|
// Repositories
|
|
taskRepo repository.TaskRepository
|
|
warmupRepo repository.WarmupRepository
|
|
warmupRoutingRepo repository.WarmupRoutingRepository
|
|
warmupContentRepo repository.WarmupContentRepository
|
|
campaignProgressRepo repository.CampaignProgressRepository
|
|
emailRepo repository.EmailRepository
|
|
campaignRepo repository.CampaignRepository
|
|
contactRepo repository.ContactRepository
|
|
segmentRepo repository.SegmentRepository
|
|
campaignLogRepo repository.CampaignLogRepository
|
|
// orgRiskRepo bars a restricted organization from the paid warmup pool.
|
|
// Optional/nil-safe.
|
|
orgRiskRepo repository.OrgRiskRepository
|
|
attachmentRepo repository.AttachmentRepository
|
|
trackedLinkRepo repository.TrackedLinkRepository
|
|
|
|
// automationRunner launches automations from a campaign "run_automation" step.
|
|
automationRunner AutomationRunner
|
|
|
|
// formLinks resolves {{form_link:...}} markers to per-recipient URLs.
|
|
formLinks FormLinkMinter
|
|
|
|
// aiProvider + aiCredits back the campaign "switch" sequence step (SetAI).
|
|
aiProvider generation.Provider
|
|
aiCredits credits.CreditService
|
|
aiSearch generation.SearchClient
|
|
// aiTools sources the web tools research-mode AI variables run a bounded
|
|
// agent over (SetAITools). Nil = research degrades.
|
|
aiTools AIToolSource
|
|
|
|
// warmupSettings caches the warmup generation settings in-process so the
|
|
// per-send AI-vs-static decision doesn't hit Postgres on every warmup.
|
|
warmupSettings *warmupSettingsCache
|
|
|
|
// domainAuth resolves the operator's sending-domain authentication gate.
|
|
// Optional/nil-safe: without it the persisted auth state stays
|
|
// observe-only and no warmup send is ever blocked.
|
|
domainAuth DomainAuthPolicy
|
|
// cloudLink is nil on instances that are not linked to Warmbly Cloud.
|
|
cloudLink CloudLinkReader
|
|
|
|
// unsubLinks mints the signed per-recipient unsubscribe links. Nil or
|
|
// disabled means no link can be minted: the List-Unsubscribe header is
|
|
// left off and link-mode footers fall back to the text line.
|
|
unsubLinks *unsublink.Signer
|
|
}
|
|
|
|
// SetUnsubscribeLinks wires the unsubscribe link signer.
|
|
func (s *tasksService) SetUnsubscribeLinks(signer *unsublink.Signer) {
|
|
s.unsubLinks = signer
|
|
}
|
|
|
|
// DomainAuthPolicy resolves whether the sending-domain authentication gate is
|
|
// enforced, and how long a domain must stay failing before it applies. Narrow
|
|
// and primitive-typed so this package does not depend on the settings package;
|
|
// instancesettings.Service satisfies it.
|
|
type DomainAuthPolicy interface {
|
|
DomainAuth(ctx context.Context) (enforce bool, grace time.Duration)
|
|
}
|
|
|
|
// warmupSettingsCache is a tiny TTL cache over the generation settings.
|
|
type warmupSettingsCache struct {
|
|
mu sync.RWMutex
|
|
val models.WarmupGenerationSettings
|
|
fetched time.Time
|
|
}
|
|
|
|
func NewService(
|
|
tasksClient tasksched.Scheduler,
|
|
generationClient *generation.GenerationClient,
|
|
streamingPublisher *pubsub.StreamingPublisher,
|
|
eventsPublisher events.Publisher,
|
|
scheduler scheduler.SchedulerService,
|
|
cipherService cipher.CipherService,
|
|
emailSender EmailSender,
|
|
featureGate feature.FeatureGateService,
|
|
warmupHealth warmupapp.Service,
|
|
taskRepo repository.TaskRepository,
|
|
warmupRepo repository.WarmupRepository,
|
|
warmupRoutingRepo repository.WarmupRoutingRepository,
|
|
warmupContentRepo repository.WarmupContentRepository,
|
|
campaignProgressRepo repository.CampaignProgressRepository,
|
|
emailRepo repository.EmailRepository,
|
|
campaignRepo repository.CampaignRepository,
|
|
contactRepo repository.ContactRepository,
|
|
campaignLogRepo repository.CampaignLogRepository,
|
|
advanced advanced.Service,
|
|
attachmentRepo repository.AttachmentRepository,
|
|
trackedLinkRepo repository.TrackedLinkRepository,
|
|
automationRunner AutomationRunner,
|
|
) TasksService {
|
|
return &tasksService{
|
|
tasksClient: tasksClient,
|
|
generationClient: generationClient,
|
|
streamingPublisher: streamingPublisher,
|
|
eventsPublisher: eventsPublisher,
|
|
scheduler: scheduler,
|
|
cipherService: cipherService,
|
|
emailSender: emailSender,
|
|
featureGate: featureGate,
|
|
advanced: advanced,
|
|
warmupHealth: warmupHealth,
|
|
taskRepo: taskRepo,
|
|
warmupRepo: warmupRepo,
|
|
warmupRoutingRepo: warmupRoutingRepo,
|
|
warmupContentRepo: warmupContentRepo,
|
|
campaignProgressRepo: campaignProgressRepo,
|
|
emailRepo: emailRepo,
|
|
campaignRepo: campaignRepo,
|
|
contactRepo: contactRepo,
|
|
campaignLogRepo: campaignLogRepo,
|
|
attachmentRepo: attachmentRepo,
|
|
trackedLinkRepo: trackedLinkRepo,
|
|
automationRunner: automationRunner,
|
|
warmupSettings: &warmupSettingsCache{},
|
|
}
|
|
}
|
|
|
|
// SetAI wires the LLM provider + credit ledger for campaign "switch" steps.
|
|
func (s *tasksService) SetAI(p generation.Provider, c credits.CreditService) {
|
|
s.aiProvider = p
|
|
s.aiCredits = c
|
|
}
|
|
|
|
func (s *tasksService) SetAISearch(sc generation.SearchClient) {
|
|
s.aiSearch = sc
|
|
}
|
|
|
|
func (s *tasksService) SetAITools(src AIToolSource) {
|
|
s.aiTools = src
|
|
}
|
|
|
|
// SetDomainAuthPolicy wires the sending-domain authentication gate.
|
|
func (s *tasksService) SetDomainAuthPolicy(p DomainAuthPolicy) {
|
|
s.domainAuth = p
|
|
}
|
|
|
|
// SetCloudLink wires the cloud-warmed mailbox check.
|
|
func (s *tasksService) SetFormLinks(m FormLinkMinter) {
|
|
s.formLinks = m
|
|
}
|
|
|
|
func (s *tasksService) SetCloudLink(r CloudLinkReader) {
|
|
s.cloudLink = r
|
|
}
|
|
|
|
// domainAuthBlocked reports whether this mailbox may not send because its
|
|
// sending domain has been failing authentication past the grace window.
|
|
// Nil policy or enforcement off means never blocked.
|
|
func (s *tasksService) domainAuthBlocked(ctx context.Context, account *models.Email) bool {
|
|
if s.domainAuth == nil || account == nil {
|
|
return false
|
|
}
|
|
enforce, grace := s.domainAuth.DomainAuth(ctx)
|
|
return enforce && account.DomainAuthBlocked(time.Now(), grace)
|
|
}
|
|
|
|
// WireOrgRisk attaches the organization risk posture. Kept off the constructor
|
|
// so the service stays constructible in tests and in deployments without it.
|
|
func (s *tasksService) WireOrgRisk(r repository.OrgRiskRepository) {
|
|
s.orgRiskRepo = r
|
|
}
|
|
|
|
// OrgRiskAware is the optional capability the caller uses to attach org risk.
|
|
type OrgRiskAware interface {
|
|
WireOrgRisk(r repository.OrgRiskRepository)
|
|
}
|
|
|
|
// WireSegments attaches the segment repository the add_to_segment and
|
|
// remove_from_segment action nodes write through.
|
|
func (s *tasksService) WireSegments(r repository.SegmentRepository) {
|
|
s.segmentRepo = r
|
|
}
|
|
|
|
// SegmentAware is the optional capability the caller uses to attach segments.
|
|
type SegmentAware interface {
|
|
WireSegments(r repository.SegmentRepository)
|
|
}
|