From 7f1bae8cacc32470c9df04b18418dcd4a56b7196 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 20:18:26 -0700 Subject: [PATCH 01/11] feat: default the OpenAI writing, agent and warmup content batch models to gpt-6-luna instead of gpt-4o-mini, gpt-4o and gpt-5-mini, and document the new preset default in the deployment guide --- docs/content/docs/api/reference/deliverability-ops.mdx | 2 +- docs/content/docs/development/architecture.mdx | 2 +- docs/content/docs/development/deployment-guide.mdx | 2 +- internal/models/warmup_content.go | 4 ++-- internal/pkg/generation/anthropic.go | 4 ++-- internal/pkg/generation/conversation.go | 4 ++-- internal/pkg/generation/openai_provider.go | 2 +- internal/pkg/generation/provider.go | 2 +- internal/pkg/generation/resolve.go | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/content/docs/api/reference/deliverability-ops.mdx b/docs/content/docs/api/reference/deliverability-ops.mdx index d21519336..08a42eb0a 100644 --- a/docs/content/docs/api/reference/deliverability-ops.mdx +++ b/docs/content/docs/api/reference/deliverability-ops.mdx @@ -812,7 +812,7 @@ At least one of the three must carry something, and the three together are cappe "score": 84, "issues": [] }, - "model": "gpt-4o-mini", + "model": "gpt-6-luna", "tokens_used": 812, "credits_remaining": 248, "credits_charged": 2 diff --git a/docs/content/docs/development/architecture.mdx b/docs/content/docs/development/architecture.mdx index 5879cddfe..f52d9c482 100644 --- a/docs/content/docs/development/architecture.mdx +++ b/docs/content/docs/development/architecture.mdx @@ -185,7 +185,7 @@ Cold-email throughput is mailbox-first, not worker-first. A worker's safe outbou Pool model: warmup traffic is segregated into `free` and `premium` pools (the `warmup_pools` tables in `internal/infrastructure/db/migrations/000001_baseline.up.sql`). Pool membership is a property of the mailbox, independent of which worker happens to be sending for it. -Warmup content generation is an offline control-plane workload. The autonomous controller runs every six hours, derives a shared-bank target from seven-day send demand, maintains a 200-thread floor, scales up to 5,000 threads, and submits at most 250 replacements per batch within a 1,000-thread daily cap. It deliberately does not split generation by customer mailbox tags, which would create unbounded queues and smaller, more repetitive content cohorts. Scheduled top-ups use `gpt-5-mini` through durable provider batch jobs rather than synchronous model calls, with a database uniqueness guard that prevents duplicate in-flight scheduled batches when several backend replicas are running. Content with at least 20 sampled sends is automatically archived when it has at least 3 spam placements and a placement rate of 15% or more. The send path atomically selects and increments a least-used active conversation through an indexed query, then falls back to the static reviewed library if no generated content is available. Model availability therefore never gates a warmup send. +Warmup content generation is an offline control-plane workload. The autonomous controller runs every six hours, derives a shared-bank target from seven-day send demand, maintains a 200-thread floor, scales up to 5,000 threads, and submits at most 250 replacements per batch within a 1,000-thread daily cap. It deliberately does not split generation by customer mailbox tags, which would create unbounded queues and smaller, more repetitive content cohorts. Scheduled top-ups use `gpt-6-luna` through durable provider batch jobs rather than synchronous model calls, with a database uniqueness guard that prevents duplicate in-flight scheduled batches when several backend replicas are running. Content with at least 20 sampled sends is automatically archived when it has at least 3 spam placements and a placement rate of 15% or more. The send path atomically selects and increments a least-used active conversation through an indexed query, then falls back to the static reviewed library if no generated content is available. Model availability therefore never gates a warmup send. ## Anti-abuse layers diff --git a/docs/content/docs/development/deployment-guide.mdx b/docs/content/docs/development/deployment-guide.mdx index 332723761..ead0999d9 100644 --- a/docs/content/docs/development/deployment-guide.mdx +++ b/docs/content/docs/development/deployment-guide.mdx @@ -675,7 +675,7 @@ AI_BASE_URL= # required for custom (any OpenAI-compatible endpoint) | `AI_PROVIDER` | Endpoint | Notes | |---|---|---| -| `openai` | api.openai.com | also the only provider for warmup content batch generation | +| `openai` | api.openai.com | defaults to `gpt-6-luna`; also the only provider for warmup content batch generation | | `openrouter` | openrouter.ai | one key fronts every vendor; switch via `AI_MODEL` | | `groq` | api.groq.com | fast, free tier | | `ollama` | localhost:11434 | no key, free local model (`AI_FREE` is automatically true) | diff --git a/internal/models/warmup_content.go b/internal/models/warmup_content.go index b9ebeea33..33132dd40 100644 --- a/internal/models/warmup_content.go +++ b/internal/models/warmup_content.go @@ -140,7 +140,7 @@ func DefaultWarmupGenerationSettings() WarmupGenerationSettings { CadenceHours: 6, RefreshEnabled: true, RefreshPerRun: 10, - Model: "gpt-5-mini", + Model: "gpt-6-luna", MaxMessagesPerThread: 5, DailyGenerationCap: 1000, AISelectionShare: 70, @@ -174,7 +174,7 @@ func (s *WarmupGenerationSettings) Normalize() { s.RefreshPerRun = 25 s.DailyGenerationCap = 1000 s.AISelectionShare = 70 - s.Model = "gpt-5-mini" + s.Model = "gpt-6-luna" s.MaxMessagesPerThread = 5 s.Engagement.SpamRescueRate = clampPct(s.Engagement.SpamRescueRate) s.Engagement.MarkImportantRate = clampPct(s.Engagement.MarkImportantRate) diff --git a/internal/pkg/generation/anthropic.go b/internal/pkg/generation/anthropic.go index aca86ed50..d0b3b250f 100644 --- a/internal/pkg/generation/anthropic.go +++ b/internal/pkg/generation/anthropic.go @@ -27,8 +27,8 @@ const ( // OpenAI models. These are the PRIMARY writing/agent models for Warmbly's // hosted product (OpenAI-first) and the default for any OpenAI-compatible // self-hosted endpoint. - ModelWritingFreeOpenAI = "gpt-4o-mini" - ModelWritingPaidOpenAI = "gpt-4o" + ModelWritingFreeOpenAI = "gpt-6-luna" + ModelWritingPaidOpenAI = "gpt-6-luna" // Agent-loop models for the Anthropic connector (self-host only): haiku for // trial orgs, sonnet for paid. diff --git a/internal/pkg/generation/conversation.go b/internal/pkg/generation/conversation.go index c5ff121a1..e9b18fe53 100644 --- a/internal/pkg/generation/conversation.go +++ b/internal/pkg/generation/conversation.go @@ -97,7 +97,7 @@ func conversationResponseFormat() openai.ChatCompletionNewParamsResponseFormatUn // thread. Both GenerateConversation (sync) and SubmitBatch (Batch API) use this // so the request bodies are identical; the only difference is the transport. func buildConversationParams(theme, model string, maxMessages int) openai.ChatCompletionNewParams { - chatModel := openai.ChatModelGPT4oMini + chatModel := openai.ChatModel(ModelWritingFreeOpenAI) if model != "" { chatModel = openai.ChatModel(model) } @@ -116,7 +116,7 @@ func buildConversationParams(theme, model string, maxMessages int) openai.ChatCo // The prompt is tuned for warmup deliverability: plaintext, a few short // sentences, one natural question, and explicitly NO links, phone numbers, // attachments, emoji, or marketing language (all of which raise spam scores). -// Empty model falls back to gpt-4o-mini for direct callers. +// Empty model falls back to ModelWritingFreeOpenAI for direct callers. func (c *GenerationClient) GenerateConversation(ctx context.Context, theme, model string, maxMessages int) (*Conversation, error) { req := buildConversationParams(theme, model, normalizeMaxMessages(maxMessages)) diff --git a/internal/pkg/generation/openai_provider.go b/internal/pkg/generation/openai_provider.go index 9b878b3a3..aaf229b84 100644 --- a/internal/pkg/generation/openai_provider.go +++ b/internal/pkg/generation/openai_provider.go @@ -57,7 +57,7 @@ const defaultOpenAIBaseURL = "https://api.openai.com/v1" // defaultLocalModel is the fall-back model tag for a free/local backend // (AI_FREE) when no explicit model id is set, so a dev who forgets -// AI_MODEL does not 404 on "gpt-4o-mini". llama3.1 has reliable +// AI_MODEL does not 404 on "gpt-6-luna". llama3.1 has reliable // tool-calling in Ollama; override with AI_MODEL (or AI_MODEL_TRIAL / _PAID). const defaultLocalModel = "llama3.1" diff --git a/internal/pkg/generation/provider.go b/internal/pkg/generation/provider.go index 32ed066d9..1dfaa9a06 100644 --- a/internal/pkg/generation/provider.go +++ b/internal/pkg/generation/provider.go @@ -219,7 +219,7 @@ type ProviderConfig struct { OpenAIBaseURL string // OpenAIModelTrial / OpenAIModelPaid override the per-tier model ids (so a // self-hoster can route to whatever their endpoint serves). Empty uses the - // built-in gpt-4o-mini / gpt-4o defaults (or the local default when Local). + // built-in gpt-6-luna default (or the local default when Local). OpenAIModelTrial string OpenAIModelPaid string diff --git a/internal/pkg/generation/resolve.go b/internal/pkg/generation/resolve.go index b179280c1..99c48444b 100644 --- a/internal/pkg/generation/resolve.go +++ b/internal/pkg/generation/resolve.go @@ -44,7 +44,7 @@ type providerPreset struct { func presetFor(name string) (providerPreset, bool) { switch name { case "openai": - // Model default handled by newOpenAIProvider (gpt-4o-mini / gpt-4o). + // Model default handled by newOpenAIProvider (gpt-6-luna). return providerPreset{baseURL: defaultOpenAIBaseURL, needsKey: true}, true case "openrouter": return providerPreset{baseURL: "https://openrouter.ai/api/v1", defaultModel: "meta-llama/llama-3.1-8b-instruct:free", needsKey: true}, true From 7f324a38acba69d4ef17802cc31905f5e7370616 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 20:48:15 -0700 Subject: [PATCH 02/11] feat: open inbox placement tests to workspaces with probes rendered like the campaign send and paced as placement tasks, Message-ID matching instead of a subject token and warmup header, instance, workspace and Warmbly Cloud seed panels, a tracking comparison, scheduled campaign monitors with alerts and optional auto-pause, monthly allowances, realtime updates and org transfer registration --- cmd/backend/main.go | 38 +- cmd/consumer/main.go | 1 + internal/api/handler/placement.go | 729 ++++++++--- internal/api/routes.go | 25 + internal/app/cloudlink/placement.go | 56 + internal/app/cloudlink/service.go | 8 + internal/app/consumer/event_new_email.go | 8 + internal/app/consumer/event_send_result.go | 11 + internal/app/consumer/service.go | 9 +- internal/app/email/handler.go | 9 + internal/app/email/loader.go | 12 + internal/app/email/service.go | 14 + internal/app/guardrail/guardrail.go | 3 + internal/app/guardrail/service.go | 26 + internal/app/instancesettings/document.go | 73 ++ internal/app/instancesettings/service.go | 8 + internal/app/notification/push.go | 4 + internal/app/orgtransfer/spec.go | 26 +- internal/app/placement/monitor.go | 254 ++++ internal/app/placement/remote.go | 219 ++++ internal/app/placement/seeds.go | 186 +++ internal/app/placement/service.go | 826 ++++++++++--- internal/app/placement/tick.go | 159 +++ internal/app/placement/view.go | 251 ++++ internal/config/constants.go | 20 + .../000217_placement_tests.down.sql | 65 + .../migrations/000217_placement_tests.up.sql | 115 ++ internal/infrastructure/pubsub/events.go | 34 + internal/jobs/placement_poller.go | 19 +- internal/models/advanced_outreach.go | 20 +- internal/models/audit.go | 5 + internal/models/notification.go | 15 + internal/models/placement.go | 374 ++++++ internal/pkg/mailhost/mailhost.go | 23 + internal/pkg/mailhost/mailhost_test.go | 22 + internal/repository/pg_advanced_outreach.go | 9 +- internal/repository/pg_behavior.go | 2 +- internal/repository/pg_email.go | 6 + internal/repository/pg_placement.go | 1090 ++++++++++++----- internal/repository/pg_task.go | 39 +- internal/tasks/dispatch.go | 2 + internal/tasks/placement_task.go | 270 ++++ internal/tasks/service.go | 7 + .../lib/realtime_web/channels/org_channel.ex | 4 + 44 files changed, 4375 insertions(+), 721 deletions(-) create mode 100644 internal/app/cloudlink/placement.go create mode 100644 internal/app/placement/monitor.go create mode 100644 internal/app/placement/remote.go create mode 100644 internal/app/placement/seeds.go create mode 100644 internal/app/placement/tick.go create mode 100644 internal/app/placement/view.go create mode 100644 internal/infrastructure/db/migrations/000217_placement_tests.down.sql create mode 100644 internal/infrastructure/db/migrations/000217_placement_tests.up.sql create mode 100644 internal/models/placement.go create mode 100644 internal/tasks/placement_task.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index bb7546964..2cec9ff67 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1945,14 +1945,6 @@ func main() { emailVerificationScheduler := jobs.NewEmailVerificationScheduler(emailVerificationJob, time.Duration(config.VerificationIntervalSeconds)*time.Second) go emailVerificationScheduler.Start(ctx) - // Seed inbox-placement testing: send a tokenized copy of a template - // through a real sender to the seed panel, then classify where it landed - // by looking the token up in each seed's synced unibox entries. - placementRepository = repository.NewPlacementRepository(primaryDB) - placementService = placement.NewService(placementRepository, emailRepostory, emailSender) - placementPoller := jobs.NewPlacementPoller(placementService, 2*time.Minute) - go placementPoller.Start(ctx) - // Auto-pause guardrails: pause any active campaign whose bounce, // complaint, or reply rate has left the band its owner set. Fifteen // minutes is fast enough that a campaign cannot do much damage inside @@ -1968,6 +1960,36 @@ func main() { guardrailScheduler := jobs.NewGuardrailScheduler(guardrailJob, 15*time.Minute) go guardrailScheduler.Start(ctx) + // Inbox placement tests: a template or campaign step rendered as the + // campaign would send it, one paced task per seed, read back from each + // seed's synced mail by Message-ID. + placementRepository = repository.NewPlacementRepository(primaryDB) + placementDeps := placement.Deps{ + Repo: placementRepository, + Emails: emailRepostory, + Campaigns: campaignRepostory, + Contacts: contactRepostory, + Tasks: taskRepository, + Scheduler: tasksClient, + Policy: instanceSettings, + Gate: featureGateService, + Notifier: notificationService, + Mailboxes: emailService, + Pauser: guardrailService, + } + if streamingPublisher != nil { + placementDeps.Publisher = streamingPublisher + } + // A self-hosted instance borrows Warmbly Cloud's panel through its link; + // the hosted product is the cloud and runs its own. + if config.SelfHosted() && cloudLinkService != nil { + placementDeps.Cloud = cloudLinkService + } + placementService = placement.NewService(placementDeps) + tasksService.SetPlacement(placementRepository) + emailService.WireSeedScope(placementRepository) + go jobs.NewPlacementPoller(placementService, 30*time.Second).Start(ctx) + // Advisor. Detection is deterministic Go over a per-org snapshot and // always runs; the narrator is optional and only rewrites the card copy, // so an install with no LLM provider still gets every recommendation. diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index fb5d17232..e26e1a869 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -469,6 +469,7 @@ func main() { WarmupContentRepo: repository.NewWarmupContentRepository(primaryDB.Pool), WarmupEngagementRepo: repository.NewWarmupEngagementRepository(primaryDB.Pool), WarmupPlacementRepo: repository.NewWarmupPlacementRepository(primaryDB), + PlacementRepo: repository.NewPlacementRepository(primaryDB), WarmupService: warmupService, WorkerRepo: workerRepo, FleetNodeRepo: repository.NewFleetNodeRepository(primaryDB), diff --git a/internal/api/handler/placement.go b/internal/api/handler/placement.go index 6ddd84ef6..735ef56c5 100644 --- a/internal/api/handler/placement.go +++ b/internal/api/handler/placement.go @@ -2,101 +2,95 @@ package handler import ( "net/http" - "time" + "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/app/placement" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" - "github.com/warmbly/warmbly/internal/repository" ) -// placementEntity tags placement actions in the admin audit trail. -const placementEntity models.AuditEntityType = "placement_test" - -// --- DTOs -------------------------------------------------------------- - -type placementTestRow struct { - ID uuid.UUID `json:"id"` - OrganizationID *uuid.UUID `json:"organization_id"` - SenderAccountID uuid.UUID `json:"sender_account_id"` - Subject string `json:"subject"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - FinishedAt *time.Time `json:"finished_at"` -} - -func toPlacementTestRow(t repository.PlacementTest) placementTestRow { - return placementTestRow{ - ID: t.ID, - OrganizationID: t.OrganizationID, - SenderAccountID: t.SenderAccountID, - Subject: t.Subject, - Status: t.Status, - CreatedAt: t.CreatedAt, - FinishedAt: t.FinishedAt, +func (h *Handler) placementReady(c *gin.Context) bool { + if h.PlacementService == nil { + errx.JSON(c, errx.New(errx.NotImplemented, "placement testing is not configured")) + return false } + return true } -type placementResultRow struct { - SeedAccountID uuid.UUID `json:"seed_account_id"` - Provider string `json:"provider"` - Folder string `json:"folder"` - DetectedAt *time.Time `json:"detected_at"` - RawFlags string `json:"raw_flags"` -} - -// providerRollup aggregates per-provider folder counts for a test. -type providerRollup struct { - Provider string `json:"provider"` - Inbox int `json:"inbox"` - Promotions int `json:"promotions"` - Spam int `json:"spam"` - Other int `json:"other"` - Pending int `json:"pending"` - Total int `json:"total"` -} - -type seedAccountRow struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Provider string `json:"provider"` - Status string `json:"status"` - WorkerID *uuid.UUID `json:"worker_id"` - IsSeed bool `json:"is_seed"` -} - -func toSeedRow(s repository.SeedAccount) seedAccountRow { - return seedAccountRow{ - ID: s.ID, - Email: s.Email, - Name: s.Name, - Provider: s.Provider, - Status: s.Status, - WorkerID: s.WorkerID, - IsSeed: s.IsSeed, +// placementLimit parses ?limit into [1, 100], 400 on anything else. +func placementLimit(c *gin.Context) (int, bool) { + raw := c.Query("limit") + if raw == "" { + return 25, true } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 100 { + errx.JSON(c, errx.New(errx.BadRequest, "invalid limit")) + return 0, false + } + return n, true } -// --- Tests ------------------------------------------------------------- +func optionalUUID(c *gin.Context, raw, field string) (*uuid.UUID, bool) { + if raw == "" { + return nil, true + } + id, err := uuid.Parse(raw) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid "+field)) + return nil, false + } + return &id, true +} + +// --- Workspace ---------------------------------------------------------- + +// GetPlacementOverview lists the seed panels this workspace can test on and +// its monthly allowance. +func (h *Handler) GetPlacementOverview(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + out, xerr := h.PlacementService.Overview(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} type createPlacementTestRequest struct { SenderAccountID string `json:"sender_account_id"` + CampaignID string `json:"campaign_id"` + SequenceID string `json:"sequence_id"` + ContactID string `json:"contact_id"` Subject string `json:"subject"` - BodyPlain string `json:"body_plain"` BodyHTML string `json:"body_html"` + BodyPlain string `json:"body_plain"` + Tracking string `json:"tracking"` + Panel string `json:"panel"` } -// AdminCreatePlacementTest sends a tokenized copy of a template through a chosen -// sender to every active seed mailbox, recording one pending result per seed. -func (h *Handler) AdminCreatePlacementTest(c *gin.Context) { - if h.PlacementService == nil { - errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured")) +// CreatePlacementTest starts a test: one test, or two for a tracking +// comparison. +func (h *Handler) CreatePlacementTest(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { return } - var req createPlacementTestRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.InvalidBody(err)) @@ -107,24 +101,346 @@ func (h *Handler) AdminCreatePlacementTest(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, "invalid sender_account_id")) return } - - test, serr := h.PlacementService.CreateTest(c.Request.Context(), nil, senderID, req.Subject, req.BodyPlain, req.BodyHTML) - if serr != nil { - errx.JSON(c, errx.New(errx.BadRequest, serr.Error())) + if !middleware.APIKeyAllowsEmailAccount(c, senderID) { + errx.JSON(c, errx.New(errx.Forbidden, "this API key cannot send from that mailbox")) return } + campaignID, ok := optionalUUID(c, req.CampaignID, "campaign_id") + if !ok { + return + } + sequenceID, ok := optionalUUID(c, req.SequenceID, "sequence_id") + if !ok { + return + } + contactID, ok := optionalUUID(c, req.ContactID, "contact_id") + if !ok { + return + } + var userID *uuid.UUID + if id, err := middleware.GetUserUUID(c); err == nil && id != uuid.Nil { + userID = &id + } - h.audit(c, models.AuditActionCreate, placementEntity, &test.ID, map[string]string{ - "sender_account_id": senderID.String(), - "subject": test.Subject, + tests, xerr := h.PlacementService.CreateTests(c.Request.Context(), placement.CreateInput{ + OrgID: *orgID, + UserID: userID, + SenderAccountID: senderID, + CampaignID: campaignID, + SequenceID: sequenceID, + ContactID: contactID, + Subject: req.Subject, + BodyHTML: req.BodyHTML, + BodyPlain: req.BodyPlain, + Tracking: req.Tracking, + Panel: req.Panel, + Origin: models.PlacementOriginManual, }) - c.JSON(http.StatusOK, gin.H{"data": toPlacementTestRow(*test)}) + if xerr != nil { + errx.JSON(c, xerr) + return + } + for _, t := range tests { + id := t.ID + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityPlacementTest, &id, nil, map[string]string{ + "sender": t.SenderEmail, + "panel": t.Panel, + }) + } + c.JSON(http.StatusCreated, gin.H{"data": tests}) } -// AdminListPlacementTests lists placement tests, newest first. -func (h *Handler) AdminListPlacementTests(c *gin.Context) { +// ListPlacementTests lists the workspace's tests, newest first. +func (h *Handler) ListPlacementTests(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + offset, ok := decodeOffsetCursor(c.Query("cursor")) + if !ok { + errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor")) + return + } + limit, ok := placementLimit(c) + if !ok { + return + } + campaignID, ok := optionalUUID(c, c.Query("campaign_id"), "campaign_id") + if !ok { + return + } + tests, total, xerr := h.PlacementService.ListTests(c.Request.Context(), orgID, campaignID, limit, offset) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": tests, "pagination": pageMetaFor(offset, limit, len(tests), total)}) +} + +// GetPlacementTest returns one test with every copy, the content check and, +// for a tracking comparison, the other half. +func (h *Handler) GetPlacementTest(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + detail, xerr := h.PlacementService.GetTest(c.Request.Context(), orgID, id) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": detail}) +} + +// CancelPlacementTest stops the copies that have not been sent yet. +func (h *Handler) CancelPlacementTest(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + view, xerr := h.PlacementService.CancelTest(c.Request.Context(), *orgID, id) + if xerr != nil { + errx.JSON(c, xerr) + return + } + h.auditOrg(c, models.AuditActionStop, models.AuditEntityPlacementTest, &id, nil, nil) + c.JSON(http.StatusOK, gin.H{"data": view}) +} + +// ListPlacementSeeds lists the workspace's mailboxes and which are its seed +// inboxes. +func (h *Handler) ListPlacementSeeds(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + seeds, xerr := h.PlacementService.ListWorkspaceSeeds(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + allowed := seeds[:0] + for _, s := range seeds { + if middleware.APIKeyAllowsEmailAccount(c, s.EmailAccountID) { + allowed = append(allowed, s) + } + } + c.JSON(http.StatusOK, gin.H{"data": allowed}) +} + +type setPlacementSeedRequest struct { + Seed *bool `json:"seed"` +} + +// SetPlacementSeed makes a workspace mailbox a seed inbox, or not. +func (h *Handler) SetPlacementSeed(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + var req setPlacementSeedRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + if req.Seed == nil { + errx.JSON(c, errx.New(errx.BadRequest, "seed is required")) + return + } + seed, xerr := h.PlacementService.SetWorkspaceSeed(c.Request.Context(), *orgID, id, *req.Seed) + if xerr != nil { + errx.JSON(c, xerr) + return + } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityEmailAccount, &id, map[string]string{ + "placement_seed": strconv.FormatBool(*req.Seed), + }, map[string]string{"email": seed.Email}) + c.JSON(http.StatusOK, gin.H{"data": seed}) +} + +// GetPlacementMonitor returns a campaign's scheduled placement test, null when +// it has none. +func (h *Handler) GetPlacementMonitor(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + m, xerr := h.PlacementService.GetMonitor(c.Request.Context(), *orgID, campaignID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": m}) +} + +// PutPlacementMonitor creates or updates a campaign's scheduled test. A PUT of +// the same body lands on the same state, so it needs no Idempotency-Key. +func (h *Handler) PutPlacementMonitor(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + var in placement.MonitorInput + if err := c.ShouldBindJSON(&in); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + userID, _ := middleware.GetUserUUID(c) + m, xerr := h.PlacementService.PutMonitor(c.Request.Context(), *orgID, userID, campaignID, in) + if xerr != nil { + errx.JSON(c, xerr) + return + } + id := m.ID + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityPlacementMonitor, &id, nil, map[string]string{ + "campaign_id": campaignID.String(), + }) + c.JSON(http.StatusOK, gin.H{"data": m}) +} + +// DeletePlacementMonitor removes a campaign's scheduled test. +func (h *Handler) DeletePlacementMonitor(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + if !h.placementReady(c) { + return + } + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + if xerr := h.PlacementService.DeleteMonitor(c.Request.Context(), *orgID, campaignID); xerr != nil { + errx.JSON(c, xerr) + return + } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityPlacementMonitor, nil, nil, map[string]string{ + "campaign_id": campaignID.String(), + }) + c.Status(http.StatusNoContent) +} + +// --- Admin ---------------------------------------------------------------- + +type adminCreatePlacementTestRequest struct { + SenderAccountID string `json:"sender_account_id"` + Subject string `json:"subject"` + BodyPlain string `json:"body_plain"` + BodyHTML string `json:"body_html"` + Tracking string `json:"tracking"` +} + +// AdminCreatePlacementTest runs a test from any mailbox on the instance panel, +// outside every workspace allowance. +func (h *Handler) AdminCreatePlacementTest(c *gin.Context) { + if !h.placementReady(c) { + return + } + var req adminCreatePlacementTestRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + senderID, err := uuid.Parse(req.SenderAccountID) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid sender_account_id")) + return + } if h.PlacementRepo == nil { - errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured")) + errx.JSON(c, errx.New(errx.NotImplemented, "placement testing is not configured")) + return + } + sender, err := h.PlacementRepo.GetSeedAccount(c.Request.Context(), senderID) + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + if sender == nil || sender.OrganizationID == nil { + errx.JSON(c, errx.New(errx.NotFound, "sending mailbox not found")) + return + } + tests, xerr := h.PlacementService.CreateTests(c.Request.Context(), placement.CreateInput{ + OrgID: *sender.OrganizationID, + SenderAccountID: senderID, + Subject: req.Subject, + BodyHTML: req.BodyHTML, + BodyPlain: req.BodyPlain, + Tracking: req.Tracking, + Panel: models.PlacementPanelInstance, + Origin: models.PlacementOriginAdmin, + }) + if xerr != nil { + errx.JSON(c, xerr) + return + } + for _, t := range tests { + id := t.ID + h.audit(c, models.AuditActionCreate, models.AuditEntityPlacementTest, &id, map[string]string{ + "sender_account_id": senderID.String(), + }) + } + c.JSON(http.StatusOK, gin.H{"data": tests}) +} + +// AdminListPlacementTests lists every workspace's tests, newest first. +func (h *Handler) AdminListPlacementTests(c *gin.Context) { + if !h.placementReady(c) { return } offset, ok := decodeOffsetCursor(c.Query("cursor")) @@ -133,24 +449,17 @@ func (h *Handler) AdminListPlacementTests(c *gin.Context) { return } limit := parseLimit(c.Query("limit"), 25) - - tests, total, err := h.PlacementRepo.ListTests(c.Request.Context(), nil, limit, offset) - if err != nil { - errx.JSON(c, errx.InternalError()) + tests, total, xerr := h.PlacementService.ListTests(c.Request.Context(), nil, nil, limit, offset) + if xerr != nil { + errx.JSON(c, xerr) return } - rows := make([]placementTestRow, 0, len(tests)) - for _, t := range tests { - rows = append(rows, toPlacementTestRow(t)) - } - c.JSON(http.StatusOK, gin.H{"data": rows, "pagination": pageMetaFor(offset, limit, len(tests), total)}) + c.JSON(http.StatusOK, gin.H{"data": tests, "pagination": pageMetaFor(offset, limit, len(tests), total)}) } -// AdminGetPlacementTest returns a test with its per-provider rollup and the -// per-seed detail rows. +// AdminGetPlacementTest returns any test in full. func (h *Handler) AdminGetPlacementTest(c *gin.Context) { - if h.PlacementRepo == nil { - errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured")) + if !h.placementReady(c) { return } id, err := uuid.Parse(c.Param("id")) @@ -158,113 +467,43 @@ func (h *Handler) AdminGetPlacementTest(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) return } - - test, results, err := h.PlacementRepo.GetTestWithResults(c.Request.Context(), id) - if err != nil { - errx.JSON(c, errx.InternalError()) + detail, xerr := h.PlacementService.GetTest(c.Request.Context(), nil, id) + if xerr != nil { + errx.JSON(c, xerr) return } - if test == nil { - errx.JSON(c, errx.New(errx.NotFound, "placement test not found")) - return - } - - rollups := map[string]*providerRollup{} - resultRows := make([]placementResultRow, 0, len(results)) - for _, r := range results { - provider := r.Provider - if provider == "" { - provider = "unknown" - } - ru, ok := rollups[provider] - if !ok { - ru = &providerRollup{Provider: provider} - rollups[provider] = ru - } - ru.Total++ - switch r.Folder { - case repository.PlacementFolderInbox: - ru.Inbox++ - case repository.PlacementFolderPromotions: - ru.Promotions++ - case repository.PlacementFolderSpam: - ru.Spam++ - case repository.PlacementFolderOther: - ru.Other++ - default: - ru.Pending++ - } - resultRows = append(resultRows, placementResultRow{ - SeedAccountID: r.SeedAccountID, - Provider: r.Provider, - Folder: r.Folder, - DetectedAt: r.DetectedAt, - RawFlags: r.RawFlags, - }) - } - - rollup := make([]providerRollup, 0, len(rollups)) - for _, ru := range rollups { - rollup = append(rollup, *ru) - } - - c.JSON(http.StatusOK, gin.H{ - "data": gin.H{ - "test": toPlacementTestRow(*test), - "rollup": rollup, - "results": resultRows, - }, - }) + c.JSON(http.StatusOK, gin.H{"data": detail}) } -// --- Seeds ------------------------------------------------------------- - -// AdminListSeedMailboxes lists the configured seed panel. +// AdminListSeedMailboxes lists the instance panel. func (h *Handler) AdminListSeedMailboxes(c *gin.Context) { - if h.PlacementRepo == nil { - errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured")) + if !h.placementReady(c) { return } - seeds, err := h.PlacementRepo.ListSeedAccounts(c.Request.Context(), false) - if err != nil { - errx.JSON(c, errx.InternalError()) + seeds, xerr := h.PlacementService.AdminListSeeds(c.Request.Context()) + if xerr != nil { + errx.JSON(c, xerr) return } - rows := make([]seedAccountRow, 0, len(seeds)) - for _, s := range seeds { - rows = append(rows, toSeedRow(s)) - } - c.JSON(http.StatusOK, gin.H{"data": rows}) + c.JSON(http.StatusOK, gin.H{"data": seeds}) } -// AdminListSeedCandidates lists connected mailboxes an admin can flag as seeds, -// optionally filtered by an email substring. +// AdminListSeedCandidates searches connected mailboxes to add to the panel. func (h *Handler) AdminListSeedCandidates(c *gin.Context) { - if h.PlacementRepo == nil { - errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured")) + if !h.placementReady(c) { return } - limit := parseLimit(c.Query("limit"), 50) - candidates, err := h.PlacementRepo.ListSeedCandidates(c.Request.Context(), c.Query("search"), limit) - if err != nil { - errx.JSON(c, errx.InternalError()) + seeds, xerr := h.PlacementService.AdminSeedCandidates(c.Request.Context(), c.Query("search"), parseLimit(c.Query("limit"), 50)) + if xerr != nil { + errx.JSON(c, xerr) return } - rows := make([]seedAccountRow, 0, len(candidates)) - for _, s := range candidates { - rows = append(rows, toSeedRow(s)) - } - c.JSON(http.StatusOK, gin.H{"data": rows}) + c.JSON(http.StatusOK, gin.H{"data": seeds}) } -type setSeedRequest struct { - IsSeed bool `json:"is_seed"` -} - -// AdminSetSeedMailbox toggles is_seed on a mailbox (register/unregister a seed). +// AdminSetSeedMailbox adds a mailbox to the instance panel or takes it off. func (h *Handler) AdminSetSeedMailbox(c *gin.Context) { - if h.PlacementRepo == nil { - errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured")) + if !h.placementReady(c) { return } id, err := uuid.Parse(c.Param("id")) @@ -272,31 +511,117 @@ func (h *Handler) AdminSetSeedMailbox(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) return } - var req setSeedRequest + var req setPlacementSeedRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.InvalidBody(err)) return } - - seed, err := h.PlacementRepo.GetSeedAccount(c.Request.Context(), id) - if err != nil { - errx.JSON(c, errx.InternalError()) + if req.Seed == nil { + errx.JSON(c, errx.New(errx.BadRequest, "seed is required")) return } - if seed == nil { - errx.JSON(c, errx.New(errx.NotFound, "mailbox not found")) + seed, xerr := h.PlacementService.AdminSetSeed(c.Request.Context(), id, *req.Seed) + if xerr != nil { + errx.JSON(c, xerr) return } - - if err := h.PlacementRepo.SetIsSeed(c.Request.Context(), id, req.IsSeed); err != nil { - errx.JSON(c, errx.InternalError()) - return - } - - action := models.AuditActionUpdate - h.audit(c, action, placementEntity, &id, map[string]string{ - "is_seed": map[bool]string{true: "true", false: "false"}[req.IsSeed], - "email": seed.Email, + h.audit(c, models.AuditActionUpdate, models.AuditEntityPlacementTest, &id, map[string]string{ + "seed": strconv.FormatBool(*req.Seed), + "email": seed.Email, }) - c.JSON(http.StatusOK, gin.H{"ok": true}) + c.JSON(http.StatusOK, gin.H{"data": seed}) +} + +// --- Warmbly Cloud, for linked instances ----------------------------------- + +// PoolLinkPlacementPanel is the cloud seed panel as a linked instance sees it. +func (h *Handler) PoolLinkPlacementPanel(c *gin.Context) { + inst := middleware.GetPoolLinkInstance(c) + if inst == nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + if !h.placementReady(c) { + return + } + out, xerr := h.PlacementService.RemotePanel(c.Request.Context(), inst) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, out) +} + +// PoolLinkStartPlacement hands a linked instance the seeds for a test. +func (h *Handler) PoolLinkStartPlacement(c *gin.Context) { + inst := middleware.GetPoolLinkInstance(c) + if inst == nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + if !h.placementReady(c) { + return + } + var req models.PlacementCloudStartRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + out, xerr := h.PlacementService.RemoteStart(c.Request.Context(), inst, req) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, out) +} + +// PoolLinkPlacementSends records the Message-IDs of the copies an instance +// sent. +func (h *Handler) PoolLinkPlacementSends(c *gin.Context) { + inst := middleware.GetPoolLinkInstance(c) + if inst == nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + if !h.placementReady(c) { + return + } + testID, err := uuid.Parse(c.Param("testId")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid test id")) + return + } + var req models.PlacementCloudSends + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + if xerr := h.PlacementService.RemoteSends(c.Request.Context(), inst, testID, req.Sends); xerr != nil { + errx.JSON(c, xerr) + return + } + c.Status(http.StatusNoContent) +} + +// PoolLinkPlacementVerdicts reports where each copy landed so far. +func (h *Handler) PoolLinkPlacementVerdicts(c *gin.Context) { + inst := middleware.GetPoolLinkInstance(c) + if inst == nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + if !h.placementReady(c) { + return + } + testID, err := uuid.Parse(c.Param("testId")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid test id")) + return + } + out, xerr := h.PlacementService.RemoteGet(c.Request.Context(), inst, testID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, out) } diff --git a/internal/api/routes.go b/internal/api/routes.go index 62c19223d..bc0ae3872 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -662,6 +662,11 @@ func Run( campaigns.POST("/:id/preflight", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.RunCampaignPreflight) campaigns.GET("/:id/ab-analysis", m.RequireOrganization(), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetCampaignABAnalysis) campaigns.POST("/:id/test-email", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.SendTestEmail) + // The campaign's scheduled placement test. PUT is a full-state + // write, so a retry lands on the same monitor. + campaigns.GET("/:id/placement-monitor", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetPlacementMonitor) + campaigns.PUT("/:id/placement-monitor", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.PutPlacementMonitor) + campaigns.DELETE("/:id/placement-monitor", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.DeletePlacementMonitor) // Campaign start/stop campaigns.POST("/:id/start", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StartCampaign) @@ -980,6 +985,21 @@ func Run( analytics.GET("/usage", h.GetUsageOverview) } + // Inbox placement tests: a template or campaign step sent to a seed + // panel from a real mailbox. Reads are analytics; starting one sends + // mail, so it takes the campaign-sending gate like a test email. + placementTests := protected.Group("/placement") + placementTests.Use(m.RequireOrganization()) + { + placementTests.GET("/overview", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetPlacementOverview) + placementTests.GET("/tests", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.ListPlacementTests) + placementTests.GET("/tests/:id", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetPlacementTest) + placementTests.POST("/tests", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CreatePlacementTest) + placementTests.POST("/tests/:id/cancel", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CancelPlacementTest) + placementTests.GET("/seeds", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.ListPlacementSeeds) + placementTests.PUT("/seeds/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.SetPlacementSeed) + } + // Audit logs auditLogs := protected.Group("/audit-logs") auditLogs.Use(m.RateLimitMiddleware(models.RateLimitRead), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAuditLogs)) @@ -1482,6 +1502,11 @@ func Run( poolLinkInstance.POST("/mailboxes/:remoteId/warmup-deliveries", h.PoolLinkVerifyWarmupDelivery) poolLinkInstance.GET("/workspace-mailboxes", h.PoolLinkWorkspaceMailboxes) poolLinkInstance.POST("/mailboxes/adopt", h.PoolLinkAdopt) + // The placement seed panel lent to linked instances. + poolLinkInstance.GET("/placement/panel", h.PoolLinkPlacementPanel) + poolLinkInstance.POST("/placement/tests", h.PoolLinkStartPlacement) + poolLinkInstance.GET("/placement/tests/:testId", h.PoolLinkPlacementVerdicts) + poolLinkInstance.POST("/placement/tests/:testId/sends", h.PoolLinkPlacementSends) } // Self-hosted side: Settings > Warmbly Cloud. diff --git a/internal/app/cloudlink/placement.go b/internal/app/cloudlink/placement.go new file mode 100644 index 000000000..f7d47e011 --- /dev/null +++ b/internal/app/cloudlink/placement.go @@ -0,0 +1,56 @@ +package cloudlink + +import ( + "context" + "net/http" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +func (s *service) PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error) { + l, xerr := s.link(ctx) + if xerr != nil { + return nil, xerr + } + var out models.PlacementCloudPanel + if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance/placement/panel", nil, &out); xerr != nil { + return nil, xerr + } + return &out, nil +} + +func (s *service) StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) { + l, xerr := s.link(ctx) + if xerr != nil { + return nil, xerr + } + var out models.PlacementCloudStart + if xerr := s.clientFor(l).do(ctx, http.MethodPost, "/instance/placement/tests", req, &out); xerr != nil { + return nil, xerr + } + return &out, nil +} + +func (s *service) ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error { + l, xerr := s.link(ctx) + if xerr != nil { + return xerr + } + return s.clientFor(l).do(ctx, http.MethodPost, "/instance/placement/tests/"+testID.String()+"/sends", + models.PlacementCloudSends{Sends: sends}, nil) +} + +func (s *service) PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) { + l, xerr := s.link(ctx) + if xerr != nil { + return nil, xerr + } + var out models.PlacementCloudTest + if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance/placement/tests/"+testID.String(), nil, &out); xerr != nil { + return nil, xerr + } + return &out, nil +} diff --git a/internal/app/cloudlink/service.go b/internal/app/cloudlink/service.go index ec5b9030e..03aab65a0 100644 --- a/internal/app/cloudlink/service.go +++ b/internal/app/cloudlink/service.go @@ -121,6 +121,14 @@ type Service interface { // IsCloudWarmupThreadReply asks by ancestry: whether what a tokenless // message answers is a turn of one of the cloud's warmup conversations. IsCloudWarmupThreadReply(ctx context.Context, accountID uuid.UUID, messageID string, inReplyTo []string) (bool, error) + + // The placement seed panel the cloud lends a linked instance. The + // instance renders and sends every copy; the cloud only hands out seed + // addresses and reports where each copy landed. + PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error) + StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) + ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error + PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) } type service struct { diff --git a/internal/app/consumer/event_new_email.go b/internal/app/consumer/event_new_email.go index 745c31d21..c1fdbe85f 100644 --- a/internal/app/consumer/event_new_email.go +++ b/internal/app/consumer/event_new_email.go @@ -83,6 +83,14 @@ func (s *JobsService) ingestNewEmail(ctx context.Context, e *models.JobEventNewE return nil } + // A placement test's copies are the seeds' business; the sender's own + // sent copy of each stays out of its unibox. + if s.PlacementRepo != nil && e.Message.Folder == models.FolderSent && e.Message.MessageID != "" { + if probe, perr := s.PlacementRepo.IsProbeSentCopy(ctx, e.Message.EmailID, e.Message.MessageID); perr == nil && probe { + return nil + } + } + // A pool-linked mailbox is warmup-only: everything else is dropped unread. if s.PoolLinkRepo != nil { if linked, lerr := s.PoolLinkRepo.GetMailboxByAccount(ctx, e.Message.EmailID); lerr == nil && linked != nil { diff --git a/internal/app/consumer/event_send_result.go b/internal/app/consumer/event_send_result.go index ac0341a0e..0d6eb3ae2 100644 --- a/internal/app/consumer/event_send_result.go +++ b/internal/app/consumer/event_send_result.go @@ -74,6 +74,13 @@ func (s *JobsService) HandleEmailSent(ctx context.Context, result models.SendEma log.Warn().Err(err).Str("email_account_id", task.EmailAccountID.String()).Msg("could not anchor the cold ramp") } } + case "placement": + // The seed is searched for the Message-ID the provider actually sent. + if s.PlacementRepo != nil && result.MessageID != "" { + if err := s.PlacementRepo.SetProbeMessageIDByTask(ctx, task.ID, result.MessageID); err != nil { + log.Warn().Err(err).Str("task_id", task.ID.String()).Msg("could not record a placement probe's message id") + } + } case "warmup": // Without this the recipient has nothing to match a warmup email // against when the verify header did not survive delivery. @@ -163,6 +170,10 @@ func (s *JobsService) HandleEmailFailed(ctx context.Context, result models.SendE return s.failCampaignSend(ctx, task, reason, code, nil) case "email": s.notifyUserSendFailed(ctx, task, reason) + case "placement": + if s.PlacementRepo != nil { + return s.PlacementRepo.FailProbeByTask(ctx, task.ID, reason) + } } return nil } diff --git a/internal/app/consumer/service.go b/internal/app/consumer/service.go index 148a1d9ff..84da2ea0e 100644 --- a/internal/app/consumer/service.go +++ b/internal/app/consumer/service.go @@ -51,9 +51,12 @@ type JobsService struct { WarmupEngagementRepo repository.WarmupEngagementRepository // WarmupPlacementRepo keeps each sender's daily placement history. Optional. WarmupPlacementRepo repository.WarmupPlacementRepository - WarmupService warmupapp.Service - WorkerRepo repository.WorkerRepository - FleetNodeRepo repository.FleetNodeRepository + // PlacementRepo resolves placement test probes from worker send results. + // Optional. + PlacementRepo repository.PlacementRepository + WarmupService warmupapp.Service + WorkerRepo repository.WorkerRepository + FleetNodeRepo repository.FleetNodeRepository // LifecycleRepo moves mailboxes in and out of cold rotation. Nil disables // the lifecycle rebalancer entirely. LifecycleRepo repository.SendLifecycleRepository diff --git a/internal/app/email/handler.go b/internal/app/email/handler.go index e72acc39b..027f756ce 100644 --- a/internal/app/email/handler.go +++ b/internal/app/email/handler.go @@ -109,6 +109,15 @@ func (s *emailService) BulkUpdateTags(ctx context.Context, orgID string, emailID // of an organization's mailbox, and the route's gate is already the // organization's manage-emails permission. func (s *emailService) SetWarmupLifecycle(ctx context.Context, orgID, emailAccountID, action string) (*models.Email, *errx.Error) { + // A placement seed stays a stranger to every sender, so it never warms. + if (action == "start" || action == "resume") && s.seedScope != nil { + if id, perr := uuid.Parse(emailAccountID); perr == nil { + if scope, err := s.seedScope.SeedScope(ctx, id); err == nil && scope != "" { + return nil, errx.NewWithIdentifier(errx.Conflict, "mailbox_is_seed", + "This mailbox is a placement seed inbox, which never warms up. Remove it from the seed inboxes first.") + } + } + } account, err := s.emailRepository.SetWarmupLifecycle(ctx, orgID, emailAccountID, action) if err != nil { return nil, err diff --git a/internal/app/email/loader.go b/internal/app/email/loader.go index d1d3e0bf4..70ea030d0 100644 --- a/internal/app/email/loader.go +++ b/internal/app/email/loader.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/app/instancesettings" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" @@ -468,6 +469,17 @@ func (s *emailService) syncDataFor(ctx context.Context, emailID uuid.UUID) (*mod data.Policy.BackfillMessages = 25 } } + // A seed receives every workspace's test copies, so the per-mailbox budget + // that fits one person's mail would defer them past the classify window. + // It never needs its history. + if s.seedScope != nil { + if scope, err := s.seedScope.SeedScope(ctx, emailID); err == nil && scope != "" { + data.Policy.DailyMessages = max(data.Policy.DailyMessages, config.PlacementSeedDailySyncMessages) + data.Policy.OrgDailyMessages = max(data.Policy.OrgDailyMessages, config.PlacementSeedDailySyncMessages) + data.Policy.BackfillDays = 1 + data.Policy.BackfillMessages = 25 + } + } if s.syncState != nil { if saved, err := s.syncState.Get(ctx, emailID); err == nil { data.State = saved diff --git a/internal/app/email/service.go b/internal/app/email/service.go index be607aa2a..361726238 100644 --- a/internal/app/email/service.go +++ b/internal/app/email/service.go @@ -116,6 +116,9 @@ type EmailService interface { WireMailboxes(repo repository.MailboxRepository) WireUnibox(repo repository.UniboxRepository) WireSyncBudget(src SyncBudgetSource) + // WireSeedScope lets the loader give a placement seed mailbox the sync + // allowance a whole instance's test traffic needs. + WireSeedScope(src SeedScopeSource) WirePoolLink(repo repository.PoolLinkRepository) // WireCloudLink marks managed mailboxes, which ship to the worker without a credential. WireCloudLink(repo repository.CloudLinkRepository) @@ -178,6 +181,7 @@ type emailService struct { syncState repository.EmailSyncStateRepository mailboxes repository.MailboxRepository syncBudget SyncBudgetSource + seedScope SeedScopeSource // poolLink marks linked warmup-only mailboxes, which sync with no history. poolLink repository.PoolLinkRepository // cloudLink marks managed mailboxes whose credential the cloud holds. @@ -261,6 +265,16 @@ func (s *emailService) WireSyncBudget(src SyncBudgetSource) { s.syncBudget = src } +// SeedScopeSource reports whether a mailbox is a placement seed. +type SeedScopeSource interface { + SeedScope(ctx context.Context, accountID uuid.UUID) (string, error) +} + +// WireSeedScope attaches the seed lookup the loader reads. +func (s *emailService) WireSeedScope(src SeedScopeSource) { + s.seedScope = src +} + // WirePoolLink attaches the pool-link repository so linked mailboxes get the // warmup-only sync policy. func (s *emailService) WireCloudLink(repo repository.CloudLinkRepository) { diff --git a/internal/app/guardrail/guardrail.go b/internal/app/guardrail/guardrail.go index e47b5faa0..cc9d3ee15 100644 --- a/internal/app/guardrail/guardrail.go +++ b/internal/app/guardrail/guardrail.go @@ -28,6 +28,9 @@ const ( RuleComplaintRate Rule = "complaint_rate" RuleBounceRate Rule = "bounce_rate" RuleReplyRate Rule = "reply_rate" + // RulePlacement is a scheduled inbox placement test below its alert + // threshold, on a monitor set to pause the campaign. + RulePlacement Rule = "placement" ) // Breach is one rule's verdict on one campaign. diff --git a/internal/app/guardrail/service.go b/internal/app/guardrail/service.go index c6ad9f38b..e6d255f67 100644 --- a/internal/app/guardrail/service.go +++ b/internal/app/guardrail/service.go @@ -22,6 +22,10 @@ type Service interface { Sweep(ctx context.Context) (int, error) // Clear wipes a campaign's tripped marker. Called when it is started again. Clear(ctx context.Context, campaignID uuid.UUID) error + // PausePlacementAlert auto-pauses a campaign whose scheduled placement + // test landed below its threshold. The placement alert is the + // notification, so this only pauses and records it. + PausePlacementAlert(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error } type service struct { @@ -75,6 +79,28 @@ func (s *service) Sweep(ctx context.Context) (int, error) { return paused, nil } +func (s *service) PausePlacementAlert(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error { + tripped, err := s.repo.TripGuardrail(ctx, campaignID, reason) + if err != nil || !tripped { + return err + } + if s.logRepo != nil { + _ = s.logRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaignID, + EventType: "guardrail_paused", + Message: reason, + Metadata: map[string]interface{}{"rule": string(RulePlacement)}, + }) + } + if s.audit != nil { + s.audit.LogAction(ctx, orgID, uuid.Nil, + models.AuditActionPause, models.AuditEntityCampaign, &campaignID, "", "", + map[string]string{"status": "active -> paused_guardrail"}, + map[string]string{"reason": string(RulePlacement)}) + } + return nil +} + func (s *service) Clear(ctx context.Context, campaignID uuid.UUID) error { return s.repo.ClearGuardrail(ctx, campaignID) } diff --git a/internal/app/instancesettings/document.go b/internal/app/instancesettings/document.go index 048f2b5e7..6f3f8a8c3 100644 --- a/internal/app/instancesettings/document.go +++ b/internal/app/instancesettings/document.go @@ -187,6 +187,56 @@ type Deliverability struct { AuthGraceHours int `json:"auth_grace_hours"` } +// Placement holds the inbox placement test knobs. +type Placement struct { + // TestsPerMonthTrial and TestsPerMonthPaid are how many tests one workspace + // may run on the instance and cloud seed panels per calendar month. Tests + // on a workspace's own seed mailboxes are not counted. + TestsPerMonthTrial int `json:"tests_per_month_trial"` + TestsPerMonthPaid int `json:"tests_per_month_paid"` + // SeedsPerTest caps how many seed mailboxes one test sends to, which is + // also how many sends it takes from the sender's day. + SeedsPerTest int `json:"seeds_per_test"` + // SpacingSeconds is the gap between two probes from one sender. + SpacingSeconds int `json:"spacing_seconds"` +} + +// DefaultPlacement is the compiled placement test allowance and pacing. +func DefaultPlacement() Placement { + return Placement{ + TestsPerMonthTrial: config.PlacementTestsPerMonthTrialDefault, + TestsPerMonthPaid: config.PlacementTestsPerMonthPaidDefault, + SeedsPerTest: config.PlacementSeedsPerTestDefault, + SpacingSeconds: config.PlacementSpacingSecondsDefault, + } +} + +// Normalize clamps every knob; zero and negative resolve to the compiled +// default, so a document written before this section existed keeps it. +func (p *Placement) Normalize() { + month := func(v, def int) int { + if v <= 0 { + return def + } + return min(v, config.PlacementTestsPerMonthMax) + } + p.TestsPerMonthTrial = month(p.TestsPerMonthTrial, config.PlacementTestsPerMonthTrialDefault) + p.TestsPerMonthPaid = month(p.TestsPerMonthPaid, config.PlacementTestsPerMonthPaidDefault) + if p.SeedsPerTest <= 0 { + p.SeedsPerTest = config.PlacementSeedsPerTestDefault + } + p.SeedsPerTest = min(p.SeedsPerTest, config.PlacementSeedsPerTestMax) + if p.SpacingSeconds <= 0 { + p.SpacingSeconds = config.PlacementSpacingSecondsDefault + } + p.SpacingSeconds = max(config.PlacementSpacingSecondsMin, min(p.SpacingSeconds, config.PlacementSpacingSecondsMax)) +} + +// Spacing is the gap between two probes as a duration. +func (p Placement) Spacing() time.Duration { + return time.Duration(p.SpacingSeconds) * time.Second +} + // Document is the whole Tier B settings document. Every key here is one no // environment variable owns, so there is no precedence to resolve. type Document struct { @@ -196,6 +246,7 @@ type Document struct { Retention Retention `json:"retention"` Tracking Tracking `json:"tracking"` Deliverability Deliverability `json:"deliverability"` + Placement Placement `json:"placement"` Notifications Notifications `json:"notifications"` } @@ -213,6 +264,7 @@ func Defaults() Document { Retention: DefaultRetention(), Tracking: DefaultTracking(), Deliverability: DefaultDeliverability(), + Placement: DefaultPlacement(), } } @@ -291,6 +343,7 @@ func (d *Document) Normalize() { d.Retention.Normalize() d.Tracking.Normalize() d.Deliverability.Normalize() + d.Placement.Normalize() d.Notifications.Normalize() } @@ -371,6 +424,12 @@ type Patch struct { EnforceDomainAuth *bool `json:"enforce_domain_auth"` AuthGraceHours *int `json:"auth_grace_hours"` } `json:"deliverability"` + Placement *struct { + TestsPerMonthTrial *int `json:"tests_per_month_trial"` + TestsPerMonthPaid *int `json:"tests_per_month_paid"` + SeedsPerTest *int `json:"seeds_per_test"` + SpacingSeconds *int `json:"spacing_seconds"` + } `json:"placement"` // Channels replaces the whole list when present. A channel that comes back // with a masked target or secret keeps the stored value, so the admin panel // can round-trip a redacted read without wiping credentials. @@ -452,6 +511,20 @@ func (p Patch) Apply(doc Document) Document { } } } + if p.Placement != nil { + if p.Placement.TestsPerMonthTrial != nil { + doc.Placement.TestsPerMonthTrial = *p.Placement.TestsPerMonthTrial + } + if p.Placement.TestsPerMonthPaid != nil { + doc.Placement.TestsPerMonthPaid = *p.Placement.TestsPerMonthPaid + } + if p.Placement.SeedsPerTest != nil { + doc.Placement.SeedsPerTest = *p.Placement.SeedsPerTest + } + if p.Placement.SpacingSeconds != nil { + doc.Placement.SpacingSeconds = *p.Placement.SpacingSeconds + } + } if p.Notifications != nil && p.Notifications.Channels != nil { doc.Notifications.Channels = mergeChannels(doc.Notifications.Channels, *p.Notifications.Channels) } diff --git a/internal/app/instancesettings/service.go b/internal/app/instancesettings/service.go index b3cd0023e..b8b5580e5 100644 --- a/internal/app/instancesettings/service.go +++ b/internal/app/instancesettings/service.go @@ -51,6 +51,8 @@ type Service interface { // DomainAuth is the sending-domain authentication gate: whether it is // enforced at all, and how long a domain must stay failing first. DomainAuth(ctx context.Context) (enforce bool, grace time.Duration) + // PlacementPolicy is the inbox placement test section, already normalized. + PlacementPolicy(ctx context.Context) Placement } type service struct { @@ -156,6 +158,12 @@ func (s *service) TrackingPolicy(ctx context.Context) Tracking { return t } +func (s *service) PlacementPolicy(ctx context.Context) Placement { + p := s.Get(ctx).Placement + p.Normalize() + return p +} + func (s *service) DomainAuth(ctx context.Context) (bool, time.Duration) { d := s.Get(ctx).Deliverability d.Normalize() diff --git a/internal/app/notification/push.go b/internal/app/notification/push.go index d75a8dc79..b283ef050 100644 --- a/internal/app/notification/push.go +++ b/internal/app/notification/push.go @@ -227,6 +227,10 @@ func digestTitle(category models.NotificationCategory, n int) string { return fmt.Sprintf("%d campaigns paused automatically", n) case models.NotifDomainAuth: return fmt.Sprintf("%d domains failing authentication", n) + case models.NotifPlacementFinished: + return fmt.Sprintf("%d placement tests finished", n) + case models.NotifPlacementAlert: + return fmt.Sprintf("%d placement alerts", n) default: return fmt.Sprintf("%d new notifications", n) } diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index 71a60cf4d..79665ce46 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -91,7 +91,7 @@ const ( orgMailboxes = `(SELECT id FROM email_accounts WHERE organization_id = $1)` orgCampaigns = `(SELECT id FROM campaigns WHERE organization_id = $1)` orgContacts = `(SELECT id FROM contacts WHERE organization_id = $1)` - orgTasks = `(SELECT id FROM tasks WHERE email_account_id IN ` + orgMailboxes + `)` + orgTasks = `(SELECT id FROM tasks WHERE email_account_id IN ` + orgMailboxes + ` AND task_type <> 'placement')` orgThreads = `(SELECT DISTINCT thread_id FROM unibox_emails WHERE email_id IN ` + orgMailboxes + `)` orgPipelines = `(SELECT id FROM pipelines WHERE organization_id = $1)` orgInvitations = `(SELECT id FROM organization_invitations WHERE organization_id = $1)` @@ -195,9 +195,12 @@ var Tables = []Table{ // mailbox it watched send. Importing "resting" would silence a mailbox // on the destination for a reason nothing there observed; importing // "active" would assert readiness the destination has not seen. + // seed_scope is the operator's choice of test inboxes on this + // instance; an archive must not add mailboxes to another instance's + // seed panel. ResetOnImport: []string{ "worker_id", "auth_checked_at", "auth_failing_since", "cold_ramp_started_at", - "send_lifecycle", "send_lifecycle_since", "send_lifecycle_reason", + "send_lifecycle", "send_lifecycle_since", "send_lifecycle_reason", "seed_scope", }, }, { @@ -426,6 +429,13 @@ var Tables = []Table{ Name: "campaign_leads", Group: models.OrgDataGroupCampaigns, Scope: `campaign_id IN ` + orgCampaigns, }, + { + // A campaign's scheduled placement test. Its run history names this + // instance's tests, which import later in the list. + Name: "placement_monitors", Group: models.OrgDataGroupCampaigns, + Scope: scopeOrg, + ResetOnImport: []string{"last_test_id", "last_run_at", "last_alert_at", "last_error"}, + }, { // The recipient's opt-out address. It travels because an unsubscribe // link a recipient already holds is a commitment for as long as it @@ -662,7 +672,9 @@ var Tables = []Table{ // ---------- send pipeline ---------- { Name: "tasks", Group: models.OrgDataGroupSending, - Scope: `email_account_id IN ` + orgMailboxes, + // A placement probe's task stays behind: a pending one would send from + // the destination to the source instance's seeds. + Scope: `email_account_id IN ` + orgMailboxes + ` AND task_type <> 'placement'`, // The handle belongs to the source instance's queue. ResetOnImport: []string{"cloud_task_name"}, }, @@ -748,12 +760,16 @@ var Tables = []Table{ Scope: scopeOrg, }, { + // The results travel as a record. The link to a cloud-run test and the + // seeds on the source instance's panel do not. Name: "placement_tests", Group: models.OrgDataGroupEvents, - Scope: scopeOrg, + Scope: scopeOrg, + ResetOnImport: []string{"remote_instance_id", "remote_test_id"}, }, { Name: "placement_results", Group: models.OrgDataGroupEvents, - Scope: `test_id IN ` + orgPlacements, + Scope: `test_id IN ` + orgPlacements, + ResetOnImport: []string{"seed_account_id", "remote_seed_id", "task_id", "remote_synced_at"}, }, { Name: "webhook_deliveries", Group: models.OrgDataGroupEvents, diff --git a/internal/app/placement/monitor.go b/internal/app/placement/monitor.go new file mode 100644 index 000000000..e469f71eb --- /dev/null +++ b/internal/app/placement/monitor.go @@ -0,0 +1,254 @@ +package placement + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/errx" + "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" +) + +// MonitorInput configures a campaign's scheduled placement test. Nil fields +// keep the stored value, or the default on a new monitor. +type MonitorInput struct { + Enabled *bool `json:"enabled"` + IntervalDays *int `json:"interval_days"` + Panel *string `json:"panel"` + AlertBelow *int `json:"alert_below"` + PauseOnAlert *bool `json:"pause_on_alert"` +} + +func (s *service) ownedCampaign(ctx context.Context, orgID, campaignID uuid.UUID) (*models.Campaign, *errx.Error) { + c, err := s.Campaigns.GetByID(ctx, campaignID) + if err != nil || c == nil || c.OrganizationID == nil || *c.OrganizationID != orgID { + return nil, errx.New(errx.NotFound, "campaign not found") + } + return c, nil +} + +// GetMonitor is a campaign's monitor, nil when it has none. +func (s *service) GetMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (*models.PlacementMonitor, *errx.Error) { + if _, xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil { + return nil, xerr + } + m, err := s.Repo.GetMonitor(ctx, orgID, campaignID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + return m, nil +} + +// PutMonitor creates or updates a campaign's monitor. A new or re-enabled +// monitor runs its first test within the hour, not an interval from now. +func (s *service) PutMonitor(ctx context.Context, orgID, userID, campaignID uuid.UUID, in MonitorInput) (*models.PlacementMonitor, *errx.Error) { + if _, xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil { + return nil, xerr + } + current, err := s.Repo.GetMonitor(ctx, orgID, campaignID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + m := models.PlacementMonitor{ + OrganizationID: orgID, + CampaignID: campaignID, + CreatedBy: &userID, + Enabled: true, + IntervalDays: config.PlacementMonitorIntervalDaysDef, + Panel: models.PlacementPanelInstance, + AlertBelow: config.PlacementMonitorAlertBelowDefault, + NextRunAt: s.now().Add(5 * time.Minute), + } + if current != nil { + m = *current + } + wasEnabled := current != nil && current.Enabled + if in.Enabled != nil { + m.Enabled = *in.Enabled + } + if in.IntervalDays != nil { + if *in.IntervalDays < config.PlacementMonitorIntervalDaysMin || *in.IntervalDays > config.PlacementMonitorIntervalDaysMax { + return nil, errx.New(errx.BadRequest, fmt.Sprintf("interval_days must be between %d and %d", + config.PlacementMonitorIntervalDaysMin, config.PlacementMonitorIntervalDaysMax)) + } + m.IntervalDays = *in.IntervalDays + } + if in.Panel != nil { + if !models.ValidPlacementPanel(*in.Panel) { + return nil, errx.New(errx.BadRequest, "panel must be instance, workspace or cloud") + } + m.Panel = *in.Panel + } + if in.AlertBelow != nil { + if *in.AlertBelow < 0 || *in.AlertBelow > 100 { + return nil, errx.New(errx.BadRequest, "alert_below must be between 0 and 100") + } + m.AlertBelow = *in.AlertBelow + } + if in.PauseOnAlert != nil { + m.PauseOnAlert = *in.PauseOnAlert + } + if m.Enabled && !wasEnabled { + m.NextRunAt = s.now().Add(5 * time.Minute) + } else if current != nil && in.IntervalDays != nil && current.LastRunAt != nil { + m.NextRunAt = current.LastRunAt.Add(time.Duration(m.IntervalDays) * 24 * time.Hour) + } + if err := s.Repo.UpsertMonitor(ctx, &m); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + return &m, nil +} + +// DeleteMonitor removes a campaign's monitor. +func (s *service) DeleteMonitor(ctx context.Context, orgID, campaignID uuid.UUID) *errx.Error { + if _, xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil { + return xerr + } + ok, err := s.Repo.DeleteMonitor(ctx, orgID, campaignID) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if !ok { + return errx.New(errx.NotFound, "this campaign has no placement monitor") + } + return nil +} + +// runMonitors starts the test of every due monitor: the campaign's first +// email step, from the next of its mailboxes in turn, because every mailbox +// the campaign sends from has its own standing. +func (s *service) runMonitors(ctx context.Context) { + now := s.now() + due, err := s.Repo.ListDueMonitors(ctx, now, 20) + if err != nil { + errs.CaptureException(err) + return + } + for _, m := range due { + next := now.Add(time.Duration(m.IntervalDays) * 24 * time.Hour) + testID, senderID, reason := s.runMonitor(ctx, m) + if reason != "" && testID == nil { + // Nothing started: look again in a day rather than a whole interval. + next = now.Add(24 * time.Hour) + } + if err := s.Repo.MarkMonitorRun(ctx, m.ID, next, testID, senderID, reason); err != nil { + errs.CaptureException(err) + } + } +} + +func (s *service) runMonitor(ctx context.Context, m models.PlacementMonitor) (*uuid.UUID, *uuid.UUID, string) { + campaign, xerr := s.ownedCampaign(ctx, m.OrganizationID, m.CampaignID) + if xerr != nil { + return nil, nil, "The campaign no longer exists." + } + if campaign.Status != "active" { + return nil, nil, "The campaign is not running, so nothing was tested." + } + seqs, err := s.Campaigns.GetSequencesByCampaignID(ctx, campaign.ID) + if err != nil { + errs.CaptureException(err) + return nil, nil, "The campaign's steps could not be read." + } + sort.Slice(seqs, func(i, j int) bool { return seqs[i].Position < seqs[j].Position }) + var step *models.Sequence + for i := range seqs { + if strings.TrimSpace(seqs[i].Subject) != "" && (mailhtml.HasContent(seqs[i].BodyHTML) || strings.TrimSpace(seqs[i].BodyPlain) != "") { + step = &seqs[i] + break + } + } + if step == nil { + return nil, nil, "The campaign has no email step to test." + } + sender := s.nextSender(ctx, campaign, m.LastSenderID) + if sender == nil { + return nil, nil, "None of the campaign's mailboxes is connected and running." + } + views, xerr := s.CreateTests(ctx, CreateInput{ + OrgID: m.OrganizationID, + UserID: m.CreatedBy, + SenderAccountID: *sender, + CampaignID: &campaign.ID, + SequenceID: &step.ID, + Tracking: models.PlacementTrackingCampaign, + Panel: m.Panel, + Origin: models.PlacementOriginMonitor, + MonitorID: &m.ID, + }) + if xerr != nil { + return nil, sender, xerr.Message + } + id := views[0].ID + return &id, sender, "" +} + +// nextSender is the campaign's mailbox after the one tested last, in a +// stable order, skipping any that cannot send. +func (s *service) nextSender(ctx context.Context, campaign *models.Campaign, last *uuid.UUID) *uuid.UUID { + pool, xerr := repository.ResolveCampaignSenderPool(ctx, s.Emails, campaign) + if xerr != nil { + return nil + } + var ids []uuid.UUID + for _, a := range pool.Accounts { + if a.Status == "active" && a.WorkerID != nil { + ids = append(ids, a.ID) + } + } + if len(ids) == 0 { + return nil + } + sort.Slice(ids, func(i, j int) bool { return ids[i].String() < ids[j].String() }) + pick := ids[0] + if last != nil { + for i, id := range ids { + if id == *last { + pick = ids[(i+1)%len(ids)] + break + } + } + } + return &pick +} + +// checkMonitorAlert raises the alert when a monitor's test lands below its +// threshold, and pauses the campaign when the monitor is set to. +func (s *service) checkMonitorAlert(ctx context.Context, monitorID, orgID uuid.UUID, campaignID *uuid.UUID, c models.PlacementCounts, body, link string) { + m, err := s.Repo.GetMonitorByID(ctx, monitorID) + if err != nil || m == nil || m.OrganizationID != orgID { + return + } + rate := percent(c.InboxRate) + if rate < 0 || rate >= m.AlertBelow { + return + } + title := fmt.Sprintf("Inbox placement dropped to %d%%", rate) + if m.PauseOnAlert && s.Pauser != nil && campaignID != nil { + reason := fmt.Sprintf("Scheduled placement test landed %d%% in the inbox, below the %d%% alert threshold", rate, m.AlertBelow) + if err := s.Pauser.PausePlacementAlert(ctx, orgID, *campaignID, reason); err != nil { + errs.CaptureException(err) + } else { + title += "; campaign paused" + } + } + if s.Notifier != nil { + s.Notifier.NotifyOrg(ctx, orgID, models.PermViewCampaigns, uuid.Nil, models.NotifPlacementAlert, title, body, link, + map[string]any{"campaign_id": m.CampaignID.String()}, "placement_alert:"+m.ID.String()) + } + if err := s.Repo.MarkMonitorAlert(ctx, m.ID, s.now()); err != nil { + errs.CaptureException(err) + } +} diff --git a/internal/app/placement/remote.go b/internal/app/placement/remote.go new file mode 100644 index 000000000..6b1abe48e --- /dev/null +++ b/internal/app/placement/remote.go @@ -0,0 +1,219 @@ +package placement + +import ( + "context" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" +) + +// The cloud side: Warmbly Cloud lends its instance panel to a linked +// self-hosted instance. The instance renders and sends every copy itself, so +// the cloud never holds the template; it hands out seed addresses, takes the +// Message-IDs back, and answers with where each copy landed. + +// RemotePanel is the cloud panel and the linked workspace's allowance. +func (s *service) RemotePanel(ctx context.Context, inst *models.PoolLinkInstance) (*models.PlacementCloudPanel, *errx.Error) { + rows, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, true) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + usage, xerr := s.usage(ctx, inst.OrganizationID) + if xerr != nil { + return nil, xerr + } + panel := panelInfo(models.PlacementPanelCloud, familySeeds(rows), true) + if !panel.Available { + panel.Reason = "Warmbly Cloud has no seed inboxes available right now." + } + return &models.PlacementCloudPanel{Panel: panel, Usage: usage}, nil +} + +// RemoteStart opens one test per requested variant against the same seeds, +// charged to the linked workspace's allowance. +func (s *service) RemoteStart(ctx context.Context, inst *models.PoolLinkInstance, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) { + if req.Tests < 1 || req.Tests > 2 { + return nil, errx.New(errx.BadRequest, "tests must be 1 or 2") + } + usage, xerr := s.usage(ctx, inst.OrganizationID) + if xerr != nil { + return nil, xerr + } + if usage.Limit != nil && usage.Used+req.Tests > *usage.Limit { + return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded", + "This Warmbly Cloud workspace has used its placement tests for the month.") + } + running, err := s.Repo.CountRunning(ctx, inst.OrganizationID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if running+req.Tests > config.PlacementRunningPerOrgMax { + return nil, placementErr(errx.TooManyRequests, "placement_too_many_running", + "This workspace already has placement tests running. Wait for one to finish.") + } + rows, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, true) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + seeds := pickSeeds(rows, uuid.Nil, req.SenderDomain, s.policy(ctx).SeedsPerTest) + if len(seeds) == 0 { + return nil, placementErr(errx.Conflict, "placement_no_seeds", noSeedsMessage(models.PlacementPanelCloud)) + } + + org := inst.OrganizationID + instanceID := inst.ID + out := &models.PlacementCloudStart{Usage: usage} + for _, seed := range seeds { + out.Seeds = append(out.Seeds, models.PlacementCloudSeed{ID: *seed.AccountID, Address: seed.Address, Family: seed.Family}) + } + for range req.Tests { + test := models.PlacementTest{ + ID: uuid.New(), + OrganizationID: &org, + Origin: models.PlacementOriginRemote, + Panel: models.PlacementPanelInstance, + Status: models.PlacementStatusRunning, + RemoteInstanceID: &instanceID, + } + results := make([]models.PlacementResult, 0, len(seeds)) + for _, seed := range seeds { + results = append(results, models.PlacementResult{ + SeedAccountID: seed.AccountID, + SeedAddress: seed.Address, + Family: seed.Family, + Folder: models.PlacementFolderPending, + }) + } + if err := s.Repo.CreateTest(ctx, &test, results, nil); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + out.TestIDs = append(out.TestIDs, test.ID) + } + out.Usage.Used += req.Tests + return out, nil +} + +// RemoteSends records the Message-IDs an instance put on its copies. +func (s *service) RemoteSends(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error { + if len(sends) > config.PlacementSeedsPerTestMax { + return errx.New(errx.BadRequest, "too many sends in one report") + } + test, _, err := s.Repo.GetRemoteTest(ctx, inst.ID, testID) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if test == nil { + return errx.New(errx.NotFound, "placement test not found") + } + for _, snd := range sends { + if snd.Error == "" && snd.MessageID == "" { + continue + } + if err := s.Repo.RecordRemoteSent(ctx, inst.ID, testID, snd.SeedID, snd.MessageID, snd.SentAt, snd.Error); err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + } + return nil +} + +// RemoteGet answers with the verdicts so far. +func (s *service) RemoteGet(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) { + test, results, err := s.Repo.GetRemoteTest(ctx, inst.ID, testID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if test == nil { + return nil, errx.New(errx.NotFound, "placement test not found") + } + out := &models.PlacementCloudTest{TestID: test.ID, Status: test.Status, Verdicts: []models.PlacementCloudVerdict{}} + for _, r := range results { + if r.SeedAccountID == nil || !models.PlacementFolderResolved(r.Folder) { + continue + } + out.Verdicts = append(out.Verdicts, models.PlacementCloudVerdict{SeedID: *r.SeedAccountID, Folder: r.Folder, DetectedAt: r.DetectedAt}) + } + return out, nil +} + +// syncCloud is the instance side: report what left, pull back what landed. +// It returns the local tests that got a verdict. +func (s *service) syncCloud(ctx context.Context) map[uuid.UUID]bool { + touched := map[uuid.UUID]bool{} + if s.Cloud == nil { + return touched + } + reports, err := s.Repo.ListRemoteReports(ctx, 200) + if err != nil { + errs.CaptureException(err) + return touched + } + byTest := map[uuid.UUID][]int{} + for i, r := range reports { + byTest[r.RemoteTestID] = append(byTest[r.RemoteTestID], i) + } + for remoteTest, idx := range byTest { + sends := make([]models.PlacementCloudSend, 0, len(idx)) + ids := make([]uuid.UUID, 0, len(idx)) + for _, i := range idx { + r := reports[i] + snd := models.PlacementCloudSend{SeedID: r.RemoteSeedID, MessageID: r.MessageID, SentAt: r.SentAt} + if r.Folder == models.PlacementFolderFailed || r.Folder == models.PlacementFolderCancelled { + snd = models.PlacementCloudSend{SeedID: r.RemoteSeedID, Error: firstNonEmpty(r.Error, "Not sent")} + } + sends = append(sends, snd) + ids = append(ids, r.ResultID) + } + if xerr := s.Cloud.ReportPlacementSends(ctx, remoteTest, sends); xerr != nil { + continue + } + if err := s.Repo.MarkRemoteReported(ctx, ids); err != nil { + errs.CaptureException(err) + } + } + + open, err := s.Repo.ListRemoteOpenTests(ctx, 50) + if err != nil { + errs.CaptureException(err) + return touched + } + for _, t := range open { + if t.RemoteTestID == nil { + continue + } + answer, xerr := s.Cloud.PlacementVerdicts(ctx, *t.RemoteTestID) + if xerr != nil || answer == nil { + continue + } + for _, v := range answer.Verdicts { + if !models.PlacementFolderResolved(v.Folder) { + continue + } + if err := s.Repo.RecordRemoteVerdict(ctx, t.ID, v.SeedID, v.Folder, v.DetectedAt); err != nil { + errs.CaptureException(err) + continue + } + touched[t.ID] = true + } + } + return touched +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/app/placement/seeds.go b/internal/app/placement/seeds.go new file mode 100644 index 000000000..ab543ef91 --- /dev/null +++ b/internal/app/placement/seeds.go @@ -0,0 +1,186 @@ +package placement + +import ( + "context" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" + "github.com/warmbly/warmbly/internal/pkg/mailhost" + "github.com/warmbly/warmbly/internal/repository" +) + +func workspaceSeed(r repository.SeedAccount, blocker string) models.PlacementWorkspaceSeed { + fam := string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email)) + return models.PlacementWorkspaceSeed{ + EmailAccountID: r.ID, + Email: r.Email, + Family: fam, + Label: familyLabel(fam), + Status: r.Status, + Seed: r.SeedScope == models.SeedScopeWorkspace, + Blocker: blocker, + } +} + +// ListWorkspaceSeeds lists a workspace's mailboxes with which are its seed +// inboxes and which cannot become one. +func (s *service) ListWorkspaceSeeds(ctx context.Context, orgID uuid.UUID) ([]models.PlacementWorkspaceSeed, *errx.Error) { + rows, blockers, err := s.Repo.ListOrgMailboxes(ctx, orgID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + out := make([]models.PlacementWorkspaceSeed, 0, len(rows)) + for _, r := range rows { + out = append(out, workspaceSeed(r, blockers[r.ID])) + } + return out, nil +} + +// SetWorkspaceSeed makes one of the workspace's mailboxes a seed inbox, or an +// ordinary mailbox again. A seed stops warming and stops sending campaigns: +// a test inbox that talks to the pool or to prospects learns its senders and +// stops being a stranger's inbox. +func (s *service) SetWorkspaceSeed(ctx context.Context, orgID, accountID uuid.UUID, enabled bool) (*models.PlacementWorkspaceSeed, *errx.Error) { + row, err := s.Repo.GetSeedAccount(ctx, accountID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if row == nil || row.OrganizationID == nil || *row.OrganizationID != orgID { + return nil, errx.New(errx.NotFound, "mailbox not found") + } + if row.SeedScope == models.SeedScopeInstance { + return nil, placementErr(errx.Conflict, "placement_seed_unavailable", "This mailbox is on the instance seed panel.") + } + if enabled == (row.SeedScope == models.SeedScopeWorkspace) { + v := workspaceSeed(*row, "") + return &v, nil + } + if enabled { + own, err := s.Repo.ListSeeds(ctx, models.SeedScopeWorkspace, &orgID, false) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if len(own) >= config.PlacementSeedsPerWorkspaceMax { + return nil, placementErr(errx.Conflict, "placement_seed_limit", "This workspace has the most seed inboxes it can have.") + } + if busy, err := s.Repo.SenderBusy(ctx, accountID); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } else if busy { + return nil, placementErr(errx.Conflict, "placement_seed_unavailable", "A placement test is sending from this mailbox.") + } + } + scope := "" + if enabled { + scope = models.SeedScopeWorkspace + } + if xerr := s.applySeedScope(ctx, orgID, accountID, scope); xerr != nil { + return nil, xerr + } + row.SeedScope = scope + v := workspaceSeed(*row, "") + return &v, nil +} + +// applySeedScope writes the scope, stops warmup on a new seed and reloads the +// mailbox so its worker syncs it under the seed allowance. +func (s *service) applySeedScope(ctx context.Context, orgID, accountID uuid.UUID, scope string) *errx.Error { + if err := s.Repo.SetSeedScope(ctx, accountID, scope); err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if s.Mailboxes == nil { + return nil + } + if scope != "" { + if _, xerr := s.Mailboxes.SetWarmupLifecycle(ctx, orgID.String(), accountID.String(), "disable"); xerr != nil && xerr.Code != errx.NotFound { + errs.CaptureException(xerr) + } + } + if err := s.Mailboxes.LoadAccountOntoWorker(ctx, accountID); err != nil { + // The next reconcile pass reloads it; the scope is already written. + errs.CaptureException(err) + } + return nil +} + +func adminSeed(r repository.SeedAccount) AdminSeed { + fam := string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email)) + return AdminSeed{ + ID: r.ID, + OrganizationID: r.OrganizationID, + Email: r.Email, + Name: r.Name, + Provider: r.Provider, + Family: fam, + FamilyLabel: familyLabel(fam), + Status: r.Status, + WorkerID: r.WorkerID, + SeedScope: r.SeedScope, + } +} + +// AdminListSeeds is the instance panel. +func (s *service) AdminListSeeds(ctx context.Context) ([]AdminSeed, *errx.Error) { + rows, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, false) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + out := make([]AdminSeed, 0, len(rows)) + for _, r := range rows { + out = append(out, adminSeed(r)) + } + return out, nil +} + +// AdminSeedCandidates searches connected mailboxes an operator may add. +func (s *service) AdminSeedCandidates(ctx context.Context, search string, limit int) ([]AdminSeed, *errx.Error) { + rows, err := s.Repo.ListSeedCandidates(ctx, search, limit) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + out := make([]AdminSeed, 0, len(rows)) + for _, r := range rows { + out = append(out, adminSeed(r)) + } + return out, nil +} + +// AdminSetSeed adds a mailbox to the instance panel or takes it off. +func (s *service) AdminSetSeed(ctx context.Context, accountID uuid.UUID, enabled bool) (*AdminSeed, *errx.Error) { + row, err := s.Repo.GetSeedAccount(ctx, accountID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if row == nil { + return nil, errx.New(errx.NotFound, "mailbox not found") + } + if !enabled && row.SeedScope != models.SeedScopeInstance { + v := adminSeed(*row) + return &v, nil + } + scope := "" + if enabled { + scope = models.SeedScopeInstance + } + orgID := uuid.Nil + if row.OrganizationID != nil { + orgID = *row.OrganizationID + } + if xerr := s.applySeedScope(ctx, orgID, accountID, scope); xerr != nil { + return nil, xerr + } + row.SeedScope = scope + v := adminSeed(*row) + return &v, nil +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index 375fd3640..c328d6707 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -1,259 +1,693 @@ -// Package placement implements seed inbox-placement testing: send a tokenized -// copy of a real template through a real sender to a panel of Warmbly-controlled -// SEED mailboxes, then classify where each landed (Inbox / Spam / Promotions / -// other) per provider. +// Package placement runs inbox placement tests: a template or a campaign step +// is sent from a real sender mailbox to a panel of seed mailboxes, each copy +// rendered the way the campaign would render it and paced as its own task, and +// where each copy landed (inbox, a Gmail tab, spam, never arrived) is read +// back from the seed's synced mail by Message-ID. // -// Classification reuses the warmup landing-folder detection (the same `\Junk` -// /spam-flag logic in the consumer) and reads it out of the UNIBOX — the -// received-mail store the worker syncs every mailbox into. A seed is an -// ordinary connected + synced email_account flagged is_seed, so its received -// mail lands in the unibox like any other mailbox; the poller (ClassifyPending) -// looks up the test token in the seed's unibox entries and reads the folder -// flags. No consumer hot-path hook is added. +// Three panels exist: the instance panel the operator runs for everyone, a +// workspace's own test inboxes, and Warmbly Cloud's panel for a linked +// self-hosted instance. package placement import ( "context" - "errors" - "fmt" + "math/rand/v2" + "sort" + "strconv" "strings" "time" "github.com/google/uuid" - "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/app/instancesettings" + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" + "github.com/warmbly/warmbly/internal/pkg/mailhost" + "github.com/warmbly/warmbly/internal/pkg/mailhtml" "github.com/warmbly/warmbly/internal/repository" - "github.com/warmbly/warmbly/internal/tasks" + "github.com/warmbly/warmbly/internal/tasks/proto" + "github.com/warmbly/warmbly/internal/tasksched" ) -// classifyTimeout bounds how long a result stays pending before the poller -// gives up and records it as "other" (the message never showed up in the -// seed's unibox — dropped, blocked at the gateway, or sync lag past the -// window). Tests are marked completed once every result resolves or this -// elapses. -const classifyTimeout = 2 * time.Hour +// Policy is the operator's placement section, satisfied by +// instancesettings.Service. +type Policy interface { + PlacementPolicy(ctx context.Context) instancesettings.Placement +} -// subjectTokenMarker prefixes the token embedded in the test subject. The -// worker injects only the warmup verify header (config.WarmupVerifyHeader), -// which this control-plane package can't change, so the subject is the token -// carrier that reliably survives sync into the unibox. Format: -// -// [wmpl:] -// -// The marker is matched verbatim by the repo's FindTokenInUnibox subject LIKE. -const subjectTokenMarker = "wmpl:" +// Entitlements answers who may test and on which allowance. feature.Gate +// satisfies it. +type Entitlements interface { + CanSendCampaignEmail(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) + IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) +} -// Service is the seed inbox-placement testing service. +// Publisher tells the dashboard a test moved. pubsub.StreamingPublisher +// satisfies it. +type Publisher interface { + PublishPlacementTest(ctx context.Context, orgID, testID uuid.UUID, campaignID *uuid.UUID, status string) +} + +// Notifier reaches people about a finished test or a monitor alert. +type Notifier interface { + Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any) + NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string) +} + +// MailboxControl is what a seed toggle needs from the mailbox service: stop +// its warmup and reload it onto its worker with the seed sync allowance. +type MailboxControl interface { + SetWarmupLifecycle(ctx context.Context, orgID, emailAccountID, action string) (*models.Email, *errx.Error) + LoadAccountOntoWorker(ctx context.Context, accountID uuid.UUID) error +} + +// CampaignPauser pauses a campaign whose monitor alerted, when asked to. +type CampaignPauser interface { + PausePlacementAlert(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error +} + +// CloudPanel is the instance side of Warmbly Cloud's seed panel. The +// cloudlink service satisfies it; nil when the instance is not linked. +type CloudPanel interface { + PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error) + StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) + ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error + PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) +} + +// Deps are the service's collaborators. Repo, Emails, Campaigns, Tasks and +// Scheduler are required; the rest are optional. +type Deps struct { + Repo repository.PlacementRepository + Emails repository.EmailRepository + Campaigns repository.CampaignRepository + Contacts repository.ContactRepository + Tasks repository.TaskRepository + Scheduler tasksched.Scheduler + Policy Policy + Gate Entitlements + Publisher Publisher + Notifier Notifier + Mailboxes MailboxControl + Pauser CampaignPauser + Cloud CloudPanel +} + +// Service is the placement test surface. type Service interface { - // CreateTest generates a unique token, persists the test plus one pending - // result per active seed, and sends a tokenized copy of the template from - // the sender to every active seed address. - CreateTest(ctx context.Context, orgID *uuid.UUID, senderAccountID uuid.UUID, subject, bodyPlain, bodyHTML string) (*repository.PlacementTest, error) - // ClassifyPending resolves pending results by looking up each test's token - // in the seed's unibox entries and classifying the folder from flags. It - // marks a test completed once all its results resolve or the timeout passes. - ClassifyPending(ctx context.Context) error + Overview(ctx context.Context, orgID uuid.UUID) (*Overview, *errx.Error) + CreateTests(ctx context.Context, in CreateInput) ([]TestView, *errx.Error) + ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]TestView, int, *errx.Error) + GetTest(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) (*TestDetail, *errx.Error) + CancelTest(ctx context.Context, orgID, id uuid.UUID) (*TestView, *errx.Error) + + ListWorkspaceSeeds(ctx context.Context, orgID uuid.UUID) ([]models.PlacementWorkspaceSeed, *errx.Error) + SetWorkspaceSeed(ctx context.Context, orgID, accountID uuid.UUID, enabled bool) (*models.PlacementWorkspaceSeed, *errx.Error) + + GetMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (*models.PlacementMonitor, *errx.Error) + PutMonitor(ctx context.Context, orgID, userID, campaignID uuid.UUID, in MonitorInput) (*models.PlacementMonitor, *errx.Error) + DeleteMonitor(ctx context.Context, orgID, campaignID uuid.UUID) *errx.Error + + AdminListSeeds(ctx context.Context) ([]AdminSeed, *errx.Error) + AdminSeedCandidates(ctx context.Context, search string, limit int) ([]AdminSeed, *errx.Error) + AdminSetSeed(ctx context.Context, accountID uuid.UUID, enabled bool) (*AdminSeed, *errx.Error) + + RemotePanel(ctx context.Context, inst *models.PoolLinkInstance) (*models.PlacementCloudPanel, *errx.Error) + RemoteStart(ctx context.Context, inst *models.PoolLinkInstance, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) + RemoteSends(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error + RemoteGet(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) + + // Tick classifies delivered probes, closes finished tests, syncs cloud + // tests and runs due monitors. The poller calls it. + Tick(ctx context.Context) error } type service struct { - repo repository.PlacementRepository - emailRepo repository.EmailRepository - sender tasks.EmailSender + Deps + now func() time.Time } -// NewService wires the placement service. emailRepo resolves sender/seed -// mailbox rows; sender is the existing send primitive (publishes a SendEmail -// event to the assigned worker over Kafka — no Cloud Task / task row needed -// for a one-off probe). -func NewService(repo repository.PlacementRepository, emailRepo repository.EmailRepository, sender tasks.EmailSender) Service { - return &service{repo: repo, emailRepo: emailRepo, sender: sender} +// NewService wires the placement service. +func NewService(d Deps) Service { + return &service{Deps: d, now: time.Now} } -func (s *service) CreateTest(ctx context.Context, orgID *uuid.UUID, senderAccountID uuid.UUID, subject, bodyPlain, bodyHTML string) (*repository.PlacementTest, error) { - subject = strings.TrimSpace(subject) - if subject == "" { - return nil, errors.New("subject is required") +// CreateInput is one request to test a template. +type CreateInput struct { + OrgID uuid.UUID + UserID *uuid.UUID + SenderAccountID uuid.UUID + CampaignID *uuid.UUID + SequenceID *uuid.UUID + ContactID *uuid.UUID + Subject string + BodyHTML string + BodyPlain string + Tracking string + Panel string + Origin string + MonitorID *uuid.UUID +} + +func (s *service) policy(ctx context.Context) instancesettings.Placement { + if s.Policy == nil { + p := instancesettings.DefaultPlacement() + return p } - if bodyPlain == "" && bodyHTML == "" { - return nil, errors.New("a plaintext or HTML body is required") + return s.Policy.PlacementPolicy(ctx) +} + +func placementErr(code errx.Code, id, msg string) *errx.Error { + return errx.NewWithIdentifier(code, id, msg) +} + +// CreateTests validates a request, picks the seeds, writes the test (two for +// a tracking comparison) with one task per probe, and schedules the tasks. +func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView, *errx.Error) { + if in.Panel == "" { + in.Panel = models.PlacementPanelInstance + } + if !models.ValidPlacementPanel(in.Panel) { + return nil, errx.New(errx.BadRequest, "panel must be instance, workspace or cloud") + } + if in.Origin == "" { + in.Origin = models.PlacementOriginManual + } + if in.Tracking == "" { + in.Tracking = models.PlacementTrackingCampaign + } + switch in.Tracking { + case models.PlacementTrackingCampaign, models.PlacementTrackingOn, models.PlacementTrackingOff, models.PlacementTrackingCompare: + default: + return nil, errx.New(errx.BadRequest, "tracking must be campaign, on, off or compare") } - sender, xerr := s.emailRepo.GetByID(ctx, senderAccountID) - if xerr != nil || sender == nil { - return nil, fmt.Errorf("sender account not found") - } - if sender.WorkerID == nil { - return nil, fmt.Errorf("sender account %s has no assigned worker", senderAccountID) + if s.Gate != nil && !config.SelfHosted() { + if ok, _ := s.Gate.CanSendCampaignEmail(ctx, in.OrgID); !ok { + return nil, placementErr(errx.PaymentRequired, "placement_not_entitled", "Placement tests need an active trial or subscription.") + } } - seeds, err := s.repo.ListSeedAccounts(ctx, true) - if err != nil { - return nil, fmt.Errorf("list seed accounts: %w", err) + // The sender: an active mailbox of this workspace, on a worker, not itself + // a seed. + sender, xerr := s.Emails.GetByID(ctx, in.SenderAccountID) + if xerr != nil || sender == nil || sender.OrganizationID == nil || *sender.OrganizationID != in.OrgID { + return nil, errx.New(errx.NotFound, "sending mailbox not found") + } + if sender.Status != "active" || sender.WorkerID == nil { + return nil, placementErr(errx.Conflict, "placement_sender_unavailable", "The sending mailbox is not connected and running, so it cannot send a test.") + } + if scope, err := s.Repo.SeedScope(ctx, sender.ID); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } else if scope != "" { + return nil, placementErr(errx.Conflict, "placement_sender_unavailable", "A seed mailbox receives tests; it cannot send one.") + } + + // The copy: a campaign step (snapshotted now, so an edit mid-test does not + // change what the later seeds get) or an ad-hoc template. + var campaign *models.Campaign + if in.CampaignID != nil { + c, err := s.Campaigns.GetByID(ctx, *in.CampaignID) + if err != nil || c == nil || c.OrganizationID == nil || *c.OrganizationID != in.OrgID { + return nil, errx.New(errx.NotFound, "campaign not found") + } + campaign = c + if in.SequenceID != nil { + seq := s.campaignStep(ctx, c.ID, *in.SequenceID) + if seq == nil { + return nil, errx.New(errx.NotFound, "campaign step not found") + } + if strings.TrimSpace(in.Subject) == "" && in.BodyHTML == "" && in.BodyPlain == "" { + in.Subject, in.BodyHTML, in.BodyPlain = seq.Subject, seq.BodyHTML, seq.BodyPlain + } + if strings.TrimSpace(in.Subject) == "" { + in.Subject = s.threadSubject(ctx, c.ID, seq) + } + } + } else if in.SequenceID != nil { + return nil, errx.New(errx.BadRequest, "sequence_id needs campaign_id") + } + // A campaign test renders for the campaign's first lead unless told + // otherwise, so merge fields and AI blocks read as a lead would get them. + if in.ContactID == nil && campaign != nil { + if lead, err := s.Repo.SampleLead(ctx, campaign.ID); err == nil { + in.ContactID = lead + } + } + if in.ContactID != nil { + found, xerr := s.contactsInOrg(ctx, in.OrgID, *in.ContactID) + if xerr != nil { + return nil, xerr + } + if !found { + return nil, errx.New(errx.NotFound, "contact not found") + } + } + in.Subject = strings.TrimSpace(in.Subject) + if in.Subject == "" { + return nil, errx.New(errx.BadRequest, "subject is required") + } + if !mailhtml.HasContent(in.BodyHTML) && strings.TrimSpace(in.BodyPlain) == "" { + return nil, errx.New(errx.BadRequest, "a plain-text or HTML body is required") + } + if len(in.Subject) > config.SequenceSubjectLimit*4 || len(in.BodyHTML)+len(in.BodyPlain) > config.SequenceBodyLimit*4 { + return nil, errx.New(errx.BadRequest, "the template is too long") + } + + // Tracking, resolved to what each test's copies carry. + textOnly := campaign != nil && campaign.TextOnly + campOpen, campLink := false, false + if campaign != nil { + campOpen, campLink = campaign.OpenTracking, campaign.LinkTracking + } + type variant struct{ open, link bool } + var variants []variant + switch in.Tracking { + case models.PlacementTrackingCampaign: + variants = []variant{{campOpen, campLink}} + case models.PlacementTrackingOn: + variants = []variant{{true, true}} + case models.PlacementTrackingOff: + variants = []variant{{false, false}} + case models.PlacementTrackingCompare: + tracked := variant{campOpen, campLink} + if !tracked.open && !tracked.link { + tracked = variant{true, true} + } + variants = []variant{{false, false}, tracked} + } + if textOnly && (in.Tracking == models.PlacementTrackingOn || in.Tracking == models.PlacementTrackingCompare) { + return nil, placementErr(errx.BadRequest, "placement_invalid_tracking", "This campaign sends plain text, which carries no tracking to compare.") + } + if textOnly { + variants = []variant{{false, false}} + } + + // Limits: tests in flight, the sender's own queue, the monthly allowance. + if in.Origin == models.PlacementOriginManual || in.Origin == models.PlacementOriginMonitor { + running, err := s.Repo.CountRunning(ctx, in.OrgID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if running+len(variants) > config.PlacementRunningPerOrgMax { + return nil, placementErr(errx.TooManyRequests, "placement_too_many_running", + "This workspace already has placement tests running. Wait for one to finish.") + } + } + if busy, err := s.Repo.SenderBusy(ctx, sender.ID); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } else if busy { + return nil, placementErr(errx.Conflict, "placement_sender_busy", "A placement test is still sending from this mailbox.") + } + metered := in.Panel == models.PlacementPanelInstance && in.Origin != models.PlacementOriginAdmin + if metered { + usage, xerr := s.usage(ctx, in.OrgID) + if xerr != nil { + return nil, xerr + } + if usage.Limit != nil && usage.Used+len(variants) > *usage.Limit { + return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded", + "This workspace has used its placement tests for the month.") + } + } + + pol := s.policy(ctx) + senderDomain := domainOf(sender.SendFrom()) + + // Seeds, per panel. + var seeds []models.PlacementSeed + var cloudStart *models.PlacementCloudStart + switch in.Panel { + case models.PlacementPanelInstance, models.PlacementPanelWorkspace: + scope, orgFilter := models.SeedScopeInstance, (*uuid.UUID)(nil) + if in.Panel == models.PlacementPanelWorkspace { + scope, orgFilter = models.SeedScopeWorkspace, &in.OrgID + } + rows, err := s.Repo.ListSeeds(ctx, scope, orgFilter, true) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + seeds = pickSeeds(rows, sender.ID, senderDomain, pol.SeedsPerTest) + case models.PlacementPanelCloud: + if s.Cloud == nil { + return nil, placementErr(errx.Conflict, "placement_panel_unavailable", "Link this instance to Warmbly Cloud to test on its seed panel.") + } + start, xerr := s.Cloud.StartPlacement(ctx, models.PlacementCloudStartRequest{SenderDomain: senderDomain, Tests: len(variants)}) + if xerr != nil { + return nil, xerr + } + if len(start.TestIDs) != len(variants) { + return nil, errx.New(errx.ServiceUnavailable, "Warmbly Cloud did not open the test") + } + cloudStart = start + for _, cs := range start.Seeds { + id := cs.ID + seeds = append(seeds, models.PlacementSeed{RemoteSeedID: &id, Address: cs.Address, Family: cs.Family}) + } } if len(seeds) == 0 { - return nil, errors.New("no active seed mailboxes are configured") + return nil, placementErr(errx.Conflict, "placement_no_seeds", noSeedsMessage(in.Panel)) } - token := uuid.NewString() - test := &repository.PlacementTest{ - ID: uuid.New(), - OrganizationID: orgID, - SenderAccountID: senderAccountID, - Subject: subject, - BodyPlain: bodyPlain, - BodyHTML: bodyHTML, - Token: token, - Status: repository.PlacementStatusPending, - } - if err := s.repo.CreateTest(ctx, test); err != nil { - return nil, fmt.Errorf("create test: %w", err) + // Budget: every probe is a send from the sender's day. + probes := len(seeds) * len(variants) + if s.Tasks != nil { + sent, err := s.Tasks.CountCampaignEmailsSentToday(ctx, sender.ID) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if left := sender.CampaignLimit - sent; probes > left { + return nil, placementErr(errx.Conflict, "placement_daily_budget", + "This test sends "+strconv.Itoa(probes)+" emails from "+sender.Email+", which has "+strconv.Itoa(max(0, left))+ + " left of its daily limit today.") + } } - // Tokenized subject: the token lives in a marker the unibox subject search - // can match. Keeping the original subject first means the seed inbox shows - // the real template subject for classification fidelity. - taggedSubject := fmt.Sprintf("%s [%s%s]", subject, subjectTokenMarker, token) - - sentCount := 0 + // Build the tests, their probes and one task per probe. + now := s.now() + var group *uuid.UUID + if len(variants) > 1 { + g := uuid.New() + group = &g + } + type built struct { + test models.PlacementTest + results []models.PlacementResult + tasks []repository.Task + } + out := make([]built, len(variants)) + for i, v := range variants { + senderID := sender.ID + out[i].test = models.PlacementTest{ + ID: uuid.New(), + OrganizationID: &in.OrgID, + SenderAccountID: &senderID, + SenderEmail: sender.SendFrom(), + CreatedBy: in.UserID, + CampaignID: in.CampaignID, + SequenceID: in.SequenceID, + ContactID: in.ContactID, + MonitorID: in.MonitorID, + Subject: in.Subject, + BodyHTML: in.BodyHTML, + BodyPlain: in.BodyPlain, + OpenTracking: v.open, + LinkTracking: v.link, + CompareGroupID: group, + Origin: in.Origin, + Panel: in.Panel, + Status: models.PlacementStatusRunning, + } + if cloudStart != nil { + remote := cloudStart.TestIDs[i] + out[i].test.RemoteTestID = &remote + } + } + // One probe every spacing, jittered, alternating variants per seed in a + // random order so both see the same conditions. + at := now.Add(5 * time.Second) + spacing := pol.Spacing() for _, seed := range seeds { - if err := s.repo.CreatePendingResult(ctx, test.ID, seed.ID, seed.Provider); err != nil { - log.Warn().Err(err). - Str("test_id", test.ID.String()). - Str("seed_id", seed.ID.String()). - Msg("placement: failed to create pending result") - continue + order := rand.Perm(len(variants)) + for _, vi := range order { + taskID := uuid.New() + slot := at + out[vi].tasks = append(out[vi].tasks, repository.Task{ + ID: taskID, TaskType: "placement", EmailAccountID: sender.ID, Status: "pending", ScheduledAt: &slot, + }) + seed := seed + out[vi].results = append(out[vi].results, models.PlacementResult{ + ID: uuid.New(), + SeedAccountID: seed.AccountID, + SeedAddress: seed.Address, + Family: seed.Family, + RemoteSeedID: seed.RemoteSeedID, + TaskID: &taskID, + Folder: models.PlacementFolderPending, + ScheduledAt: &slot, + }) + at = at.Add(jitter(spacing)) } - - msg := tasks.EmailMessage{ - From: sender.Email, - To: []string{seed.Email}, - Subject: taggedSubject, - BodyPlain: bodyPlain, - BodyHTML: bodyHTML, - MessageID: fmt.Sprintf("<%s@%s>", uuid.NewString(), domainOf(sender.Email)), - // We also pass the token through the warmup verify header lane via - // WarmupToken so that, IF a future worker change starts persisting - // that header into unibox flags, the same token is already present. - // Today the header is consumed by the warmup detector and not stored, - // so the subject marker remains the authoritative carrier. - WarmupToken: token, - } - - if err := s.sender.Send(ctx, uuid.New(), msg, *sender); err != nil { - log.Warn().Err(err). - Str("test_id", test.ID.String()). - Str("seed_id", seed.ID.String()). - Msg("placement: failed to publish test send to worker") - continue - } - sentCount++ } - if sentCount == 0 { - // Nothing went out — mark the test completed immediately so it doesn't - // dangle pending forever. Results remain "pending" for visibility. - now := time.Now() - _ = s.repo.SetTestStatus(ctx, test.ID, repository.PlacementStatusCompleted, &now) - return nil, errors.New("failed to send any placement probes") + views := make([]TestView, 0, len(out)) + for i := range out { + b := &out[i] + if err := s.Repo.CreateTest(ctx, &b.test, b.results, b.tasks); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + s.enqueue(ctx, b.tasks, b.results) + s.publish(ctx, &b.test) + views = append(views, s.view(b.test, b.results, false)) } - - return test, nil + return views, nil } -func (s *service) ClassifyPending(ctx context.Context) error { - jobs, err := s.repo.ListPendingResults(ctx, 200) +// enqueue hands each probe's task to the scheduler. The local scheduler picks +// due pending rows on its own; Cloud Tasks needs the handle stored. +func (s *service) enqueue(ctx context.Context, tasks []repository.Task, results []models.PlacementResult) { + if s.Scheduler == nil { + return + } + for i, t := range tasks { + name, err := s.Scheduler.CreateTask(ctx, &proto.ProcessTask{TaskId: t.ID.String()}, *t.ScheduledAt) + if err != nil { + errs.CaptureException(err) + _ = s.Repo.FailProbe(ctx, results[i].ID, "The send could not be scheduled") + continue + } + if name != "" && s.Tasks != nil { + _ = s.Tasks.UpdateTaskScheduledAt(ctx, t.ID, *t.ScheduledAt, name) + } + } +} + +func (s *service) publish(ctx context.Context, t *models.PlacementTest) { + if s.Publisher == nil || t.OrganizationID == nil { + return + } + s.Publisher.PublishPlacementTest(ctx, *t.OrganizationID, t.ID, t.CampaignID, t.Status) +} + +// threadSubject is the subject a threading follow-up inherits: the subject of +// the closest earlier step that has one. A probe is a standalone message, so +// it goes without the "Re:" a real reply would carry. +func (s *service) threadSubject(ctx context.Context, campaignID uuid.UUID, step *models.Sequence) string { + seqs, err := s.Campaigns.GetSequencesByCampaignID(ctx, campaignID) if err != nil { - return fmt.Errorf("list pending results: %w", err) + return "" } - - touchedTests := map[uuid.UUID]struct{}{} - - for _, j := range jobs { - touchedTests[j.TestID] = struct{}{} - - match, err := s.repo.FindTokenInUnibox(ctx, j.SeedUserID, j.SeedAccountID, j.Token, j.TestCreatedAt) - if err != nil { - log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: unibox lookup failed") - continue + sort.Slice(seqs, func(i, j int) bool { return seqs[i].Position < seqs[j].Position }) + subject := "" + for _, q := range seqs { + if q.Position >= step.Position { + break } - - if match != nil { - folder := classifyFolder(match.Flags) - rawFlags := strings.Join(match.Flags, ",") - if err := s.repo.RecordResult(ctx, j.ResultID, folder, rawFlags, time.Now()); err != nil { - log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: failed to record result") - } - continue - } - - // Not found yet. If the test is older than the classify timeout, the - // probe almost certainly never landed in this seat — record "other" - // (could be a gateway block, a hard bounce, or a sync gap) so the test - // can complete instead of hanging pending forever. - if time.Since(j.TestCreatedAt) > classifyTimeout { - if err := s.repo.RecordResult(ctx, j.ResultID, repository.PlacementFolderOther, "timeout", time.Now()); err != nil { - log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: failed to timeout result") - } + if strings.TrimSpace(q.Subject) != "" { + subject = q.Subject } } + return subject +} - // Mark any touched test completed once it has no pending results left. - for testID := range touchedTests { - pending, err := s.repo.CountPendingForTest(ctx, testID) - if err != nil { - continue - } - if pending == 0 { - now := time.Now() - _ = s.repo.SetTestStatus(ctx, testID, repository.PlacementStatusCompleted, &now) +// campaignStep is one step of a campaign, nil when the id names another +// campaign's step. +func (s *service) campaignStep(ctx context.Context, campaignID, sequenceID uuid.UUID) *models.Sequence { + seqs, err := s.Campaigns.GetSequencesByCampaignID(ctx, campaignID) + if err != nil { + return nil + } + for i := range seqs { + if seqs[i].ID == sequenceID { + return &seqs[i] } } - return nil } -// classifyFolder maps a synced message's flags/labels to a placement folder. -// It reuses the SAME spam-flag detection the warmup consumer uses -// (containsSpamFlag: \Junk / \Spam / SPAM / Junk). -// -// Promotions detection requires a Gmail CATEGORY_PROMOTIONS label to be present -// in the flags. NOTE: the current Gmail sync only maps UNREAD/STARRED/IMPORTANT -// /DRAFT into flags (internal/client/goog/message.go GmailMessageToEmailData) -// and does not capture CATEGORY_* tab labels. Capturing those needs a Gmail -// category-label sync change in the worker — a deliberate follow-up. Until then -// a Gmail-tab message reads as "inbox" here, which is the correct conservative -// default (it did reach the inbox, just a tab). -func classifyFolder(flags []string) string { - if containsSpamFlag(flags) { - return repository.PlacementFolderSpam +func (s *service) contactsInOrg(ctx context.Context, orgID, contactID uuid.UUID) (bool, *errx.Error) { + if s.Contacts == nil { + return false, nil } - if hasPromotionsLabel(flags) { - return repository.PlacementFolderPromotions + found, xerr := s.Contacts.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{contactID}) + if xerr != nil { + return false, xerr } - return repository.PlacementFolderInbox + return len(found) == 1, nil } -// containsSpamFlag reads the same list as warmup spam-placement detection, so -// placement classification and warmup agree on what "landed in spam" means. -func containsSpamFlag(flags []string) bool { - return models.HasSpamFlag(flags) -} - -// hasPromotionsLabel reports whether a Gmail CATEGORY_PROMOTIONS label is -// present. See classifyFolder for the sync caveat. -func hasPromotionsLabel(flags []string) bool { - for _, f := range flags { - if strings.EqualFold(f, "CATEGORY_PROMOTIONS") { - return true +// pickSeeds chooses up to n seeds, never the sender and never one on the +// sender's domain, round-robin across host families so a small test still +// covers every provider. +func pickSeeds(rows []repository.SeedAccount, senderID uuid.UUID, senderDomain string, n int) []models.PlacementSeed { + byFamily := map[string][]models.PlacementSeed{} + var families []string + for _, r := range rows { + if r.ID == senderID || strings.EqualFold(domainOf(r.Email), senderDomain) { + continue + } + fam := string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email)) + if _, ok := byFamily[fam]; !ok { + families = append(families, fam) + } + id := r.ID + byFamily[fam] = append(byFamily[fam], models.PlacementSeed{AccountID: &id, Address: r.Email, Family: fam, OrganizationID: r.OrganizationID}) + } + sort.Strings(families) + for _, f := range families { + rand.Shuffle(len(byFamily[f]), func(i, j int) { byFamily[f][i], byFamily[f][j] = byFamily[f][j], byFamily[f][i] }) + } + var out []models.PlacementSeed + for len(out) < n { + added := false + for _, f := range families { + if len(out) >= n { + break + } + if len(byFamily[f]) == 0 { + continue + } + out = append(out, byFamily[f][0]) + byFamily[f] = byFamily[f][1:] + added = true + } + if !added { + break } } - return false + return out } -func domainOf(email string) string { - if at := strings.LastIndex(email, "@"); at >= 0 && at < len(email)-1 { - return email[at+1:] +func noSeedsMessage(panel string) string { + switch panel { + case models.PlacementPanelWorkspace: + return "Add a test inbox under Placement tests > Seed inboxes first. It has to be on a different domain from the sender." + case models.PlacementPanelCloud: + return "Warmbly Cloud has no seed inboxes available for this sender right now." } - return "localhost" + return "This instance has no seed inboxes on a different domain from the sender yet." +} + +// jitter spreads a gap by up to a third either way. +func jitter(d time.Duration) time.Duration { + third := int64(d) / 3 + if third <= 0 { + return d + } + return time.Duration(int64(d) - third + rand.Int64N(2*third+1)) +} + +func domainOf(address string) string { + if at := strings.LastIndex(address, "@"); at >= 0 && at < len(address)-1 { + return strings.ToLower(strings.TrimSpace(address[at+1:])) + } + return "" +} + +// CancelTest stops the probes of a running test that have not left yet. +func (s *service) CancelTest(ctx context.Context, orgID, id uuid.UUID) (*TestView, *errx.Error) { + ok, err := s.Repo.CancelTest(ctx, orgID, id) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + test, err := s.Repo.GetOrgTest(ctx, orgID, id) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if test == nil { + return nil, errx.New(errx.NotFound, "placement test not found") + } + if !ok { + return nil, placementErr(errx.Conflict, "placement_not_running", "This placement test is no longer running.") + } + results, err := s.Repo.ListResults(ctx, []uuid.UUID{id}) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + s.publish(ctx, test) + v := s.view(*test, results[id], false) + return &v, nil +} + +// ListTests lists tests newest first; a nil orgID lists every workspace's. +func (s *service) ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]TestView, int, *errx.Error) { + tests, total, err := s.Repo.ListTests(ctx, repository.PlacementTestFilter{ + OrganizationID: orgID, CampaignID: campaignID, Limit: limit, Offset: offset, + }) + if err != nil { + errs.CaptureException(err) + return nil, 0, errx.InternalError() + } + ids := make([]uuid.UUID, len(tests)) + for i, t := range tests { + ids[i] = t.ID + } + results, err := s.Repo.ListResults(ctx, ids) + if err != nil { + errs.CaptureException(err) + return nil, 0, errx.InternalError() + } + out := make([]TestView, 0, len(tests)) + for _, t := range tests { + out = append(out, s.view(t, results[t.ID], false)) + } + return out, total, nil +} + +// GetTest is one test with every probe, the content check of its copy and, +// for a tracking comparison, the other half. +func (s *service) GetTest(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) (*TestDetail, *errx.Error) { + var test *models.PlacementTest + var err error + if orgID != nil { + test, err = s.Repo.GetOrgTest(ctx, *orgID, id) + } else { + test, err = s.Repo.GetTest(ctx, id) + } + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + if test == nil { + return nil, errx.New(errx.NotFound, "placement test not found") + } + ids := []uuid.UUID{test.ID} + var sibling *models.PlacementTest + if test.CompareGroupID != nil && test.OrganizationID != nil { + group, err := s.Repo.ListGroup(ctx, *test.OrganizationID, *test.CompareGroupID) + if err == nil { + for i := range group { + if group[i].ID != test.ID { + sibling = &group[i] + ids = append(ids, sibling.ID) + } + } + } + } + results, err := s.Repo.ListResults(ctx, ids) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + detail := &TestDetail{ + TestView: s.view(*test, results[test.ID], true), + Results: resultViews(test.Panel, results[test.ID]), + Content: contentCheck(*test), + } + if sibling != nil { + v := s.view(*sibling, results[sibling.ID], false) + detail.Compare = &v + } + return detail, nil } diff --git a/internal/app/placement/tick.go b/internal/app/placement/tick.go new file mode 100644 index 000000000..7df5e52ac --- /dev/null +++ b/internal/app/placement/tick.go @@ -0,0 +1,159 @@ +package placement + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" + "github.com/warmbly/warmbly/internal/repository" +) + +// Tick is one pass of the poller: read verdicts, expire what never arrived, +// sync the cloud's panel, close finished tests and start due monitors. +func (s *service) Tick(ctx context.Context) error { + now := s.now() + touched := map[uuid.UUID]bool{} + + landings, err := s.Repo.FindLandings(ctx, 500) + if err != nil { + errs.CaptureException(err) + return err + } + for _, l := range landings { + folder := models.ClassifyPlacementLanding(l.Folder, l.Flags) + if err := s.Repo.RecordLanding(ctx, l.ResultID, folder, strings.Join(l.Flags, ","), now); err != nil { + errs.CaptureException(err) + continue + } + touched[l.TestID] = true + } + + timeout := now.Add(-time.Duration(config.PlacementClassifyTimeoutMinutes) * time.Minute) + // An instance sends its whole test within the hour; six covers a slow one. + if err := s.Repo.ExpireProbes(ctx, timeout, timeout, now.Add(-6*time.Hour)); err != nil { + errs.CaptureException(err) + } + + for id := range s.syncCloud(ctx) { + touched[id] = true + } + + finished, err := s.Repo.FinishTests(ctx) + if err != nil { + errs.CaptureException(err) + } + notifiedGroups := map[uuid.UUID]bool{} + for _, f := range finished { + delete(touched, f.ID) + if f.OrganizationID != nil && s.Publisher != nil { + s.Publisher.PublishPlacementTest(ctx, *f.OrganizationID, f.ID, f.CampaignID, f.Status) + } + s.onFinished(ctx, f, notifiedGroups) + } + for id := range touched { + if t, err := s.Repo.GetTest(ctx, id); err == nil && t != nil { + s.publish(ctx, t) + } + } + + s.runMonitors(ctx) + return nil +} + +// onFinished tells whoever started a test where it landed, and checks a +// monitor's test against its threshold. A tracking comparison reports once, +// when both halves are done. +func (s *service) onFinished(ctx context.Context, f repository.PlacementFinished, notifiedGroups map[uuid.UUID]bool) { + if f.OrganizationID == nil || f.Status == models.PlacementStatusCancelled { + return + } + ids := []uuid.UUID{f.ID} + var tests []models.PlacementTest + if f.CompareGroupID != nil { + if notifiedGroups[*f.CompareGroupID] { + return + } + group, err := s.Repo.ListGroup(ctx, *f.OrganizationID, *f.CompareGroupID) + if err != nil { + return + } + for _, t := range group { + if t.FinishedAt == nil && t.ID != f.ID { + return + } + } + notifiedGroups[*f.CompareGroupID] = true + tests = group + ids = ids[:0] + for _, t := range group { + ids = append(ids, t.ID) + } + } else { + t, err := s.Repo.GetTest(ctx, f.ID) + if err != nil || t == nil { + return + } + tests = []models.PlacementTest{*t} + } + results, err := s.Repo.ListResults(ctx, ids) + if err != nil { + return + } + + var headline models.PlacementCounts + lines := make([]string, 0, len(tests)) + for _, t := range tests { + v := s.view(t, results[t.ID], false) + if len(tests) > 1 { + label := "Without tracking" + if t.Tracked() { + label = "With tracking" + } + lines = append(lines, label+": "+summaryLine(v.Summary)) + } else { + lines = append(lines, summaryLine(v.Summary)) + } + // The alert reads the copy the campaign really sends. + if len(tests) == 1 || t.Tracked() { + headline = v.Summary + } + } + link := "/app/placement/" + f.ID.String() + body := strings.Join(lines, "\n") + + switch f.Origin { + case models.PlacementOriginManual: + if f.CreatedBy != nil && s.Notifier != nil { + s.Notifier.Notify(ctx, *f.CreatedBy, f.OrganizationID, models.NotifPlacementFinished, + "Placement test finished", body, link, map[string]any{"placement_test_id": f.ID.String()}) + } + case models.PlacementOriginMonitor: + if f.MonitorID != nil { + s.checkMonitorAlert(ctx, *f.MonitorID, *f.OrganizationID, f.CampaignID, headline, body, link) + } + } +} + +// summaryLine reads a test in one sentence. +func summaryLine(c models.PlacementCounts) string { + if c.Delivered == 0 { + return "No copy reached a seed inbox." + } + return fmt.Sprintf("%d of %d copies reached the inbox, %d a Gmail tab, %d spam and %d never arrived.", + c.Inbox, c.Delivered, c.Promotions+c.Other, c.Spam, c.Missing) +} + +// percent is a rate as a whole percentage, -1 when there is none. +func percent(r *float64) int { + if r == nil { + return -1 + } + return int(math.Round(*r * 100)) +} diff --git a/internal/app/placement/view.go b/internal/app/placement/view.go new file mode 100644 index 000000000..9f0f4bea3 --- /dev/null +++ b/internal/app/placement/view.go @@ -0,0 +1,251 @@ +package placement + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" + "github.com/warmbly/warmbly/internal/pkg/mailhost" + "github.com/warmbly/warmbly/internal/pkg/warmlint" + "github.com/warmbly/warmbly/internal/repository" +) + +// TestView is a test with where its copies landed, overall and per host +// family. The copy itself is left out of lists. +type TestView struct { + models.PlacementTest + Summary models.PlacementCounts `json:"summary"` + Families []models.PlacementFamilyCounts `json:"families"` +} + +// ResultView is one probe as the dashboard shows it. +type ResultView struct { + // Seed is the seed's address, masked on the shared panels so the panel + // cannot be listed and whitelisted. + Seed string `json:"seed"` + Family string `json:"family"` + FamilyLabel string `json:"family_label"` + Folder string `json:"folder"` + ScheduledAt *time.Time `json:"scheduled_at"` + SentAt *time.Time `json:"sent_at"` + DetectedAt *time.Time `json:"detected_at"` + Error string `json:"error,omitempty"` +} + +// ContentCheck is the rules pass over the test's copy. +type ContentCheck struct { + Score int `json:"score"` + Issues []warmlint.Issue `json:"issues"` +} + +// TestDetail is one test in full. +type TestDetail struct { + TestView + Results []ResultView `json:"results"` + Content ContentCheck `json:"content"` + Compare *TestView `json:"compare,omitempty"` +} + +// Overview is what a workspace can test on and how much of its allowance is +// left. +type Overview struct { + Panels []models.PlacementPanelInfo `json:"panels"` + Usage models.PlacementUsage `json:"usage"` + WorkspaceSeeds int `json:"workspace_seeds"` + SeedsPerTest int `json:"seeds_per_test"` + SpacingSeconds int `json:"spacing_seconds"` +} + +// AdminSeed is a mailbox on (or offered for) the instance panel. +type AdminSeed struct { + ID uuid.UUID `json:"id"` + OrganizationID *uuid.UUID `json:"organization_id"` + Email string `json:"email"` + Name string `json:"name"` + Provider string `json:"provider"` + Family string `json:"family"` + FamilyLabel string `json:"family_label"` + Status string `json:"status"` + WorkerID *uuid.UUID `json:"worker_id"` + SeedScope string `json:"seed_scope"` +} + +func familyLabel(f string) string { + if l := mailhost.Host(f).Label(); l != "" { + return l + } + return "Other provider" +} + +// view counts a test's probes overall and per family. withBody keeps the copy. +func (s *service) view(t models.PlacementTest, results []models.PlacementResult, withBody bool) TestView { + if !withBody { + t.BodyHTML, t.BodyPlain = "", "" + } + v := TestView{PlacementTest: t, Families: []models.PlacementFamilyCounts{}} + byFamily := map[string]*models.PlacementCounts{} + for _, r := range results { + v.Summary.Add(r.Folder) + c, ok := byFamily[r.Family] + if !ok { + c = &models.PlacementCounts{} + byFamily[r.Family] = c + } + c.Add(r.Folder) + } + v.Summary.Finish() + for fam, c := range byFamily { + c.Finish() + v.Families = append(v.Families, models.PlacementFamilyCounts{Family: fam, Label: familyLabel(fam), Counts: *c}) + } + sort.Slice(v.Families, func(i, j int) bool { return v.Families[i].Label < v.Families[j].Label }) + return v +} + +func resultViews(panel string, results []models.PlacementResult) []ResultView { + out := make([]ResultView, 0, len(results)) + for _, r := range results { + seed := r.SeedAddress + if panel != models.PlacementPanelWorkspace { + seed = maskAddress(seed) + } + out = append(out, ResultView{ + Seed: seed, + Family: r.Family, + FamilyLabel: familyLabel(r.Family), + Folder: r.Folder, + ScheduledAt: r.ScheduledAt, + SentAt: r.SentAt, + DetectedAt: r.DetectedAt, + Error: r.Error, + }) + } + return out +} + +// maskAddress keeps the first letter of the local part and the domain. +func maskAddress(address string) string { + at := strings.LastIndex(address, "@") + if at <= 0 { + return "***" + } + return address[:1] + "***" + address[at:] +} + +func contentCheck(t models.PlacementTest) ContentCheck { + res := warmlint.Score(t.Subject, t.BodyHTML, t.BodyPlain) + return ContentCheck{Score: res.Score, Issues: res.Issues} +} + +// usage is a workspace's count against the monthly allowance on the metered +// panel. A self-hosted instance does not meter its own seeds. +func (s *service) usage(ctx context.Context, orgID uuid.UUID) (models.PlacementUsage, *errx.Error) { + now := s.now().UTC() + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + u := models.PlacementUsage{PeriodStart: start, PeriodEnd: start.AddDate(0, 1, 0)} + used, err := s.Repo.CountMeteredTests(ctx, orgID, start) + if err != nil { + errs.CaptureException(err) + return u, errx.InternalError() + } + u.Used = used + if config.SelfHosted() { + return u, nil + } + pol := s.policy(ctx) + limit := pol.TestsPerMonthTrial + if s.Gate != nil { + if paid, _ := s.Gate.IsPaidOrganization(ctx, orgID); paid { + limit = pol.TestsPerMonthPaid + } + } + u.Limit = &limit + return u, nil +} + +func panelInfo(panel string, seeds []models.PlacementSeed, metered bool) models.PlacementPanelInfo { + info := models.PlacementPanelInfo{Panel: panel, Seeds: len(seeds), Metered: metered, Families: []models.PlacementPanelFamily{}} + counts := map[string]int{} + for _, s := range seeds { + counts[s.Family]++ + } + for fam, n := range counts { + info.Families = append(info.Families, models.PlacementPanelFamily{Family: fam, Label: familyLabel(fam), Seeds: n}) + } + sort.Slice(info.Families, func(i, j int) bool { return info.Families[i].Label < info.Families[j].Label }) + info.Available = len(seeds) > 0 + return info +} + +// Overview lists the three panels with their seed mix, and the allowance. +func (s *service) Overview(ctx context.Context, orgID uuid.UUID) (*Overview, *errx.Error) { + pol := s.policy(ctx) + out := &Overview{SeedsPerTest: pol.SeedsPerTest, SpacingSeconds: pol.SpacingSeconds} + usage, xerr := s.usage(ctx, orgID) + if xerr != nil { + return nil, xerr + } + out.Usage = usage + + instance, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, true) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + inst := panelInfo(models.PlacementPanelInstance, familySeeds(instance), !config.SelfHosted()) + if !inst.Available { + inst.Reason = "No seed inboxes are on this instance's panel yet." + if config.SelfHosted() { + inst.Reason = "No seed inboxes are on this instance's panel yet. An administrator adds them in the admin panel." + } + } + out.Panels = append(out.Panels, inst) + + own, err := s.Repo.ListSeeds(ctx, models.SeedScopeWorkspace, &orgID, true) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + ws := panelInfo(models.PlacementPanelWorkspace, familySeeds(own), false) + if !ws.Available { + ws.Reason = "Mark one of this workspace's mailboxes as a seed inbox to test on it." + } + out.Panels = append(out.Panels, ws) + out.WorkspaceSeeds = len(own) + + cloud := models.PlacementPanelInfo{Panel: models.PlacementPanelCloud, Metered: true, Families: []models.PlacementPanelFamily{}} + switch { + case !config.SelfHosted(): + // The hosted product's own panel is the instance panel. + cloud.Reason = "Warmbly Cloud's panel is the instance panel here." + case s.Cloud == nil: + cloud.Reason = "Link this instance to Warmbly Cloud to test on its seed panel." + default: + if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr != nil { + cloud.Reason = xerr.Message + } else if panel != nil { + cloud = panel.Panel + cloud.Panel = models.PlacementPanelCloud + cloud.Metered = true + } + } + if config.SelfHosted() || cloud.Available { + out.Panels = append(out.Panels, cloud) + } + return out, nil +} + +func familySeeds(rows []repository.SeedAccount) []models.PlacementSeed { + out := make([]models.PlacementSeed, 0, len(rows)) + for _, r := range rows { + out = append(out, models.PlacementSeed{Address: r.Email, Family: string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email))}) + } + return out +} diff --git a/internal/config/constants.go b/internal/config/constants.go index bd1bb4956..b7cc14bdb 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -102,6 +102,26 @@ const ( // window keeps double coverage. Operator-editable under Instance settings. FormEventsRetentionDaysDefault = 180 + // Inbox placement tests. The monthly allowance covers tests on the + // instance and cloud seed panels only; a workspace's own seeds cost the + // operator nothing. Operator-editable under Instance settings. + PlacementTestsPerMonthTrialDefault = 3 + PlacementTestsPerMonthPaidDefault = 40 + PlacementTestsPerMonthMax = 100_000 + PlacementSeedsPerTestDefault = 40 + PlacementSeedsPerTestMax = 100 + PlacementSpacingSecondsDefault = 20 // gap between two probes from one sender + PlacementSpacingSecondsMin = 5 + PlacementSpacingSecondsMax = 600 + PlacementClassifyTimeoutMinutes = 120 // after the send, a copy not seen in the seed is missing + PlacementRunningPerOrgMax = 3 // tests one workspace may have in flight at once + PlacementSeedsPerWorkspaceMax = 50 // a workspace's own seed mailboxes + PlacementSeedDailySyncMessages = 50_000 + PlacementMonitorIntervalDaysMin = 1 + PlacementMonitorIntervalDaysMax = 30 + PlacementMonitorIntervalDaysDef = 7 + PlacementMonitorAlertBelowDefault = 70 // inbox rate, percent + // Sequences. Empty by default so the editor shows a smart, position-based // label (e.g. "Email 1") until the user names the step themselves. SequenceDefaultName = "" diff --git a/internal/infrastructure/db/migrations/000217_placement_tests.down.sql b/internal/infrastructure/db/migrations/000217_placement_tests.down.sql new file mode 100644 index 000000000..4dc0b4da9 --- /dev/null +++ b/internal/infrastructure/db/migrations/000217_placement_tests.down.sql @@ -0,0 +1,65 @@ +-- task_type keeps 'placement': Postgres cannot drop an enum value. +DROP INDEX IF EXISTS idx_placement_results_task; +DROP INDEX IF EXISTS idx_placement_results_test_address; + +-- Results of tests that never had a local seed cannot go back to a NOT NULL +-- seed column. +DELETE FROM placement_results WHERE seed_account_id IS NULL; +UPDATE placement_results SET folder = 'other', raw_flags = 'timeout' WHERE folder = 'missing'; +UPDATE placement_results SET folder = 'other' WHERE folder IN ('failed', 'cancelled'); + +ALTER TABLE placement_results + DROP CONSTRAINT IF EXISTS placement_results_folder_check, + DROP COLUMN IF EXISTS seed_address, + DROP COLUMN IF EXISTS remote_seed_id, + DROP COLUMN IF EXISTS task_id, + DROP COLUMN IF EXISTS message_id, + DROP COLUMN IF EXISTS scheduled_at, + DROP COLUMN IF EXISTS sent_at, + DROP COLUMN IF EXISTS remote_synced_at, + DROP COLUMN IF EXISTS error, + ALTER COLUMN seed_account_id SET NOT NULL, + ADD CONSTRAINT placement_results_folder_check + CHECK (folder IN ('inbox', 'promotions', 'spam', 'other', 'pending')); + +ALTER TABLE placement_monitors DROP CONSTRAINT IF EXISTS placement_monitors_last_test_fkey; + +DROP INDEX IF EXISTS idx_placement_tests_running; +DROP INDEX IF EXISTS idx_placement_tests_campaign; +DROP INDEX IF EXISTS idx_placement_tests_group; + +DELETE FROM placement_tests WHERE sender_account_id IS NULL; +UPDATE placement_tests SET status = 'pending' WHERE status = 'running'; +UPDATE placement_tests SET status = 'completed' WHERE status IN ('cancelled', 'failed'); + +ALTER TABLE placement_tests + DROP CONSTRAINT IF EXISTS placement_tests_status_check, + DROP CONSTRAINT IF EXISTS placement_tests_origin_check, + DROP CONSTRAINT IF EXISTS placement_tests_panel_check, + ALTER COLUMN status SET DEFAULT 'pending', + DROP COLUMN IF EXISTS created_by, + DROP COLUMN IF EXISTS campaign_id, + DROP COLUMN IF EXISTS sequence_id, + DROP COLUMN IF EXISTS contact_id, + DROP COLUMN IF EXISTS monitor_id, + DROP COLUMN IF EXISTS open_tracking, + DROP COLUMN IF EXISTS link_tracking, + DROP COLUMN IF EXISTS compare_group_id, + DROP COLUMN IF EXISTS origin, + DROP COLUMN IF EXISTS panel, + DROP COLUMN IF EXISTS sender_email, + DROP COLUMN IF EXISTS remote_instance_id, + DROP COLUMN IF EXISTS remote_test_id, + DROP COLUMN IF EXISTS error, + ALTER COLUMN sender_account_id SET NOT NULL, + ADD COLUMN token text NOT NULL UNIQUE DEFAULT gen_random_uuid()::text; + +DROP TABLE IF EXISTS placement_monitors; + +ALTER TABLE email_accounts ADD COLUMN is_seed boolean NOT NULL DEFAULT false; +UPDATE email_accounts SET is_seed = true WHERE seed_scope = 'instance'; +DROP INDEX IF EXISTS idx_email_accounts_seed_scope; +ALTER TABLE email_accounts + DROP CONSTRAINT IF EXISTS email_accounts_seed_scope_check, + DROP COLUMN IF EXISTS seed_scope; +CREATE INDEX idx_email_accounts_is_seed ON email_accounts (is_seed) WHERE is_seed = true; diff --git a/internal/infrastructure/db/migrations/000217_placement_tests.up.sql b/internal/infrastructure/db/migrations/000217_placement_tests.up.sql new file mode 100644 index 000000000..b1761c076 --- /dev/null +++ b/internal/infrastructure/db/migrations/000217_placement_tests.up.sql @@ -0,0 +1,115 @@ +-- Inbox placement tests for workspaces. A test renders a template (or a +-- campaign step) the way a campaign would, sends one copy per seed mailbox +-- from a real sender mailbox, paced as scheduled tasks, and reads where each +-- copy landed from the seed's synced mail, matched on Message-ID. + +-- One probe send per seed is a task of its own, so it shares the min-gap clock +-- and the daily counts every other send from the mailbox uses. Postgres cannot +-- drop an enum value, so the down migration leaves it. +ALTER TYPE public.task_type ADD VALUE IF NOT EXISTS 'placement'; + +-- Who a seed serves: the instance panel every workspace tests against, or one +-- workspace's own test inboxes. NULL is an ordinary mailbox. +ALTER TABLE email_accounts ADD COLUMN seed_scope text; +UPDATE email_accounts SET seed_scope = 'instance' WHERE is_seed; +ALTER TABLE email_accounts + ADD CONSTRAINT email_accounts_seed_scope_check + CHECK (seed_scope IS NULL OR seed_scope IN ('instance', 'workspace')); +DROP INDEX IF EXISTS idx_email_accounts_is_seed; +ALTER TABLE email_accounts DROP COLUMN is_seed; +CREATE INDEX idx_email_accounts_seed_scope + ON email_accounts (seed_scope, organization_id) + WHERE seed_scope IS NOT NULL; + +-- Scheduled re-tests of an active campaign's first step, rotating through its +-- sending mailboxes, with an alert when the inbox rate drops. +CREATE TABLE placement_monitors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + campaign_id uuid NOT NULL UNIQUE REFERENCES campaigns (id) ON DELETE CASCADE, + created_by uuid REFERENCES users (id) ON DELETE SET NULL, + enabled boolean NOT NULL DEFAULT true, + interval_days integer NOT NULL DEFAULT 7 CHECK (interval_days BETWEEN 1 AND 30), + panel text NOT NULL DEFAULT 'instance' CHECK (panel IN ('instance', 'workspace', 'cloud')), + alert_below integer NOT NULL DEFAULT 70 CHECK (alert_below BETWEEN 0 AND 100), + pause_on_alert boolean NOT NULL DEFAULT false, + next_run_at timestamptz NOT NULL DEFAULT NOW(), + last_run_at timestamptz, + last_test_id uuid, + last_sender_id uuid REFERENCES email_accounts (id) ON DELETE SET NULL, + last_alert_at timestamptz, + last_error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_placement_monitors_due ON placement_monitors (next_run_at) WHERE enabled; +CREATE INDEX idx_placement_monitors_org ON placement_monitors (organization_id); + +-- A copy is matched on its Message-ID now, so the subject token goes. A test +-- the cloud runs for a linked instance has no local sender. +ALTER TABLE placement_tests + DROP COLUMN token, + ALTER COLUMN sender_account_id DROP NOT NULL, + ADD COLUMN created_by uuid REFERENCES users (id) ON DELETE SET NULL, + ADD COLUMN campaign_id uuid REFERENCES campaigns (id) ON DELETE SET NULL, + ADD COLUMN sequence_id uuid REFERENCES sequences (id) ON DELETE SET NULL, + ADD COLUMN contact_id uuid REFERENCES contacts (id) ON DELETE SET NULL, + ADD COLUMN monitor_id uuid REFERENCES placement_monitors (id) ON DELETE SET NULL, + ADD COLUMN open_tracking boolean NOT NULL DEFAULT false, + ADD COLUMN link_tracking boolean NOT NULL DEFAULT false, + ADD COLUMN compare_group_id uuid, + -- Existing rows were all run from the admin panel. + ADD COLUMN origin text NOT NULL DEFAULT 'admin', + ADD COLUMN panel text NOT NULL DEFAULT 'instance', + ADD COLUMN sender_email text NOT NULL DEFAULT '', + ADD COLUMN remote_instance_id uuid, + ADD COLUMN remote_test_id uuid, + ADD COLUMN error text NOT NULL DEFAULT ''; + +UPDATE placement_tests SET status = 'running' WHERE status = 'pending'; + +ALTER TABLE placement_tests + ALTER COLUMN status SET DEFAULT 'running', + ALTER COLUMN origin SET DEFAULT 'manual', + ADD CONSTRAINT placement_tests_status_check + CHECK (status IN ('running', 'completed', 'cancelled', 'failed')), + ADD CONSTRAINT placement_tests_origin_check + CHECK (origin IN ('manual', 'monitor', 'admin', 'remote')), + ADD CONSTRAINT placement_tests_panel_check + CHECK (panel IN ('instance', 'workspace', 'cloud')); + +ALTER TABLE placement_monitors + ADD CONSTRAINT placement_monitors_last_test_fkey + FOREIGN KEY (last_test_id) REFERENCES placement_tests (id) ON DELETE SET NULL; + +CREATE INDEX idx_placement_tests_running ON placement_tests (created_at) WHERE status = 'running'; +CREATE INDEX idx_placement_tests_campaign ON placement_tests (campaign_id, created_at DESC) WHERE campaign_id IS NOT NULL; +CREATE INDEX idx_placement_tests_group ON placement_tests (compare_group_id) WHERE compare_group_id IS NOT NULL; + +-- A result names its seed by address and host family as well as by id, so a +-- cloud seed (no local mailbox) and an archive import (the seed stays behind) +-- still read. 'missing' is a copy that never arrived, 'failed' one that never +-- left, 'other' a Gmail tab other than Promotions. +ALTER TABLE placement_results + DROP CONSTRAINT IF EXISTS placement_results_folder_check, + ALTER COLUMN seed_account_id DROP NOT NULL, + ADD COLUMN seed_address text NOT NULL DEFAULT '', + ADD COLUMN remote_seed_id uuid, + ADD COLUMN task_id uuid REFERENCES tasks (id) ON DELETE SET NULL, + ADD COLUMN message_id text NOT NULL DEFAULT '', + ADD COLUMN scheduled_at timestamptz, + ADD COLUMN sent_at timestamptz, + ADD COLUMN remote_synced_at timestamptz, + ADD COLUMN error text NOT NULL DEFAULT ''; + +UPDATE placement_results SET folder = 'missing' WHERE folder = 'other' AND raw_flags = 'timeout'; +UPDATE placement_results pr SET seed_address = ea.email +FROM email_accounts ea WHERE ea.id = pr.seed_account_id; + +ALTER TABLE placement_results + ADD CONSTRAINT placement_results_folder_check + CHECK (folder IN ('pending', 'inbox', 'promotions', 'other', 'spam', 'missing', 'failed', 'cancelled')); + +CREATE UNIQUE INDEX idx_placement_results_test_address ON placement_results (test_id, lower(seed_address)); +CREATE UNIQUE INDEX idx_placement_results_task ON placement_results (task_id) WHERE task_id IS NOT NULL; diff --git a/internal/infrastructure/pubsub/events.go b/internal/infrastructure/pubsub/events.go index 6f8c0c3f5..32a23fc1c 100644 --- a/internal/infrastructure/pubsub/events.go +++ b/internal/infrastructure/pubsub/events.go @@ -51,6 +51,9 @@ const ( // or stopped holding it. Status carries the backfill status, Reason the // throttle reason (empty when released). EventAccountSyncState EventType = "ACCOUNT_SYNC_STATE" + // EventPlacementTest: an inbox placement test started, got a verdict for + // a copy, or finished. + EventPlacementTest EventType = "PLACEMENT_TEST_UPDATED" // Bulk operation events EventBulkStarted EventType = "BULK_STARTED" @@ -592,6 +595,37 @@ func (p *StreamingPublisher) PublishFormSubmission(ctx context.Context, orgID, f } } +// PlacementTestEvent is the org-scoped placement test signal. Ids and status +// only: where the copies landed stays behind the read endpoint's permission. +type PlacementTestEvent struct { + BaseEvent + OrgID string `json:"org_id"` + TestID string `json:"test_id"` + CampaignID string `json:"campaign_id,omitempty"` + Status string `json:"status"` +} + +// PublishPlacementTest emits an org-scoped placement test signal. +func (p *StreamingPublisher) PublishPlacementTest(ctx context.Context, orgID, testID uuid.UUID, campaignID *uuid.UUID, status string) { + if p == nil || p.client == nil || orgID == uuid.Nil { + return + } + event := &PlacementTestEvent{ + BaseEvent: BaseEvent{EventType: EventPlacementTest, Timestamp: time.Now()}, + OrgID: orgID.String(), + TestID: testID.String(), + Status: status, + } + if campaignID != nil { + event.CampaignID = campaignID.String() + } + attrs := map[string]string{ + "org_id": orgID.String(), + "event_type": string(EventPlacementTest), + } + _ = p.client.Publish(ctx, TopicUserEvents, event, attrs) +} + // AutomationEvent is an org-scoped automation lifecycle/run signal. The web // client invalidates the ['automations'] queries on any "AUTOMATION" event. type AutomationEvent struct { diff --git a/internal/jobs/placement_poller.go b/internal/jobs/placement_poller.go index 6db47fa58..c62150b42 100644 --- a/internal/jobs/placement_poller.go +++ b/internal/jobs/placement_poller.go @@ -10,13 +10,10 @@ import ( "github.com/warmbly/warmbly/internal/jobrun" ) -// PlacementPoller reconciles pending seed inbox-placement results: each tick it -// looks up each in-flight test's token in the receiving seed's unibox entries -// and classifies where the probe landed (Inbox / Spam / Promotions / other), -// completing the test once every result resolves or the classify timeout -// passes. It is a thin scheduler around placement.Service.ClassifyPending; all -// policy lives in the service. A ~2-minute tick balances responsiveness against -// the latency of mailbox sync delivering the probe into the unibox. +// PlacementPoller drives placement.Service.Tick: it reads where delivered +// probes landed, closes finished tests, syncs Warmbly Cloud's panel and starts +// due monitors. All policy lives in the service; a short tick keeps a running +// test's panel live without waiting on mailbox sync any longer than it takes. type PlacementPoller struct { svc placement.Service interval time.Duration @@ -26,7 +23,7 @@ type PlacementPoller struct { // NewPlacementPoller creates the poller. func NewPlacementPoller(svc placement.Service, interval time.Duration) *PlacementPoller { if interval <= 0 { - interval = 2 * time.Minute + interval = 30 * time.Second } return &PlacementPoller{ svc: svc, @@ -35,13 +32,13 @@ func NewPlacementPoller(svc placement.Service, interval time.Duration) *Placemen } } -// Run performs one classification pass. Safe to call frequently — ClassifyPending -// no-ops when there are no pending results. +// Run performs one pass. Safe to call often: an idle tick is a handful of +// indexed reads. func (p *PlacementPoller) Run(ctx context.Context) error { if p.svc == nil { return nil } - if err := p.svc.ClassifyPending(ctx); err != nil { + if err := p.svc.Tick(ctx); err != nil { errs.CaptureException(err) return err } diff --git a/internal/models/advanced_outreach.go b/internal/models/advanced_outreach.go index 39e6d2de2..b77193697 100644 --- a/internal/models/advanced_outreach.go +++ b/internal/models/advanced_outreach.go @@ -925,14 +925,18 @@ type CampaignDeliverability struct { // ProviderPlacement is one recipient provider's seed placement rollup in the // window (from placement_results; folders mirror the table CHECK constraint). type ProviderPlacement struct { - Provider string `json:"provider"` - Samples int `json:"samples"` - Inbox int `json:"inbox"` - Promotions int `json:"promotions"` - Spam int `json:"spam"` - Other int `json:"other"` - InboxRate float64 `json:"inbox_rate"` - SpamRate float64 `json:"spam_rate"` + // Provider is the seed's host family (a mailhost value), Label its name. + Provider string `json:"provider"` + Label string `json:"label"` + Samples int `json:"samples"` + Inbox int `json:"inbox"` + Promotions int `json:"promotions"` + Spam int `json:"spam"` + Other int `json:"other"` + // Missing is copies that never arrived. + Missing int `json:"missing"` + InboxRate float64 `json:"inbox_rate"` + SpamRate float64 `json:"spam_rate"` } // WarmupDomainPlacement is one recipient domain's warmup placement rollup: diff --git a/internal/models/audit.go b/internal/models/audit.go index 99d3657c2..9af262f07 100644 --- a/internal/models/audit.go +++ b/internal/models/audit.go @@ -146,6 +146,11 @@ const ( // spine so the settings page and mailbox lists refresh for every member. AuditEntityPoolLink AuditEntityType = "pool_link" AuditEntityCloudLink AuditEntityType = "cloud_link" + + // Inbox placement: a test started or cancelled, and a campaign's + // scheduled test set up, changed or removed. + AuditEntityPlacementTest AuditEntityType = "placement_test" + AuditEntityPlacementMonitor AuditEntityType = "placement_monitor" ) // AuditActor is the minimal identity of the member who performed an action, diff --git a/internal/models/notification.go b/internal/models/notification.go index 7d307a3e5..5302cce47 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -28,6 +28,12 @@ const ( // It is the warning shot before the send/warmup gate applies, so it must // reach the owner while there is still time to fix the DNS records. NotifDomainAuth NotificationCategory = "health_domain_auth" + // NotifPlacementFinished tells whoever started an inbox placement test + // where its copies landed. + NotifPlacementFinished NotificationCategory = "placement_finished" + // NotifPlacementAlert fires when a campaign's scheduled placement test + // comes back below its alert threshold, so it emails by default. + NotifPlacementAlert NotificationCategory = "placement_alert" ) // ChannelPrefs is the per-category delivery toggles: in-app feed, account @@ -58,6 +64,9 @@ type NotificationPreferences struct { TeamActivity CategoryPref `json:"team_activity"` CampaignPaused CategoryPref `json:"campaign_paused"` DomainAuth CategoryPref `json:"health_domain_auth"` + // PlacementFinished and PlacementAlert are the inbox placement test pair. + PlacementFinished CategoryPref `json:"placement_finished"` + PlacementAlert CategoryPref `json:"placement_alert"` // EmailDigestMinutes is the email-channel bundling window: pending // notification emails hold this long, then flush as one email. Bounded @@ -95,6 +104,8 @@ func DefaultNotificationPreferences() NotificationPreferences { TeamActivity: on, CampaignPaused: campaignPaused, DomainAuth: domainAuth, + PlacementFinished: on, + PlacementAlert: domainAuth, EmailDigestMinutes: 30, } } @@ -122,6 +133,10 @@ func (p NotificationPreferences) CategoryPref(c NotificationCategory) CategoryPr return p.CampaignPaused case NotifDomainAuth: return p.DomainAuth + case NotifPlacementFinished: + return p.PlacementFinished + case NotifPlacementAlert: + return p.PlacementAlert default: return CategoryPref{} } diff --git a/internal/models/placement.go b/internal/models/placement.go new file mode 100644 index 000000000..0090a993e --- /dev/null +++ b/internal/models/placement.go @@ -0,0 +1,374 @@ +package models + +import ( + "strings" + "time" + + "github.com/google/uuid" +) + +// Seed panels a placement test can send to. +const ( + // PlacementPanelInstance is the seed panel the operator runs for every + // workspace on the instance (on the hosted product, Warmbly's own). + PlacementPanelInstance = "instance" + // PlacementPanelWorkspace is the workspace's own test inboxes. + PlacementPanelWorkspace = "workspace" + // PlacementPanelCloud is Warmbly Cloud's panel, for a linked self-hosted + // instance. + PlacementPanelCloud = "cloud" +) + +// ValidPlacementPanel reports whether p names a seed panel. +func ValidPlacementPanel(p string) bool { + switch p { + case PlacementPanelInstance, PlacementPanelWorkspace, PlacementPanelCloud: + return true + } + return false +} + +// Seed scopes, stored as email_accounts.seed_scope. +const ( + SeedScopeInstance = "instance" + SeedScopeWorkspace = "workspace" +) + +// Placement test statuses. +const ( + PlacementStatusRunning = "running" + PlacementStatusCompleted = "completed" + PlacementStatusCancelled = "cancelled" + PlacementStatusFailed = "failed" +) + +// What started a placement test. +const ( + PlacementOriginManual = "manual" + PlacementOriginMonitor = "monitor" + PlacementOriginAdmin = "admin" + // PlacementOriginRemote is a test the cloud runs for a linked instance. + PlacementOriginRemote = "remote" +) + +// Where one probe landed, stored as placement_results.folder. +const ( + PlacementFolderPending = "pending" + PlacementFolderInbox = "inbox" + PlacementFolderPromotions = "promotions" + // PlacementFolderOther is a Gmail tab other than Promotions (Updates, + // Social, Forums). + PlacementFolderOther = "other" + PlacementFolderSpam = "spam" + // PlacementFolderMissing is a copy that left the sender and never showed + // up in the seed within the classify window. + PlacementFolderMissing = "missing" + // PlacementFolderFailed is a copy that never left the sender. + PlacementFolderFailed = "failed" + PlacementFolderCancelled = "cancelled" +) + +// PlacementFolderResolved reports whether a folder is a final answer about +// where a delivered copy went (not pending, failed or cancelled). +func PlacementFolderResolved(folder string) bool { + switch folder { + case PlacementFolderInbox, PlacementFolderPromotions, PlacementFolderOther, + PlacementFolderSpam, PlacementFolderMissing: + return true + } + return false +} + +// ClassifyPlacementLanding reads where an arriving probe sits from its +// canonical folder and provider flags, splitting Gmail's Promotions tab from +// the other tabs because Promotions is where cold mail goes to be ignored. +func ClassifyPlacementLanding(folder string, flags []string) string { + if folder == FolderSpam || HasSpamFlag(flags) { + return PlacementFolderSpam + } + tab := "" + for _, f := range flags { + switch strings.ToUpper(f) { + case "CATEGORY_PROMOTIONS": + return PlacementFolderPromotions + case "CATEGORY_UPDATES", "CATEGORY_SOCIAL", "CATEGORY_FORUMS": + tab = PlacementFolderOther + } + } + if tab != "" { + return tab + } + return PlacementFolderInbox +} + +// Tracking choices when a test is created. +const ( + // PlacementTrackingCampaign copies the campaign's own open and click + // tracking; an ad-hoc test has none. + PlacementTrackingCampaign = "campaign" + PlacementTrackingOn = "on" + PlacementTrackingOff = "off" + // PlacementTrackingCompare runs the same copy twice, once tracked and once + // not, interleaved per seed so both see the same conditions. + PlacementTrackingCompare = "compare" +) + +// PlacementTest is one run: a template sent from one sender to a seed panel. +type PlacementTest struct { + ID uuid.UUID `json:"id"` + OrganizationID *uuid.UUID `json:"-"` + SenderAccountID *uuid.UUID `json:"sender_account_id"` + SenderEmail string `json:"sender_email"` + CreatedBy *uuid.UUID `json:"created_by"` + CampaignID *uuid.UUID `json:"campaign_id"` + SequenceID *uuid.UUID `json:"sequence_id"` + ContactID *uuid.UUID `json:"contact_id"` + MonitorID *uuid.UUID `json:"monitor_id"` + Subject string `json:"subject"` + BodyHTML string `json:"body_html,omitempty"` + BodyPlain string `json:"body_plain,omitempty"` + OpenTracking bool `json:"open_tracking"` + LinkTracking bool `json:"link_tracking"` + CompareGroupID *uuid.UUID `json:"compare_group_id"` + Origin string `json:"origin"` + Panel string `json:"panel"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + // RemoteInstanceID is set on the cloud's copy of a test it runs for a + // linked instance; RemoteTestID on the instance's copy, naming the cloud's. + RemoteInstanceID *uuid.UUID `json:"-"` + RemoteTestID *uuid.UUID `json:"-"` + CreatedAt time.Time `json:"created_at"` + FinishedAt *time.Time `json:"finished_at"` +} + +// Tracked reports whether the test's copies carry any tracking. +func (t PlacementTest) Tracked() bool { return t.OpenTracking || t.LinkTracking } + +// PlacementResult is one probe: one copy to one seed. +type PlacementResult struct { + ID uuid.UUID `json:"id"` + TestID uuid.UUID `json:"test_id"` + SeedAccountID *uuid.UUID `json:"-"` + SeedAddress string `json:"seed_address"` + // Family is who hosts the seed, a mailhost value (google_workspace, + // gmail, microsoft365, outlook, yahoo, ...). Stored as provider. + Family string `json:"family"` + RemoteSeedID *uuid.UUID `json:"-"` + TaskID *uuid.UUID `json:"-"` + MessageID string `json:"-"` + Folder string `json:"folder"` + ScheduledAt *time.Time `json:"scheduled_at"` + SentAt *time.Time `json:"sent_at"` + DetectedAt *time.Time `json:"detected_at"` + RawFlags string `json:"-"` + Error string `json:"error,omitempty"` + RemoteSyncedAt *time.Time `json:"-"` +} + +// PlacementCounts is where a set of probes landed. +type PlacementCounts struct { + Total int `json:"total"` + Pending int `json:"pending"` + Inbox int `json:"inbox"` + Promotions int `json:"promotions"` + Other int `json:"other"` + Spam int `json:"spam"` + Missing int `json:"missing"` + Failed int `json:"failed"` + Cancelled int `json:"cancelled"` + // Delivered is every copy that left and got a verdict: + // inbox + promotions + other + spam + missing. + Delivered int `json:"delivered"` + // InboxRate is the primary inbox share of Delivered, TabsRate the Gmail + // tabs, SpamRate spam and MissingRate the copies never seen. Nil until + // something is delivered. + InboxRate *float64 `json:"inbox_rate"` + TabsRate *float64 `json:"tabs_rate"` + SpamRate *float64 `json:"spam_rate"` + MissingRate *float64 `json:"missing_rate"` +} + +// Add counts one probe. +func (c *PlacementCounts) Add(folder string) { + c.Total++ + switch folder { + case PlacementFolderInbox: + c.Inbox++ + case PlacementFolderPromotions: + c.Promotions++ + case PlacementFolderOther: + c.Other++ + case PlacementFolderSpam: + c.Spam++ + case PlacementFolderMissing: + c.Missing++ + case PlacementFolderFailed: + c.Failed++ + case PlacementFolderCancelled: + c.Cancelled++ + default: + c.Pending++ + } +} + +// Finish derives Delivered and the rates. +func (c *PlacementCounts) Finish() { + c.Delivered = c.Inbox + c.Promotions + c.Other + c.Spam + c.Missing + c.InboxRate, c.TabsRate, c.SpamRate, c.MissingRate = nil, nil, nil, nil + if c.Delivered == 0 { + return + } + rate := func(n int) *float64 { + r := float64(n) / float64(c.Delivered) + return &r + } + c.InboxRate = rate(c.Inbox) + c.TabsRate = rate(c.Promotions + c.Other) + c.SpamRate = rate(c.Spam) + c.MissingRate = rate(c.Missing) +} + +// PlacementFamilyCounts is one host family's share of a test. +type PlacementFamilyCounts struct { + Family string `json:"family"` + Label string `json:"label"` + Counts PlacementCounts `json:"counts"` +} + +// PlacementSeed is a seed mailbox as a test sees it. +type PlacementSeed struct { + AccountID *uuid.UUID + RemoteSeedID *uuid.UUID + Address string + Family string + OrganizationID *uuid.UUID +} + +// PlacementPanelFamily counts the seeds one host family contributes. +type PlacementPanelFamily struct { + Family string `json:"family"` + Label string `json:"label"` + Seeds int `json:"seeds"` +} + +// PlacementPanelInfo says whether a workspace can test on a panel. +type PlacementPanelInfo struct { + Panel string `json:"panel"` + Available bool `json:"available"` + // Reason explains an unavailable panel in one sentence. + Reason string `json:"reason,omitempty"` + Seeds int `json:"seeds"` + Families []PlacementPanelFamily `json:"families"` + // Metered panels count against the monthly allowance. + Metered bool `json:"metered"` +} + +// PlacementUsage is a workspace's monthly allowance on the metered panels. +type PlacementUsage struct { + Used int `json:"used"` + // Limit is nil when the instance does not meter tests (self-hosted). + Limit *int `json:"limit"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` +} + +// Remaining is how many metered tests are left, -1 when unmetered. +func (u PlacementUsage) Remaining() int { + if u.Limit == nil { + return -1 + } + return max(0, *u.Limit-u.Used) +} + +// PlacementWorkspaceSeed is one of a workspace's own mailboxes as a seed +// candidate. +type PlacementWorkspaceSeed struct { + EmailAccountID uuid.UUID `json:"email_account_id"` + Email string `json:"email"` + Family string `json:"family"` + Label string `json:"label"` + Status string `json:"status"` + Seed bool `json:"seed"` + // Blocker is why the mailbox cannot become a seed, empty when it can. + Blocker string `json:"blocker,omitempty"` +} + +// PlacementMonitor re-tests a campaign's first step on a schedule. +type PlacementMonitor struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"-"` + CampaignID uuid.UUID `json:"campaign_id"` + CreatedBy *uuid.UUID `json:"created_by"` + Enabled bool `json:"enabled"` + IntervalDays int `json:"interval_days"` + Panel string `json:"panel"` + AlertBelow int `json:"alert_below"` + PauseOnAlert bool `json:"pause_on_alert"` + NextRunAt time.Time `json:"next_run_at"` + LastRunAt *time.Time `json:"last_run_at"` + LastTestID *uuid.UUID `json:"last_test_id"` + LastSenderID *uuid.UUID `json:"-"` + LastAlertAt *time.Time `json:"last_alert_at"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PlacementCloudStartRequest is a linked instance asking Warmbly Cloud for +// seeds to run a test against. +type PlacementCloudStartRequest struct { + // SenderDomain keeps seeds on the sender's own domain out of the panel: + // mail inside one tenant skips the filtering the test is there to see. + SenderDomain string `json:"sender_domain"` + // Tests is how many tests the request covers (two for a tracking + // comparison), each charged against the cloud workspace's allowance. + Tests int `json:"tests"` +} + +// PlacementCloudSeed is one cloud seed a linked instance sends a copy to. +type PlacementCloudSeed struct { + ID uuid.UUID `json:"id"` + Address string `json:"address"` + Family string `json:"family"` +} + +// PlacementCloudStart is the cloud's answer: the seeds, once per test. +type PlacementCloudStart struct { + TestIDs []uuid.UUID `json:"test_ids"` + Seeds []PlacementCloudSeed `json:"seeds"` + Usage PlacementUsage `json:"usage"` +} + +// PlacementCloudSend reports one copy the instance sent, or failed to send. +type PlacementCloudSend struct { + SeedID uuid.UUID `json:"seed_id"` + MessageID string `json:"message_id,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` + Error string `json:"error,omitempty"` +} + +// PlacementCloudSends is the batch an instance reports. +type PlacementCloudSends struct { + Sends []PlacementCloudSend `json:"sends"` +} + +// PlacementCloudVerdict is where one cloud seed saw its copy land. +type PlacementCloudVerdict struct { + SeedID uuid.UUID `json:"seed_id"` + Folder string `json:"folder"` + DetectedAt *time.Time `json:"detected_at,omitempty"` +} + +// PlacementCloudTest is the cloud's view of one test it runs for an instance. +type PlacementCloudTest struct { + TestID uuid.UUID `json:"test_id"` + Status string `json:"status"` + Verdicts []PlacementCloudVerdict `json:"verdicts"` +} + +// PlacementCloudPanel is the cloud panel as a linked instance sees it. +type PlacementCloudPanel struct { + Panel PlacementPanelInfo `json:"panel"` + Usage PlacementUsage `json:"usage"` +} diff --git a/internal/pkg/mailhost/mailhost.go b/internal/pkg/mailhost/mailhost.go index ebf50466d..bd7e6a935 100644 --- a/internal/pkg/mailhost/mailhost.go +++ b/internal/pkg/mailhost/mailhost.go @@ -91,6 +91,29 @@ func (h Host) Google() bool { return h == GoogleWorkspace || h == Gmail } // Microsoft reports whether h is either Microsoft product. func (h Host) Microsoft() bool { return h == Microsoft365 || h == Outlook } +// ForMailbox is who hosts a connected mailbox: its stored mail_host when the +// connect path recorded one, else read from how it connects and its address. +// provider is the email_provider value (gmail, outlook, smtp_imap). +func ForMailbox(stored, provider, address string) Host { + domain := "" + if at := strings.LastIndex(address, "@"); at >= 0 { + domain = NormalizeDomain(address[at+1:]) + } + if h := Host(stored); stored != "" && Valid(stored) { + return Refine(h, domain) + } + switch provider { + case "gmail": + return Refine(Gmail, domain) + case "outlook": + return Refine(Microsoft365, domain) + } + if h, ok := knownDomain(domain); ok { + return h + } + return Other +} + // PasswordAuth says how a password signs in on a host. type PasswordAuth string diff --git a/internal/pkg/mailhost/mailhost_test.go b/internal/pkg/mailhost/mailhost_test.go index 4b52624cb..7996c0ada 100644 --- a/internal/pkg/mailhost/mailhost_test.go +++ b/internal/pkg/mailhost/mailhost_test.go @@ -558,3 +558,25 @@ func TestValidAndLabels(t *testing.T) { t.Fatal("Label") } } + +func TestForMailbox(t *testing.T) { + cases := []struct { + stored, provider, address string + want Host + }{ + {"", "gmail", "a@gmail.com", Gmail}, + {"", "gmail", "a@acme.io", GoogleWorkspace}, + {"", "outlook", "a@hotmail.com", Outlook}, + {"", "outlook", "a@acme.io", Microsoft365}, + {"", "smtp_imap", "a@yahoo.com", Yahoo}, + {"", "smtp_imap", "a@acme.io", Other}, + {"zoho", "smtp_imap", "a@acme.io", Zoho}, + {"google_workspace", "smtp_imap", "a@gmail.com", Gmail}, + {"bogus", "smtp_imap", "a@acme.io", Other}, + } + for _, c := range cases { + if got := ForMailbox(c.stored, c.provider, c.address); got != c.want { + t.Errorf("ForMailbox(%q, %q, %q) = %q, want %q", c.stored, c.provider, c.address, got, c.want) + } + } +} diff --git a/internal/repository/pg_advanced_outreach.go b/internal/repository/pg_advanced_outreach.go index 7aeed673a..88d16833d 100644 --- a/internal/repository/pg_advanced_outreach.go +++ b/internal/repository/pg_advanced_outreach.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/mailhost" ) type AdvancedOutreachRepository interface { @@ -743,7 +744,9 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont placementQuery := ` SELECT pr.provider, pr.folder, COUNT(*) FROM placement_results pr JOIN placement_tests pt ON pt.id = pr.test_id - WHERE pt.organization_id = $1 AND pr.detected_at >= $2 AND pr.detected_at <= $3 AND pr.folder <> 'pending' + WHERE pt.organization_id = $1 AND pr.detected_at >= $2 AND pr.detected_at <= $3 + AND pr.folder IN ('inbox', 'promotions', 'other', 'spam', 'missing') + AND pt.origin <> 'remote' GROUP BY pr.provider, pr.folder` if rows, perr := r.db.Query(ctx, placementQuery, organizationID, from, to); perr == nil { for rows.Next() { @@ -753,7 +756,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont placementTotal += n p := byProvider[provider] if p == nil { - p = &models.ProviderPlacement{Provider: provider} + p = &models.ProviderPlacement{Provider: provider, Label: mailhost.Host(provider).Label()} byProvider[provider] = p } p.Samples += n @@ -766,6 +769,8 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont p.Inbox += n case "promotions": p.Promotions += n + case "missing": + p.Missing += n default: p.Other += n } diff --git a/internal/repository/pg_behavior.go b/internal/repository/pg_behavior.go index b89664c67..2f92d13f5 100644 --- a/internal/repository/pg_behavior.go +++ b/internal/repository/pg_behavior.go @@ -232,7 +232,7 @@ func (r *behaviorRepository) CountSendsBetween(ctx context.Context, accountID uu SELECT COUNT(*) FROM tasks t WHERE t.email_account_id = $1 - AND t.task_type = $2::task_type + AND (t.task_type = $2::task_type OR ($2 = 'campaign' AND t.task_type = 'placement')) AND t.status = 'completed' AND t.completed_at >= $3 AND t.completed_at < $4 diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index 68e38af99..72a8df969 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -2215,6 +2215,8 @@ func (r *emailRepository) GetByTags(ctx context.Context, scope AccountScope, tag WHERE ea.organization_id = $1 AND eat.tag_id = ANY($2) AND ea.status = 'active' + -- A seed mailbox is a placement test inbox and never sends campaign mail. + AND ea.seed_scope IS NULL ORDER BY ea.id ` @@ -2268,6 +2270,8 @@ func (r *emailRepository) GetAllActiveInScope(ctx context.Context, scope Account FROM email_accounts ea WHERE ea.organization_id = $1 AND ea.status = 'active' + -- A seed mailbox is a placement test inbox and never sends campaign mail. + AND ea.seed_scope IS NULL ORDER BY ea.id ` @@ -2337,6 +2341,8 @@ func (r *emailRepository) GetByCampaignSenders(ctx context.Context, scope Accoun AND cs.enabled AND ea.organization_id = $1 AND ea.status = 'active' + -- A seed mailbox is a placement test inbox and never sends campaign mail. + AND ea.seed_scope IS NULL ORDER BY ea.id ` diff --git a/internal/repository/pg_placement.go b/internal/repository/pg_placement.go index a0937040b..1e0c87eb7 100644 --- a/internal/repository/pg_placement.go +++ b/internal/repository/pg_placement.go @@ -3,123 +3,162 @@ package repository import ( "context" "errors" + "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/infrastructure/db" + "github.com/warmbly/warmbly/internal/models" ) -// Placement folder classifications. These mirror the CHECK constraint on -// placement_results.folder. -const ( - PlacementFolderPending = "pending" - PlacementFolderInbox = "inbox" - PlacementFolderPromotions = "promotions" - PlacementFolderSpam = "spam" - PlacementFolderOther = "other" -) - -// Placement test statuses. -const ( - PlacementStatusPending = "pending" - PlacementStatusCompleted = "completed" -) - -// PlacementTest is one inbox-placement run: a tokenized copy of a template -// sent from one sender mailbox to the active seed panel. -type PlacementTest struct { - ID uuid.UUID - OrganizationID *uuid.UUID - SenderAccountID uuid.UUID - Subject string - BodyPlain string - BodyHTML string - Token string - Status string - CreatedAt time.Time - FinishedAt *time.Time -} - -// PlacementResult is the classification for one (test, seed) pair. -type PlacementResult struct { - ID uuid.UUID - TestID uuid.UUID - SeedAccountID uuid.UUID - Provider string - Folder string - DetectedAt *time.Time - RawFlags string -} - -// SeedAccount is a connected mailbox flagged is_seed, used as a placement -// recipient. UserID is needed to query that seed's unibox entries (unibox is -// keyed by the owning user, not the org). +// SeedAccount is a mailbox as the seed panel sees it. type SeedAccount struct { - ID uuid.UUID - UserID uuid.UUID - Email string - Name string - Provider string - Status string - WorkerID *uuid.UUID - IsSeed bool + ID uuid.UUID + OrganizationID *uuid.UUID + UserID uuid.UUID + Email string + Name string + Provider string + MailHost string + Status string + WorkerID *uuid.UUID + SeedScope string } -// PendingResultJob is the minimal view the poller needs to classify one -// pending result: which seed, which user owns its unibox, and the test token. -type PendingResultJob struct { - ResultID uuid.UUID - TestID uuid.UUID - SeedAccountID uuid.UUID - SeedUserID uuid.UUID - Provider string - Token string - TestCreatedAt time.Time +// PlacementProbe is one scheduled send, with the test it belongs to. +type PlacementProbe struct { + Test models.PlacementTest + Result models.PlacementResult } -// UniboxTokenMatch is a unibox entry whose subject (or stored token) matched a -// placement token, with the flags/labels needed to classify the folder. -type UniboxTokenMatch struct { - InternalID uuid.UUID - Subject string - Flags []string +// PlacementLanding is a delivered probe found in its seed's synced mail. +type PlacementLanding struct { + ResultID uuid.UUID + TestID uuid.UUID + Folder string + Flags []string } -// PlacementRepository is the data access surface for seed inbox-placement -// testing. It deliberately does not depend on the worker (control plane only). +// PlacementFinished is a test that just resolved its last probe. +type PlacementFinished struct { + ID uuid.UUID + OrganizationID *uuid.UUID + CreatedBy *uuid.UUID + Origin string + Status string + MonitorID *uuid.UUID + CampaignID *uuid.UUID + CompareGroupID *uuid.UUID +} + +// PlacementTestFilter narrows a test listing. A nil OrganizationID lists every +// workspace's tests (admin only). +type PlacementTestFilter struct { + OrganizationID *uuid.UUID + CampaignID *uuid.UUID + Limit int + Offset int +} + +// PlacementRemoteReport is a delivered cloud probe the instance still has to +// tell the cloud about. +type PlacementRemoteReport struct { + ResultID uuid.UUID + RemoteTestID uuid.UUID + RemoteSeedID uuid.UUID + MessageID string + SentAt *time.Time + Folder string + Error string +} + +// PlacementRepository stores placement tests, their probes, the seed panels +// and the monitors. Control plane only. type PlacementRepository interface { - CreateTest(ctx context.Context, t *PlacementTest) error - GetTest(ctx context.Context, id uuid.UUID) (*PlacementTest, error) - ListTests(ctx context.Context, orgID *uuid.UUID, limit, offset int) ([]PlacementTest, int, error) - SetTestStatus(ctx context.Context, id uuid.UUID, status string, finishedAt *time.Time) error + // CreateTest writes a test, one pending result per probe and, for probes + // sent from this instance, the pending task that sends each one, in one + // transaction. tasks may be nil for a test with nothing to send here. + CreateTest(ctx context.Context, t *models.PlacementTest, results []models.PlacementResult, tasks []Task) error + GetTest(ctx context.Context, id uuid.UUID) (*models.PlacementTest, error) + // GetOrgTest is GetTest scoped to one workspace; nil when it is another's. + GetOrgTest(ctx context.Context, orgID, id uuid.UUID) (*models.PlacementTest, error) + ListTests(ctx context.Context, f PlacementTestFilter) ([]models.PlacementTest, int, error) + ListResults(ctx context.Context, testIDs []uuid.UUID) (map[uuid.UUID][]models.PlacementResult, error) + ListGroup(ctx context.Context, orgID, groupID uuid.UUID) ([]models.PlacementTest, error) + // CancelTest stops the probes that have not left yet. Delivered probes + // keep being classified. False when the test is not running. + CancelTest(ctx context.Context, orgID, id uuid.UUID) (bool, error) - CreatePendingResult(ctx context.Context, testID, seedAccountID uuid.UUID, provider string) error - GetTestWithResults(ctx context.Context, id uuid.UUID) (*PlacementTest, []PlacementResult, error) - RecordResult(ctx context.Context, resultID uuid.UUID, folder, rawFlags string, detectedAt time.Time) error + // GetProbeByTask loads the probe a placement task sends. + GetProbeByTask(ctx context.Context, taskID uuid.UUID) (*PlacementProbe, error) + MarkProbeSent(ctx context.Context, resultID uuid.UUID, messageID string, sentAt time.Time) error + // FailProbe records a probe that never left. No-op once it has a verdict. + FailProbe(ctx context.Context, resultID uuid.UUID, reason string) error + // SetProbeMessageIDByTask stores the Message-ID the provider actually put + // on the wire, which Gmail and Graph restamp. + SetProbeMessageIDByTask(ctx context.Context, taskID uuid.UUID, messageID string) error + // FailProbeByTask walks a sent probe back when the worker reports the + // send failed. + FailProbeByTask(ctx context.Context, taskID uuid.UUID, reason string) error - // ListPendingResults returns unresolved results joined to their seed's - // owning user + the test token, so the poller can look each one up. - ListPendingResults(ctx context.Context, limit int) ([]PendingResultJob, error) - // CountPendingForTest reports how many results are still pending, used to - // decide when a test is fully resolved. - CountPendingForTest(ctx context.Context, testID uuid.UUID) (int, error) + // FindLandings looks every delivered, unresolved probe on a local seed up + // in that seed's synced mail by Message-ID. + FindLandings(ctx context.Context, limit int) ([]PlacementLanding, error) + RecordLanding(ctx context.Context, resultID uuid.UUID, folder, rawFlags string, at time.Time) error + // ExpireProbes marks delivered probes not seen since sentBefore missing, + // and probes never sent by scheduledBefore failed. On the cloud, a copy + // its instance never reported by remoteBefore fails too. + ExpireProbes(ctx context.Context, sentBefore, scheduledBefore, remoteBefore time.Time) error + // FinishTests closes every running or cancelled test with nothing pending + // and returns the ones it closed. + FinishTests(ctx context.Context) ([]PlacementFinished, error) - // ListSeedAccounts returns seed mailboxes. activeOnly restricts to - // status='active' (the send/classify panel); pass false for management UI. - ListSeedAccounts(ctx context.Context, activeOnly bool) ([]SeedAccount, error) + // ListSeeds lists seed mailboxes. scope "" lists both kinds; orgID limits + // to one workspace's mailboxes. + ListSeeds(ctx context.Context, scope string, orgID *uuid.UUID, activeOnly bool) ([]SeedAccount, error) GetSeedAccount(ctx context.Context, id uuid.UUID) (*SeedAccount, error) - SetIsSeed(ctx context.Context, accountID uuid.UUID, isSeed bool) error - // ListSeedCandidates lists connected mailboxes (optionally filtered by - // substring) so an admin can pick which to flag as seeds. + // SetSeedScope makes a mailbox a seed of the given scope, or an ordinary + // mailbox again with "". The sender pools skip a seed on read; the caller + // takes it out of warmup. + SetSeedScope(ctx context.Context, accountID uuid.UUID, scope string) error + // SeedScope is a mailbox's seed scope, "" for an ordinary mailbox. + SeedScope(ctx context.Context, accountID uuid.UUID) (string, error) ListSeedCandidates(ctx context.Context, search string, limit int) ([]SeedAccount, error) + // ListOrgMailboxes lists a workspace's active and inactive mailboxes with + // what would stop each from becoming a seed. + ListOrgMailboxes(ctx context.Context, orgID uuid.UUID) ([]SeedAccount, map[uuid.UUID]string, error) - // FindTokenInUnibox looks for a placement token inside a seed's received - // mail. Reuses the unibox store (unibox_emails) the worker syncs into. We - // match the token embedded in the subject (the only token carrier that - // survives sync without a worker change — see service.go) and return the - // matching entry's flags/labels for classification. - FindTokenInUnibox(ctx context.Context, userID, seedAccountID uuid.UUID, token string, since time.Time) (*UniboxTokenMatch, error) + // CountMeteredTests counts a workspace's tests on the metered panels + // since a moment. + CountMeteredTests(ctx context.Context, orgID uuid.UUID, since time.Time) (int, error) + CountRunning(ctx context.Context, orgID uuid.UUID) (int, error) + // SenderBusy reports whether a sender still has probes waiting to leave. + SenderBusy(ctx context.Context, senderID uuid.UUID) (bool, error) + // IsProbeSentCopy reports whether a message a mailbox synced is its own + // sent copy of a placement probe. + IsProbeSentCopy(ctx context.Context, accountID uuid.UUID, messageID string) (bool, error) + // SampleLead is the campaign's first lead, the contact a test renders the + // copy for when none is chosen. Nil for a campaign with no leads. + SampleLead(ctx context.Context, campaignID uuid.UUID) (*uuid.UUID, error) + + // Cloud side: a test the cloud runs for a linked instance. + RecordRemoteSent(ctx context.Context, instanceID, testID, seedAccountID uuid.UUID, messageID string, sentAt *time.Time, failure string) error + GetRemoteTest(ctx context.Context, instanceID, testID uuid.UUID) (*models.PlacementTest, []models.PlacementResult, error) + // Instance side: probes to report to the cloud, and verdicts back. + ListRemoteReports(ctx context.Context, limit int) ([]PlacementRemoteReport, error) + MarkRemoteReported(ctx context.Context, resultIDs []uuid.UUID) error + ListRemoteOpenTests(ctx context.Context, limit int) ([]models.PlacementTest, error) + RecordRemoteVerdict(ctx context.Context, testID, remoteSeedID uuid.UUID, folder string, at *time.Time) error + + GetMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (*models.PlacementMonitor, error) + GetMonitorByID(ctx context.Context, id uuid.UUID) (*models.PlacementMonitor, error) + UpsertMonitor(ctx context.Context, m *models.PlacementMonitor) error + DeleteMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (bool, error) + ListDueMonitors(ctx context.Context, now time.Time, limit int) ([]models.PlacementMonitor, error) + MarkMonitorRun(ctx context.Context, id uuid.UUID, next time.Time, testID, senderID *uuid.UUID, lastErr string) error + MarkMonitorAlert(ctx context.Context, id uuid.UUID, at time.Time) error } type placementRepository struct { @@ -130,27 +169,15 @@ func NewPlacementRepository(db *db.DB) PlacementRepository { return &placementRepository{db: db} } -func (r *placementRepository) CreateTest(ctx context.Context, t *PlacementTest) error { - query := ` - INSERT INTO placement_tests (id, organization_id, sender_account_id, subject, body_plain, body_html, token, status, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) - ` - _, err := r.db.Exec(ctx, query, - t.ID, t.OrganizationID, t.SenderAccountID, t.Subject, t.BodyPlain, t.BodyHTML, t.Token, t.Status, - ) - return err -} +const placementTestCols = `id, organization_id, sender_account_id, sender_email, created_by, campaign_id, + sequence_id, contact_id, monitor_id, subject, body_plain, body_html, open_tracking, link_tracking, + compare_group_id, origin, panel, status, error, remote_instance_id, remote_test_id, created_at, finished_at` -func (r *placementRepository) GetTest(ctx context.Context, id uuid.UUID) (*PlacementTest, error) { - query := ` - SELECT id, organization_id, sender_account_id, subject, body_plain, body_html, token, status, created_at, finished_at - FROM placement_tests - WHERE id = $1 - ` - var t PlacementTest - err := r.db.QueryRow(ctx, query, id).Scan( - &t.ID, &t.OrganizationID, &t.SenderAccountID, &t.Subject, &t.BodyPlain, &t.BodyHTML, &t.Token, &t.Status, &t.CreatedAt, &t.FinishedAt, - ) +func scanPlacementTest(row pgx.Row) (*models.PlacementTest, error) { + var t models.PlacementTest + err := row.Scan(&t.ID, &t.OrganizationID, &t.SenderAccountID, &t.SenderEmail, &t.CreatedBy, &t.CampaignID, + &t.SequenceID, &t.ContactID, &t.MonitorID, &t.Subject, &t.BodyPlain, &t.BodyHTML, &t.OpenTracking, &t.LinkTracking, + &t.CompareGroupID, &t.Origin, &t.Panel, &t.Status, &t.Error, &t.RemoteInstanceID, &t.RemoteTestID, &t.CreatedAt, &t.FinishedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -160,164 +187,392 @@ func (r *placementRepository) GetTest(ctx context.Context, id uuid.UUID) (*Place return &t, nil } -func (r *placementRepository) ListTests(ctx context.Context, orgID *uuid.UUID, limit, offset int) ([]PlacementTest, int, error) { - if limit <= 0 { - limit = 25 +const placementResultCols = `id, test_id, seed_account_id, seed_address, provider, remote_seed_id, task_id, + message_id, folder, scheduled_at, sent_at, detected_at, raw_flags, error, remote_synced_at` + +func scanPlacementResult(row pgx.Row) (models.PlacementResult, error) { + var r models.PlacementResult + err := row.Scan(&r.ID, &r.TestID, &r.SeedAccountID, &r.SeedAddress, &r.Family, &r.RemoteSeedID, &r.TaskID, + &r.MessageID, &r.Folder, &r.ScheduledAt, &r.SentAt, &r.DetectedAt, &r.RawFlags, &r.Error, &r.RemoteSyncedAt) + return r, err +} + +func (r *placementRepository) CreateTest(ctx context.Context, t *models.PlacementTest, results []models.PlacementResult, tasks []Task) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err } + defer func() { _ = tx.Rollback(ctx) }() + + err = tx.QueryRow(ctx, ` + INSERT INTO placement_tests (id, organization_id, sender_account_id, sender_email, created_by, campaign_id, + sequence_id, contact_id, monitor_id, subject, body_plain, body_html, open_tracking, link_tracking, + compare_group_id, origin, panel, status, error, remote_instance_id, remote_test_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, NOW()) + RETURNING created_at + `, t.ID, t.OrganizationID, t.SenderAccountID, t.SenderEmail, t.CreatedBy, t.CampaignID, + t.SequenceID, t.ContactID, t.MonitorID, t.Subject, t.BodyPlain, t.BodyHTML, t.OpenTracking, t.LinkTracking, + t.CompareGroupID, t.Origin, t.Panel, t.Status, t.Error, t.RemoteInstanceID, t.RemoteTestID).Scan(&t.CreatedAt) + if err != nil { + return err + } + + batch := &pgx.Batch{} + // The task rows go first: each result points at its task. + for i := range tasks { + task := &tasks[i] + batch.Queue(` + INSERT INTO tasks (id, task_type, email_account_id, status, message_id, scheduled_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + `, task.ID, task.TaskType, task.EmailAccountID, task.Status, task.MessageID, task.ScheduledAt) + } + for i := range results { + res := &results[i] + if res.ID == uuid.Nil { + res.ID = uuid.New() + } + folder := res.Folder + if folder == "" { + folder = models.PlacementFolderPending + } + batch.Queue(` + INSERT INTO placement_results (id, test_id, seed_account_id, seed_address, provider, remote_seed_id, + task_id, message_id, folder, scheduled_at, error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, res.ID, t.ID, res.SeedAccountID, res.SeedAddress, res.Family, res.RemoteSeedID, + res.TaskID, res.MessageID, folder, res.ScheduledAt, res.Error) + } + br := tx.SendBatch(ctx, batch) + for range len(tasks) + len(results) { + if _, err := br.Exec(); err != nil { + _ = br.Close() + return err + } + } + if err := br.Close(); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *placementRepository) GetTest(ctx context.Context, id uuid.UUID) (*models.PlacementTest, error) { + return scanPlacementTest(r.db.QueryRow(ctx, `SELECT `+placementTestCols+` FROM placement_tests WHERE id = $1`, id)) +} + +func (r *placementRepository) GetOrgTest(ctx context.Context, orgID, id uuid.UUID) (*models.PlacementTest, error) { + return scanPlacementTest(r.db.QueryRow(ctx, + `SELECT `+placementTestCols+` FROM placement_tests WHERE id = $2 AND organization_id = $1`, orgID, id)) +} + +func (r *placementRepository) ListTests(ctx context.Context, f PlacementTestFilter) ([]models.PlacementTest, int, error) { + if f.Limit <= 0 { + f.Limit = 25 + } + where := `($1::uuid IS NULL OR organization_id = $1) AND ($2::uuid IS NULL OR campaign_id = $2)` var total int - countQuery := `SELECT COUNT(*) FROM placement_tests WHERE ($1::uuid IS NULL OR organization_id = $1)` - if err := r.db.QueryRow(ctx, countQuery, orgID).Scan(&total); err != nil { + if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM placement_tests WHERE `+where, f.OrganizationID, f.CampaignID).Scan(&total); err != nil { return nil, 0, err } - - query := ` - SELECT id, organization_id, sender_account_id, subject, body_plain, body_html, token, status, created_at, finished_at + rows, err := r.db.Query(ctx, ` + SELECT `+placementTestCols+` FROM placement_tests - WHERE ($1::uuid IS NULL OR organization_id = $1) - ORDER BY created_at DESC - LIMIT $2 OFFSET $3 - ` - rows, err := r.db.Query(ctx, query, orgID, limit, offset) + WHERE `+where+` + ORDER BY created_at DESC, id DESC + LIMIT $3 OFFSET $4 + `, f.OrganizationID, f.CampaignID, f.Limit, f.Offset) if err != nil { return nil, 0, err } defer rows.Close() - - tests := make([]PlacementTest, 0, limit) + out := make([]models.PlacementTest, 0, f.Limit) for rows.Next() { - var t PlacementTest - if err := rows.Scan( - &t.ID, &t.OrganizationID, &t.SenderAccountID, &t.Subject, &t.BodyPlain, &t.BodyHTML, &t.Token, &t.Status, &t.CreatedAt, &t.FinishedAt, - ); err != nil { + t, err := scanPlacementTest(rows) + if err != nil { return nil, 0, err } - tests = append(tests, t) + out = append(out, *t) } - return tests, total, rows.Err() + return out, total, rows.Err() } -func (r *placementRepository) SetTestStatus(ctx context.Context, id uuid.UUID, status string, finishedAt *time.Time) error { - _, err := r.db.Exec(ctx, - `UPDATE placement_tests SET status = $2, finished_at = $3 WHERE id = $1`, - id, status, finishedAt, - ) - return err -} - -func (r *placementRepository) CreatePendingResult(ctx context.Context, testID, seedAccountID uuid.UUID, provider string) error { - _, err := r.db.Exec(ctx, ` - INSERT INTO placement_results (test_id, seed_account_id, provider, folder) - VALUES ($1, $2, $3, 'pending') - ON CONFLICT (test_id, seed_account_id) DO NOTHING - `, testID, seedAccountID, provider) - return err -} - -func (r *placementRepository) GetTestWithResults(ctx context.Context, id uuid.UUID) (*PlacementTest, []PlacementResult, error) { - test, err := r.GetTest(ctx, id) - if err != nil || test == nil { - return test, nil, err +func (r *placementRepository) ListResults(ctx context.Context, testIDs []uuid.UUID) (map[uuid.UUID][]models.PlacementResult, error) { + out := make(map[uuid.UUID][]models.PlacementResult, len(testIDs)) + if len(testIDs) == 0 { + return out, nil } - rows, err := r.db.Query(ctx, ` - SELECT id, test_id, seed_account_id, provider, folder, detected_at, raw_flags + SELECT `+placementResultCols+` FROM placement_results - WHERE test_id = $1 - ORDER BY provider ASC, seed_account_id ASC - `, id) + WHERE test_id = ANY($1) + ORDER BY provider ASC, seed_address ASC + `, testIDs) if err != nil { - return nil, nil, err + return nil, err } defer rows.Close() - - results := make([]PlacementResult, 0) for rows.Next() { - var res PlacementResult - if err := rows.Scan( - &res.ID, &res.TestID, &res.SeedAccountID, &res.Provider, &res.Folder, &res.DetectedAt, &res.RawFlags, - ); err != nil { - return nil, nil, err + res, err := scanPlacementResult(rows) + if err != nil { + return nil, err } - results = append(results, res) + out[res.TestID] = append(out[res.TestID], res) } - return test, results, rows.Err() + return out, rows.Err() } -func (r *placementRepository) RecordResult(ctx context.Context, resultID uuid.UUID, folder, rawFlags string, detectedAt time.Time) error { +func (r *placementRepository) ListGroup(ctx context.Context, orgID, groupID uuid.UUID) ([]models.PlacementTest, error) { + rows, err := r.db.Query(ctx, ` + SELECT `+placementTestCols+` + FROM placement_tests + WHERE organization_id = $1 AND compare_group_id = $2 + ORDER BY link_tracking OR open_tracking, created_at + `, orgID, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.PlacementTest + for rows.Next() { + t, err := scanPlacementTest(rows) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +func (r *placementRepository) CancelTest(ctx context.Context, orgID, id uuid.UUID) (bool, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + + tag, err := tx.Exec(ctx, ` + UPDATE placement_tests SET status = 'cancelled' + WHERE id = $2 AND organization_id = $1 AND status = 'running' + `, orgID, id) + if err != nil { + return false, err + } + if tag.RowsAffected() == 0 { + return false, nil + } + // The dispatcher skips a task that is no longer pending, so flipping the + // row is the whole cancellation. + if _, err := tx.Exec(ctx, ` + UPDATE tasks SET status = 'cancelled', updated_at = NOW() + WHERE status = 'pending' AND id IN ( + SELECT task_id FROM placement_results + WHERE test_id = $1 AND sent_at IS NULL AND task_id IS NOT NULL + ) + `, id); err != nil { + return false, err + } + if _, err := tx.Exec(ctx, ` + UPDATE placement_results SET folder = 'cancelled', error = 'Cancelled before it was sent' + WHERE test_id = $1 AND folder = 'pending' AND sent_at IS NULL + `, id); err != nil { + return false, err + } + return true, tx.Commit(ctx) +} + +func (r *placementRepository) GetProbeByTask(ctx context.Context, taskID uuid.UUID) (*PlacementProbe, error) { + res, err := scanPlacementResult(r.db.QueryRow(ctx, + `SELECT `+placementResultCols+` FROM placement_results WHERE task_id = $1`, taskID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + test, err := r.GetTest(ctx, res.TestID) + if err != nil || test == nil { + return nil, err + } + return &PlacementProbe{Test: *test, Result: res}, nil +} + +func (r *placementRepository) MarkProbeSent(ctx context.Context, resultID uuid.UUID, messageID string, sentAt time.Time) error { _, err := r.db.Exec(ctx, ` - UPDATE placement_results - SET folder = $2, raw_flags = $3, detected_at = $4 + UPDATE placement_results SET message_id = $2, sent_at = $3 WHERE id = $1 AND folder = 'pending' - `, resultID, folder, rawFlags, detectedAt) + `, resultID, messageID, sentAt) return err } -func (r *placementRepository) ListPendingResults(ctx context.Context, limit int) ([]PendingResultJob, error) { - if limit <= 0 { - limit = 200 +func (r *placementRepository) FailProbe(ctx context.Context, resultID uuid.UUID, reason string) error { + _, err := r.db.Exec(ctx, ` + UPDATE placement_results SET folder = 'failed', error = $2, detected_at = NOW() + WHERE id = $1 AND folder = 'pending' + `, resultID, truncateRunes(reason, 500)) + return err +} + +func (r *placementRepository) SetProbeMessageIDByTask(ctx context.Context, taskID uuid.UUID, messageID string) error { + if strings.TrimSpace(messageID) == "" { + return nil } + _, err := r.db.Exec(ctx, `UPDATE placement_results SET message_id = $2 WHERE task_id = $1`, taskID, messageID) + return err +} + +func (r *placementRepository) FailProbeByTask(ctx context.Context, taskID uuid.UUID, reason string) error { + _, err := r.db.Exec(ctx, ` + UPDATE placement_results SET folder = 'failed', error = $2, detected_at = NOW() + WHERE task_id = $1 AND folder = 'pending' + `, taskID, truncateRunes(reason, 500)) + return err +} + +func (r *placementRepository) FindLandings(ctx context.Context, limit int) ([]PlacementLanding, error) { + if limit <= 0 { + limit = 500 + } + // The window reaches an hour before the send: internal_date is the + // message's own Date header on some providers, and clocks drift. rows, err := r.db.Query(ctx, ` - SELECT pr.id, pr.test_id, pr.seed_account_id, ea.user_id, pr.provider, pt.token, pt.created_at + SELECT pr.id, pr.test_id, ue.folder, ue.flags FROM placement_results pr - JOIN placement_tests pt ON pt.id = pr.test_id JOIN email_accounts ea ON ea.id = pr.seed_account_id + JOIN LATERAL ( + SELECT u.folder, u.flags + FROM unibox_emails u + WHERE u.user_id = ea.user_id + AND u.email_id = pr.seed_account_id + AND u.internal_date >= pr.sent_at - interval '1 hour' + AND btrim(u.message_id, '<> ') = btrim(pr.message_id, '<> ') + ORDER BY u.internal_date DESC + LIMIT 1 + ) ue ON true WHERE pr.folder = 'pending' - ORDER BY pt.created_at ASC + AND pr.sent_at IS NOT NULL + AND pr.message_id <> '' + AND pr.seed_account_id IS NOT NULL LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() - - jobs := make([]PendingResultJob, 0, limit) + var out []PlacementLanding for rows.Next() { - var j PendingResultJob - if err := rows.Scan( - &j.ResultID, &j.TestID, &j.SeedAccountID, &j.SeedUserID, &j.Provider, &j.Token, &j.TestCreatedAt, - ); err != nil { + var l PlacementLanding + if err := rows.Scan(&l.ResultID, &l.TestID, &l.Folder, &l.Flags); err != nil { return nil, err } - jobs = append(jobs, j) + out = append(out, l) } - return jobs, rows.Err() + return out, rows.Err() } -func (r *placementRepository) CountPendingForTest(ctx context.Context, testID uuid.UUID) (int, error) { - var n int - err := r.db.QueryRow(ctx, - `SELECT COUNT(*) FROM placement_results WHERE test_id = $1 AND folder = 'pending'`, - testID, - ).Scan(&n) - return n, err +func (r *placementRepository) RecordLanding(ctx context.Context, resultID uuid.UUID, folder, rawFlags string, at time.Time) error { + _, err := r.db.Exec(ctx, ` + UPDATE placement_results SET folder = $2, raw_flags = $3, detected_at = $4 + WHERE id = $1 AND folder = 'pending' + `, resultID, folder, truncateRunes(rawFlags, 1000), at) + return err } -const seedSelectCols = `ea.id, ea.user_id, ea.email, ea.name, ea.provider, ea.status, ea.worker_id, ea.is_seed` - -func scanSeed(rows pgx.Rows) (SeedAccount, error) { - var s SeedAccount - err := rows.Scan(&s.ID, &s.UserID, &s.Email, &s.Name, &s.Provider, &s.Status, &s.WorkerID, &s.IsSeed) - return s, err -} - -func (r *placementRepository) ListSeedAccounts(ctx context.Context, activeOnly bool) ([]SeedAccount, error) { - query := ` - SELECT ` + seedSelectCols + ` - FROM email_accounts ea - WHERE ea.is_seed = true - ` - if activeOnly { - query += ` AND ea.status = 'active'` +func (r *placementRepository) ExpireProbes(ctx context.Context, sentBefore, scheduledBefore, remoteBefore time.Time) error { + // A cloud seed's verdict comes back on the next sync, so it gets half an + // hour more before the instance calls it missing itself. + if _, err := r.db.Exec(ctx, ` + UPDATE placement_results SET folder = 'missing', detected_at = NOW() + WHERE folder = 'pending' AND sent_at IS NOT NULL + AND sent_at < $1 - CASE WHEN remote_seed_id IS NULL THEN interval '0' ELSE interval '30 minutes' END + `, sentBefore); err != nil { + return err } - query += ` ORDER BY ea.email ASC` + if _, err := r.db.Exec(ctx, ` + UPDATE placement_results SET folder = 'failed', error = 'The instance never reported this send', detected_at = NOW() + WHERE folder = 'pending' AND sent_at IS NULL AND scheduled_at IS NULL + AND test_id IN (SELECT id FROM placement_tests WHERE origin = 'remote' AND created_at < $1) + `, remoteBefore); err != nil { + return err + } + // A probe whose task never ran (a lost dispatch, a removed worker) would + // otherwise hold its test open for ever. + _, err := r.db.Exec(ctx, ` + UPDATE placement_results SET folder = 'failed', error = 'The send was never dispatched', detected_at = NOW() + WHERE folder = 'pending' AND sent_at IS NULL AND scheduled_at IS NOT NULL AND scheduled_at < $1 + `, scheduledBefore) + return err +} - rows, err := r.db.Query(ctx, query) +func (r *placementRepository) FinishTests(ctx context.Context) ([]PlacementFinished, error) { + rows, err := r.db.Query(ctx, ` + UPDATE placement_tests pt + SET status = CASE + WHEN pt.status <> 'running' THEN pt.status + WHEN NOT EXISTS ( + SELECT 1 FROM placement_results pr + WHERE pr.test_id = pt.id AND pr.folder IN ('inbox', 'promotions', 'other', 'spam', 'missing') + ) THEN 'failed' + ELSE 'completed' + END, + error = CASE + WHEN pt.status = 'running' AND NOT EXISTS ( + SELECT 1 FROM placement_results pr + WHERE pr.test_id = pt.id AND pr.folder IN ('inbox', 'promotions', 'other', 'spam', 'missing') + ) THEN 'No copy of the test left the sender' + ELSE pt.error + END, + finished_at = NOW() + WHERE pt.status IN ('running', 'cancelled') + AND pt.finished_at IS NULL + AND pt.origin <> 'remote' + AND NOT EXISTS ( + SELECT 1 FROM placement_results pr WHERE pr.test_id = pt.id AND pr.folder = 'pending' + ) + RETURNING pt.id, pt.organization_id, pt.created_by, pt.origin, pt.status, pt.monitor_id, pt.campaign_id, pt.compare_group_id + `) if err != nil { return nil, err } defer rows.Close() + var out []PlacementFinished + for rows.Next() { + var f PlacementFinished + if err := rows.Scan(&f.ID, &f.OrganizationID, &f.CreatedBy, &f.Origin, &f.Status, &f.MonitorID, &f.CampaignID, &f.CompareGroupID); err != nil { + return nil, err + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + return nil, err + } + // The cloud's copy of a remote test closes on its own clock: the instance + // reports sends in batches and may never report some. + if _, err := r.db.Exec(ctx, ` + UPDATE placement_tests pt SET status = 'completed', finished_at = NOW() + WHERE pt.origin = 'remote' AND pt.status = 'running' + AND NOT EXISTS (SELECT 1 FROM placement_results pr WHERE pr.test_id = pt.id AND pr.folder = 'pending') + `); err != nil { + return nil, err + } + return out, nil +} +const seedSelectCols = `ea.id, ea.organization_id, ea.user_id, ea.email, ea.name, ea.provider::text, ea.mail_host, + ea.status::text, ea.worker_id, COALESCE(ea.seed_scope, '')` + +func scanSeed(rows pgx.Row) (SeedAccount, error) { + var s SeedAccount + err := rows.Scan(&s.ID, &s.OrganizationID, &s.UserID, &s.Email, &s.Name, &s.Provider, &s.MailHost, + &s.Status, &s.WorkerID, &s.SeedScope) + return s, err +} + +func (r *placementRepository) querySeeds(ctx context.Context, query string, args ...any) ([]SeedAccount, error) { + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() out := make([]SeedAccount, 0) for rows.Next() { s, err := scanSeed(rows) @@ -329,91 +584,280 @@ func (r *placementRepository) ListSeedAccounts(ctx context.Context, activeOnly b return out, rows.Err() } -func (r *placementRepository) GetSeedAccount(ctx context.Context, id uuid.UUID) (*SeedAccount, error) { - rows, err := r.db.Query(ctx, ` +func (r *placementRepository) ListSeeds(ctx context.Context, scope string, orgID *uuid.UUID, activeOnly bool) ([]SeedAccount, error) { + return r.querySeeds(ctx, ` SELECT `+seedSelectCols+` FROM email_accounts ea - WHERE ea.id = $1 - `, id) - if err != nil { - return nil, err - } - defer rows.Close() - - if !rows.Next() { - return nil, nil - } - s, err := scanSeed(rows) - if err != nil { - return nil, err - } - return &s, nil + WHERE ea.seed_scope IS NOT NULL + AND ($1 = '' OR ea.seed_scope = $1) + AND ($2::uuid IS NULL OR ea.organization_id = $2) + AND (NOT $3 OR (ea.status = 'active' AND ea.worker_id IS NOT NULL)) + ORDER BY ea.email ASC + `, scope, orgID, activeOnly) } -func (r *placementRepository) SetIsSeed(ctx context.Context, accountID uuid.UUID, isSeed bool) error { - _, err := r.db.Exec(ctx, - `UPDATE email_accounts SET is_seed = $2, updated_at = NOW() WHERE id = $1`, - accountID, isSeed, - ) +func (r *placementRepository) GetSeedAccount(ctx context.Context, id uuid.UUID) (*SeedAccount, error) { + seeds, err := r.querySeeds(ctx, `SELECT `+seedSelectCols+` FROM email_accounts ea WHERE ea.id = $1`, id) + if err != nil || len(seeds) == 0 { + return nil, err + } + return &seeds[0], nil +} + +func (r *placementRepository) SetSeedScope(ctx context.Context, accountID uuid.UUID, scope string) error { + var value *string + if scope != "" { + value = &scope + } + _, err := r.db.Exec(ctx, `UPDATE email_accounts SET seed_scope = $2, updated_at = NOW() WHERE id = $1`, accountID, value) return err } +func (r *placementRepository) SeedScope(ctx context.Context, accountID uuid.UUID) (string, error) { + var scope string + err := r.db.QueryRow(ctx, `SELECT COALESCE(seed_scope, '') FROM email_accounts WHERE id = $1`, accountID).Scan(&scope) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return scope, err +} + func (r *placementRepository) ListSeedCandidates(ctx context.Context, search string, limit int) ([]SeedAccount, error) { if limit <= 0 || limit > 100 { limit = 50 } - query := ` - SELECT ` + seedSelectCols + ` + return r.querySeeds(ctx, ` + SELECT `+seedSelectCols+` FROM email_accounts ea WHERE ea.status = 'active' - ` - args := []any{} - if search != "" { - query += ` AND ea.email ILIKE $1` - args = append(args, "%"+search+"%") - query += ` ORDER BY ea.is_seed DESC, ea.email ASC LIMIT $2` - args = append(args, limit) - } else { - query += ` ORDER BY ea.is_seed DESC, ea.email ASC LIMIT $1` - args = append(args, limit) - } + AND ($1 = '' OR ea.email ILIKE '%' || $1 || '%') + ORDER BY (ea.seed_scope IS NOT NULL) DESC, ea.email ASC + LIMIT $2 + `, strings.TrimSpace(search), limit) +} - rows, err := r.db.Query(ctx, query, args...) +func (r *placementRepository) ListOrgMailboxes(ctx context.Context, orgID uuid.UUID) ([]SeedAccount, map[uuid.UUID]string, error) { + rows, err := r.db.Query(ctx, ` + SELECT `+seedSelectCols+`, + CASE + WHEN ea.seed_scope = 'instance' THEN 'This mailbox is on the instance seed panel' + WHEN ea.seed_scope IS NULL AND EXISTS ( + SELECT 1 FROM placement_tests pt + WHERE pt.sender_account_id = ea.id AND pt.status = 'running' + ) THEN 'A placement test is sending from this mailbox' + ELSE '' + END + FROM email_accounts ea + WHERE ea.organization_id = $1 + ORDER BY (ea.seed_scope IS NOT NULL) DESC, ea.email ASC + `, orgID) + if err != nil { + return nil, nil, err + } + defer rows.Close() + out := make([]SeedAccount, 0) + blockers := map[uuid.UUID]string{} + for rows.Next() { + var s SeedAccount + var blocker string + if err := rows.Scan(&s.ID, &s.OrganizationID, &s.UserID, &s.Email, &s.Name, &s.Provider, &s.MailHost, + &s.Status, &s.WorkerID, &s.SeedScope, &blocker); err != nil { + return nil, nil, err + } + out = append(out, s) + if blocker != "" { + blockers[s.ID] = blocker + } + } + return out, blockers, rows.Err() +} + +func (r *placementRepository) CountMeteredTests(ctx context.Context, orgID uuid.UUID, since time.Time) (int, error) { + var n int + err := r.db.QueryRow(ctx, ` + SELECT COUNT(*) FROM placement_tests + WHERE organization_id = $1 + AND panel IN ('instance', 'cloud') + AND origin IN ('manual', 'monitor', 'remote') + AND created_at >= $2 + `, orgID, since).Scan(&n) + return n, err +} + +func (r *placementRepository) CountRunning(ctx context.Context, orgID uuid.UUID) (int, error) { + var n int + err := r.db.QueryRow(ctx, ` + SELECT COUNT(*) FROM placement_tests + WHERE organization_id = $1 AND status = 'running' AND origin IN ('manual', 'monitor', 'remote') + `, orgID).Scan(&n) + return n, err +} + +func (r *placementRepository) SenderBusy(ctx context.Context, senderID uuid.UUID) (bool, error) { + var busy bool + err := r.db.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM placement_results pr + JOIN placement_tests pt ON pt.id = pr.test_id + WHERE pt.sender_account_id = $1 AND pt.status = 'running' + AND pr.folder = 'pending' AND pr.sent_at IS NULL + ) + `, senderID).Scan(&busy) + return busy, err +} + +func (r *placementRepository) IsProbeSentCopy(ctx context.Context, accountID uuid.UUID, messageID string) (bool, error) { + messageID = strings.Trim(strings.TrimSpace(messageID), "<>") + if messageID == "" { + return false, nil + } + var found bool + err := r.db.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM tasks + WHERE message_id IN ($2, '<' || $2 || '>') + AND email_account_id = $1 + AND task_type = 'placement' + ) + `, accountID, messageID).Scan(&found) + return found, err +} + +func (r *placementRepository) SampleLead(ctx context.Context, campaignID uuid.UUID) (*uuid.UUID, error) { + var id uuid.UUID + err := r.db.QueryRow(ctx, ` + SELECT contact_id FROM campaign_leads + WHERE campaign_id = $1 + ORDER BY position NULLS LAST, contact_id + LIMIT 1 + `, campaignID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &id, nil +} + +func (r *placementRepository) RecordRemoteSent(ctx context.Context, instanceID, testID, seedAccountID uuid.UUID, messageID string, sentAt *time.Time, failure string) error { + if failure != "" { + _, err := r.db.Exec(ctx, ` + UPDATE placement_results pr SET folder = 'failed', error = $4, detected_at = NOW() + FROM placement_tests pt + WHERE pt.id = pr.test_id AND pt.id = $2 AND pt.remote_instance_id = $1 + AND pr.seed_account_id = $3 AND pr.folder = 'pending' + `, instanceID, testID, seedAccountID, truncateRunes(failure, 500)) + return err + } + _, err := r.db.Exec(ctx, ` + UPDATE placement_results pr SET message_id = $4, sent_at = COALESCE($5, NOW()) + FROM placement_tests pt + WHERE pt.id = pr.test_id AND pt.id = $2 AND pt.remote_instance_id = $1 + AND pr.seed_account_id = $3 AND pr.folder = 'pending' + `, instanceID, testID, seedAccountID, messageID, sentAt) + return err +} + +func (r *placementRepository) GetRemoteTest(ctx context.Context, instanceID, testID uuid.UUID) (*models.PlacementTest, []models.PlacementResult, error) { + test, err := scanPlacementTest(r.db.QueryRow(ctx, + `SELECT `+placementTestCols+` FROM placement_tests WHERE id = $2 AND remote_instance_id = $1`, instanceID, testID)) + if err != nil || test == nil { + return nil, nil, err + } + results, err := r.ListResults(ctx, []uuid.UUID{test.ID}) + if err != nil { + return nil, nil, err + } + return test, results[test.ID], nil +} + +func (r *placementRepository) ListRemoteReports(ctx context.Context, limit int) ([]PlacementRemoteReport, error) { + if limit <= 0 { + limit = 200 + } + rows, err := r.db.Query(ctx, ` + SELECT pr.id, pt.remote_test_id, pr.remote_seed_id, pr.message_id, pr.sent_at, pr.folder, pr.error + FROM placement_results pr + JOIN placement_tests pt ON pt.id = pr.test_id + WHERE pt.remote_test_id IS NOT NULL + AND pr.remote_seed_id IS NOT NULL + AND pr.remote_synced_at IS NULL + AND ((pr.sent_at IS NOT NULL AND pr.message_id <> '') OR pr.folder IN ('failed', 'cancelled')) + ORDER BY pr.sent_at NULLS LAST + LIMIT $1 + `, limit) if err != nil { return nil, err } defer rows.Close() - - out := make([]SeedAccount, 0) + var out []PlacementRemoteReport for rows.Next() { - s, err := scanSeed(rows) - if err != nil { + var rep PlacementRemoteReport + if err := rows.Scan(&rep.ResultID, &rep.RemoteTestID, &rep.RemoteSeedID, &rep.MessageID, &rep.SentAt, &rep.Folder, &rep.Error); err != nil { return nil, err } - out = append(out, s) + out = append(out, rep) } return out, rows.Err() } -func (r *placementRepository) FindTokenInUnibox(ctx context.Context, userID, seedAccountID uuid.UUID, token string, since time.Time) (*UniboxTokenMatch, error) { - // The placement token is embedded in the message subject (see - // placement.Service — the worker injects only the warmup verify header, - // which we don't control here, so the subject is the carrier that survives - // sync into the unibox without a worker change). We match the most recent - // entry for this seed whose subject contains the token. `since` bounds the - // scan to mail that arrived after the test was created. - query := ` - SELECT id, subject, flags - FROM unibox_emails - WHERE user_id = $1 - AND email_id = $2 - AND subject LIKE '%' || $3 || '%' - AND internal_date >= $4 - ORDER BY internal_date DESC - LIMIT 1 - ` - var m UniboxTokenMatch - err := r.db.QueryRow(ctx, query, userID, seedAccountID, token, since).Scan(&m.InternalID, &m.Subject, &m.Flags) +func (r *placementRepository) MarkRemoteReported(ctx context.Context, resultIDs []uuid.UUID) error { + if len(resultIDs) == 0 { + return nil + } + _, err := r.db.Exec(ctx, `UPDATE placement_results SET remote_synced_at = NOW() WHERE id = ANY($1)`, resultIDs) + return err +} + +func (r *placementRepository) ListRemoteOpenTests(ctx context.Context, limit int) ([]models.PlacementTest, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.db.Query(ctx, ` + SELECT `+placementTestCols+` + FROM placement_tests pt + WHERE pt.remote_test_id IS NOT NULL + AND pt.status IN ('running', 'cancelled') + AND EXISTS ( + SELECT 1 FROM placement_results pr + WHERE pr.test_id = pt.id AND pr.folder = 'pending' AND pr.remote_synced_at IS NOT NULL + ) + ORDER BY pt.created_at + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.PlacementTest + for rows.Next() { + t, err := scanPlacementTest(rows) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +func (r *placementRepository) RecordRemoteVerdict(ctx context.Context, testID, remoteSeedID uuid.UUID, folder string, at *time.Time) error { + _, err := r.db.Exec(ctx, ` + UPDATE placement_results SET folder = $3, detected_at = COALESCE($4, NOW()) + WHERE test_id = $1 AND remote_seed_id = $2 AND folder = 'pending' + `, testID, remoteSeedID, folder, at) + return err +} + +const placementMonitorCols = `id, organization_id, campaign_id, created_by, enabled, interval_days, panel, + alert_below, pause_on_alert, next_run_at, last_run_at, last_test_id, last_sender_id, last_alert_at, + last_error, created_at, updated_at` + +func scanPlacementMonitor(row pgx.Row) (*models.PlacementMonitor, error) { + var m models.PlacementMonitor + err := row.Scan(&m.ID, &m.OrganizationID, &m.CampaignID, &m.CreatedBy, &m.Enabled, &m.IntervalDays, &m.Panel, + &m.AlertBelow, &m.PauseOnAlert, &m.NextRunAt, &m.LastRunAt, &m.LastTestID, &m.LastSenderID, &m.LastAlertAt, + &m.LastError, &m.CreatedAt, &m.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -422,3 +866,97 @@ func (r *placementRepository) FindTokenInUnibox(ctx context.Context, userID, see } return &m, nil } + +func (r *placementRepository) GetMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (*models.PlacementMonitor, error) { + return scanPlacementMonitor(r.db.QueryRow(ctx, + `SELECT `+placementMonitorCols+` FROM placement_monitors WHERE organization_id = $1 AND campaign_id = $2`, orgID, campaignID)) +} + +func (r *placementRepository) GetMonitorByID(ctx context.Context, id uuid.UUID) (*models.PlacementMonitor, error) { + return scanPlacementMonitor(r.db.QueryRow(ctx, `SELECT `+placementMonitorCols+` FROM placement_monitors WHERE id = $1`, id)) +} + +func (r *placementRepository) UpsertMonitor(ctx context.Context, m *models.PlacementMonitor) error { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + row := r.db.QueryRow(ctx, ` + INSERT INTO placement_monitors (id, organization_id, campaign_id, created_by, enabled, interval_days, + panel, alert_below, pause_on_alert, next_run_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (campaign_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + interval_days = EXCLUDED.interval_days, + panel = EXCLUDED.panel, + alert_below = EXCLUDED.alert_below, + pause_on_alert = EXCLUDED.pause_on_alert, + next_run_at = EXCLUDED.next_run_at, + last_error = '', + updated_at = NOW() + WHERE placement_monitors.organization_id = EXCLUDED.organization_id + RETURNING `+placementMonitorCols, + m.ID, m.OrganizationID, m.CampaignID, m.CreatedBy, m.Enabled, m.IntervalDays, + m.Panel, m.AlertBelow, m.PauseOnAlert, m.NextRunAt) + saved, err := scanPlacementMonitor(row) + if err != nil { + return err + } + if saved == nil { + return pgx.ErrNoRows + } + *m = *saved + return nil +} + +func (r *placementRepository) DeleteMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (bool, error) { + tag, err := r.db.Exec(ctx, `DELETE FROM placement_monitors WHERE organization_id = $1 AND campaign_id = $2`, orgID, campaignID) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +func (r *placementRepository) ListDueMonitors(ctx context.Context, now time.Time, limit int) ([]models.PlacementMonitor, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.db.Query(ctx, ` + SELECT `+placementMonitorCols+` + FROM placement_monitors + WHERE enabled AND next_run_at <= $1 + ORDER BY next_run_at + LIMIT $2 + `, now, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.PlacementMonitor + for rows.Next() { + m, err := scanPlacementMonitor(rows) + if err != nil { + return nil, err + } + out = append(out, *m) + } + return out, rows.Err() +} + +func (r *placementRepository) MarkMonitorRun(ctx context.Context, id uuid.UUID, next time.Time, testID, senderID *uuid.UUID, lastErr string) error { + _, err := r.db.Exec(ctx, ` + UPDATE placement_monitors SET + next_run_at = $2, + last_run_at = NOW(), + last_test_id = COALESCE($3, last_test_id), + last_sender_id = COALESCE($4, last_sender_id), + last_error = $5, + updated_at = NOW() + WHERE id = $1 + `, id, next, testID, senderID, truncateRunes(lastErr, 500)) + return err +} + +func (r *placementRepository) MarkMonitorAlert(ctx context.Context, id uuid.UUID, at time.Time) error { + _, err := r.db.Exec(ctx, `UPDATE placement_monitors SET last_alert_at = $2 WHERE id = $1`, id, at) + return err +} diff --git a/internal/repository/pg_task.go b/internal/repository/pg_task.go index fb3a32258..b05998e9c 100644 --- a/internal/repository/pg_task.go +++ b/internal/repository/pg_task.go @@ -453,16 +453,21 @@ const taskDispatchedEmail = `(t.task_type <> 'campaign' OR t.message_id <> '' OR SELECT 1 FROM campaign_contact_progress ccp WHERE ccp.dispatch_task_id = t.id))` // CountCampaignEmailsSentToday counts the campaign emails a mailbox dispatched -// today (excludes warmup, and the campaign chain's own wake-ups). +// today (excludes warmup, and the campaign chain's own wake-ups). Placement +// test probes count too: they are cold mail from the same daily budget. func (r *taskRepository) CountCampaignEmailsSentToday(ctx context.Context, accountID uuid.UUID) (int, error) { + // Two counts so the campaign half keeps its partial index; the probe half + // is a range on (email_account_id, completed_at). query := ` - SELECT COUNT(*) - FROM tasks t - WHERE t.email_account_id = $1 - AND t.status = 'completed' - AND t.task_type = 'campaign' - AND DATE(t.completed_at) = CURRENT_DATE - AND ` + taskDispatchedEmail + ` + SELECT ( + SELECT COUNT(*) + FROM tasks t + WHERE t.email_account_id = $1 + AND t.status = 'completed' + AND t.task_type = 'campaign' + AND DATE(t.completed_at) = CURRENT_DATE + AND ` + taskDispatchedEmail + ` + ) + (` + placementSentTodaySQL + ` AND t.email_account_id = $1) ` var count int @@ -517,6 +522,14 @@ func (r *taskRepository) CountCampaignEmailsSentTodayByAccounts(ctx context.Cont AND DATE(t.completed_at) = CURRENT_DATE AND ` + taskDispatchedEmail + ` GROUP BY t.email_account_id + UNION ALL + SELECT t.email_account_id, COUNT(*) + FROM tasks t + WHERE t.email_account_id = ANY($1) + AND t.status = 'completed' + AND t.task_type = 'placement' + AND t.completed_at >= CURRENT_DATE AND t.completed_at < CURRENT_DATE + 1 + GROUP BY t.email_account_id ` rows, err := r.db.Query(ctx, query, accountIDs) if err != nil { @@ -529,11 +542,19 @@ func (r *taskRepository) CountCampaignEmailsSentTodayByAccounts(ctx context.Cont if err := rows.Scan(&id, &n); err != nil { return nil, err } - out[id] = n + out[id] += n } return out, rows.Err() } +// placementSentTodaySQL counts today's placement probes; callers add the +// mailbox predicate. +const placementSentTodaySQL = ` + SELECT COUNT(*) FROM tasks t + WHERE t.status = 'completed' + AND t.task_type = 'placement' + AND t.completed_at >= CURRENT_DATE AND t.completed_at < CURRENT_DATE + 1` + // CountCampaignSendsTodayBySender is CountCampaignEmailsSentToday for one // campaign, split by mailbox. Same ledger and the same day boundary, so the // two agree on what a mailbox has spent. diff --git a/internal/tasks/dispatch.go b/internal/tasks/dispatch.go index 7ce4b2435..86166eaf7 100644 --- a/internal/tasks/dispatch.go +++ b/internal/tasks/dispatch.go @@ -43,6 +43,8 @@ func (s *tasksService) HandleTask(task *proto.ProcessTask) *errx.Error { // anywhere in the database. case "email": return s.HandleUserEmailTask(task) + case "placement": + return s.HandlePlacementTask(task) default: log.Warn().Str("task_id", taskID.String()).Str("task_type", rec.TaskType).Msg("task dispatch: unknown task type") return errx.New(errx.BadRequest, "unknown task type") diff --git a/internal/tasks/placement_task.go b/internal/tasks/placement_task.go new file mode 100644 index 000000000..5099a6db4 --- /dev/null +++ b/internal/tasks/placement_task.go @@ -0,0 +1,270 @@ +package tasks + +import ( + "context" + "errors" + "fmt" + "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/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/tasks/proto" +) + +// SetPlacement wires the placement test store the probe handler reads. +func (s *tasksService) SetPlacement(repo repository.PlacementRepository) { + s.placementRepo = repo +} + +// HandlePlacementTask sends one placement probe: the test's copy, rendered the +// way the campaign would render it, from the test's sender to one seed. The +// copy carries nothing a real send would not (no marker, no warmup header, +// no CC or BCC); the probe is found again by its Message-ID. +func (s *tasksService) HandlePlacementTask(task *proto.ProcessTask) *errx.Error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + taskID, err := uuid.Parse(task.TaskId) + if err != nil { + return errx.New(errx.BadRequest, "invalid task ID") + } + if s.placementRepo == nil { + return errx.New(errx.Internal, "placement tests are not configured") + } + record, err := s.taskRepo.GetTask(ctx, taskID) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if record == nil || record.Status != "pending" { + return nil + } + probe, err := s.placementRepo.GetProbeByTask(ctx, taskID) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if probe == nil || probe.Result.Folder != models.PlacementFolderPending || probe.Test.Status != models.PlacementStatusRunning { + _ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") + return nil + } + fail := func(reason string) *errx.Error { + _ = s.taskRepo.RecordTaskFailure(ctx, taskID, "Placement probe not sent", reason) + _ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") + if err := s.placementRepo.FailProbe(ctx, probe.Result.ID, reason); err != nil { + errs.CaptureException(err) + } + return nil + } + + test := probe.Test + if test.SenderAccountID == nil || test.OrganizationID == nil { + return fail("The test has no sender") + } + account, xerr := s.emailRepo.GetByID(ctx, *test.SenderAccountID) + if xerr != nil || account == nil { + return fail("The sending mailbox no longer exists") + } + // Tenancy: the sender, and everything rendered below, belongs to the test's + // workspace. + if account.OrganizationID == nil || *account.OrganizationID != *test.OrganizationID { + return fail("The sending mailbox is not in this workspace") + } + if account.Status != "active" { + return fail("The sending mailbox is not active") + } + + msg, reason := s.renderPlacementProbe(ctx, taskID, &test, account, probe.Result.SeedAddress) + if reason != "" { + return fail(reason) + } + + if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if err := s.emailSender.Send(ctx, taskID, msg, *account); err != nil { + switch { + case errors.Is(err, ErrSendDispatchUnknown): + // The bus may have taken it: leave the probe to the worker's answer + // or to the classify window. + _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed") + _ = s.placementRepo.MarkProbeSent(ctx, probe.Result.ID, msg.MessageID, time.Now()) + return nil + case errors.Is(err, ErrWorkerOffline), errors.Is(err, ErrWorkerUnconfirmed): + return fail("The sending mailbox's worker is offline") + default: + return fail("The send could not be handed to a worker") + } + } + if err := s.taskRepo.UpdateTaskMessageID(ctx, taskID, msg.MessageID); err != nil { + errs.CaptureException(err) + } + if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil { + errs.CaptureException(err) + } + if err := s.placementRepo.MarkProbeSent(ctx, probe.Result.ID, msg.MessageID, time.Now()); err != nil { + errs.CaptureException(err) + } + log.Debug().Str("task_id", taskID.String()).Str("test_id", test.ID.String()).Msg("placement probe sent") + return nil +} + +// renderPlacementProbe builds one probe the way HandleCampaignTask builds a +// send, in the same order: form links, merge fields and spintax, AI blocks, +// plain-text rule, unsubscribe link, tracking, signature, opt-out footer, +// CSS inlining. What it leaves out is what only a real lead has: threading, +// A/B arm selection and the campaign's CC and BCC. The second result is why +// the probe cannot be sent, empty when it can. +func (s *tasksService) renderPlacementProbe(ctx context.Context, taskID uuid.UUID, test *models.PlacementTest, account *models.Email, recipient string) (EmailMessage, string) { + orgID := *test.OrganizationID + + // An ad-hoc test renders against a campaign with the platform defaults, so + // the opt-out and the List-Unsubscribe header match a new campaign. + campaign := &models.Campaign{ID: uuid.Nil, OrganizationID: &orgID, UnsubscribeHeader: true} + var sequenceID uuid.UUID + if test.CampaignID != nil { + c, err := s.campaignRepo.GetByID(ctx, *test.CampaignID) + if err != nil || c == nil || c.OrganizationID == nil || *c.OrganizationID != orgID { + return EmailMessage{}, "The campaign no longer exists" + } + campaign = c + } + if test.SequenceID != nil { + sequenceID = *test.SequenceID + } + + contact := testContact(recipient) + realContact := false + if test.ContactID != nil && s.contactRepo != nil { + if found, xerr := s.contactRepo.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{*test.ContactID}); xerr == nil && len(found) == 1 { + contact = found[0] + realContact = true + } + } + + rawSubject, rawHTML, rawPlain := test.Subject, test.BodyHTML, test.BodyPlain + if test.CampaignID != nil && realContact { + s.resolveFormLinks(ctx, orgID, campaign, &contact, &rawSubject, &rawHTML, &rawPlain) + } else { + // No lead to personalise a form for: drop the markers, as the send path + // does when a form cannot be resolved. + for _, part := range []*string{&rawSubject, &rawHTML, &rawPlain} { + *part = models.FormLinkMarkerRE.ReplaceAllString(*part, "") + } + } + + optOut := s.resolveOptOut(ctx, orgID, campaign) + var unsubscribeURL string + if s.unsubLinks != nil && s.unsubLinks.Enabled() { + // No contact: clicking it can never suppress anyone. + unsubscribeURL = s.mintUnsubscribeLink(ctx, resolveOptOutOrigin(account, campaign), orgID, campaign.ID, uuid.Nil) + } + extra := map[string]string{UnsubscribeLinkVar: unsubscribeURL} + subject := expandSpintax(RenderTemplateWith(rawSubject, contact, extra)) + bodyHTML := expandSpintax(RenderTemplateWith(rawHTML, contact, extra)) + bodyPlain := expandSpintax(RenderTemplateWith(rawPlain, contact, extra)) + if bodyPlain == "" && bodyHTML != "" { + bodyPlain = ExtractPlainTextFromHTML(bodyHTML) + } + + // AI blocks resolve for the chosen lead through the send path's own cache, + // so every probe reads the copy that lead would get. + if realContact && test.CampaignID != nil && sequenceID != uuid.Nil && s.aiProvider != nil && s.aiCredits != nil { + var err error + subject, bodyHTML, bodyPlain, err = s.resolveAIVariables(ctx, campaign, &contact, sequenceID, subject, bodyHTML, bodyPlain) + if err != nil { + return EmailMessage{}, "The AI blocks in the copy could not be generated" + } + } + + if campaign.TextOnly { + bodyHTML = "" + } + bodyHTML = dropBlankHTMLPart(bodyHTML, bodyPlain) + if !mailhtml.HasContent(bodyHTML) && bodyPlain == "" { + return EmailMessage{}, "The copy rendered empty" + } + bodyHTML = linkifyUnsubscribeURL(bodyHTML, unsubscribeURL, optOut.LinkText) + + trackingDomain, _ := resolveTrackingHost(config.TrackingHost(), account, campaign) + openTracking := test.OpenTracking && bodyHTML != "" && trackingDomain != "" + linkTracking := test.LinkTracking && bodyHTML != "" && trackingDomain != "" + if openTracking { + bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, trackingDomain) + } + if bodyHTML != "" && (linkTracking || campaign.UTMTracking) { + opts := LinkTracking{ + TaskID: taskID, + CampaignID: campaign.ID, + TrackingDomain: trackingDomain, + Wrap: linkTracking, + UTM: CampaignUTM(campaign), + } + tracked, links := TrackLinks(bodyHTML, opts) + switch { + case len(links) == 0: + bodyHTML = tracked + case s.trackedLinkRepo == nil: + bodyHTML, _ = TrackLinks(bodyHTML, LinkTracking{TrackingDomain: trackingDomain, UTM: opts.UTM}) + default: + if err := s.trackedLinkRepo.CreateBatch(ctx, links); err != nil { + bodyHTML, _ = TrackLinks(bodyHTML, LinkTracking{TrackingDomain: trackingDomain, UTM: opts.UTM}) + } else { + bodyHTML = tracked + } + } + } + if campaign.UTMTracking && bodyPlain != "" { + bodyPlain = TagPlainTextLinks(bodyPlain, CampaignUTM(campaign), trackingDomain) + } + + if account.SignatureSync { + if bodyHTML != "" { + bodyHTML = AddSignature(bodyHTML, account.SignatureHTML, true) + } + if bodyPlain != "" { + bodyPlain = AddSignature(bodyPlain, account.SignaturePlain, false) + } + } + bodyHTML, bodyPlain = appendOptOut(bodyHTML, bodyPlain, optOut, unsubscribeURL) + bodyHTML = mailhtml.InlineCSS(bodyHTML) + + if s.cipherService != nil { + if _, err := s.cipherService.Cipher(ctx, orgID); err != nil { + errs.CaptureException(fmt.Errorf("placement probe: organization key: %w", err)) + return EmailMessage{}, "The workspace's encryption key is unavailable" + } + } + + var tracking *models.TrackingInfo + if openTracking || linkTracking { + tracking = &models.TrackingInfo{OpenTracking: openTracking, LinkTracking: linkTracking, TrackingDomain: trackingDomain} + } + headerURL := "" + if campaign.UnsubscribeHeader { + headerURL = unsubscribeURL + } + var attachments []models.AttachmentRef + if test.CampaignID != nil { + attachments = s.campaignAttachmentRefs(ctx, campaign.ID, sequenceID) + } + return EmailMessage{ + From: account.Email, + To: []string{recipient}, + Subject: subject, + BodyHTML: bodyHTML, + BodyPlain: bodyPlain, + MessageID: generateMessageID(account.SendFrom()), + Tracking: tracking, + UnsubscribeURL: headerURL, + Attachments: attachments, + }, "" +} diff --git a/internal/tasks/service.go b/internal/tasks/service.go index a92c75a42..d89b85c14 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -94,6 +94,10 @@ type TasksService interface { // SetFormLinks wires the minter that resolves {{form_link:...}} markers // into per-recipient form URLs. Nil-safe: markers drop with a warning. SetFormLinks(m FormLinkMinter) + // SetPlacement wires the placement test store; without it a placement + // task fails cleanly. + SetPlacement(repo repository.PlacementRepository) + HandlePlacementTask(task *proto.ProcessTask) *errx.Error } // CloudLinkReader reports whether Warmbly Cloud warms a mailbox. @@ -181,6 +185,9 @@ type tasksService struct { // unsubTickets stores the short form of those links. Nil-safe: without it // the address is the signed token, which is longer but works the same. unsubTickets repository.UnsubscribeLinkRepository + + // placementRepo backs the placement probe handler (SetPlacement). + placementRepo repository.PlacementRepository } // SetUnsubscribeLinks wires the unsubscribe link signer and ticket store. diff --git a/realtime/lib/realtime_web/channels/org_channel.ex b/realtime/lib/realtime_web/channels/org_channel.ex index a445a76f6..da104f1b3 100644 --- a/realtime/lib/realtime_web/channels/org_channel.ex +++ b/realtime/lib/realtime_web/channels/org_channel.ex @@ -467,6 +467,10 @@ defmodule RealtimeWeb.OrgChannel do String.contains?(event_type, "DIRECT_EMAIL_") -> has.(:view_analytics) + # Inbox placement tests: results sit behind the analytics read endpoints. + String.contains?(event_type, "PLACEMENT_TEST") -> + has.(:view_analytics) + # Campaign activity: lifecycle, task progress, send/open/click/reply pulses String.contains?(event_type, "CAMPAIGN") or String.contains?(event_type, "TASK_PROGRESS") or event_type in [ From 099233ed0838fbc5a02e0c3422e2d27c37a6ecbd Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 20:52:48 -0700 Subject: [PATCH 03/11] feat: cast the placement expiry cutoff to timestamptz so Postgres does not read it as an interval, and cover probe matching by Message-ID, expiry, cancellation, daily-budget charging, monitors, seed selection across host families and every create refusal with live and unit tests --- internal/app/placement/service_test.go | 264 +++++++++++++++++ internal/repository/pg_placement.go | 2 +- internal/repository/placement_live_test.go | 326 +++++++++++++++++++++ 3 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 internal/app/placement/service_test.go create mode 100644 internal/repository/placement_live_test.go diff --git a/internal/app/placement/service_test.go b/internal/app/placement/service_test.go new file mode 100644 index 000000000..a38d3549c --- /dev/null +++ b/internal/app/placement/service_test.go @@ -0,0 +1,264 @@ +package placement + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/instancesettings" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" + "github.com/warmbly/warmbly/internal/tasks/proto" +) + +// Each fake embeds the interface it stands in for, so a call the test does +// not expect panics instead of passing silently. + +type fakeRepo struct { + repository.PlacementRepository + seeds []repository.SeedAccount + scopes map[uuid.UUID]string + running int + busy bool + metered int + created []models.PlacementTest + results [][]models.PlacementResult + tasks [][]repository.Task + failures []uuid.UUID +} + +func (f *fakeRepo) SeedScope(_ context.Context, id uuid.UUID) (string, error) { + return f.scopes[id], nil +} +func (f *fakeRepo) CountRunning(context.Context, uuid.UUID) (int, error) { return f.running, nil } +func (f *fakeRepo) SenderBusy(context.Context, uuid.UUID) (bool, error) { return f.busy, nil } +func (f *fakeRepo) CountMeteredTests(context.Context, uuid.UUID, time.Time) (int, error) { + return f.metered, nil +} +func (f *fakeRepo) SampleLead(context.Context, uuid.UUID) (*uuid.UUID, error) { return nil, nil } +func (f *fakeRepo) ListSeeds(_ context.Context, scope string, org *uuid.UUID, _ bool) ([]repository.SeedAccount, error) { + var out []repository.SeedAccount + for _, s := range f.seeds { + if s.SeedScope == scope && (org == nil || (s.OrganizationID != nil && *s.OrganizationID == *org)) { + out = append(out, s) + } + } + return out, nil +} +func (f *fakeRepo) CreateTest(_ context.Context, t *models.PlacementTest, results []models.PlacementResult, tasks []repository.Task) error { + f.created = append(f.created, *t) + f.results = append(f.results, results) + f.tasks = append(f.tasks, tasks) + return nil +} +func (f *fakeRepo) FailProbe(_ context.Context, id uuid.UUID, _ string) error { + f.failures = append(f.failures, id) + return nil +} + +type fakeEmails struct { + repository.EmailRepository + accounts map[uuid.UUID]*models.Email +} + +func (f *fakeEmails) GetByID(_ context.Context, id uuid.UUID) (*models.Email, *errx.Error) { + if a, ok := f.accounts[id]; ok { + return a, nil + } + return nil, errx.ErrNotFound +} + +type fakeTasks struct { + repository.TaskRepository + sentToday int +} + +func (f *fakeTasks) CountCampaignEmailsSentToday(context.Context, uuid.UUID) (int, error) { + return f.sentToday, nil +} + +type fakeScheduler struct{ scheduled int } + +func (f *fakeScheduler) CreateTask(context.Context, *proto.ProcessTask, time.Time) (string, error) { + f.scheduled++ + return "", nil +} +func (f *fakeScheduler) DeleteTask(context.Context, string) error { return nil } + +type fakePolicy struct{ p instancesettings.Placement } + +func (f fakePolicy) PlacementPolicy(context.Context) instancesettings.Placement { return f.p } + +type harness struct { + svc *service + repo *fakeRepo + tasks *fakeTasks + sched *fakeScheduler + org uuid.UUID + sender uuid.UUID +} + +func newHarness(t *testing.T) *harness { + t.Helper() + t.Setenv("DEPLOYMENT_MODE", "self_hosted") + org, operator, sender, worker := uuid.New(), uuid.New(), uuid.New(), uuid.New() + h := &harness{org: org, sender: sender, repo: &fakeRepo{scopes: map[uuid.UUID]string{}}, tasks: &fakeTasks{}, sched: &fakeScheduler{}} + seed := func(addr, host string) repository.SeedAccount { + return repository.SeedAccount{ID: uuid.New(), OrganizationID: &operator, Email: addr, Provider: "smtp_imap", + MailHost: host, Status: "active", WorkerID: &worker, SeedScope: models.SeedScopeInstance} + } + h.repo.seeds = []repository.SeedAccount{ + seed("a@gmail.com", "gmail"), seed("b@gmail.com", "gmail"), seed("c@gmail.com", "gmail"), + seed("d@contoso.test", "microsoft365"), seed("e@yahoo.com", "yahoo"), + // On the sender's own domain: never a seed for this sender. + seed("f@acme.test", "google_workspace"), + } + emails := &fakeEmails{accounts: map[uuid.UUID]*models.Email{ + sender: {ID: sender, OrganizationID: &org, Email: "rep@acme.test", Status: "active", WorkerID: &worker, CampaignLimit: 50}, + }} + policy := instancesettings.DefaultPlacement() + policy.SeedsPerTest = 4 + h.svc = &service{Deps: Deps{ + Repo: h.repo, Emails: emails, Tasks: h.tasks, Scheduler: h.sched, Policy: fakePolicy{policy}, + }, now: time.Now} + return h +} + +func (h *harness) input() CreateInput { + return CreateInput{OrgID: h.org, SenderAccountID: h.sender, Subject: "Quick question", BodyPlain: "Hi there"} +} + +func TestCreateTestsPicksAcrossFamiliesAndSkipsTheSendersDomain(t *testing.T) { + h := newHarness(t) + views, xerr := h.svc.CreateTests(context.Background(), h.input()) + if xerr != nil { + t.Fatalf("CreateTests: %v", xerr) + } + if len(views) != 1 || len(h.repo.results[0]) != 4 { + t.Fatalf("got %d tests and %d probes; want one test with the 4-seed cap", len(views), len(h.repo.results[0])) + } + families := map[string]int{} + for _, r := range h.repo.results[0] { + if strings.HasSuffix(r.SeedAddress, "@acme.test") { + t.Fatalf("seed %s is on the sender's domain", r.SeedAddress) + } + families[r.Family]++ + } + if families["gmail"] != 2 || families["microsoft365"] != 1 || families["yahoo"] != 1 { + t.Fatalf("families = %v; want every family before a second gmail", families) + } + if h.sched.scheduled != 4 { + t.Fatalf("scheduled %d tasks, want 4", h.sched.scheduled) + } + // One task per probe, all from the sender, in order and spaced out. + tasks := h.repo.tasks[0] + for i, task := range tasks { + if task.TaskType != "placement" || task.EmailAccountID != h.sender || *h.repo.results[0][i].TaskID != task.ID { + t.Fatalf("task %d = %+v; want the sender's placement task behind probe %d", i, task, i) + } + if i > 0 && !task.ScheduledAt.After(*tasks[i-1].ScheduledAt) { + t.Fatalf("probes %d and %d are not spaced", i-1, i) + } + } +} + +func TestCreateTestsCompareSendsBothVariantsToTheSameSeeds(t *testing.T) { + h := newHarness(t) + in := h.input() + in.Tracking = models.PlacementTrackingCompare + views, xerr := h.svc.CreateTests(context.Background(), in) + if xerr != nil { + t.Fatalf("CreateTests: %v", xerr) + } + if len(views) != 2 || views[0].CompareGroupID == nil || views[1].CompareGroupID == nil || *views[0].CompareGroupID != *views[1].CompareGroupID { + t.Fatalf("want two tests in one comparison group, got %+v", views) + } + if views[0].Tracked() == views[1].Tracked() { + t.Fatalf("want one tracked and one untracked test") + } + seeds := func(i int) string { + var out []string + for _, r := range h.repo.results[i] { + out = append(out, r.SeedAddress) + } + return strings.Join(out, ",") + } + if seeds(0) != seeds(1) { + t.Fatalf("variants went to different seeds: %s vs %s", seeds(0), seeds(1)) + } +} + +func TestCreateTestsRefusals(t *testing.T) { + cases := []struct { + name string + setup func(*harness, *CreateInput) + wantID string + }{ + {"sender in another workspace", func(h *harness, in *CreateInput) { in.OrgID = uuid.New() }, ""}, + {"sender is a seed", func(h *harness, in *CreateInput) { h.repo.scopes[h.sender] = models.SeedScopeWorkspace }, "placement_sender_unavailable"}, + {"sender still sending a test", func(h *harness, in *CreateInput) { h.repo.busy = true }, "placement_sender_busy"}, + {"too many running", func(h *harness, in *CreateInput) { h.repo.running = 3 }, "placement_too_many_running"}, + {"daily limit spent", func(h *harness, in *CreateInput) { h.tasks.sentToday = 48 }, "placement_daily_budget"}, + {"no own seeds", func(h *harness, in *CreateInput) { in.Panel = models.PlacementPanelWorkspace }, "placement_no_seeds"}, + {"no cloud link", func(h *harness, in *CreateInput) { in.Panel = models.PlacementPanelCloud }, "placement_panel_unavailable"}, + {"empty body", func(h *harness, in *CreateInput) { in.BodyPlain, in.BodyHTML = "", "
" }, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t) + in := h.input() + c.setup(h, &in) + _, xerr := h.svc.CreateTests(context.Background(), in) + if xerr == nil { + t.Fatalf("CreateTests succeeded; want a refusal") + } + if c.wantID != "" && xerr.Identifier != c.wantID { + t.Fatalf("refused with %q (%s); want %q", xerr.Identifier, xerr.Message, c.wantID) + } + if len(h.repo.created) != 0 || h.sched.scheduled != 0 { + t.Fatalf("a refused test wrote %d tests and scheduled %d tasks", len(h.repo.created), h.sched.scheduled) + } + }) + } +} + +func TestCreateTestsMetersTheInstancePanelOnTheHostedProduct(t *testing.T) { + h := newHarness(t) + t.Setenv("DEPLOYMENT_MODE", "cloud") + h.repo.metered = 3 // the trial allowance + _, xerr := h.svc.CreateTests(context.Background(), h.input()) + if xerr == nil || xerr.Identifier != "placement_quota_exceeded" { + t.Fatalf("got %v; want placement_quota_exceeded", xerr) + } + in := h.input() + in.Origin = models.PlacementOriginAdmin + if _, xerr := h.svc.CreateTests(context.Background(), in); xerr != nil { + t.Fatalf("an operator's test is not metered: %v", xerr) + } +} + +func TestSummaryAndMasking(t *testing.T) { + var c models.PlacementCounts + for _, f := range []string{"inbox", "inbox", "promotions", "spam", "missing", "failed", "pending"} { + c.Add(f) + } + c.Finish() + if c.Delivered != 5 || percent(c.InboxRate) != 40 || percent(c.TabsRate) != 20 { + t.Fatalf("counts = %+v", c) + } + if got := summaryLine(c); !strings.HasPrefix(got, "2 of 5 copies reached the inbox") { + t.Fatalf("summary = %q", got) + } + if got := maskAddress("seed.one@gmail.com"); got != "s***@gmail.com" { + t.Fatalf("mask = %q", got) + } + for range 200 { + if d := jitter(30 * time.Second); d < 20*time.Second || d > 40*time.Second { + t.Fatalf("jitter %v outside a third of 30s", d) + } + } +} diff --git a/internal/repository/pg_placement.go b/internal/repository/pg_placement.go index 1e0c87eb7..461c3386b 100644 --- a/internal/repository/pg_placement.go +++ b/internal/repository/pg_placement.go @@ -483,7 +483,7 @@ func (r *placementRepository) ExpireProbes(ctx context.Context, sentBefore, sche if _, err := r.db.Exec(ctx, ` UPDATE placement_results SET folder = 'missing', detected_at = NOW() WHERE folder = 'pending' AND sent_at IS NOT NULL - AND sent_at < $1 - CASE WHEN remote_seed_id IS NULL THEN interval '0' ELSE interval '30 minutes' END + AND sent_at < $1::timestamptz - CASE WHEN remote_seed_id IS NULL THEN interval '0' ELSE interval '30 minutes' END `, sentBefore); err != nil { return err } diff --git a/internal/repository/placement_live_test.go b/internal/repository/placement_live_test.go new file mode 100644 index 000000000..eaa42e1dd --- /dev/null +++ b/internal/repository/placement_live_test.go @@ -0,0 +1,326 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// Run against a migrated scratch database: +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/?sslmode=disable \ +// go test ./internal/repository/ -run LivePlacement -v + +type placementFixture struct { + t *testing.T + repo PlacementRepository + tasks TaskRepository + exec func(string, ...any) + owner uuid.UUID + org uuid.UUID + sender uuid.UUID + opOwner uuid.UUID + opOrg uuid.UUID + seeds []uuid.UUID + sameHost uuid.UUID +} + +func newPlacementFixture(t *testing.T) *placementFixture { + t.Helper() + handle, pool := liveContactDB(t) + requireSchemaVersion(t, pool, 217) + ctx := context.Background() + f := &placementFixture{ + t: t, + repo: NewPlacementRepository(handle), + tasks: NewTaskRepository(pool), + owner: uuid.New(), org: uuid.New(), sender: uuid.New(), + opOwner: uuid.New(), opOrg: uuid.New(), sameHost: uuid.New(), + } + f.exec = func(query string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, query, args...); err != nil { + t.Fatalf("fixture: %v", err) + } + } + tag := uuid.NewString()[:8] + for _, u := range []struct { + id uuid.UUID + org uuid.UUID + }{{f.owner, f.org}, {f.opOwner, f.opOrg}} { + f.exec(`INSERT INTO users (id, first_name, last_name, email) VALUES ($1, 'Placement', 'Live', $2)`, + u.id, "placement-"+uuid.NewString()+"@example.test") + f.exec(`INSERT INTO organizations (id, name, owner_user_id) VALUES ($1, 'Placement live', $2)`, u.org, u.id) + } + mailbox := func(id, user, org uuid.UUID, address, provider string) { + f.exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider, campaign_limit) + VALUES ($1, $2, $3, $4, 'Placement', '', '', $5, 50)`, id, user, org, address, provider) + } + mailbox(f.sender, f.owner, f.org, "sender-"+tag+"@acme-"+tag+".test", "gmail") + for i := range 2 { + id := uuid.New() + f.seeds = append(f.seeds, id) + mailbox(id, f.opOwner, f.opOrg, "seed"+string(rune('a'+i))+"-"+tag+"@gmail.com", "gmail") + } + mailbox(f.sameHost, f.opOwner, f.opOrg, "seed-"+tag+"@acme-"+tag+".test", "gmail") + t.Cleanup(func() { + c := context.Background() + for _, q := range []struct { + sql string + arg uuid.UUID + }{ + {`DELETE FROM placement_monitors WHERE organization_id = $1`, f.org}, + {`DELETE FROM placement_tests WHERE organization_id = $1`, f.org}, + {`DELETE FROM tasks WHERE email_account_id = $1`, f.sender}, + {`DELETE FROM unibox_emails WHERE user_id = $1`, f.opOwner}, + {`DELETE FROM email_accounts WHERE organization_id = $1`, f.org}, + {`DELETE FROM email_accounts WHERE organization_id = $1`, f.opOrg}, + {`DELETE FROM organizations WHERE id = $1`, f.org}, + {`DELETE FROM organizations WHERE id = $1`, f.opOrg}, + {`DELETE FROM users WHERE id = $1`, f.owner}, + {`DELETE FROM users WHERE id = $1`, f.opOwner}, + } { + if _, err := pool.Exec(c, q.sql, q.arg); err != nil { + t.Errorf("cleanup: %v", err) + } + } + }) + return f +} + +// newTest writes a running test with one scheduled probe per seed. +func (f *placementFixture) newTest(seeds []uuid.UUID, scheduled time.Time) (models.PlacementTest, []models.PlacementResult, []Task) { + f.t.Helper() + sender := f.sender + test := models.PlacementTest{ + ID: uuid.New(), OrganizationID: &f.org, SenderAccountID: &sender, SenderEmail: "sender@example.test", + Subject: "Quick question", BodyPlain: "Hi there", Origin: models.PlacementOriginManual, + Panel: models.PlacementPanelInstance, Status: models.PlacementStatusRunning, + } + var results []models.PlacementResult + var tasks []Task + for i, seed := range seeds { + seed := seed + taskID := uuid.New() + at := scheduled.Add(time.Duration(i) * time.Second) + tasks = append(tasks, Task{ID: taskID, TaskType: "placement", EmailAccountID: f.sender, Status: "pending", ScheduledAt: &at}) + results = append(results, models.PlacementResult{ + ID: uuid.New(), SeedAccountID: &seed, SeedAddress: seed.String() + "@gmail.com", Family: "gmail", + TaskID: &taskID, Folder: models.PlacementFolderPending, ScheduledAt: &at, + }) + } + if err := f.repo.CreateTest(context.Background(), &test, results, tasks); err != nil { + f.t.Fatalf("CreateTest: %v", err) + } + return test, results, tasks +} + +func TestLivePlacementProbesResolveByMessageID(t *testing.T) { + f := newPlacementFixture(t) + ctx := context.Background() + now := time.Now() + + for _, id := range append([]uuid.UUID{f.sameHost}, f.seeds...) { + if err := f.repo.SetSeedScope(ctx, id, models.SeedScopeInstance); err != nil { + t.Fatalf("SetSeedScope: %v", err) + } + } + if scope, err := f.repo.SeedScope(ctx, f.seeds[0]); err != nil || scope != models.SeedScopeInstance { + t.Fatalf("SeedScope = %q, %v", scope, err) + } + all, err := f.repo.ListSeeds(ctx, models.SeedScopeInstance, &f.opOrg, false) + if err != nil || len(all) != 3 { + t.Fatalf("ListSeeds = %d, %v; want 3", len(all), err) + } + // None is on a worker, so none can take a test. + if live, err := f.repo.ListSeeds(ctx, models.SeedScopeInstance, &f.opOrg, true); err != nil || len(live) != 0 { + t.Fatalf("active ListSeeds = %d, %v; want 0 without a worker", len(live), err) + } + + test, results, tasks := f.newTest(f.seeds, now.Add(-3*time.Hour)) + probe, err := f.repo.GetProbeByTask(ctx, tasks[0].ID) + if err != nil || probe == nil || probe.Result.ID != results[0].ID || probe.Test.ID != test.ID { + t.Fatalf("GetProbeByTask = %+v, %v", probe, err) + } + if busy, err := f.repo.SenderBusy(ctx, f.sender); err != nil || !busy { + t.Fatalf("SenderBusy = %v, %v; want true with unsent probes", busy, err) + } + + // Probe one arrived in spam with brackets stripped on the seed's side; + // probe two left three hours ago and never showed up. + if err := f.repo.MarkProbeSent(ctx, results[0].ID, "", now.Add(-10*time.Minute)); err != nil { + t.Fatalf("MarkProbeSent: %v", err) + } + if err := f.repo.MarkProbeSent(ctx, results[1].ID, "", now.Add(-3*time.Hour)); err != nil { + t.Fatalf("MarkProbeSent: %v", err) + } + f.exec(`INSERT INTO unibox_emails (id, user_id, email_id, folder, provider_folder, message_id, thread_id, + from_addr, subject, body_text, in_reply_to, internal_date, flags) + VALUES ($1, $2, $3, 'spam', 'spam', $4, $5, ARRAY['sender@acme.test'], 'Quick question', 'Hi there', '{}', $6, ARRAY['CATEGORY_PROMOTIONS'])`, + uuid.New(), f.opOwner, f.seeds[0], "one-"+test.ID.String()+"@acme.test", uuid.NewString(), now.Add(-9*time.Minute)) + + landings, err := f.repo.FindLandings(ctx, 100) + if err != nil { + t.Fatalf("FindLandings: %v", err) + } + var mine []PlacementLanding + for _, l := range landings { + if l.TestID == test.ID { + mine = append(mine, l) + } + } + if len(mine) != 1 || mine[0].ResultID != results[0].ID || mine[0].Folder != "spam" { + t.Fatalf("landings = %+v; want probe one in spam", mine) + } + folder := models.ClassifyPlacementLanding(mine[0].Folder, mine[0].Flags) + if folder != models.PlacementFolderSpam { + t.Fatalf("classified %q, want spam over the Promotions label", folder) + } + if err := f.repo.RecordLanding(ctx, mine[0].ResultID, folder, "", now); err != nil { + t.Fatalf("RecordLanding: %v", err) + } + if err := f.repo.ExpireProbes(ctx, now.Add(-2*time.Hour), now.Add(-2*time.Hour), now.Add(-6*time.Hour)); err != nil { + t.Fatalf("ExpireProbes: %v", err) + } + + finished, err := f.repo.FinishTests(ctx) + if err != nil { + t.Fatalf("FinishTests: %v", err) + } + var closed *PlacementFinished + for i := range finished { + if finished[i].ID == test.ID { + closed = &finished[i] + } + } + if closed == nil || closed.Status != models.PlacementStatusCompleted { + t.Fatalf("FinishTests closed %+v; want the test completed", closed) + } + byTest, err := f.repo.ListResults(ctx, []uuid.UUID{test.ID}) + if err != nil { + t.Fatalf("ListResults: %v", err) + } + var counts models.PlacementCounts + for _, r := range byTest[test.ID] { + counts.Add(r.Folder) + } + counts.Finish() + if counts.Spam != 1 || counts.Missing != 1 || counts.Delivered != 2 { + t.Fatalf("counts = %+v; want one spam and one missing", counts) + } +} + +func TestLivePlacementProbesChargeTheSendersDay(t *testing.T) { + f := newPlacementFixture(t) + ctx := context.Background() + + _, results, tasks := f.newTest(f.seeds[:1], time.Now()) + f.exec(`UPDATE tasks SET status = 'completed', completed_at = NOW(), message_id = '' WHERE id = $1`, tasks[0].ID) + if err := f.repo.MarkProbeSent(ctx, results[0].ID, "", time.Now()); err != nil { + t.Fatalf("MarkProbeSent: %v", err) + } + + sent, err := f.tasks.CountCampaignEmailsSentToday(ctx, f.sender) + if err != nil || sent != 1 { + t.Fatalf("CountCampaignEmailsSentToday = %d, %v; want the probe counted", sent, err) + } + byAccount, err := f.tasks.CountCampaignEmailsSentTodayByAccounts(ctx, []uuid.UUID{f.sender}) + if err != nil || byAccount[f.sender] != 1 { + t.Fatalf("CountCampaignEmailsSentTodayByAccounts = %v, %v; want 1", byAccount, err) + } + if copy, err := f.repo.IsProbeSentCopy(ctx, f.sender, "probe@acme.test"); err != nil || !copy { + t.Fatalf("IsProbeSentCopy = %v, %v; want the sender's own copy recognised", copy, err) + } + if copy, err := f.repo.IsProbeSentCopy(ctx, f.seeds[0], "probe@acme.test"); err != nil || copy { + t.Fatalf("IsProbeSentCopy on the seed = %v, %v; a seed's copy must be kept", copy, err) + } + if n, err := f.repo.CountMeteredTests(ctx, f.org, time.Now().Add(-time.Hour)); err != nil || n != 1 { + t.Fatalf("CountMeteredTests = %d, %v; want 1", n, err) + } +} + +func TestLivePlacementCancelStopsUnsentProbes(t *testing.T) { + f := newPlacementFixture(t) + ctx := context.Background() + + test, results, tasks := f.newTest(f.seeds, time.Now().Add(time.Hour)) + if err := f.repo.MarkProbeSent(ctx, results[0].ID, "", time.Now()); err != nil { + t.Fatalf("MarkProbeSent: %v", err) + } + if ok, err := f.repo.CancelTest(ctx, f.opOrg, test.ID); err != nil || ok { + t.Fatalf("CancelTest from another workspace = %v, %v; want refused", ok, err) + } + if ok, err := f.repo.CancelTest(ctx, f.org, test.ID); err != nil || !ok { + t.Fatalf("CancelTest = %v, %v", ok, err) + } + byTest, err := f.repo.ListResults(ctx, []uuid.UUID{test.ID}) + if err != nil { + t.Fatalf("ListResults: %v", err) + } + folders := map[uuid.UUID]string{} + for _, r := range byTest[test.ID] { + folders[r.ID] = r.Folder + } + if folders[results[0].ID] != models.PlacementFolderPending || folders[results[1].ID] != models.PlacementFolderCancelled { + t.Fatalf("folders = %v; want the sent probe still pending and the unsent one cancelled", folders) + } + task, err := f.tasks.GetTask(ctx, tasks[1].ID) + if err != nil || task == nil || task.Status != "cancelled" { + t.Fatalf("unsent task = %+v, %v; want cancelled", task, err) + } + if running, err := f.repo.CountRunning(ctx, f.org); err != nil || running != 0 { + t.Fatalf("CountRunning = %d, %v; want 0 after cancel", running, err) + } +} + +func TestLivePlacementMonitorRoundTrip(t *testing.T) { + f := newPlacementFixture(t) + ctx := context.Background() + campaignID := uuid.New() + f.exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, days, status, updated_at, created_at) + VALUES ($1, $2, $3, 'Placement live', '', 62, 'active', NOW(), NOW())`, campaignID, f.owner, f.org) + + m := &models.PlacementMonitor{ + OrganizationID: f.org, CampaignID: campaignID, CreatedBy: &f.owner, Enabled: true, + IntervalDays: 7, Panel: models.PlacementPanelInstance, AlertBelow: 70, NextRunAt: time.Now().Add(-time.Minute), + } + if err := f.repo.UpsertMonitor(ctx, m); err != nil { + t.Fatalf("UpsertMonitor: %v", err) + } + // Another workspace cannot take the campaign's monitor over. + stolen := *m + stolen.ID, stolen.OrganizationID = uuid.New(), f.opOrg + if err := f.repo.UpsertMonitor(ctx, &stolen); err == nil { + t.Fatalf("UpsertMonitor from another workspace succeeded") + } + due, err := f.repo.ListDueMonitors(ctx, time.Now(), 500) + if err != nil { + t.Fatalf("ListDueMonitors: %v", err) + } + found := false + for _, d := range due { + found = found || d.ID == m.ID + } + if !found { + t.Fatalf("monitor not due") + } + next := time.Now().Add(7 * 24 * time.Hour) + if err := f.repo.MarkMonitorRun(ctx, m.ID, next, nil, &f.sender, ""); err != nil { + t.Fatalf("MarkMonitorRun: %v", err) + } + got, err := f.repo.GetMonitor(ctx, f.org, campaignID) + if err != nil || got == nil || got.LastSenderID == nil || *got.LastSenderID != f.sender || got.LastRunAt == nil { + t.Fatalf("GetMonitor = %+v, %v", got, err) + } + if ok, err := f.repo.DeleteMonitor(ctx, f.opOrg, campaignID); err != nil || ok { + t.Fatalf("DeleteMonitor from another workspace = %v, %v", ok, err) + } + if ok, err := f.repo.DeleteMonitor(ctx, f.org, campaignID); err != nil || !ok { + t.Fatalf("DeleteMonitor = %v, %v", ok, err) + } + f.exec(`DELETE FROM campaigns WHERE id = $1`, campaignID) +} From d91b78458b3daf45d173a6a3e0130c97e88893d9 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 20:53:52 -0700 Subject: [PATCH 04/11] feat: skip placement probes from a suspended workspace the same way warmup sends are skipped --- internal/tasks/placement_task.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/tasks/placement_task.go b/internal/tasks/placement_task.go index 5099a6db4..395a804fd 100644 --- a/internal/tasks/placement_task.go +++ b/internal/tasks/placement_task.go @@ -80,6 +80,10 @@ func (s *tasksService) HandlePlacementTask(task *proto.ProcessTask) *errx.Error if account.Status != "active" { return fail("The sending mailbox is not active") } + // A probe is outbound mail from the same domains a suspension protects. + if s.orgBlocksSending(ctx, account.OrganizationID) { + return fail("Sending is suspended for this workspace") + } msg, reason := s.renderPlacementProbe(ctx, taskID, &test, account, probe.Result.SeedAddress) if reason != "" { From bbcf8725c366528b13dc7b8d288b634b900a7f3d Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 20:56:02 -0700 Subject: [PATCH 05/11] feat: add warmbly placement CLI commands for overview, test, list, view, cancel, seed inboxes and campaign monitors, the list_placement_tests, get_placement_test and run_placement_test assistant tools, and the placement commands in the warmbly-cli skill --- cmd/backend/main.go | 1 + cmd/cli/specs.go | 111 ++++++++++- internal/app/aitools/tools_placement.go | 235 ++++++++++++++++++++++++ skills/warmbly-cli/SKILL.md | 12 +- 4 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 internal/app/aitools/tools_placement.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 2cec9ff67..b31dc7c22 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1986,6 +1986,7 @@ func main() { placementDeps.Cloud = cloudLinkService } placementService = placement.NewService(placementDeps) + aitools.RegisterPlacementTools(aiToolRegistry, placementService, auditService) tasksService.SetPlacement(placementRepository) emailService.WireSeedScope(placementRepository) go jobs.NewPlacementPoller(placementService, 30*time.Second).Start(ctx) diff --git a/cmd/cli/specs.go b/cmd/cli/specs.go index 47192f969..ad93a4fb0 100644 --- a/cmd/cli/specs.go +++ b/cmd/cli/specs.go @@ -35,7 +35,7 @@ func resourceSpecs() []resource { out = append(out, campaignSpec(), contactSpec(), suppressionSpec(), mailboxSpec(), inboxSpec()) out = append(out, segmentSpec(), templateSpec(), automationSpec(), formSpec()) out = append(out, dealSpec(), pipelineSpec(), taskSpec()) - out = append(out, analyticsSpec(), auditSpec(), advisorSpec()) + out = append(out, analyticsSpec(), placementSpec(), auditSpec(), advisorSpec()) out = append(out, webhookSpec(), keySpec(), oauthAppSpec(), toolSpec()) out = append(out, orgSpec(), teamSpec(), settingsSpec(), warmupRoutingSpec(), integrationSpec()) return out @@ -1460,6 +1460,115 @@ func taskSpec() resource { } } +func placementSpec() resource { + testTable := output.Table{ + Root: "data", + Columns: []output.Column{ + col("ID", "id"), + colt("SENDER", "sender_email", 30), + colt("SUBJECT", "subject", 30), + col("PANEL", "panel"), + col("STATUS", "status"), + col("INBOX", "summary.inbox"), + col("TABS", "summary.promotions"), + col("SPAM", "summary.spam"), + col("MISSING", "summary.missing"), + colf("CREATED", "created_at", "time"), + }, + Empty: "No placement tests yet. Start one with `warmbly placement test --mailbox MAILBOX_ID --campaign CAMPAIGN_ID`.", + } + return resource{ + Name: "placement", + Aliases: []string{"placement-test", "placement-tests"}, + Short: "Test where a template lands: inbox, a Gmail tab or spam", + Group: groupData, + Long: `Send a campaign step or a template from one of your mailboxes to a panel of +seed inboxes and see where each copy landed. + +Every copy is a real send from that mailbox, counted against its daily limit, +so starting a test asks before it does it.`, + Endpoints: []endpoint{ + {Name: "overview", Short: "The seed panels you can test on and this month's allowance", Method: http.MethodGet, Path: "/placement/overview"}, + { + Name: "list", Aliases: []string{"ls"}, Short: "List placement tests", + Method: http.MethodGet, Path: "/placement/tests", Paginate: true, + Flag: withPaging(flagSpec{Name: "campaign", Help: "Only this campaign's tests", Query: true, Key: "campaign_id"}), + Table: testTable, + }, + { + Name: "view", Aliases: []string{"get", "show"}, Short: "Show one test with where every copy landed", + Method: http.MethodGet, Path: "/placement/tests/{id}", + Args: []argSpec{{Name: "id", Help: "The test's id"}}, + }, + { + Name: "test", Aliases: []string{"run", "start"}, Short: "Start a placement test", + Method: http.MethodPost, Path: "/placement/tests", Body: bodyRequired, Sends: true, Idempotent: true, + Example: " $ warmbly placement test --mailbox MAILBOX_ID --campaign CAMPAIGN_ID --step STEP_ID\n" + + " $ warmbly placement test --mailbox MAILBOX_ID --subject \"Quick question\" --text \"Hi there\" --tracking compare", + Flag: []flagSpec{ + {Name: "mailbox", Help: "The mailbox to send from (required)", Key: "sender_account_id"}, + {Name: "campaign", Help: "Test this campaign's copy", Key: "campaign_id"}, + {Name: "step", Help: "The step to test; with --campaign", Key: "sequence_id"}, + {Name: "contact", Help: "Render the copy for this contact's id; the campaign's first lead when omitted", Key: "contact_id"}, + {Name: "subject", Help: "Subject of an ad-hoc template", Key: "subject"}, + {Name: "text", Help: "Plain-text body of an ad-hoc template", Key: "body_plain"}, + {Name: "html", Help: "HTML body of an ad-hoc template", Key: "body_html"}, + {Name: "tracking", Help: "campaign, on, off or compare (sends twice, with and without)", Key: "tracking"}, + {Name: "panel", Help: "instance, workspace or cloud", Key: "panel"}, + }, + Table: testTable, + }, + { + Name: "cancel", Short: "Stop the copies of a running test that have not been sent", + Method: http.MethodPost, Path: "/placement/tests/{id}/cancel", Body: bodyOptional, + Args: []argSpec{{Name: "id", Help: "The test's id"}}, + Success: "Placement test cancelled.", + }, + { + Name: "seeds", Short: "List your mailboxes and which are seed inboxes", + Method: http.MethodGet, Path: "/placement/seeds", + Table: output.Table{Root: "data", Columns: []output.Column{ + col("ID", "email_account_id"), colt("MAILBOX", "email", 36), col("PROVIDER", "label"), + colf("SEED", "seed", "bool"), colt("NOTE", "blocker", 40), + }, Empty: "No mailboxes in this workspace."}, + }, + { + Name: "set-seed", Short: "Make a mailbox a seed inbox, or an ordinary mailbox again", + Method: http.MethodPut, Path: "/placement/seeds/{id}", Body: bodyRequired, + Args: []argSpec{{Name: "id", Help: "The mailbox's id"}}, + Example: " $ warmbly placement set-seed MAILBOX_ID --seed\n $ warmbly placement set-seed MAILBOX_ID --seed=false", + Flag: []flagSpec{{Name: "seed", Help: "true to make it a seed inbox, false to stop", Kind: flagBool, Key: "seed"}}, + Success: "Seed inbox updated.", + }, + { + Name: "monitor", Short: "Show a campaign's scheduled placement test", + Method: http.MethodGet, Path: "/campaigns/{id}/placement-monitor", + Args: []argSpec{{Name: "id", Help: "The campaign's id"}}, + }, + { + Name: "set-monitor", Short: "Schedule or change a campaign's placement test", + Method: http.MethodPut, Path: "/campaigns/{id}/placement-monitor", Body: bodyRequired, + Args: []argSpec{{Name: "id", Help: "The campaign's id"}}, + Example: " $ warmbly placement set-monitor CAMPAIGN_ID --enabled --interval-days 7 --alert-below 70", + Flag: []flagSpec{ + {Name: "enabled", Help: "Run the scheduled test", Kind: flagBool, Key: "enabled"}, + {Name: "interval-days", Help: "Days between tests, 1 to 30", Kind: flagInt, Key: "interval_days"}, + {Name: "alert-below", Help: "Alert when the inbox rate falls below this percent", Kind: flagInt, Key: "alert_below"}, + {Name: "panel", Help: "instance, workspace or cloud", Key: "panel"}, + {Name: "pause-on-alert", Help: "Pause the campaign when it alerts", Kind: flagBool, Key: "pause_on_alert"}, + }, + Success: "Placement monitor saved.", + }, + { + Name: "delete-monitor", Short: "Stop a campaign's scheduled placement test", + Method: http.MethodDelete, Path: "/campaigns/{id}/placement-monitor", + Args: []argSpec{{Name: "id", Help: "The campaign's id"}}, + Success: "Placement monitor removed.", + }, + }, + } +} + func analyticsSpec() resource { return resource{ Name: "analytics", diff --git a/internal/app/aitools/tools_placement.go b/internal/app/aitools/tools_placement.go new file mode 100644 index 000000000..7590ede44 --- /dev/null +++ b/internal/app/aitools/tools_placement.go @@ -0,0 +1,235 @@ +package aitools + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/audit" + "github.com/warmbly/warmbly/internal/app/placement" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/generation" +) + +// PlacementTests is the slice of placement.Service the tools use. +type PlacementTests interface { + CreateTests(ctx context.Context, in placement.CreateInput) ([]placement.TestView, *errx.Error) + ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]placement.TestView, int, *errx.Error) + GetTest(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) (*placement.TestDetail, *errx.Error) +} + +type placementTools struct { + svc PlacementTests + audit audit.AuditService +} + +// RegisterPlacementTools adds the inbox placement tools once the placement +// service exists, which is after the registry is built. +func RegisterPlacementTools(r *Registry, svc PlacementTests, auditSvc audit.AuditService) { + if r == nil || svc == nil { + return + } + p := placementTools{svc: svc, audit: auditSvc} + + r.Register(Tool{ + Name: "list_placement_tests", + Description: "List the workspace's inbox placement tests, newest first, with where each test's copies landed (primary inbox, Gmail tabs, spam, never arrived). Use this to answer whether copy is landing in the inbox before or during a campaign.", + InputSchema: objectSchema(map[string]any{ + "campaign_id": strProp("Only this campaign's tests (UUID)."), + "limit": intProp("Maximum tests to return (default 10, max 50)."), + }), + Risk: generation.RiskRead, + RequiredOrgPerm: models.PermViewAnalytics, + RequiredAPIPerm: models.APIPermReadAnalytics, + Handler: p.list, + }) + + r.Register(Tool{ + Name: "get_placement_test", + Description: "Read one inbox placement test in full: where every copy landed per mail provider, the content check of the tested copy, and for a tracking comparison the untracked and tracked halves side by side.", + InputSchema: objectSchema(map[string]any{ + "test_id": strProp("The placement test's UUID."), + }, "test_id"), + Risk: generation.RiskRead, + RequiredOrgPerm: models.PermViewAnalytics, + RequiredAPIPerm: models.APIPermReadAnalytics, + Handler: p.get, + }) + + r.Register(Tool{ + Name: "run_placement_test", + Description: "Start an inbox placement test: send a campaign step (or an ad-hoc subject and body) from one of the workspace's mailboxes to a panel of seed inboxes and report where each copy lands. " + + "Every copy is a real send counted against that mailbox's daily limit, so only run it when the user asked for a placement test. Results arrive over the next minutes; read them with get_placement_test.", + InputSchema: objectSchema(map[string]any{ + "sender_account_id": strProp("The mailbox to send from (UUID)."), + "campaign_id": strProp("Test this campaign's copy (UUID)."), + "sequence_id": strProp("The campaign step to test (UUID); requires campaign_id."), + "subject": strProp("Subject of an ad-hoc template, when not testing a campaign step."), + "body_plain": strProp("Plain-text body of an ad-hoc template."), + "tracking": enumProp("Open and click tracking on the copies (default campaign).", "campaign", "on", "off", "compare"), + "panel": enumProp("Which seed inboxes to test on (default instance).", "instance", "workspace", "cloud"), + }, "sender_account_id"), + Risk: generation.RiskSend, + RequiredOrgPerm: models.PermSendCampaigns, + RequiredAPIPerm: models.APIPermSendCampaigns, + Handler: p.run, + }) +} + +func placementSummary(v placement.TestView) map[string]any { + out := map[string]any{ + "test_id": v.ID.String(), + "status": v.Status, + "sender": v.SenderEmail, + "subject": v.Subject, + "panel": v.Panel, + "open_tracking": v.OpenTracking, + "link_tracking": v.LinkTracking, + "created_at": v.CreatedAt, + "summary": v.Summary, + "by_provider": v.Families, + } + if v.CampaignID != nil { + out["campaign_id"] = v.CampaignID.String() + } + if v.Error != "" { + out["error"] = v.Error + } + return out +} + +func (p placementTools) list(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) { + in, err := decodeArgs[struct { + CampaignID string `json:"campaign_id"` + Limit int `json:"limit"` + }](args) + if err != nil { + return "", err + } + var campaignID *uuid.UUID + if in.CampaignID != "" { + id, err := parseUUIDArg(in.CampaignID) + if err != nil { + return "", err + } + campaignID = &id + } + limit := in.Limit + if limit <= 0 { + limit = 10 + } + limit = min(limit, 50) + org := inv.OrgID + tests, total, xerr := p.svc.ListTests(ctx, &org, campaignID, limit, 0) + if xerr != nil { + return "", fromErrx(xerr) + } + out := make([]map[string]any, 0, len(tests)) + for _, t := range tests { + out = append(out, placementSummary(t)) + } + return jsonResult(map[string]any{"tests": out, "total": total}) +} + +func (p placementTools) get(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) { + in, err := decodeArgs[struct { + TestID string `json:"test_id"` + }](args) + if err != nil { + return "", err + } + id, err := parseUUIDArg(in.TestID) + if err != nil { + return "", err + } + org := inv.OrgID + detail, xerr := p.svc.GetTest(ctx, &org, id) + if xerr != nil { + return "", fromErrx(xerr) + } + out := placementSummary(detail.TestView) + out["content_score"] = detail.Content.Score + out["content_issues"] = detail.Content.Issues + results := make([]map[string]any, 0, len(detail.Results)) + for _, r := range detail.Results { + row := map[string]any{"provider": r.FamilyLabel, "folder": r.Folder} + if r.Error != "" { + row["error"] = r.Error + } + results = append(results, row) + } + out["copies"] = results + if detail.Compare != nil { + out["comparison"] = placementSummary(*detail.Compare) + } + return jsonResult(out) +} + +func (p placementTools) run(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) { + in, err := decodeArgs[struct { + SenderAccountID string `json:"sender_account_id"` + CampaignID string `json:"campaign_id"` + SequenceID string `json:"sequence_id"` + Subject string `json:"subject"` + BodyPlain string `json:"body_plain"` + Tracking string `json:"tracking"` + Panel string `json:"panel"` + }](args) + if err != nil { + return "", err + } + sender, err := parseUUIDArg(in.SenderAccountID) + if err != nil { + return "", err + } + optional := func(s string) (*uuid.UUID, error) { + if s == "" { + return nil, nil + } + id, err := parseUUIDArg(s) + return &id, err + } + campaignID, err := optional(in.CampaignID) + if err != nil { + return "", err + } + sequenceID, err := optional(in.SequenceID) + if err != nil { + return "", err + } + var userID *uuid.UUID + if inv.UserID != uuid.Nil { + u := inv.UserID + userID = &u + } + tests, xerr := p.svc.CreateTests(ctx, placement.CreateInput{ + OrgID: inv.OrgID, + UserID: userID, + SenderAccountID: sender, + CampaignID: campaignID, + SequenceID: sequenceID, + Subject: in.Subject, + BodyPlain: in.BodyPlain, + Tracking: in.Tracking, + Panel: in.Panel, + Origin: models.PlacementOriginManual, + }) + if xerr != nil { + return "", fromErrx(xerr) + } + out := make([]map[string]any, 0, len(tests)) + for _, t := range tests { + id := t.ID + if p.audit != nil { + p.audit.LogAction(ctx, inv.OrgID, inv.UserID, models.AuditActionCreate, models.AuditEntityPlacementTest, &id, + inv.IP, inv.UserAgent, nil, map[string]string{"sender": t.SenderEmail, "panel": t.Panel}) + } + out = append(out, placementSummary(t)) + } + return jsonResult(map[string]any{ + "tests": out, + "note": "Copies go out one at a time; read the result with get_placement_test in a few minutes.", + }) +} diff --git a/skills/warmbly-cli/SKILL.md b/skills/warmbly-cli/SKILL.md index 0bface539..04d226e72 100644 --- a/skills/warmbly-cli/SKILL.md +++ b/skills/warmbly-cli/SKILL.md @@ -80,6 +80,7 @@ gives the arguments and flags. Ids are positional, not flags. | `segment`, `template`, `automation`, `form` | audiences, reply templates, automations, lead capture | | `deal`, `pipeline`, `task` | the CRM | | `analytics`, `audit`, `advisor` | numbers, the audit trail, recommendations | +| `placement` | inbox placement tests: overview, test, list, view, cancel, seed inboxes, a campaign's scheduled test | | `webhook`, `key`, `oauth-app`, `integration` | the developer surface | | `org`, `team`, `settings`, `warmup-routing` | the workspace surface a key can reach | | `tool` | the AI tool registry, listed and called | @@ -102,8 +103,9 @@ warmbly api /contacts/search -X POST --input filter.json ## Sending safety, read before anything that sends These put real mail on the wire and prompt before doing so: -`campaign start`, `campaign test`, `mailbox send`, `inbox reply`, -`inbox compose`, `inbox approve-draft`. Everything else is safe to run freely. +`campaign start`, `campaign test`, `placement test`, `mailbox send`, +`inbox reply`, `inbox compose`, `inbox approve-draft`. Everything else is safe +to run freely. - With no terminal they refuse rather than send. `--yes` is what proceeds, so **only pass `--yes` when the user asked for that specific send.** Never add @@ -130,6 +132,12 @@ These put real mail on the wire and prompt before doing so: that reason. - If deliverability shows rising bounces or complaints, stop the campaign and report. Do not push volume into a degrading mailbox. +- To check where copy lands before a launch, `warmbly placement test --mailbox + MAILBOX_ID --campaign CAMPAIGN_ID --step STEP_ID` sends one copy to each seed + inbox, every one counted against that mailbox's daily limit; `placement view + TEST_ID` reads the result. One test is a signal, not a verdict: compare a few + before rewriting copy, and `--tracking compare` isolates open and click + tracking. ## Errors From d4467ce10e04bb06cee47e9a124d040a4065000f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 20:56:11 -0700 Subject: [PATCH 06/11] feat: add the admin Seed panel page to manage the instance placement seed inboxes, run operator tests and read every workspace's results, plus the placement allowance and pacing settings on the Configuration page --- admin/src/app/dashboard/PlacementPage.tsx | 558 ++++++++++++++++++ .../dashboard/configuration/SettingsTab.tsx | 129 ++++ .../app/dashboard/placement/RunTestDialog.tsx | 269 +++++++++ .../dashboard/placement/TestDetailSheet.tsx | 279 +++++++++ admin/src/app/dashboard/placement/badges.tsx | 54 ++ admin/src/app/dashboard/placement/format.ts | 31 + .../app/dashboard/placement/useDebounced.ts | 11 + admin/src/components/layout/Sidebar.tsx | 2 + admin/src/lib/api/client/admin/instance.ts | 9 + admin/src/lib/api/client/admin/placement.ts | 177 ++++++ admin/src/lib/realtime/RealtimeManager.tsx | 5 + admin/src/main.tsx | 2 + 12 files changed, 1526 insertions(+) create mode 100644 admin/src/app/dashboard/PlacementPage.tsx create mode 100644 admin/src/app/dashboard/placement/RunTestDialog.tsx create mode 100644 admin/src/app/dashboard/placement/TestDetailSheet.tsx create mode 100644 admin/src/app/dashboard/placement/badges.tsx create mode 100644 admin/src/app/dashboard/placement/format.ts create mode 100644 admin/src/app/dashboard/placement/useDebounced.ts create mode 100644 admin/src/lib/api/client/admin/placement.ts diff --git a/admin/src/app/dashboard/PlacementPage.tsx b/admin/src/app/dashboard/PlacementPage.tsx new file mode 100644 index 000000000..91195b8a5 --- /dev/null +++ b/admin/src/app/dashboard/PlacementPage.tsx @@ -0,0 +1,558 @@ +// Inbox placement seed panel. The instance panel is the set of mailboxes every +// workspace's placement tests send to; this page is where the operator builds +// it and watches the tests that run against it. No polling: the realtime +// spine's placement group invalidates ["admin","placement"] on +// PLACEMENT_TEST_UPDATED. + +import { useMemo, useState } from "react"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; +import { Play, Plus, Search, Trash2 } from "lucide-react"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorState } from "@/components/ErrorState"; +import { useConfirm } from "@/components/ConfirmDialog"; +import { DataTable, type Column } from "@/components/data/DataTable"; +import { useAdminPerm } from "@/hooks/useAdminPerm"; +import { AdminPerm } from "@/lib/auth/permissions"; +import { cn } from "@/lib/utils"; +import { + listPlacementSeeds, + listPlacementTests, + searchPlacementSeedCandidates, + setPlacementSeed, + type AdminPlacementSeed, + type PlacementTestView, +} from "@/lib/api/client/admin/placement"; +import { absolute, relative } from "@/app/dashboard/jobs/format"; +import { StatusBadge } from "@/app/dashboard/placement/badges"; +import { describeError, ORIGIN_LABEL, PANEL_LABEL, pct } from "@/app/dashboard/placement/format"; +import { RunTestDialog } from "@/app/dashboard/placement/RunTestDialog"; +import { TestDetailSheet } from "@/app/dashboard/placement/TestDetailSheet"; +import { useDebounced } from "@/app/dashboard/placement/useDebounced"; + +// A provider family with fewer seeds than this gives a verdict one mailbox can swing. +const MIN_SEEDS_PER_FAMILY = 3; + +const SEED_STATUS_TONE: Record = { + active: "border-emerald-300 bg-emerald-50 text-emerald-700", + inactive: "border-amber-300 bg-amber-50 text-amber-700", + revoked: "border-red-300 bg-red-50 text-red-700", +}; + +export default function PlacementPage() { + const [params, setParams] = useSearchParams(); + const openTest = params.get("test"); + const [runOpen, setRunOpen] = useState(false); + const canManage = useAdminPerm(AdminPerm.ManageWarmupBans); + + function setOpenTest(id: string | null) { + setParams( + (p) => { + if (id) p.set("test", id); + else p.delete("test"); + return p; + }, + { replace: true }, + ); + } + + return ( +
+ + {canManage && ( + + )} + + + + {canManage && } + + + !open && setOpenTest(null)} + onOpenTest={setOpenTest} + /> + {canManage && ( + { + setRunOpen(false); + if (tests[0]) setOpenTest(tests[0].id); + }} + /> + )} +
+ ); +} + +function SectionTitle({ title, hint }: { title: string; hint?: string }) { + return ( +
+

{title}

+ {hint &&

{hint}

} +
+ ); +} + +function SeedsSection({ canManage }: { canManage: boolean }) { + const qc = useQueryClient(); + const confirm = useConfirm(); + const canViewOrgs = useAdminPerm(AdminPerm.ViewOrganizations); + const canViewWorkers = useAdminPerm(AdminPerm.ViewWorkers); + + const seedsQ = useQuery({ + queryKey: ["admin", "placement", "seeds"], + queryFn: listPlacementSeeds, + }); + const seeds = useMemo(() => seedsQ.data ?? [], [seedsQ.data]); + + const mix = useMemo(() => { + const counts = new Map(); + for (const s of seeds) { + const label = s.family_label || "Other provider"; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + }, [seeds]); + + const remove = useMutation({ + mutationFn: (seed: AdminPlacementSeed) => setPlacementSeed(seed.id, false), + onSuccess: (seed) => { + toast.success(`${seed.email} removed from the panel`); + qc.invalidateQueries({ queryKey: ["admin", "placement"] }); + }, + onError: (err) => toast.error(describeError(err).message || "Could not remove the seed"), + }); + + async function onRemove(seed: AdminPlacementSeed) { + const ok = await confirm({ + title: "Remove this seed?", + description: `${seed.email} stops receiving copies from new placement tests. The mailbox stays connected, and its warmup stays off until someone turns it back on.`, + confirmLabel: "Remove seed", + destructive: true, + }); + if (!ok) return; + remove.mutate(seed); + } + + const columns: Column[] = [ + { + id: "mailbox", + header: "Mailbox", + cell: (s) => ( +
+
{s.email}
+ {s.name &&
{s.name}
} +
+ ), + csv: (s) => s.email, + }, + { + id: "family", + header: "Provider", + cell: (s) => {s.family_label}, + csv: (s) => s.family_label, + }, + { + id: "status", + header: "Status", + cell: (s) => ( + + {s.status} + + ), + csv: (s) => s.status, + }, + { + id: "worker", + header: "Worker", + cell: (s) => + s.worker_id ? ( + canViewWorkers ? ( + + {s.worker_id.slice(0, 8)} + + ) : ( + {s.worker_id.slice(0, 8)} + ) + ) : ( + + unassigned + + ), + csv: (s) => s.worker_id ?? "", + }, + { + id: "organization", + header: "Organization", + cell: (s) => + s.organization_id ? ( + canViewOrgs ? ( + + {s.organization_id.slice(0, 8)} + + ) : ( + {s.organization_id.slice(0, 8)} + ) + ) : ( + — + ), + csv: (s) => s.organization_id ?? "", + }, + ]; + if (canManage) { + columns.push({ + id: "actions", + header: "", + align: "right", + cell: (s) => ( + + ), + }); + } + + return ( +
+ + +
+
+ Mix by provider +
+ {seedsQ.isLoading ? ( + + ) : mix.length === 0 ? ( +

The panel is empty, so no workspace can test on it yet.

+ ) : ( +
+ {mix.map(([label, n]) => ( + + {label} + {n} + + ))} +
+ )} +

+ A healthy panel is weighted toward Microsoft 365 and Google Workspace, where most B2B recipients + are, with a few Gmail, Outlook.com and Yahoo seeds, and at least {MIN_SEEDS_PER_FAMILY} per + provider. Seeds must be mailboxes nobody reads or engages with: opening, filing or replying to a + copy teaches the provider to trust the sender and skews every later result. +

+
+ + s.id} + loading={seedsQ.isLoading} + error={seedsQ.error} + onRetry={() => seedsQ.refetch()} + errorTitle="Failed to load the seed panel" + storageKey="admin.placement.seeds" + csvName="warmbly-placement-seeds" + noun="seeds" + emptyTitle="No seeds yet" + emptyHint={ + canManage + ? "Add connected mailboxes below to build the panel." + : "An admin with the manage warmup bans permission can add them." + } + /> +
+ ); +} + +function AddSeedsSection() { + const qc = useQueryClient(); + const confirm = useConfirm(); + const [search, setSearch] = useState(""); + const debounced = useDebounced(search.trim(), 250); + + const candidatesQ = useQuery({ + queryKey: ["admin", "placement", "candidates", debounced], + queryFn: () => searchPlacementSeedCandidates(debounced), + enabled: debounced.length >= 2, + staleTime: 30_000, + }); + const rows = candidatesQ.data ?? []; + + const add = useMutation({ + mutationFn: (seed: AdminPlacementSeed) => setPlacementSeed(seed.id, true), + onSuccess: (seed) => { + toast.success(`${seed.email} added to the panel, with its warmup turned off`); + qc.invalidateQueries({ queryKey: ["admin", "placement"] }); + }, + onError: (err) => toast.error(describeError(err).message || "Could not add the seed"), + }); + + async function onAdd(seed: AdminPlacementSeed) { + if (seed.seed_scope === "workspace") { + const ok = await confirm({ + title: "Move this workspace seed?", + description: `${seed.email} is one of its workspace's own test inboxes. Adding it here moves it to the instance panel, so its workspace can no longer test on it privately.`, + confirmLabel: "Add to the panel", + }); + if (!ok) return; + } + add.mutate(seed); + } + + return ( +
+ +
+ + setSearch(e.target.value)} + placeholder="Search connected mailboxes by address…" + className="h-8 pl-8 text-[12.5px]" + autoComplete="off" + /> +
+ + {debounced.length < 2 ? null : candidatesQ.isLoading ? ( + + ) : candidatesQ.error ? ( + candidatesQ.refetch()} /> + ) : rows.length === 0 ? ( +
+ No connected mailbox matches. +
+ ) : ( +
+
+ + + + + + + + + + {rows.map((m) => { + const onPanel = m.seed_scope === "instance"; + return ( + + + + + + + ); + })} + +
MailboxProviderStatus +
+
+ {m.email} + {m.seed_scope === "workspace" && ( + + workspace seed + + )} +
+
{m.family_label} + + {m.status} + + + +
+
+
+ )} +
+ ); +} + +function TestsSection({ onOpen }: { onOpen: (id: string) => void }) { + const testsQ = useInfiniteQuery({ + queryKey: ["admin", "placement", "tests"], + queryFn: ({ pageParam }) => listPlacementTests(pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last) => + last.pagination?.has_more ? (last.pagination.next_cursor ?? undefined) : undefined, + }); + const rows = useMemo(() => (testsQ.data?.pages ?? []).flatMap((p) => p.data ?? []), [testsQ.data]); + const total = testsQ.data?.pages[0]?.pagination?.total; + + const columns: Column[] = [ + { + id: "created", + header: "Created", + cell: (t) => ( + + {relative(t.created_at)} + + ), + csv: (t) => t.created_at, + }, + { + id: "sender", + header: "Sender", + cell: (t) => {t.sender_email || "—"}, + csv: (t) => t.sender_email, + }, + { + id: "subject", + header: "Subject", + cell: (t) => {t.subject || "—"}, + csv: (t) => t.subject, + }, + { + id: "panel", + header: "Panel", + cell: (t) => {PANEL_LABEL[t.panel] ?? t.panel}, + csv: (t) => t.panel, + }, + { + id: "origin", + header: "Origin", + cell: (t) => {ORIGIN_LABEL[t.origin] ?? t.origin}, + csv: (t) => t.origin, + }, + { + id: "status", + header: "Status", + cell: (t) => , + csv: (t) => t.status, + }, + { + id: "inbox", + header: "Inbox", + align: "right", + cell: (t) => {pct(t.summary?.inbox_rate)}, + csv: (t) => pct(t.summary?.inbox_rate), + }, + { + id: "spam", + header: "Spam", + align: "right", + cell: (t) => ( + 0 && "text-red-700")}> + {pct(t.summary?.spam_rate)} + + ), + csv: (t) => pct(t.summary?.spam_rate), + }, + { + id: "delivered", + header: "Delivered", + align: "right", + cell: (t) => ( + + {t.summary?.delivered ?? 0} + / {t.summary?.total ?? 0} + + ), + csv: (t) => t.summary?.delivered ?? 0, + }, + ]; + + return ( +
+ + t.id} + loading={testsQ.isLoading} + error={testsQ.error} + onRetry={() => testsQ.refetch()} + onRowClick={(t) => onOpen(t.id)} + errorTitle="Failed to load placement tests" + storageKey="admin.placement.tests" + csvName="warmbly-placement-tests" + noun="tests" + emptyTitle="No placement tests yet" + emptyHint="Tests appear here once a workspace, a campaign monitor or an admin runs one." + /> + {testsQ.hasNextPage && ( +
+ +
+ )} +
+ ); +} diff --git a/admin/src/app/dashboard/configuration/SettingsTab.tsx b/admin/src/app/dashboard/configuration/SettingsTab.tsx index f398f2a1d..7d77e39a8 100644 --- a/admin/src/app/dashboard/configuration/SettingsTab.tsx +++ b/admin/src/app/dashboard/configuration/SettingsTab.tsx @@ -188,6 +188,52 @@ const TRACKING_FIELDS = [ type TrackingFieldKey = (typeof TRACKING_FIELDS)[number]["key"]; +// Inbox placement test allowance and pacing, mirroring internal/config/constants.go. +const PLACEMENT_FIELDS = [ + { + key: "testsTrial", + setting: "tests_per_month_trial", + label: "Tests per month on trial", + min: 1, + max: 100000, + help: "How many tests a workspace without a paid plan may run on the metered panels each calendar month.", + }, + { + key: "testsPaid", + setting: "tests_per_month_paid", + label: "Tests per month on paid plans", + min: 1, + max: 100000, + help: "The same allowance for a workspace with an active subscription.", + }, + { + key: "seedsPerTest", + setting: "seeds_per_test", + label: "Seeds per test", + min: 1, + max: 100, + help: "The most seeds one test sends to, which is also how many sends it takes from the sending mailbox's daily limit.", + }, + { + key: "spacingSeconds", + setting: "spacing_seconds", + label: "Spacing between copies (seconds)", + min: 5, + max: 600, + help: "The gap between two copies from one mailbox, jittered, so a test never leaves as a burst.", + }, +] as const; + +type PlacementFieldKey = (typeof PLACEMENT_FIELDS)[number]["key"]; + +// A backend from before the placement section omits it; the form shows the compiled defaults. +const PLACEMENT_DEFAULTS: InstanceSettings["placement"] = { + tests_per_month_trial: 3, + tests_per_month_paid: 40, + seeds_per_test: 40, + spacing_seconds: 20, +}; + interface FormState { linksEnabled: boolean; ttlHours: string; @@ -195,11 +241,13 @@ interface FormState { sync: Record; retention: Record; tracking: Record; + placement: Record; enforceDomainAuth: boolean; authGraceHours: string; } function toForm(s: InstanceSettings): FormState { + const placement = s.placement ?? PLACEMENT_DEFAULTS; return { linksEnabled: s.invitations.links_enabled, ttlHours: String(s.invitations.ttl_hours), @@ -222,6 +270,12 @@ function toForm(s: InstanceSettings): FormState { machineWindowClick: String(s.tracking.machine_window_click_seconds), machineWindowProbable: String(s.tracking.machine_window_probable_seconds), }, + placement: { + testsTrial: String(placement.tests_per_month_trial), + testsPaid: String(placement.tests_per_month_paid), + seedsPerTest: String(placement.seeds_per_test), + spacingSeconds: String(placement.spacing_seconds), + }, enforceDomainAuth: s.deliverability.enforce_domain_auth, authGraceHours: String(s.deliverability.auth_grace_hours), }; @@ -280,6 +334,13 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { !!server && !!form && TRACKING_FIELDS.some((f) => form.tracking[f.key] !== String(server.tracking[f.setting])); + const placementDirty = + !!server && + !!form && + PLACEMENT_FIELDS.some( + (f) => + form.placement[f.key] !== String((server.placement ?? PLACEMENT_DEFAULTS)[f.setting]), + ); const dirty = !!server && !!form && @@ -290,6 +351,7 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { form.authGraceHours !== String(server.deliverability.auth_grace_hours) || retentionDirty || trackingDirty || + placementDirty || syncDirty); useEffect(() => { @@ -306,6 +368,10 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { form !== null && TRACKING_FIELDS.every((f) => syncFieldValid(form.tracking[f.key], f.min, f.max)); + const placementValid = + form !== null && + PLACEMENT_FIELDS.every((f) => syncFieldValid(form.placement[f.key], f.min, f.max)); + const authGrace = form ? Number(form.authGraceHours) : NaN; const authGraceValid = form !== null && @@ -346,6 +412,10 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { ); return; } + if (!placementValid) { + toast.error("Every placement test setting must be a whole number inside the range shown under it"); + return; + } if (!authGraceValid) { toast.error( `The authentication grace period must be a whole number of hours between ${AUTH_GRACE_MIN_HOURS} and ${AUTH_GRACE_MAX_HOURS}`, @@ -377,6 +447,12 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { enforce_domain_auth: form.enforceDomainAuth, auth_grace_hours: authGrace, }, + placement: { + tests_per_month_trial: Number(form.placement.testsTrial), + tests_per_month_paid: Number(form.placement.testsPaid), + seeds_per_test: Number(form.placement.seedsPerTest), + spacing_seconds: Number(form.placement.spacingSeconds), + }, }); } @@ -701,6 +777,59 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { + + + Inbox placement tests + + A placement test sends one copy of a template to each seed on a + panel and reports where it landed. The monthly allowances count + tests on the instance panel and on Warmbly Cloud's; tests on a + workspace's own seed inboxes are never counted, and a + self-hosted instance does not meter tests at all. A tracking + comparison counts as two tests. The seeds themselves are managed on + the Seed panel page. + + + + {PLACEMENT_FIELDS.map((f) => { + const valid = syncFieldValid(form.placement[f.key], f.min, f.max); + return ( +
+ + + setForm({ + ...form, + placement: { + ...form.placement, + [f.key]: e.target.value, + }, + }) + } + aria-invalid={!valid} + className="mt-1" + /> +

+ {f.help} Between {f.min.toLocaleString()} and{" "} + {f.max.toLocaleString()}. +

+ {!valid && ( +

+ Enter a whole number between {f.min.toLocaleString()}{" "} + and {f.max.toLocaleString()}. +

+ )} +
+ ); + })} +
+
+ Sending-domain authentication diff --git a/admin/src/app/dashboard/placement/RunTestDialog.tsx b/admin/src/app/dashboard/placement/RunTestDialog.tsx new file mode 100644 index 000000000..d26fe29a2 --- /dev/null +++ b/admin/src/app/dashboard/placement/RunTestDialog.tsx @@ -0,0 +1,269 @@ +// Runs a placement test on the instance panel from any connected mailbox, +// outside every workspace allowance. + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { X } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + createPlacementTest, + searchPlacementSeedCandidates, + type AdminPlacementSeed, + type PlacementTestView, + type PlacementTracking, +} from "@/lib/api/client/admin/placement"; +import { describeError } from "./format"; +import { useDebounced } from "./useDebounced"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const TRACKING_OPTIONS: { value: PlacementTracking; label: string }[] = [ + { value: "campaign", label: "Default: untracked for an ad-hoc test" }, + { value: "off", label: "Off: no open pixel, links left as written" }, + { value: "on", label: "On: open pixel and click tracking" }, + { value: "compare", label: "Compare: one untracked and one tracked test" }, +]; + +export function RunTestDialog({ + open, + onOpenChange, + onStarted, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onStarted: (tests: PlacementTestView[]) => void; +}) { + const qc = useQueryClient(); + const [sender, setSender] = useState<{ id: string; email: string } | null>(null); + const [senderQuery, setSenderQuery] = useState(""); + const [subject, setSubject] = useState(""); + const [bodyPlain, setBodyPlain] = useState(""); + const [bodyHtml, setBodyHtml] = useState(""); + const [tracking, setTracking] = useState("campaign"); + const [failure, setFailure] = useState<{ message: string; code?: string } | null>(null); + + function reset() { + setSender(null); + setSenderQuery(""); + setSubject(""); + setBodyPlain(""); + setBodyHtml(""); + setTracking("campaign"); + setFailure(null); + } + + const debounced = useDebounced(senderQuery.trim(), 250); + const isId = UUID_RE.test(debounced); + const candidatesQ = useQuery({ + queryKey: ["admin", "placement", "candidates", "sender", debounced], + queryFn: () => searchPlacementSeedCandidates(debounced, 20), + enabled: open && !sender && debounced.length >= 2 && !isId, + staleTime: 30_000, + }); + // A seed cannot send a test, and an inactive mailbox cannot send at all. + const senders = (candidatesQ.data ?? []).filter((m) => m.seed_scope === "" && m.status === "active"); + + const senderId = sender?.id ?? (UUID_RE.test(senderQuery.trim()) ? senderQuery.trim() : ""); + + const create = useMutation({ + mutationFn: () => + createPlacementTest({ + sender_account_id: senderId, + subject: subject.trim(), + body_plain: bodyPlain.trim() || undefined, + body_html: bodyHtml.trim() || undefined, + tracking, + }), + onSuccess: (tests) => { + toast.success(tests.length > 1 ? "Tracking comparison started" : "Placement test started"); + qc.invalidateQueries({ queryKey: ["admin", "placement"] }); + reset(); + onStarted(tests); + }, + onError: (err) => setFailure(describeError(err)), + }); + + const problem = !senderId + ? "Pick the mailbox to send from, or paste its id." + : !subject.trim() + ? "Enter a subject." + : !bodyPlain.trim() && !bodyHtml.trim() + ? "Enter a plain or an HTML body." + : null; + + function pick(m: AdminPlacementSeed) { + setSender({ id: m.id, email: m.email }); + setSenderQuery(""); + setFailure(null); + } + + return ( + { + if (!v && !create.isPending) setFailure(null); + onOpenChange(v); + }} + > + + + Run a placement test + + Sends one copy to each seed on the instance panel from the mailbox you pick, spaced out, and + reports where each landed. Admin tests are never counted against the workspace's monthly + allowance, but every copy uses the mailbox's daily sending limit. + + + +
+
+ + {sender ? ( +
+ {sender.email} + +
+ ) : ( + <> + { + setSenderQuery(e.target.value); + setFailure(null); + }} + placeholder="Search by address, or paste a mailbox id" + className="mt-1 h-8 text-[12.5px]" + autoComplete="off" + /> + {debounced.length >= 2 && !isId && ( +
+ {candidatesQ.isLoading ? ( +
Searching…
+ ) : senders.length === 0 ? ( +
+ No active mailbox matches. Seeds cannot send a test. +
+ ) : ( + senders.map((m) => ( + + )) + )} +
+ )} + + )} +
+ +
+ + setSubject(e.target.value)} + className="mt-1 h-8 text-[12.5px]" + /> +
+ +
+ +