diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77be1334..f2488b0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,7 @@ jobs: uses: arduino/setup-protoc@v3 with: version: "33.x" + repo-token: ${{ github.token }} - name: Install required Go tools run: make setup-tools diff --git a/AGENTS.md b/AGENTS.md index 1b70a8ae..7f645a34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,12 @@ Commit hygiene: - when instructed to make a commit, use the subject format `feat: one line explanation` - commit messages on this repo do not include `Co-Authored-By:` or other AI/agent attribution footers. Keep messages to subject + body explaining the why. If a commit slips through with an attribution footer, rewrite it before opening or updating a PR. +Data modeling / representation: + +- we are happiest with the most **type-safe** option, but the rule is: pick the **most effective option for the actual use case**, not type-safety for its own sake. +- prefer real typed columns / enums when the data is fixed-shape, queried or filtered in SQL, or benefits from FK integrity. +- a `jsonb` column is the right call when the data is a free-form, evolving, read-then-execute blob that isn't filtered in SQL (e.g. the `sequences.conditions` branching tree and `sequences.action` node config) — keep it type-safe at the app boundary with a Go struct + validation on write, and a DB `CHECK` on any discriminator column. + ### Verification: what to run, what to skip Keep the loop fast. The signals that matter are formatting, lint, and typecheck — not local builds or browser automation. @@ -91,6 +97,8 @@ Everything in the dashboard must use our own theme, not browser/library defaults - Theme tokens: slate borders (`border-slate-200`), sky accents (`focus:border-sky-400 focus:ring-sky-100`, `bg-sky-50 text-sky-700`), `rounded-md`, `text-[12.5px]` base, `h-7` controls, `10px uppercase tracking-[0.14em]` section labels. - Multi-select tables: when rows are selected, show a floating bottom-center selection bar with the count + bulk actions (mirror `SelectionBar` in contacts `ContactsTable.tsx`). - Row actions must be reachable on touch: never hide the only affordance behind `opacity-0 group-hover` with no mobile fallback. Use `opacity-100 md:opacity-0 md:group-hover:opacity-100`, or surface actions in the detail drawer. +- Confirmations: never use the native `window.confirm` / `alert` / `prompt`. Use the in-app confirm: `const confirm = useConfirm()` (from `@/hooks/context/confirm`), then `confirm.show(text, onSubmit)`. `onSubmit` is awaited and the provider renders its own loading spinner, so pass an `async` callback (prefer `mutateAsync` over callback-style `mutate`). For the synchronous `if (!window.confirm(x)) return; act()` pattern, restructure to `confirm.show(x, act)`; for close-while-dirty guards, route every close path (Escape handler, backdrop `onMouseDown`, close button) through one `requestClose()` that calls `confirm.show(...)` when dirty. ConfirmProvider is mounted in `app/app/layout.tsx`, so `useConfirm()` works anywhere under `/app`. +- Row interactions: list rows behave like the campaigns list — clicking anywhere on a row opens that item's detail (drawer or page); right-side action buttons (3-dots / "More") open a relevant detail/tab (e.g. the mailbox 3-dots opens the Settings tab of `InboxDetails`). Inner interactive controls (checkbox, dropdown trigger, action buttons) must `e.stopPropagation()` so they don't also fire the row's open handler. - Prefer realtime over polling: subscribe to the socket and `queryClient.invalidateQueries(...)` on the relevant event instead of `refetchInterval` where an event exists (see `useRealtimeEvents` / `RealtimeManager`). ## System Shape diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 400de6dc..c32569ed 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -28,6 +28,7 @@ import ( "github.com/warmbly/warmbly/internal/app/campaign" "github.com/warmbly/warmbly/internal/app/cipher" "github.com/warmbly/warmbly/internal/app/contact" + "github.com/warmbly/warmbly/internal/app/credits" "github.com/warmbly/warmbly/internal/app/crm" "github.com/warmbly/warmbly/internal/app/dailythrottle" "github.com/warmbly/warmbly/internal/app/dangerzone" @@ -40,6 +41,7 @@ import ( "github.com/warmbly/warmbly/internal/app/group" idempotencyapp "github.com/warmbly/warmbly/internal/app/idempotency" "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/app/leadsync" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/passkey" "github.com/warmbly/warmbly/internal/app/placement" @@ -123,6 +125,9 @@ func main() { var advancedService advanced.Service var warmupContentRepo repository.WarmupContentRepository var warmupContentService warmupcontent.Service + var creditRepository repository.CreditRepository + var creditService credits.CreditService + var writingGenerator generation.WritingGenerator var emailVerifyService emailverifyapp.Service var placementRepository repository.PlacementRepository var placementService placement.Service @@ -184,6 +189,8 @@ func main() { var webhookServiceForHandler webhook.Service var integrationServiceForHandler integration.Service var contactRepoForHandler repository.ContactRepository + var attachmentRepoForHandler repository.AttachmentRepository + var leadSyncServiceForHandler leadsync.Service ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -445,6 +452,7 @@ func main() { campaignRepostory := repository.NewCampaignRepostory(primaryDB) sequenceRepostory := repository.NewSequenceRepostory(primaryDB) contactRepostory := repository.NewContactRepostory(primaryDB) + attachmentRepoForHandler = repository.NewAttachmentRepository(primaryDB) uniboxRepository := repository.NewUniboxRepository(primaryDB) encryptedKeys, err = encryptedkeys.FromEnv( encryptedkeys.Deps{DB: primaryDB}, @@ -495,6 +503,18 @@ func main() { generationClient = generation.NewClient(openaiKey) } warmupContentService = warmupcontent.NewService(warmupContentRepo, generationClient) + + // AI writing assistant: prefer Anthropic (claude-haiku free / sonnet paid); + // fall back to the existing OpenAI client when ANTHROPIC_API_KEY is unset. + // If neither is configured, writingGenerator stays nil and the endpoint + // returns 503 "not configured". + if anthropicKey := cfg.GetSecretOptional(ctx, "ANTHROPIC_API_KEY", "anthropic_api_key", ""); anthropicKey != "" { + writingGenerator = generation.NewAnthropicClient(anthropicKey) + } else if generationClient != nil { + writingGenerator = generationClient + } + creditRepository = repository.NewCreditRepository(primaryDB) + creditService = credits.NewService(creditRepository, cache) webhookRepository := repository.NewWebhookRepository(primaryDB.Pool) webhookService := webhook.NewService(webhookRepository) webhookServiceForHandler = webhookService @@ -528,6 +548,13 @@ func main() { } organizationService = organization.NewService(organizationRepository, subscriptionRepository, userRepostory, dailyThrottleService) + // Plan-based webhook/integration fan-out throttle. The cap scales with + // the org's effective mailbox allowance (see WebhookDispatchLimit) so a + // campaign "notify" action can't flood a customer's endpoints. Wired here + // now that organizationService exists; the resolved cap is cached so this + // resolver is not hit on every dispatched event. + webhookServiceForHandler.WireThrottle(cache, organizationService.WebhookDispatchLimit) + // Load Stripe config and initialize service stripeCfg, err := cfg.LoadStripeConfig(ctx) if err != nil { @@ -766,6 +793,13 @@ func main() { rateLimitService = ratelimit.NewService(cache, rateLimitRepository) sequenceService = sequence.NewService(sequenceRepostory) contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository, streamingPublisher) + + // On-demand Google Sheets -> leads sync (backend-only / control plane). + // Reuses the integration service for the Google token + sheet reads and + // the contact service for the upsert. No worker / scheduler involvement. + leadSyncRepository := repository.NewLeadSyncRepository(primaryDB.Pool) + leadSyncServiceForHandler = leadsync.NewService(leadSyncRepository, integrationServiceForHandler, contactService) + apiKeyService = apikey.NewService(cache, apiKeyRepository) crmService = crm.NewService(crmRepository) socketService = socket.NewService(cache, tokenService) @@ -785,7 +819,7 @@ func main() { // Template & email send services templateService = template.NewService(templateRepository) - schedulerService := scheduler.NewSchedulerService(taskRepository, warmupRepository, campaignProgressRepository, emailRepostory, campaignRepostory) + schedulerService := scheduler.NewSchedulerService(taskRepository, warmupRepository, campaignProgressRepository, emailRepostory, campaignRepostory, contactRepostory, campaignLogRepository) campaignService = campaign.NewService(campaignRepostory, taskRepository, emailRepostory, campaignLogRepository, featureGateService, dailyThrottleService, schedulerService, tasksClient, streamingPublisher) emailSendService = emailsend.NewService(taskRepository, emailRepostory, userRepostory, schedulerService, tasksClient, featureGateService, dailyThrottleService) // uniboxService is constructed here (rather than alongside the @@ -830,6 +864,7 @@ func main() { contactRepostory, campaignLogRepository, advancedService, + attachmentRepoForHandler, ) // Admin outreach composer — sends from the platform mailer @@ -988,6 +1023,10 @@ func main() { WarmupContentRepo: warmupContentRepo, WarmupContentService: warmupContentService, + // AI writing assistant + credit ledger + CreditService: creditService, + WritingGenerator: writingGenerator, + // Pre-send email verification EmailVerifyService: emailVerifyService, @@ -999,6 +1038,9 @@ func main() { IntegrationService: integrationServiceForHandler, ContactRepo: contactRepoForHandler, + // On-demand Google Sheets -> leads sync + LeadSyncService: leadSyncServiceForHandler, + WebsocketURI: websocketURI, // Object storage + direct repository handles for handlers @@ -1008,6 +1050,7 @@ func main() { EmailMessageMap: emailMessageMapForHandler, UserRepo: userRepoForHandler, OrgRepo: organizationRepoForHandler, + AttachmentRepo: attachmentRepoForHandler, StorageBackendRepo: storageBackendRepo, CloudCredentialRepo: cloudCredentialRepo, ProvisioningTemplateRepo: provisioningTemplateRepo, diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index cb4eea6b..166cc54c 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -195,6 +195,10 @@ func main() { // advanced repo, so no separate suppression repo is wired here. webhookRepoC := repository.NewWebhookRepository(primaryDB.Pool) webhookService := webhook.NewService(webhookRepoC) + // The consumer dispatches lower-volume reply/warmup events (not per-contact + // campaign fan-out), so a generous static cap is enough here; the plan-based + // resolver lives in the backend where campaign "notify" actions run. + webhookService.WireThrottle(redisCache, webhook.StaticLimit(config.WebhookDispatchBasePerMinute)) integrationRepoC := repository.NewIntegrationRepository(primaryDB.Pool) integrationServiceC := integration.NewService(integrationRepoC, cipherService, integration.NewOAuthManager()) webhookService.WireDispatchSink(integrationServiceC.DispatchAny) diff --git a/internal/api/handler/attachment.go b/internal/api/handler/attachment.go new file mode 100644 index 00000000..fea85484 --- /dev/null +++ b/internal/api/handler/attachment.go @@ -0,0 +1,234 @@ +// Campaign email attachment handlers — upload, list, delete. Binary lives in +// object storage (private; surfaced to the browser via short-lived presigned +// URLs and fetched by the worker at send time). Overall storage is capped per +// organization by the plan-based quota (feature gate). + +package handler + +import ( + "bytes" + "fmt" + "io" + "net/http" + "path" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +const ( + // Per-file cap. Kept under provider message ceilings (Gmail/Outlook ~25 MB + // total, and base64 inflates ~37%), so a single attachment always fits. + attachmentMaxBytes int64 = 15 * 1024 * 1024 + attachmentURLTTL = 15 * time.Minute +) + +// Executable / script types that must never ride an outbound email. Everything +// else (images, PDF, office docs, CSV, archives, …) is allowed. +var blockedAttachmentExt = map[string]bool{ + ".exe": true, ".bat": true, ".cmd": true, ".com": true, ".msi": true, + ".scr": true, ".js": true, ".jse": true, ".vbs": true, ".vbe": true, + ".ps1": true, ".sh": true, ".jar": true, ".app": true, ".dll": true, + ".cpl": true, ".hta": true, ".wsf": true, ".pif": true, +} + +func sanitizeFilename(name string) string { + name = path.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." { + return "file" + } + if len(name) > 200 { + name = name[len(name)-200:] + } + return name +} + +func mb(b int64) int64 { return b / (1024 * 1024) } + +// UploadCampaignAttachment — POST /campaigns/:id/attachments (multipart "file") +func (h *Handler) UploadCampaignAttachment(c *gin.Context) { + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + userID, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.JSON(c, errx.ErrAuth) + return + } + if h.Storage == nil { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "object storage not configured")) + return + } + + // Optional sequence_id form field scopes the attachment to one step. + var seqID *uuid.UUID + if s := strings.TrimSpace(c.PostForm("sequence_id")); s != "" { + if id, perr := uuid.Parse(s); perr == nil { + seqID = &id + } + } + + // Cap the body before parsing so a huge upload can't pin a worker. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, attachmentMaxBytes+(1<<20)) + fh, err := c.FormFile("file") + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "file is required")) + return + } + if fh.Size <= 0 || fh.Size > attachmentMaxBytes { + errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("file must be between 1 byte and %d MB", mb(attachmentMaxBytes)))) + return + } + filename := sanitizeFilename(fh.Filename) + if blockedAttachmentExt[strings.ToLower(path.Ext(filename))] { + errx.JSON(c, errx.New(errx.BadRequest, "that file type can't be attached to email")) + return + } + + // Plan-based overall storage quota (org-wide). + limit, xerr := h.FeatureGateService.GetStorageLimitBytes(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + used, err := h.AttachmentRepo.SumStorageUsedByOrg(c.Request.Context(), *orgID) + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + if used+fh.Size > limit { + errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf( + "storage limit reached (%d MB of %d MB used) — remove attachments or upgrade your plan", mb(used), mb(limit)))) + return + } + + src, err := fh.Open() + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + defer src.Close() + buf := &bytes.Buffer{} + if _, err := io.Copy(buf, src); err != nil { + errx.JSON(c, errx.InternalError()) + return + } + body := buf.Bytes() + mimeType := fh.Header.Get("Content-Type") + if mimeType == "" { + mimeType = http.DetectContentType(body) + } + + key := fmt.Sprintf("attachments/%s/%s-%s", campaignID.String(), uuid.NewString(), filename) + if err := h.Storage.Put(c.Request.Context(), key, bytes.NewReader(body), mimeType); err != nil { + errx.JSON(c, errx.InternalError()) + return + } + + att := &models.CampaignAttachment{ + CampaignID: campaignID, + SequenceID: seqID, + UserID: userID, + Filename: filename, + Size: fh.Size, + MimeType: mimeType, + S3Key: key, + } + if err := h.AttachmentRepo.Create(c.Request.Context(), att); err != nil { + _ = h.Storage.Delete(c.Request.Context(), key) // best-effort cleanup + errx.JSON(c, errx.InternalError()) + return + } + + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityCampaign, &att.ID, nil, map[string]string{ + "scope": "attachment", "campaign_id": campaignID.String(), "filename": filename, + }) + + c.JSON(http.StatusCreated, h.attachmentResponse(c, att)) +} + +// ListCampaignAttachments — GET /campaigns/:id/attachments +func (h *Handler) ListCampaignAttachments(c *gin.Context) { + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + atts, err := h.AttachmentRepo.ListByCampaign(c.Request.Context(), campaignID) + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + out := make([]gin.H, 0, len(atts)) + for i := range atts { + out = append(out, h.attachmentResponse(c, &atts[i])) + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// DeleteCampaignAttachment — DELETE /campaigns/:id/attachments/:attachmentId +func (h *Handler) DeleteCampaignAttachment(c *gin.Context) { + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + attID, err := uuid.Parse(c.Param("attachmentId")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + att, err := h.AttachmentRepo.GetByID(c.Request.Context(), attID) + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + if att == nil || att.CampaignID != campaignID { + errx.JSON(c, errx.ErrNotFound) + return + } + if err := h.AttachmentRepo.Delete(c.Request.Context(), attID); err != nil { + errx.JSON(c, errx.InternalError()) + return + } + if h.Storage != nil { + _ = h.Storage.Delete(c.Request.Context(), att.S3Key) + } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityCampaign, &attID, nil, map[string]string{ + "scope": "attachment", "campaign_id": campaignID.String(), + }) + c.Status(http.StatusNoContent) +} + +func (h *Handler) attachmentResponse(c *gin.Context, att *models.CampaignAttachment) gin.H { + url := "" + if h.Storage != nil { + if u, err := h.Storage.PresignedGetURL(c.Request.Context(), att.S3Key, attachmentURLTTL); err == nil { + url = u + } + } + return gin.H{ + "id": att.ID, + "campaign_id": att.CampaignID, + "sequence_id": att.SequenceID, + "filename": att.Filename, + "size": att.Size, + "mime_type": att.MimeType, + "url": url, + "created_at": att.CreatedAt, + } +} diff --git a/internal/api/handler/campaign.go b/internal/api/handler/campaign.go index c8b2cbb1..9e3cb41a 100644 --- a/internal/api/handler/campaign.go +++ b/internal/api/handler/campaign.go @@ -182,3 +182,74 @@ func (h *Handler) GetCampaignLogs(c *gin.Context) { c.JSON(http.StatusOK, result) } + +// ListCampaignSenders returns a campaign's explicit sender pool. +// GET /campaigns/:id/senders +func (h *Handler) ListCampaignSenders(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + senders, xerr := h.CampaignService.ListCampaignSenders(c.Request.Context(), *orgID, c.Param("id")) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + c.JSON(http.StatusOK, gin.H{"data": senders}) +} + +// ReplaceCampaignSenders atomically replaces a campaign's explicit sender pool. +// PUT /campaigns/:id/senders +func (h *Handler) ReplaceCampaignSenders(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + var body struct { + Senders []models.CampaignSenderInput `json:"senders"` + } + if err := c.ShouldBindJSON(&body); err != nil { + errx.JSON(c, errx.ErrInvalid) + return + } + + senders, xerr := h.CampaignService.ReplaceCampaignSenders(c.Request.Context(), *orgID, c.Param("id"), body.Senders) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + if campaignID, err := uuid.Parse(c.Param("id")); err == nil { + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaign, &campaignID, nil, map[string]string{"scope": "senders"}) + } + + c.JSON(http.StatusOK, gin.H{"data": senders}) +} + +// VerifyCampaignTrackingDomain resolves the campaign-scoped tracking domain's +// CNAME and flips verified on success. +// POST /campaigns/:id/tracking-domain/verify +func (h *Handler) VerifyCampaignTrackingDomain(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + status, xerr := h.CampaignService.VerifyCampaignTrackingDomain(c.Request.Context(), *orgID, c.Param("id")) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + if campaignID, err := uuid.Parse(c.Param("id")); err == nil { + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaign, &campaignID, nil, map[string]string{"scope": "tracking_domain_verify"}) + } + + c.JSON(http.StatusOK, status) +} diff --git a/internal/api/handler/generation.go b/internal/api/handler/generation.go new file mode 100644 index 00000000..b7ca44e6 --- /dev/null +++ b/internal/api/handler/generation.go @@ -0,0 +1,143 @@ +// AI writing-assistant generation endpoint. Flow: +// 1. feature-gate the org (paid or in free trial) via CanUseWritingAssistant +// 2. atomically consume one credit (DB-enforced: no negative balance, no +// double-charge on Idempotency-Key replay) +// 3. call the configured provider (Anthropic, falling back to OpenAI) +// 4. return {text, credits_remaining, model} +// +// On insufficient credits the consume step short-circuits with 402 BEFORE any +// provider call, so a depleted org never burns a paid completion. Because the +// debit happens before the provider call, a provider failure refunds the +// credit so the customer is not charged for a generation they never received. + +package handler + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/app/credits" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/pkg/generation" +) + +// creditsPerWrite is the credit cost of one writing-assistant call. Kept as a +// constant so pricing is in one place; tokens consumed are recorded separately +// on the ledger transaction for later cost analysis. +const creditsPerWrite = 1 + +// writeMaxPromptLen bounds the inbound prompt so a single request can't be used +// to drive a very large (and expensive) completion. +const writeMaxPromptLen = 8000 + +type generationWriteRequest struct { + Prompt string `json:"prompt"` + Tone string `json:"tone"` +} + +// paymentRequiredJSON emits the standard error envelope with a 402 status. +// errx has no PaymentRequired code, so this endpoint writes the 402 directly +// while keeping the same {error, message, code, request_id} shape. +func paymentRequiredJSON(c *gin.Context, message string) { + c.JSON(http.StatusPaymentRequired, gin.H{ + "error": "Payment Required", + "message": message, + "code": "insufficient_credits", + "request_id": c.GetString("request_id"), + }) +} + +// GenerateWriting — POST /generation/write +func (h *Handler) GenerateWriting(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + var req generationWriteRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.ErrInvalid) + return + } + req.Prompt = strings.TrimSpace(req.Prompt) + if req.Prompt == "" { + errx.JSON(c, errx.New(errx.BadRequest, "prompt is required")) + return + } + if len(req.Prompt) > writeMaxPromptLen { + errx.JSON(c, errx.New(errx.BadRequest, "prompt is too long")) + return + } + + // Feature gate: paid orgs and free-trial orgs may use the assistant. + allowed, xerr := h.FeatureGateService.CanUseWritingAssistant(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + if !allowed { + errx.JSON(c, errx.New(errx.Forbidden, "The AI writing assistant requires an active plan or trial.")) + return + } + + // Provider must be configured. + if h.WritingGenerator == nil { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured.")) + return + } + + // Model routing by tier. Paid orgs get the stronger model; the active + // provider (Anthropic or OpenAI fallback) decides the concrete model ID. + paid, xerr := h.FeatureGateService.IsPaidOrganization(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + model := h.WritingGenerator.ModelForTier(paid) + + // Consume one credit up front. The DB enforces the no-negative / no-replay + // invariants; on a depleted balance this returns 402 with no provider call. + idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + remaining, err := h.CreditService.Consume( + c.Request.Context(), *orgID, creditsPerWrite, + "writing_assistant", model, 0, idemKey, + ) + if err != nil { + switch { + case errors.Is(err, credits.ErrInsufficientCredits): + paymentRequiredJSON(c, "You're out of AI credits. Upgrade or purchase more to keep using the writing assistant.") + case errors.Is(err, credits.ErrCapExceeded): + errx.JSON(c, errx.New(errx.TooManyRequests, "AI writing assistant usage limit reached, please try again later.")) + default: + errx.JSON(c, errx.InternalError()) + } + return + } + + // Generate. On provider failure, refund the credit so the customer is not + // charged for a completion they never received. The refund is best-effort; + // a failed refund is logged via the audit trail rather than surfaced. + result, gerr := h.WritingGenerator.GenerateWriting(c.Request.Context(), model, req.Prompt, req.Tone) + if gerr != nil { + if bal, rerr := h.CreditService.Grant(c.Request.Context(), *orgID, creditsPerWrite, "writing_assistant_refund"); rerr == nil { + remaining = bal + } + if errors.Is(gerr, generation.ErrNotConfigured) { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured.")) + return + } + errx.JSON(c, errx.New(errx.ServiceUnavailable, "The writing assistant is temporarily unavailable. Your credit was not charged.")) + return + } + + c.JSON(http.StatusOK, gin.H{ + "text": result.Text, + "credits_remaining": remaining, + "model": result.Model, + }) +} diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 2461d08d..863cca2d 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -10,6 +10,7 @@ import ( "github.com/warmbly/warmbly/internal/app/auth" "github.com/warmbly/warmbly/internal/app/campaign" "github.com/warmbly/warmbly/internal/app/contact" + "github.com/warmbly/warmbly/internal/app/credits" "github.com/warmbly/warmbly/internal/app/crm" "github.com/warmbly/warmbly/internal/app/dangerzone" "github.com/warmbly/warmbly/internal/app/discount" @@ -19,6 +20,7 @@ import ( "github.com/warmbly/warmbly/internal/app/feature" "github.com/warmbly/warmbly/internal/app/group" "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/app/leadsync" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/passkey" "github.com/warmbly/warmbly/internal/app/placement" @@ -39,6 +41,8 @@ import ( "github.com/warmbly/warmbly/internal/app/webhook" "github.com/warmbly/warmbly/internal/app/worker" "github.com/warmbly/warmbly/internal/app/worker_orchestrator" + "github.com/warmbly/warmbly/internal/pkg/generation" + "github.com/warmbly/warmbly/internal/infrastructure/encryptedkeys" "github.com/warmbly/warmbly/internal/infrastructure/storage" "github.com/warmbly/warmbly/internal/notify" @@ -122,6 +126,10 @@ type Handler struct { WarmupContentRepo repository.WarmupContentRepository WarmupContentService warmupcontent.Service + // AI writing assistant + credit ledger. + CreditService credits.CreditService + WritingGenerator generation.WritingGenerator + // Seed inbox-placement testing. PlacementRepo repository.PlacementRepository PlacementService placement.Service @@ -134,6 +142,10 @@ type Handler struct { IntegrationService integration.Service ContactRepo repository.ContactRepository + // On-demand Google Sheets -> leads sync. Reuses the google_sheets OAuth + // connection's token to read sheets and the contact import path to upsert. + LeadSyncService leadsync.Service + // Public websocket URL used by frontend clients WebsocketURI string @@ -156,6 +168,7 @@ type Handler struct { // only when business logic accumulates. UserRepo repository.UserRepository OrgRepo repository.OrganizationRepository + AttachmentRepo repository.AttachmentRepository StorageBackendRepo repository.StorageBackendRepository CloudCredentialRepo repository.CloudCredentialRepository ProvisioningTemplateRepo repository.ProvisioningTemplateRepository diff --git a/internal/api/handler/lead_sync.go b/internal/api/handler/lead_sync.go new file mode 100644 index 00000000..d8f98e9b --- /dev/null +++ b/internal/api/handler/lead_sync.go @@ -0,0 +1,261 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// requireLeadSyncActor resolves the org + user for a lead-sync request the same +// way the contact and integration handlers do. +func (h *Handler) requireLeadSyncActor(c *gin.Context) (orgID, userID uuid.UUID, ok bool) { + orgID, ok = requireOrgID(c) + if !ok { + return uuid.Nil, uuid.Nil, false + } + uid, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return uuid.Nil, uuid.Nil, false + } + return orgID, uid, true +} + +// leadSyncConnectionResponse is the minimal connection shape the lead-sync UI +// needs to decide whether to show "Connect Google" or the sheet picker. +type leadSyncConnectionResponse struct { + Connected bool `json:"connected"` + Connection *leadSyncConnectionSummary `json:"connection"` +} + +type leadSyncConnectionSummary struct { + ID uuid.UUID `json:"id"` + ExternalAccountName string `json:"external_account_name"` + Status string `json:"status"` +} + +// GetLeadSyncGoogleConnection reports whether the org has a connected Google +// account usable for lead-sync (the hidden google_sheets OAuth connection). +func (h *Handler) GetLeadSyncGoogleConnection(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + conn, err := h.LeadSyncService.Connection(c.Request.Context(), orgID) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "failed to resolve google connection")) + return + } + if conn == nil { + c.JSON(http.StatusOK, leadSyncConnectionResponse{Connected: false, Connection: nil}) + return + } + c.JSON(http.StatusOK, leadSyncConnectionResponse{ + Connected: true, + Connection: &leadSyncConnectionSummary{ + ID: conn.ID, + ExternalAccountName: conn.ExternalAccountName, + Status: string(conn.Status), + }, + }) +} + +type leadSyncSpreadsheetPayload struct { + ConnectionID string `json:"connection_id"` + SheetID string `json:"sheet_id"` +} + +// GetLeadSyncSpreadsheet returns a sheet's title + tabs so the UI can render a +// tab picker before mapping columns. +func (h *Handler) GetLeadSyncSpreadsheet(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var p leadSyncSpreadsheetPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + connID, err := uuid.Parse(strings.TrimSpace(p.ConnectionID)) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid connection_id")) + return + } + if strings.TrimSpace(p.SheetID) == "" { + errx.JSON(c, errx.New(errx.BadRequest, "sheet_id is required")) + return + } + meta, merr := h.LeadSyncService.SpreadsheetMeta(c.Request.Context(), orgID, connID, strings.TrimSpace(p.SheetID)) + if merr != nil { + errx.JSON(c, errx.New(errx.BadRequest, "failed to read spreadsheet: "+merr.Error())) + return + } + c.JSON(http.StatusOK, meta) +} + +type leadSyncPreviewPayload struct { + ConnectionID string `json:"connection_id"` + SheetID string `json:"sheet_id"` + TabTitle string `json:"tab_title"` +} + +// PreviewLeadSync returns an ImportPreview-shaped payload (columns, sample_rows, +// total_rows, has_header, suggested_mapping) so the frontend reuses its +// contact-import column mapper verbatim. +func (h *Handler) PreviewLeadSync(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var p leadSyncPreviewPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + connID, err := uuid.Parse(strings.TrimSpace(p.ConnectionID)) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid connection_id")) + return + } + preview, xerr := h.LeadSyncService.Preview(c.Request.Context(), orgID, connID, strings.TrimSpace(p.SheetID), strings.TrimSpace(p.TabTitle)) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, preview) +} + +// ListLeadSyncSources lists this org's saved sources, optionally filtered to a +// campaign via ?campaign_id=. +func (h *Handler) ListLeadSyncSources(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var campaignID *uuid.UUID + if raw := strings.TrimSpace(c.Query("campaign_id")); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid campaign_id")) + return + } + campaignID = &id + } + sources, err := h.LeadSyncService.List(c.Request.Context(), orgID, campaignID) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "failed to list sync sources")) + return + } + if sources == nil { + sources = []models.LeadSyncSource{} + } + c.JSON(http.StatusOK, gin.H{"data": sources}) +} + +// CreateLeadSyncSource saves a new on-demand sync source. +func (h *Handler) CreateLeadSyncSource(c *gin.Context) { + orgID, userID, ok := h.requireLeadSyncActor(c) + if !ok { + return + } + var in models.CreateLeadSyncSource + if err := c.ShouldBindJSON(&in); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + src, xerr := h.LeadSyncService.Create(c.Request.Context(), orgID, userID, &in) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusCreated, src) +} + +// GetLeadSyncSource returns one saved source. +func (h *Handler) GetLeadSyncSource(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + src, xerr := h.LeadSyncService.Get(c.Request.Context(), orgID, id) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, src) +} + +// UpdateLeadSyncSource edits a saved source. +func (h *Handler) UpdateLeadSyncSource(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + var in models.UpdateLeadSyncSource + if berr := c.ShouldBindJSON(&in); berr != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + src, xerr := h.LeadSyncService.Update(c.Request.Context(), orgID, id, &in) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, src) +} + +// DeleteLeadSyncSource removes a saved source. +func (h *Handler) DeleteLeadSyncSource(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + if xerr := h.LeadSyncService.Delete(c.Request.Context(), orgID, id); xerr != nil { + errx.JSON(c, xerr) + return + } + c.Status(http.StatusNoContent) +} + +// SyncLeadSyncSourceNow runs the source on-demand: read the sheet -> upsert +// contacts via ImportCommit. Naturally idempotent (email upsert), so retries +// are safe without an Idempotency-Key. +func (h *Handler) SyncLeadSyncSourceNow(c *gin.Context) { + orgID, userID, ok := h.requireLeadSyncActor(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + result, xerr := h.LeadSyncService.SyncNow(c.Request.Context(), userID, orgID, id) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/internal/api/handler/onboarding.go b/internal/api/handler/onboarding.go index cea1fb31..c03bfffd 100644 --- a/internal/api/handler/onboarding.go +++ b/internal/api/handler/onboarding.go @@ -90,3 +90,42 @@ func (h *Handler) CompleteOnboarding(c *gin.Context) { c.Status(http.StatusNoContent) } + +type updateProfileRequest struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +// UpdateUserProfile persists editable profile fields (first/last name) from the +// profile settings page. Unlike onboarding, it carries no questionnaire answers +// and can be called any time the user renames themselves. +func (h *Handler) UpdateUserProfile(c *gin.Context) { + var req updateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + + if req.FirstName == "" || req.LastName == "" { + errx.Handle(c, errx.New(errx.BadRequest, "First name and last name are required.")) + return + } + if len(req.FirstName) > 50 || len(req.LastName) > 50 { + errx.Handle(c, errx.New(errx.BadRequest, "Name must be 50 characters or less.")) + return + } + + userID := middleware.GetUserID(c) + uid, err := uuid.Parse(userID) + if err != nil { + errx.Handle(c, errx.ErrUser) + return + } + + if xerr := h.UserService.UpdateProfile(c.Request.Context(), uid, req.FirstName, req.LastName); xerr != nil { + errx.Handle(c, xerr) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 81741925..33a8f061 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -174,6 +174,7 @@ func Run( protectedAuth.DELETE("/sessions/:id", h.SessionRevoke) protectedAuth.GET("/me", h.GetUser) + protectedAuth.PATCH("/me", h.UpdateUserProfile) protectedAuth.PATCH("/me/onboarding", h.CompleteOnboarding) protectedAuth.POST("/me/avatar", h.UploadUserAvatar) protectedAuth.DELETE("/me/avatar", h.DeleteUserAvatar) @@ -256,6 +257,9 @@ func Run( campaigns.POST("/:id/ab-variants", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.CreateCampaignABVariant) campaigns.PATCH("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.UpdateCampaignABVariant) campaigns.DELETE("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.DeleteCampaignABVariant) + campaigns.GET("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignAttachments) + campaigns.POST("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UploadCampaignAttachment) + campaigns.DELETE("/:id/attachments/:attachmentId", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteCampaignAttachment) 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) @@ -265,6 +269,13 @@ func Run( campaigns.POST("/:id/stop", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StopCampaign) campaigns.GET("/:id/logs", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignLogs) + // Explicit sender pool (rotation/weighting). + campaigns.GET("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignSenders) + campaigns.PUT("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.ReplaceCampaignSenders) + + // Campaign-scoped tracking-domain verification. + campaigns.POST("/:id/tracking-domain/verify", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.VerifyCampaignTrackingDomain) + sequences := campaigns.Group("/:id/sequences") { sequences.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetSequences) @@ -274,6 +285,12 @@ func Run( } } + generation := protected.Group("/generation") + generation.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + generation.POST("/write", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.GenerateWriting) + } + contacts := protected.Group("/contacts") contacts.Use(m.RateLimitMiddleware(models.RateLimitWrite)) { @@ -443,6 +460,27 @@ func Run( integrations.GET("/bookings", h.ListMeetingBookings) } + // On-demand Google Sheets -> leads sync (org-scoped). A saved "sync + // source" the user re-runs with "Sync now"; new rows create contacts and + // existing rows (matched by email) update. Gated under the contacts + // write permissions because it ultimately upserts contacts. The Google + // account itself is connected via the existing /integrations/oauth flow + // with provider "google_sheets". + leadSync := protected.Group("/lead-sync") + leadSync.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), m.RateLimitMiddleware(models.RateLimitWrite)) + { + leadSync.GET("/google/connection", h.GetLeadSyncGoogleConnection) + leadSync.POST("/google/spreadsheet", h.GetLeadSyncSpreadsheet) + leadSync.POST("/google/preview", h.PreviewLeadSync) + + leadSync.GET("/sources", h.ListLeadSyncSources) + leadSync.POST("/sources", h.CreateLeadSyncSource) + leadSync.GET("/sources/:id", h.GetLeadSyncSource) + leadSync.PATCH("/sources/:id", h.UpdateLeadSyncSource) + leadSync.DELETE("/sources/:id", h.DeleteLeadSyncSource) + leadSync.POST("/sources/:id/sync", h.SyncLeadSyncSourceNow) + } + // Warmup routing rules (org-scoped). Lets customers define // preferences for premium-pool partner selection — e.g. send // to Gmail recipients only from Google-classified senders. diff --git a/internal/app/advanced/events.go b/internal/app/advanced/events.go index 0961cf8d..b4b71ee0 100644 --- a/internal/app/advanced/events.go +++ b/internal/app/advanced/events.go @@ -34,3 +34,10 @@ func (s *service) emit(ctx context.Context, orgID uuid.UUID, eventType models.We } _, _ = s.dispatcher.Dispatch(ctx, orgID, eventType, data) } + +// EmitCampaignEvent dispatches a campaign event (e.g. from a sequence "notify" +// action node) to customer webhooks and wired integrations. Best-effort — a +// dispatch hiccup must never block the sending pipeline. +func (s *service) EmitCampaignEvent(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data map[string]any) { + s.emit(ctx, orgID, eventType, data) +} diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index 79021bca..2ed86cb0 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "hash/fnv" "math/rand" "net/mail" "strings" @@ -40,7 +41,7 @@ type Service interface { // (one-click POST or the manual link). Always suppresses — it's an explicit // recipient request, independent of the auto-suppress settings. Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) *errx.Error - SelectVariant(ctx context.Context, organizationID, campaignID, contactID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) + SelectVariant(ctx context.Context, organizationID, campaignID, contactID, sequenceID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) OptimizeSendTime(ctx context.Context, organizationID uuid.UUID, contact *models.Contact, base time.Time) (time.Time, *errx.Error) StartTaskExecution(ctx context.Context, taskID uuid.UUID, executionKey string, metadata map[string]interface{}) (bool, *errx.Error) @@ -57,6 +58,10 @@ type Service interface { // integration actions (Slack ping, CRM upsert). WireDispatcher(d EventDispatcher) + // EmitCampaignEvent dispatches a campaign event (e.g. from a sequence + // "notify" action node) to customer webhooks and wired integrations. + EmitCampaignEvent(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data map[string]any) + // DLQ auto-retry ProcessRetryableDeadLetters(ctx context.Context) (int, *errx.Error) } @@ -305,7 +310,56 @@ func (s *service) Unsubscribe(ctx context.Context, campaignID, contactID uuid.UU return nil } -func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, contactID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) { +// pickVariantWeightedRandom does a weighted random draw over active variants. +func pickVariantWeightedRandom(variants []models.CampaignABVariant) *models.CampaignABVariant { + total := 0 + for i := range variants { + if variants[i].Weight <= 0 { + variants[i].Weight = 100 + } + total += variants[i].Weight + } + if total <= 0 { + return nil + } + pick := rand.Intn(total) + running := 0 + for i := range variants { + running += variants[i].Weight + if pick < running { + return &variants[i] + } + } + return &variants[len(variants)-1] +} + +// pickVariantDeterministic does a weighted draw seeded by a stable string, so +// the same seed always picks the same variant (used for per-step assignment). +func pickVariantDeterministic(variants []models.CampaignABVariant, seed string) *models.CampaignABVariant { + total := 0 + for i := range variants { + if variants[i].Weight <= 0 { + variants[i].Weight = 100 + } + total += variants[i].Weight + } + if total <= 0 { + return nil + } + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + pick := int(h.Sum32() % uint32(total)) + running := 0 + for i := range variants { + running += variants[i].Weight + if pick < running { + return &variants[i] + } + } + return &variants[len(variants)-1] +} + +func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, contactID, sequenceID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) { settings, xerr := s.effectiveSettings(ctx, organizationID, campaignID) if xerr != nil { return nil, xerr @@ -314,45 +368,44 @@ func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, return &models.VariantSelection{Subject: subject, BodyHTML: bodyHTML, BodyPlain: bodyPlain}, nil } - assigned, err := s.repo.GetAssignedVariant(ctx, campaignID, contactID) + variants, err := s.repo.ListABVariants(ctx, campaignID) if err != nil { return nil, toErrx(err) } - var selected *models.CampaignABVariant - if assigned != nil && assigned.IsActive { - selected = assigned - } else { - variants, err := s.repo.ListABVariants(ctx, campaignID) - if err != nil { - return nil, toErrx(err) + // Partition active variants into step-scoped (this step) and campaign-level. + var stepVariants, campaignVariants []models.CampaignABVariant + for _, v := range variants { + if !v.IsActive { + continue } - active := make([]models.CampaignABVariant, 0, len(variants)) - totalWeight := 0 - for _, v := range variants { - if !v.IsActive { - continue + if v.SequenceID != nil { + if *v.SequenceID == sequenceID { + stepVariants = append(stepVariants, v) } - if v.Weight <= 0 { - v.Weight = 100 - } - totalWeight += v.Weight - active = append(active, v) - } - if len(active) == 0 || totalWeight <= 0 { - return &models.VariantSelection{Subject: subject, BodyHTML: bodyHTML, BodyPlain: bodyPlain}, nil + } else { + campaignVariants = append(campaignVariants, v) } + } - pick := rand.Intn(totalWeight) - running := 0 - for i := range active { - running += active[i].Weight - if pick < running { - selected = &active[i] - break - } + var selected *models.CampaignABVariant + if len(stepVariants) > 0 { + // Step-scoped: deterministic per (contact, step), so the same contact + // always gets the same variant for this step without an assignment row. + selected = pickVariantDeterministic(stepVariants, contactID.String()+":"+sequenceID.String()) + } else if len(campaignVariants) > 0 { + // Campaign-level (legacy): keep the assignment-based selection so a + // contact stays on one variant across the whole campaign. + assigned, aerr := s.repo.GetAssignedVariant(ctx, campaignID, contactID) + if aerr != nil { + return nil, toErrx(aerr) } - if selected != nil { - _ = s.repo.AssignVariant(ctx, campaignID, contactID, selected.ID) + if assigned != nil && assigned.IsActive && assigned.SequenceID == nil { + selected = assigned + } else { + selected = pickVariantWeightedRandom(campaignVariants) + if selected != nil { + _ = s.repo.AssignVariant(ctx, campaignID, contactID, selected.ID) + } } } @@ -1022,7 +1075,9 @@ func (s *service) RunPreflight(ctx context.Context, organizationID, campaignID u } if settings.Preflight.CheckScheduleWindow { - pass := campaign.StartTime < campaign.EndTime + // Per-day windows (when set) define the schedule; otherwise fall back to + // the legacy start_time/end_time check. + pass := !campaign.ScheduleWindows.IsEmpty() || campaign.StartTime < campaign.EndTime check := models.PreflightCheckResult{ Key: "schedule_window", Passed: pass, diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go index 809204d7..b9abb68b 100644 --- a/internal/app/campaign/handlers.go +++ b/internal/app/campaign/handlers.go @@ -3,6 +3,9 @@ package campaign import ( "context" "errors" + "fmt" + "net" + "strings" "time" "github.com/getsentry/sentry-go" @@ -14,6 +17,7 @@ import ( "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" "github.com/warmbly/warmbly/internal/scheduler" + "github.com/warmbly/warmbly/internal/tasks" "github.com/warmbly/warmbly/internal/tasks/proto" "github.com/warmbly/warmbly/internal/utils/validate" ) @@ -159,13 +163,26 @@ func (s *campaignService) StartCampaign(ctx context.Context, orgID uuid.UUID, ca return errx.InternalError() } - // Validate active email accounts exist for the campaign's email tags - accounts, xerr := s.emailRepo.GetByTags(ctx, campaign.UserID, campaign.EmailTags) - if xerr != nil { - return xerr - } - if len(accounts) == 0 { - return errx.New(errx.BadRequest, "no active email accounts found for campaign's email tags") + // Sender-pool validity (explicit senders OR tags OR "all" fallback) is part + // of ValidateCampaignReady above, so no separate strategy-gated check here. + + // Block start if any step's template is malformed. Without this, a broken + // conditional (e.g. an {{if}} with no {{end}}) silently degrades to literal + // template text in the sent email — better to catch it here with a clear, + // step-scoped error than to ship {{if ...}} to recipients. + if seqs, serr := s.campaignRepository.GetSequencesByCampaignID(ctx, cID); serr == nil { + for i, seq := range seqs { + for _, f := range []struct { + name, val string + }{{"subject", seq.Subject}, {"body", seq.BodyHTML}, {"plain-text body", seq.BodyPlain}} { + if terr := tasks.TemplateError(f.val); terr != nil { + return errx.New(errx.BadRequest, fmt.Sprintf( + "Step %d's %s has a template error — fix the {{if}}/{{end}} or {{eq}} syntax before starting.", + i+1, f.name, + )) + } + } + } } activeCampaigns, err := s.campaignRepository.CountActiveForOrganization(ctx, orgID) @@ -216,7 +233,10 @@ func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID } nextTime, _, accountID, err := s.scheduler.CalculateNextCampaignTime(ctx, campaignID) - if err != nil { + // A deferral still yields a usable first-send slot (nextTime) and a nominal + // pool mailbox (accountID), so fall through and schedule the first wakeup at + // the defer time rather than failing the campaign start. + if err != nil && !errors.Is(err, scheduler.ErrCampaignDeferred) { switch { case errors.Is(err, scheduler.ErrNoEmailAccounts): _ = s.campaignRepository.UpdateStatusWithLock(ctx, campaignID, "paused_no_accounts") @@ -366,3 +386,82 @@ func (s *campaignService) GetLogs(ctx context.Context, userID, campaignID string return result, nil } + +// campaignForOrg loads a campaign and verifies it belongs to the given org. +func (s *campaignService) campaignForOrg(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.Campaign, uuid.UUID, *errx.Error) { + cID, parseErr := uuid.Parse(campaignID) + if parseErr != nil { + return nil, uuid.Nil, errx.ErrUuid + } + campaign, err := s.campaignRepository.GetByID(ctx, cID) + if err != nil { + if errors.Is(err, errx.ErrResourceNotFound) { + return nil, uuid.Nil, errx.ErrNotFound + } + return nil, uuid.Nil, errx.InternalError() + } + if campaign == nil || campaign.OrganizationID == nil || *campaign.OrganizationID != orgID { + return nil, uuid.Nil, errx.ErrNotFound + } + return campaign, cID, nil +} + +// ListCampaignSenders returns the campaign's explicit sender pool. +func (s *campaignService) ListCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string) ([]models.CampaignSender, *errx.Error) { + _, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID) + if xerr != nil { + return nil, xerr + } + senders, err := s.campaignRepository.GetCampaignSenders(ctx, cID) + if err != nil { + return nil, errx.InternalError() + } + return senders, nil +} + +// ReplaceCampaignSenders atomically replaces the explicit sender pool. +func (s *campaignService) ReplaceCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { + _, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID) + if xerr != nil { + return nil, xerr + } + return s.campaignRepository.ReplaceCampaignSenders(ctx, cID, in) +} + +// trackingDomainTarget is the shared host customers point their CNAME at. Kept +// in sync with the mailbox tracking-domain resolver and the TRACKING_DOMAIN +// default. +const trackingDomainTarget = "t.warmbly.com" + +// VerifyCampaignTrackingDomain resolves the campaign-scoped tracking domain's +// CNAME and flips verified on success. Only a verified override is honored at +// send time, so an unresolved record stays "pending" rather than erroring. +func (s *campaignService) VerifyCampaignTrackingDomain(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.TrackingDomainStatus, *errx.Error) { + campaign, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID) + if xerr != nil { + return nil, xerr + } + + status := &models.TrackingDomainStatus{TrackingDomain: campaign.TrackingDomain} + if campaign.TrackingDomain == "" { + // No override configured — nothing to verify; ensure verified is cleared. + if err := s.campaignRepository.SetCampaignTrackingDomainVerified(ctx, cID, false, nil); err != nil { + return nil, errx.InternalError() + } + return status, nil + } + + if cname, err := net.DefaultResolver.LookupCNAME(ctx, campaign.TrackingDomain); err == nil { + resolved := strings.TrimSuffix(strings.ToLower(cname), ".") + if strings.Contains(resolved, trackingDomainTarget) { + now := time.Now().UTC() + status.TrackingDomainVerified = true + status.TrackingDomainVerifiedAt = &now + } + } + + if err := s.campaignRepository.SetCampaignTrackingDomainVerified(ctx, cID, status.TrackingDomainVerified, status.TrackingDomainVerifiedAt); err != nil { + return nil, errx.InternalError() + } + return status, nil +} diff --git a/internal/app/campaign/service.go b/internal/app/campaign/service.go index 2f49225e..1f440f20 100644 --- a/internal/app/campaign/service.go +++ b/internal/app/campaign/service.go @@ -29,6 +29,14 @@ type CampaignService interface { // Logs GetLogs(ctx context.Context, userID, campaignID string, limit int, cursor *string) (*models.CampaignLogsResult, *errx.Error) + + // Explicit sender pool (feature 1). + ListCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string) ([]models.CampaignSender, *errx.Error) + ReplaceCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) + + // Campaign-scoped tracking domain (feature 5). Resolves the override's CNAME + // and flips verified on success; an unresolved record stays "pending". + VerifyCampaignTrackingDomain(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.TrackingDomainStatus, *errx.Error) } type campaignService struct { diff --git a/internal/app/credits/service.go b/internal/app/credits/service.go new file mode 100644 index 00000000..280bd37a --- /dev/null +++ b/internal/app/credits/service.go @@ -0,0 +1,203 @@ +// Package credits implements the AI writing-assistant credit ledger: balance +// reads, atomic consumption with idempotency, and grants. Hard billing +// correctness (no negative balances, no double-charge on retry) lives in the +// repository's atomic SQL; this service layer adds the abuse caps (a short +// rolling window plus a daily window) on top. +package credits + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/infrastructure/cache" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +const ( + // Rolling-window cap: max writing-assistant generations per org in a 5-hour + // window. Catches burst abuse independent of credit balance. + WindowShort = 5 * time.Hour + DefaultShortLimit = 60 + + // Daily cap: max writing-assistant generations per org per UTC day. + WindowDaily = 24 * time.Hour + DefaultDailyLimit = 300 + + keyPrefixShort = "credits:cap:5h:" + keyPrefixDaily = "credits:cap:day:" +) + +// ErrInsufficientCredits signals the org has fewer credits than requested. The +// handler maps this to HTTP 402. Distinct, exported sentinels are used (rather +// than *errx.Error) because errx has no 402 code; the handler emits the 402 +// envelope itself. +var ErrInsufficientCredits = errors.New("insufficient credits") + +// ErrCapExceeded signals the org hit a rolling/daily generation cap. The +// handler maps this to HTTP 429. +var ErrCapExceeded = errors.New("generation rate cap exceeded") + +// CreditService is the application-facing API for AI credits. +type CreditService interface { + // GetBalance returns the org's current credit balance (0 if no ledger yet). + GetBalance(ctx context.Context, orgID uuid.UUID) (int, *errx.Error) + + // Consume enforces the abuse caps, then atomically debits `amount` credits. + // On success it returns the resulting balance. idempotencyKey may be empty; + // when set, a retry with the same key does not double-charge and does not + // re-count against the caps. + // + // Returns ErrInsufficientCredits (→402) or ErrCapExceeded (→429) as + // sentinel errors the handler maps; any other error is an internal failure. + Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, error) + + // Grant credits to an org (monthly plan grant or purchase). Returns the + // resulting balance. + Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *errx.Error) + + // ListTransactions returns recent ledger transactions, newest first. + ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, *errx.Error) +} + +type creditService struct { + repo repository.CreditRepository + cache *cache.Cache + shortLimit int + dailyLimit int +} + +func NewService(repo repository.CreditRepository, c *cache.Cache) CreditService { + return &creditService{ + repo: repo, + cache: c, + shortLimit: DefaultShortLimit, + dailyLimit: DefaultDailyLimit, + } +} + +func (s *creditService) GetBalance(ctx context.Context, orgID uuid.UUID) (int, *errx.Error) { + ledger, err := s.repo.GetBalance(ctx, orgID) + if err != nil { + return 0, errx.New(errx.Internal, "failed to read credit balance") + } + if ledger == nil { + return 0, nil + } + return ledger.Balance, nil +} + +func (s *creditService) Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, error) { + if amount <= 0 { + return 0, errors.New("credit amount must be positive") + } + + // Enforce the abuse caps first. The repo's Consume then handles the atomic + // debit and idempotent replay; we only count a cap hit against a *fresh* + // debit (replayed == false) so a legitimate client retry with the same + // Idempotency-Key is never penalized against the 5h/daily window. + if err := s.checkCaps(ctx, orgID, idempotencyKey); err != nil { + return 0, err + } + + bal, _, replayed, err := s.repo.Consume(ctx, orgID, amount, reason, model, tokens, idempotencyKey) + if errors.Is(err, repository.ErrInsufficientCredits) { + return 0, ErrInsufficientCredits + } + if err != nil { + return 0, err + } + + // Persist the cap increment only for fresh debits. checkCaps used a + // reserve/peek so a replay does not advance the window. + if !replayed { + s.commitCaps(ctx, orgID) + } + return bal, nil +} + +func (s *creditService) Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *errx.Error) { + if amount <= 0 { + return 0, errx.New(errx.BadRequest, "grant amount must be positive") + } + bal, _, err := s.repo.Grant(ctx, orgID, amount, reason) + if err != nil { + return 0, errx.New(errx.Internal, "failed to grant credits") + } + return bal, nil +} + +func (s *creditService) ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, *errx.Error) { + txns, err := s.repo.ListTransactions(ctx, orgID, limit) + if err != nil { + return nil, errx.New(errx.Internal, "failed to list credit transactions") + } + return txns, nil +} + +// shortKey / dailyKey are the per-org Redis keys for the two abuse windows. +func (s *creditService) shortKey(orgID uuid.UUID) string { + return keyPrefixShort + orgID.String() +} + +func (s *creditService) dailyKey(orgID uuid.UUID) string { + day := time.Now().UTC().Format("2006-01-02") + return fmt.Sprintf("%s%s:%s", keyPrefixDaily, orgID.String(), day) +} + +// checkCaps rejects when the org has already reached the rolling 5h or daily +// generation cap. It only *reads* the counters (commitCaps does the increment +// after a successful fresh debit), so an idempotent replay never advances the +// window. On Redis errors it fails open so a cache outage never blocks +// legitimate generation. It is a thin wrapper over Redis (the same key shape the +// rate-limit service uses), not a reimplementation of that service. +// +// idempotencyKey is reserved for future per-key suppression; today a replay is +// distinguished after the fact via the repo's replayed flag. +func (s *creditService) checkCaps(ctx context.Context, orgID uuid.UUID, _ string) error { + if s.cache == nil { + return nil + } + if s.atOrOverLimit(ctx, s.shortKey(orgID), s.shortLimit) { + return ErrCapExceeded + } + if s.atOrOverLimit(ctx, s.dailyKey(orgID), s.dailyLimit) { + return ErrCapExceeded + } + return nil +} + +func (s *creditService) atOrOverLimit(ctx context.Context, key string, limit int) bool { + n, err := s.cache.Get(ctx, key).Int() + if err == redis.Nil { + return false + } + if err != nil { + // Fail open on cache errors. + return false + } + return n >= limit +} + +// commitCaps increments both windows after a successful fresh debit, setting the +// TTLs on first write. Best-effort: a Redis failure here never fails the +// already-completed generation. +func (s *creditService) commitCaps(ctx context.Context, orgID uuid.UUID) { + if s.cache == nil { + return + } + s.bump(ctx, s.shortKey(orgID), WindowShort) + s.bump(ctx, s.dailyKey(orgID), WindowDaily) +} + +func (s *creditService) bump(ctx context.Context, key string, window time.Duration) { + pipe := s.cache.Pipeline() + pipe.Incr(ctx, key) + pipe.Expire(ctx, key, window) + _, _ = pipe.Exec(ctx) +} diff --git a/internal/app/feature/gate.go b/internal/app/feature/gate.go index f6a208a0..79e4c446 100644 --- a/internal/app/feature/gate.go +++ b/internal/app/feature/gate.go @@ -15,6 +15,12 @@ const ( // UnlimitedEmails indicates unlimited daily emails UnlimitedEmails = -1 + + // Attachment storage quotas (overall bytes per organization). Generous by + // design; paid orgs get a much larger pool. Tunable per-plan later via a + // plans column without changing callers. + FreeTierStorageBytes int64 = 2 << 30 // 2 GiB for free / trial orgs + PaidStorageBytes int64 = 50 << 30 // 50 GiB for paid orgs ) type FeatureGateService interface { @@ -43,6 +49,14 @@ type FeatureGateService interface { // IsPaidOrganization checks if the organization has an active paid subscription IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) + + // GetStorageLimitBytes returns the org's total attachment storage quota in + // bytes (generous; larger for paid orgs). + GetStorageLimitBytes(ctx context.Context, orgID uuid.UUID) (int64, *errx.Error) + + // CanUseWritingAssistant reports whether the org may use the AI writing + // assistant (paid or in free trial; expired/no-subscription is blocked). + CanUseWritingAssistant(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) } // SubscriptionStatus contains status info for feature gating @@ -215,6 +229,33 @@ func (s *featureGateService) GetSubscriptionStatus(ctx context.Context, orgID uu return status, nil } +// GetStorageLimitBytes returns the org's attachment storage quota. Paid orgs +// get the larger pool; everyone else (trial or no subscription) gets the free +// allowance so they can still attach files. +func (s *featureGateService) GetStorageLimitBytes(ctx context.Context, orgID uuid.UUID) (int64, *errx.Error) { + sub, err := s.subRepo.GetByOrganizationID(ctx, orgID) + if err != nil { + return 0, errx.New(errx.Internal, "failed to get subscription") + } + if sub != nil && sub.HasPaidSubscription() { + return PaidStorageBytes, nil + } + return FreeTierStorageBytes, nil +} + +// CanUseWritingAssistant — same trial allowance as warmup/unibox: paid +// subscribers and orgs inside their free-trial window may use the AI assistant. +func (s *featureGateService) CanUseWritingAssistant(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) { + sub, err := s.subRepo.GetByOrganizationID(ctx, orgID) + if err != nil { + return false, errx.New(errx.Internal, "failed to get subscription") + } + if sub == nil { + return false, nil + } + return sub.HasPaidSubscription() || sub.IsInFreeTrial(), nil +} + // IsPaidOrganization checks if the organization has an active paid subscription func (s *featureGateService) IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) { sub, err := s.subRepo.GetByOrganizationID(ctx, orgID) diff --git a/internal/app/integration/actions.go b/internal/app/integration/actions.go index 448a96ea..e7e177a3 100644 --- a/internal/app/integration/actions.go +++ b/internal/app/integration/actions.go @@ -241,18 +241,6 @@ func pipedriveJSON(ctx context.Context, method, url, token string, body []byte, return nil } -// sheetsAppendRow appends an event row to a connected Google Sheet using the -// existing Sheets client wrapper. -func sheetsAppendRow(ctx context.Context, token, sheetID string, data map[string]any) error { - client := NewSheetsClient(token) - row := []string{ - time.Now().UTC().Format(time.RFC3339), - stringFromMap(data, "contact_email", "invitee_email", "email"), - stringFromMap(data, "subject", "event_name", "campaign_name"), - } - return client.AppendValues(ctx, sheetID, "A1", [][]string{row}) -} - func shortURL(u string) string { if i := len("https://api.hubapi.com"); len(u) > i { return u[i:] diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go index a0a12481..6c5aa355 100644 --- a/internal/app/integration/catalog.go +++ b/internal/app/integration/catalog.go @@ -154,17 +154,10 @@ func Catalog() []models.IntegrationCatalogEntry { Highlights: []string{"We mint an inbound URL for you", "Booked meetings credit the originating campaign"}, }, - // Data -------------------------------------------------------------- - { - Provider: models.IntegrationGoogleSheets, - Name: "Google Sheets", - Tagline: "Pull leads from a sheet; push reply / bounce / booked rows back.", - Category: models.IntegrationCategoryData, - AuthMethod: string(models.IntegrationAuthOAuth), - DocsURL: "https://developers.google.com/sheets/api", - BetaFlag: true, - Highlights: []string{"One-click Google OAuth", "Append a row on reply / bounce"}, - Events: crmEvents, - }, + // NOTE: Google Sheets is intentionally NOT a catalog integration. The + // google_sheets OAuth connection still exists (it powers the on-demand + // Lead Sync feature under Contacts), but it is no longer surfaced as an + // integration tile and has no event-driven append-row automation. See + // internal/app/leadsync. } } diff --git a/internal/app/integration/dispatch.go b/internal/app/integration/dispatch.go index 583ecbba..745df5bb 100644 --- a/internal/app/integration/dispatch.go +++ b/internal/app/integration/dispatch.go @@ -124,17 +124,6 @@ func (s *service) execAction(ctx context.Context, target repository.DispatchTarg } return pipedriveUpsertPerson(ctx, token, data) - case models.IntegrationActionSheetsAppend: - token, terr := s.accessTokenFor(ctx, &target.Secrets) - if terr != nil { - return errReauthRequired - } - sheetID := configString(sub.Config, "sheet_id") - if sheetID == "" { - return errors.New("no sheet_id configured") - } - return sheetsAppendRow(ctx, token, sheetID, data) - default: return fmt.Errorf("unknown action: %s", sub.Action) } diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 76f93ef2..cbb08ad7 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -67,6 +67,22 @@ type Service interface { // MarkSynced records a successful/failed round-trip against a connection. MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error + // --- Google Sheets read helpers (used by the lead-sync feature) --------- + // These expose the existing google_sheets OAuth token + Sheets client to + // the leadsync package without leaking secret handling out of this service. + + // GoogleConnection returns the org's google_sheets OAuth connection, or nil + // if the org has not connected Google. Even though google_sheets is hidden + // from the integrations catalog, the underlying OAuth connection still lives + // in integration_connections — this is how lead-sync finds it. + GoogleConnection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) + // SpreadsheetMeta returns the sheet's title + tabs using the connection's + // (refreshed) Google token. + SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*SheetMeta, error) + // SpreadsheetValues reads an A1 range from the sheet using the connection's + // (refreshed) Google token. + SpreadsheetValues(ctx context.Context, orgID, connID uuid.UUID, sheetID, a1Range string) ([][]string, error) + // Dispatch fans a platform event out to every matching event subscription, // executing each provider action. Best-effort: action failures are recorded // on the connection's health but never block the caller. @@ -116,7 +132,23 @@ func (s *service) Catalog() []models.IntegrationCatalogEntry { } func (s *service) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) { - return s.repo.ListConnections(ctx, orgID) + conns, err := s.repo.ListConnections(ctx, orgID) + if err != nil { + return nil, err + } + // Google Sheets is no longer a catalog integration — its OAuth connection + // exists only to power the on-demand Lead Sync feature (see + // internal/app/leadsync). Hide it from the Integrations page so it doesn't + // render as an integration tile. GoogleConnection() still reaches it via the + // repository directly. + out := conns[:0] + for _, c := range conns { + if c.Provider == models.IntegrationGoogleSheets { + continue + } + out = append(out, c) + } + return out, nil } func (s *service) GetConnection(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error) { @@ -395,6 +427,70 @@ func (s *service) MarkSynced(ctx context.Context, id uuid.UUID, status models.In return s.repo.MarkConnectionSynced(ctx, id, status, df, errMsg) } +// --- Google Sheets read helpers --------------------------------------------- + +// GoogleConnection returns the org's google_sheets OAuth connection (the most +// recent one), or nil when none exists. Lead-sync uses this connection's token +// to read sheets even though google_sheets is no longer surfaced as a catalog +// integration. +func (s *service) GoogleConnection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) { + conns, err := s.repo.ListConnections(ctx, orgID) + if err != nil { + return nil, err + } + for i := range conns { + if conns[i].Provider == models.IntegrationGoogleSheets { + c := conns[i] + return &c, nil + } + } + return nil, nil +} + +// googleSheetsClient resolves the (refreshed) Google access token for a +// connection and returns a ready Sheets client. The connection must belong to +// orgID and be the google_sheets provider. +func (s *service) googleSheetsClient(ctx context.Context, orgID, connID uuid.UUID) (*SheetsClient, error) { + conn, err := s.repo.GetConnectionByID(ctx, orgID, connID) + if err != nil { + return nil, err + } + if conn == nil { + return nil, errors.New("connection not found") + } + if conn.Provider != models.IntegrationGoogleSheets { + return nil, errors.New("connection is not a google_sheets connection") + } + sec, err := s.repo.GetConnectionSecrets(ctx, connID) + if err != nil { + return nil, err + } + if sec == nil { + return nil, errors.New("connection secrets not found") + } + token, err := s.accessTokenFor(ctx, sec) + if err != nil { + return nil, err + } + return NewSheetsClient(token), nil +} + +func (s *service) SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*SheetMeta, error) { + client, err := s.googleSheetsClient(ctx, orgID, connID) + if err != nil { + return nil, err + } + return client.GetSpreadsheet(ctx, sheetID) +} + +func (s *service) SpreadsheetValues(ctx context.Context, orgID, connID uuid.UUID, sheetID, a1Range string) ([][]string, error) { + client, err := s.googleSheetsClient(ctx, orgID, connID) + if err != nil { + return nil, err + } + return client.ReadValues(ctx, sheetID, a1Range) +} + // --- encryption helpers ----------------------------------------------------- func (s *service) seal(ctx context.Context, userID uuid.UUID, plaintext string) (string, error) { diff --git a/internal/app/leadsync/service.go b/internal/app/leadsync/service.go new file mode 100644 index 00000000..db2d1f96 --- /dev/null +++ b/internal/app/leadsync/service.go @@ -0,0 +1,439 @@ +// Package leadsync implements the on-demand Google Sheets -> leads sync. +// +// A "lead sync source" is a saved binding between a Google Sheet and Warmbly's +// contact importer that the user re-runs with a "Sync now" button. There is no +// background scheduler and no worker involvement: this is pure control-plane +// work. SyncNow reads the sheet, encodes the rows as CSV in memory, and hands +// them to the existing contact ImportCommit path so contact creation/dedupe is +// never reimplemented here. +package leadsync + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/contact" + "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// previewRows is how many sheet rows the preview reads (header + sample). It +// mirrors the contacts import preview cap so the dashboard's column mapper sees +// the same amount of data either way. +const previewRows = models.MaxContactImportPreviewRows + 1 + +// fullColumnSpan is the widest A1 column range we read. Google Sheets caps at +// far more, but contact imports are narrow; A:Z is plenty for typical lead +// sheets and keeps the read cheap. +const fullColumnSpan = "A:Z" + +// Service is the on-demand lead-sync control-plane surface. +type Service interface { + // Connection returns the org's google_sheets OAuth connection (or nil). + Connection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) + // SpreadsheetMeta returns the sheet title + tabs for the connect step. + SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*integration.SheetMeta, error) + // Preview reads the top rows of a tab and returns an ImportPreview-shaped + // payload so the frontend reuses its contact-import column mapper verbatim. + Preview(ctx context.Context, orgID, connID uuid.UUID, sheetID, tabTitle string) (*models.ContactImportPreview, *errx.Error) + + // Source CRUD. + List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) + Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, *errx.Error) + Create(ctx context.Context, orgID, userID uuid.UUID, in *models.CreateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) + Update(ctx context.Context, orgID, id uuid.UUID, in *models.UpdateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) + Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error + + // SyncNow reads the source's sheet and upserts contacts via ImportCommit. + // triggeringUserID scopes the contact upsert (contacts are per-user). + SyncNow(ctx context.Context, triggeringUserID, orgID, sourceID uuid.UUID) (*models.LeadSyncResult, *errx.Error) +} + +type service struct { + repo repository.LeadSyncRepository + integration integration.Service + contacts contact.ContactService +} + +// NewService wires the lead-sync service to the integration service (Google +// token + sheet reads), the contact service (ImportCommit), and the lead-sync +// repository. +func NewService(repo repository.LeadSyncRepository, integrationSvc integration.Service, contactSvc contact.ContactService) Service { + return &service{repo: repo, integration: integrationSvc, contacts: contactSvc} +} + +func (s *service) Connection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) { + return s.integration.GoogleConnection(ctx, orgID) +} + +func (s *service) SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*integration.SheetMeta, error) { + return s.integration.SpreadsheetMeta(ctx, orgID, connID, sheetID) +} + +func (s *service) Preview(ctx context.Context, orgID, connID uuid.UUID, sheetID, tabTitle string) (*models.ContactImportPreview, *errx.Error) { + sheetID = strings.TrimSpace(sheetID) + if sheetID == "" { + return nil, errx.New(errx.BadRequest, "sheet_id is required") + } + a1 := buildA1Range(tabTitle, previewRows) + values, err := s.integration.SpreadsheetValues(ctx, orgID, connID, sheetID, a1) + if err != nil { + return nil, errx.New(errx.BadRequest, "failed to read sheet: "+err.Error()) + } + if len(values) == 0 { + return nil, errx.New(errx.BadRequest, "the selected sheet/tab is empty") + } + + // The first row is treated as the header (sheets almost always have one); + // the user can flip has_header in the UI, exactly like a CSV import. + headers := normalizeHeaders(values[0]) + width := len(headers) + + sample := make([][]string, 0, len(values)-1) + for i := 1; i < len(values); i++ { + sample = append(sample, padRow(values[i], width)) + } + + return &models.ContactImportPreview{ + Filename: "google-sheets-sync.csv", + Format: "csv", + TotalRows: len(values) - 1, + Columns: headers, + HasHeader: true, + SampleRows: sample, + SuggestedMapping: suggestMapping(headers), + }, nil +} + +func (s *service) List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) { + return s.repo.List(ctx, orgID, campaignID) +} + +func (s *service) Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, *errx.Error) { + src, err := s.repo.Get(ctx, orgID, id) + if err != nil { + return nil, errx.New(errx.Internal, "failed to load sync source") + } + if src == nil { + return nil, errx.New(errx.NotFound, "sync source not found") + } + return src, nil +} + +func (s *service) Create(ctx context.Context, orgID, userID uuid.UUID, in *models.CreateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) { + if in == nil { + return nil, errx.New(errx.BadRequest, "missing payload") + } + if in.ConnectionID == uuid.Nil { + return nil, errx.New(errx.BadRequest, "connection_id is required") + } + if strings.TrimSpace(in.SheetID) == "" { + return nil, errx.New(errx.BadRequest, "sheet_id is required") + } + if len(in.ColumnMapping) == 0 { + return nil, errx.New(errx.BadRequest, "column_mapping is required") + } + if xerr := validateDedup(in.Dedup); xerr != nil { + return nil, xerr + } + if xerr := validateMappingHasEmail(in.ColumnMapping); xerr != nil { + return nil, xerr + } + + // The connection must be this org's google_sheets OAuth connection. + conn, err := s.integration.GoogleConnection(ctx, orgID) + if err != nil { + return nil, errx.New(errx.Internal, "failed to resolve google connection") + } + if conn == nil || conn.ID != in.ConnectionID { + return nil, errx.New(errx.BadRequest, "connection_id is not this organization's connected Google account") + } + + subscribed := true + if in.SubscribedDefault != nil { + subscribed = *in.SubscribedDefault + } + cats := in.CategoryIDs + if cats == nil { + cats = []string{} + } + + src := &models.LeadSyncSource{ + OrganizationID: orgID, + CreatedByUserID: userID, + Provider: string(models.IntegrationGoogleSheets), + ConnectionID: in.ConnectionID, + SheetID: strings.TrimSpace(in.SheetID), + SheetTitle: strings.TrimSpace(in.SheetTitle), + TabTitle: strings.TrimSpace(in.TabTitle), + A1Range: buildA1Range(in.TabTitle, 0), + HasHeader: in.HasHeader, + ColumnMapping: in.ColumnMapping, + Dedup: in.Dedup, + TargetCampaignID: in.TargetCampaignID, + CategoryIDs: cats, + SubscribedDefault: subscribed, + Label: strings.TrimSpace(in.Label), + Status: models.LeadSyncStatusIdle, + } + if err := s.repo.Create(ctx, src); err != nil { + return nil, errx.New(errx.Internal, "failed to create sync source") + } + return src, nil +} + +func (s *service) Update(ctx context.Context, orgID, id uuid.UUID, in *models.UpdateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) { + if in == nil { + return nil, errx.New(errx.BadRequest, "missing payload") + } + src, xerr := s.Get(ctx, orgID, id) + if xerr != nil { + return nil, xerr + } + + if in.SheetID != nil { + v := strings.TrimSpace(*in.SheetID) + if v == "" { + return nil, errx.New(errx.BadRequest, "sheet_id cannot be empty") + } + src.SheetID = v + } + if in.SheetTitle != nil { + src.SheetTitle = strings.TrimSpace(*in.SheetTitle) + } + if in.TabTitle != nil { + src.TabTitle = strings.TrimSpace(*in.TabTitle) + src.A1Range = buildA1Range(src.TabTitle, 0) + } + if in.HasHeader != nil { + src.HasHeader = *in.HasHeader + } + if in.ColumnMapping != nil { + if len(*in.ColumnMapping) == 0 { + return nil, errx.New(errx.BadRequest, "column_mapping cannot be empty") + } + if xerr := validateMappingHasEmail(*in.ColumnMapping); xerr != nil { + return nil, xerr + } + src.ColumnMapping = *in.ColumnMapping + } + if in.Dedup != nil { + if xerr := validateDedup(*in.Dedup); xerr != nil { + return nil, xerr + } + src.Dedup = *in.Dedup + } + if in.ClearCampaign { + src.TargetCampaignID = nil + } else if in.TargetCampaignID != nil { + src.TargetCampaignID = in.TargetCampaignID + } + if in.CategoryIDs != nil { + src.CategoryIDs = *in.CategoryIDs + } + if in.SubscribedDefault != nil { + src.SubscribedDefault = *in.SubscribedDefault + } + if in.Label != nil { + src.Label = strings.TrimSpace(*in.Label) + } + + if err := s.repo.Update(ctx, src); err != nil { + return nil, errx.New(errx.Internal, "failed to update sync source") + } + return src, nil +} + +func (s *service) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error { + if _, xerr := s.Get(ctx, orgID, id); xerr != nil { + return xerr + } + if err := s.repo.Delete(ctx, orgID, id); err != nil { + return errx.New(errx.Internal, "failed to delete sync source") + } + return nil +} + +// SyncNow reads the source's sheet and upserts contacts through the existing +// contact ImportCommit path. +// +// Idempotency: SyncNow is naturally idempotent. ImportCommit dedupes/upserts by +// (user, email), so re-running the same source against the same sheet converges +// to the same contact set — no Idempotency-Key header is required for safe +// retries (re-runs at worst re-update unchanged rows). +func (s *service) SyncNow(ctx context.Context, triggeringUserID, orgID, sourceID uuid.UUID) (*models.LeadSyncResult, *errx.Error) { + src, xerr := s.Get(ctx, orgID, sourceID) + if xerr != nil { + return nil, xerr + } + + // Read the full tab. We deliberately re-read the whole range each run so + // new rows (appended at the bottom) are picked up. + a1 := buildA1Range(src.TabTitle, 0) + values, err := s.integration.SpreadsheetValues(ctx, orgID, src.ConnectionID, src.SheetID, a1) + if err != nil { + s.recordResult(ctx, src.ID, models.LeadSyncStatusError, nil, nil, err.Error()) + return nil, errx.New(errx.BadRequest, "failed to read sheet: "+err.Error()) + } + + csvBytes, cerr := encodeCSV(values) + if cerr != nil { + s.recordResult(ctx, src.ID, models.LeadSyncStatusError, nil, nil, cerr.Error()) + return nil, errx.New(errx.Internal, "failed to encode sheet rows") + } + + var campaignIDs []string + if src.TargetCampaignID != nil { + campaignIDs = []string{src.TargetCampaignID.String()} + } + subscribed := src.SubscribedDefault + opts := &models.ContactImportCommit{ + Mapping: src.ColumnMapping, + Dedup: src.Dedup, + HasHeader: src.HasHeader, + CategoryIDs: src.CategoryIDs, + CampaignIDs: campaignIDs, + SubscribedDefault: &subscribed, + } + + result, ierr := s.contacts.ImportCommit(ctx, triggeringUserID.String(), bytes.NewReader(csvBytes), "google-sheets-sync.csv", opts) + if ierr != nil { + s.recordResult(ctx, src.ID, models.LeadSyncStatusError, nil, nil, ierr.Message) + return nil, ierr + } + + now := time.Now().UTC() + resJSON, _ := json.Marshal(result) + s.recordResult(ctx, src.ID, models.LeadSyncStatusIdle, &now, resJSON, "") + + return &models.LeadSyncResult{SourceID: src.ID, Result: result}, nil +} + +// recordResult is a best-effort persistence of a sync outcome. Failures to +// persist the bookkeeping never fail the sync the user already paid for. +func (s *service) recordResult(ctx context.Context, id uuid.UUID, status models.LeadSyncStatus, syncedAt *time.Time, resJSON []byte, errMsg string) { + _ = s.repo.SetResult(ctx, id, status, syncedAt, resJSON, errMsg) +} + +// --- helpers ---------------------------------------------------------------- + +// buildA1Range builds an A1 range for a tab. With maxRows == 0 it returns the +// full-column span for the tab (read everything); with maxRows > 0 it bounds +// the read to the first maxRows rows (preview). +// +// Tab titles are quoted with single quotes and any embedded single quote is +// doubled, per the Sheets A1 grammar. +func buildA1Range(tabTitle string, maxRows int) string { + span := fullColumnSpan + if maxRows > 0 { + span = "A1:Z" + strconv.Itoa(maxRows) + } + tab := strings.TrimSpace(tabTitle) + if tab == "" { + return span + } + quoted := "'" + strings.ReplaceAll(tab, "'", "''") + "'" + return quoted + "!" + span +} + +// encodeCSV writes the sheet's 2-D values to an in-memory CSV buffer. The +// contact importer re-parses this with the stdlib CSV reader, so the round-trip +// stays inside one well-understood format. +func encodeCSV(values [][]string) ([]byte, error) { + var buf bytes.Buffer + w := csv.NewWriter(&buf) + for _, row := range values { + if err := w.Write(row); err != nil { + return nil, err + } + } + w.Flush() + if err := w.Error(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func validateDedup(d models.ContactImportDedupStrategy) *errx.Error { + switch d { + case models.ContactImportDedupSkip, models.ContactImportDedupUpdate, models.ContactImportDedupCreateDuplicate: + return nil + } + return errx.New(errx.BadRequest, "unknown dedup strategy: "+string(d)) +} + +// validateMappingHasEmail enforces that at least one column maps to the email +// target — without it ImportCommit would reject every row as "missing email". +func validateMappingHasEmail(mapping []models.ContactImportColumnMapping) *errx.Error { + for _, m := range mapping { + if m.Target == models.ContactImportTargetEmail { + return nil + } + } + return errx.New(errx.BadRequest, "column_mapping must map one column to 'email'") +} + +// normalizeHeaders trims the header row and synthesises a name for any blank +// cell so every column is mappable, mirroring the CSV importer's behaviour. +func normalizeHeaders(first []string) []string { + out := make([]string, len(first)) + for i, c := range first { + out[i] = strings.TrimSpace(c) + if out[i] == "" { + out[i] = "Column " + strconv.Itoa(i+1) + } + } + return out +} + +// padRow returns a copy of row padded (or truncated) to n columns. Sheets +// returns ragged rows when trailing cells are empty. +func padRow(row []string, n int) []string { + if len(row) >= n { + return row[:n] + } + out := make([]string, n) + copy(out, row) + return out +} + +// suggestMapping applies the same fuzzy header heuristics the contact importer +// uses so the dashboard's preview arrives with sensible defaults. +func suggestMapping(headers []string) []models.ContactImportColumnMapping { + out := make([]models.ContactImportColumnMapping, len(headers)) + for i, h := range headers { + out[i] = guessTarget(i, h) + } + return out +} + +func guessTarget(idx int, header string) models.ContactImportColumnMapping { + key := strings.ToLower(header) + key = strings.NewReplacer(" ", "", "_", "", "-", "", ".", "").Replace(key) + switch key { + case "email", "emailaddress", "mail", "primaryemail": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetEmail} + case "firstname", "givenname", "fname", "first": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetFirstName} + case "lastname", "familyname", "surname", "lname", "last": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetLastName} + case "company", "companyname", "organization", "organisation", "employer", "account", "accountname": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetCompany} + case "phone", "phonenumber", "mobile", "cell": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetPhone} + case "subscribed", "optin", "optedin", "subscribe": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetSubscribed} + case "categories", "category", "tags", "tag", "labels", "label": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetCategories} + } + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetIgnore} +} diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 60421fbf..86bcf8ed 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -89,6 +89,12 @@ type OrganizationService interface { // compare counts against. GetEffectiveLimits(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimits, *errx.Error) + // WebhookDispatchLimit returns the org's per-minute cap on webhook + + // integration fan-out, derived from the org's effective plan limits. Used by + // the webhook dispatch throttle. Never errors — falls back to the generous + // base cap so dispatch is never blocked by a limits-lookup failure. + WebhookDispatchLimit(ctx context.Context, orgID uuid.UUID) int + // Limit-increase request workflow. Users self-serve via // SubmitLimitIncreaseRequest from the dashboard; admins drain the // queue with the AdminListLimitRequests / ApproveLimitRequest / @@ -873,6 +879,30 @@ func (s *organizationService) GetEffectiveLimits(ctx context.Context, orgID uuid }, nil } +// WebhookDispatchLimit derives the org's per-minute webhook/integration fan-out +// cap from its effective plan limits. Plan-based: it scales with the org's +// resolved mailbox allowance (since webhook volume tracks sending activity), +// floored at a generous baseline so even free/no-plan orgs never trip the +// throttle under normal use, and ceilinged so an "unlimited" plan stays bounded. +// Fail-open generous: any error yields the base cap rather than blocking. +func (s *organizationService) WebhookDispatchLimit(ctx context.Context, orgID uuid.UUID) int { + limit := config.WebhookDispatchBasePerMinute + + eff, err := s.GetEffectiveLimits(ctx, orgID) + if err != nil || eff == nil { + return limit + } + if eff.MaxEmailAccounts != nil { + if scaled := *eff.MaxEmailAccounts * config.WebhookDispatchPerMailboxPerMinute; scaled > limit { + limit = scaled + } + } + if limit > config.WebhookDispatchMaxPerMinute { + limit = config.WebhookDispatchMaxPerMinute + } + return limit +} + // limitFieldUpdater maps a request's `field` string onto the override // column it should write. Keeping this map adjacent to the request flow // makes it obvious what's user-requestable; adding a new field is two diff --git a/internal/app/sequence/handler.go b/internal/app/sequence/handler.go index 7a597946..9258d908 100644 --- a/internal/app/sequence/handler.go +++ b/internal/app/sequence/handler.go @@ -16,6 +16,10 @@ func (s *sequenceService) Get(ctx context.Context, userID, campaignID string) ([ } func (s *sequenceService) Update(ctx context.Context, userID, campaignID, sequenceID string, data *models.UpdateSequence) (*models.Sequence, *errx.Error) { + // Branch routing is resolved (and made safe against deleted/dangling targets + // and loops) at schedule time in the repository's finder; the repository also + // validates branch shape before persisting. No cross-step write validation is + // needed here — the canvas only ever points a branch at a real step or stop. return s.sequenceRepository.Update(ctx, userID, campaignID, sequenceID, data) } diff --git a/internal/app/user/onboarding.go b/internal/app/user/onboarding.go index 4597487d..40e29743 100644 --- a/internal/app/user/onboarding.go +++ b/internal/app/user/onboarding.go @@ -16,3 +16,16 @@ func (s *userService) CompleteOnboarding(ctx context.Context, userID uuid.UUID, return nil } + +// UpdateProfile persists the user's display name (first/last) from the profile +// settings page. Distinct from CompleteOnboarding, which also captures the +// one-time questionnaire answers; this is the editable, repeatable name update. +func (s *userService) UpdateProfile(ctx context.Context, userID uuid.UUID, firstName, lastName string) *errx.Error { + if err := s.userRepository.UpdateProfile(ctx, userID, firstName, lastName); err != nil { + return errx.InternalError() + } + + s.cache.Del(ctx, getUserKey(userID)) + + return nil +} diff --git a/internal/app/user/service.go b/internal/app/user/service.go index 70404bc5..499c8c12 100644 --- a/internal/app/user/service.go +++ b/internal/app/user/service.go @@ -14,6 +14,7 @@ type UserService interface { SaveUser(ctx context.Context, user *models.User) *errx.Error GetUser(ctx context.Context, userID uuid.UUID) (*models.User, *errx.Error) CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) *errx.Error + UpdateProfile(ctx context.Context, userID uuid.UUID, firstName, lastName string) *errx.Error } type userService struct { diff --git a/internal/app/webhook/service.go b/internal/app/webhook/service.go index 687a45ea..8aed68cc 100644 --- a/internal/app/webhook/service.go +++ b/internal/app/webhook/service.go @@ -24,6 +24,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/infrastructure/cache" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" ) @@ -60,6 +61,17 @@ type Service interface { // replacement; pass nil to detach. WireDispatchSink(sink DispatchSink) + // WireThrottle attaches a Redis-backed per-org, per-event-type dispatch + // throttle. resolveLimit returns the org's per-minute cap on how many events + // of a single type it may fan out (plan-based — see organization + // WebhookDispatchLimit); over the cap, further events of that type in the + // same minute are dropped (logged) instead of reaching webhooks or + // integration sinks. The guard against a campaign "notify" action — or any + // per-contact event — flooding a customer's endpoints. The resolved cap is + // cached briefly so resolveLimit is not hit on every event. Pass a nil cache + // or nil resolver to disable (fail-open). + WireThrottle(c *cache.Cache, resolveLimit func(ctx context.Context, orgID uuid.UUID) int) + // Endpoint CRUD wrappers. CreateEndpoint(ctx context.Context, orgID uuid.UUID, url, description string, eventTypes []string, enabled bool) (*models.WebhookEndpointWithSecret, error) UpdateEndpoint(ctx context.Context, orgID, endpointID uuid.UUID, url, description string, eventTypes []string, enabled bool) (*models.WebhookEndpoint, error) @@ -70,9 +82,11 @@ type Service interface { } type service struct { - repo repository.WebhookRepository - now func() time.Time - sink DispatchSink + repo repository.WebhookRepository + now func() time.Time + sink DispatchSink + cache *cache.Cache + resolveLimit func(ctx context.Context, orgID uuid.UUID) int } func NewService(repo repository.WebhookRepository) Service { @@ -83,9 +97,78 @@ func (s *service) WireDispatchSink(sink DispatchSink) { s.sink = sink } +func (s *service) WireThrottle(c *cache.Cache, resolveLimit func(ctx context.Context, orgID uuid.UUID) int) { + s.cache = c + s.resolveLimit = resolveLimit +} + +// StaticLimit adapts a fixed per-minute cap into a throttle resolver, for +// callers without plan context (e.g. the consumer, which dispatches lower-volume +// reply/warmup events rather than per-contact campaign fan-out). +func StaticLimit(perMinute int) func(context.Context, uuid.UUID) int { + return func(context.Context, uuid.UUID) int { return perMinute } +} + +// orgLimit returns the org's per-minute dispatch cap, caching the resolved +// value in Redis for a minute so resolveLimit (a plan lookup) is not hit on +// every event. +func (s *service) orgLimit(ctx context.Context, orgID uuid.UUID) int { + key := "wh:limit:" + orgID.String() + if v, err := s.cache.Get(ctx, key).Int(); err == nil && v > 0 { + return v + } + limit := s.resolveLimit(ctx, orgID) + if limit > 0 { + s.cache.Set(ctx, key, limit, time.Minute) + } + return limit +} + +// throttled reports whether this (org, eventType) has exceeded its per-minute +// dispatch cap. Fixed one-minute window in Redis; fail-open on any cache error +// so a Redis hiccup never silently swallows events. A no-op (returns false) +// when no throttle is wired. +func (s *service) throttled(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType) bool { + if s.cache == nil || s.resolveLimit == nil { + return false + } + limit := s.orgLimit(ctx, orgID) + if limit <= 0 { + return false // resolver opted out / fail-open + } + bucket := s.now().Unix() / 60 + key := fmt.Sprintf("wh:disp:%s:%s:%d", orgID, eventType, bucket) + n, err := s.cache.Incr(ctx, key).Result() + if err != nil { + return false // fail-open + } + if n == 1 { + // First hit in this window — set a short TTL so the key self-expires. + s.cache.Expire(ctx, key, 2*time.Minute) + } + if n > int64(limit) { + log.Warn(). + Str("org_id", orgID.String()). + Str("event_type", string(eventType)). + Int64("count", n). + Int("limit_per_min", limit). + Msg("Webhook dispatch throttled — dropping event for this minute") + return true + } + return false +} + func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) { eventID := uuid.New() + // Global per-org, per-event-type fan-out throttle. Stops a per-contact + // event source (notably a campaign "notify" action over a large lead list) + // from flooding the org's webhooks and integration sinks. Checked before + // both the sink and endpoint enqueue so an over-cap event reaches neither. + if s.throttled(ctx, orgID, eventType) { + return eventID, nil + } + // Fan the event to non-webhook subscribers (integration actions) first, // independently of whether any webhook endpoint is configured. if s.sink != nil { diff --git a/internal/app/worker/event_send_email.go b/internal/app/worker/event_send_email.go index be0fa937..16292aff 100644 --- a/internal/app/worker/event_send_email.go +++ b/internal/app/worker/event_send_email.go @@ -52,14 +52,23 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, body any) error { } } - // Fetch email body from S3 - bodyPlain, bodyHTML, err := w.fetchEmailBody(ctx, sendEmail.UserID, sendEmail.BodyS3Key) + // Fetch email body from S3 (attachment refs ride inside the emsg blob). + bodyPlain, bodyHTML, attachmentRefs, err := w.fetchEmailBody(ctx, sendEmail.UserID, sendEmail.BodyS3Key) if err != nil { log.Error().Err(err).Str("s3_key", sendEmail.BodyS3Key).Msg("Failed to fetch email body from S3") w.sendEmailFailure(sendEmail.TaskID, sendEmail.EmailID, mail, fmt.Sprintf("failed to fetch email body: %v", err)) return err } + // Fetch each attachment's bytes from object storage by key. A fetch failure + // fails the send rather than silently dropping a file the user expects. + attachments, err := w.fetchAttachments(ctx, attachmentRefs) + if err != nil { + log.Error().Err(err).Str("task_id", sendEmail.TaskID.String()).Msg("Failed to fetch attachment bytes from S3") + w.sendEmailFailure(sendEmail.TaskID, sendEmail.EmailID, mail, fmt.Sprintf("failed to fetch attachment: %v", err)) + return err + } + // Use unified Send method w.recordSendAttempt() sendStart := time.Now() @@ -77,6 +86,7 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, body any) error { IsWarmup: sendEmail.IsWarmup, WarmupToken: sendEmail.WarmupToken, UnsubscribeURL: sendEmail.UnsubscribeURL, + Attachments: attachments, }) w.recordSendLatency(time.Since(sendStart)) w.recordSendOutcome(result) @@ -118,29 +128,30 @@ func (w *WorkerService) deleteTransportEmailBody(ctx context.Context, taskID uui } } -// fetchEmailBody fetches and decodes email body from S3 -func (w *WorkerService) fetchEmailBody(ctx context.Context, userID uuid.UUID, s3Key string) (string, string, error) { +// fetchEmailBody fetches and decodes the email body from S3, returning the +// decrypted plain/HTML bodies and the attachment refs carried inside the blob. +func (w *WorkerService) fetchEmailBody(ctx context.Context, userID uuid.UUID, s3Key string) (string, string, []emsg.Attachment, error) { if w.Storage == nil { - return "", "", fmt.Errorf("storage client not configured") + return "", "", nil, fmt.Errorf("storage client not configured") } // Get object from storage body, err := w.Storage.Get(ctx, s3Key) if err != nil { - return "", "", fmt.Errorf("failed to get S3 object: %w", err) + return "", "", nil, fmt.Errorf("failed to get S3 object: %w", err) } defer body.Close() // Read the body data, err := io.ReadAll(body) if err != nil { - return "", "", fmt.Errorf("failed to read S3 object: %w", err) + return "", "", nil, fmt.Errorf("failed to read S3 object: %w", err) } // Decode using emsg blob, err := emsg.DecodeBinary(bytes.NewReader(data)) if err != nil { - return "", "", fmt.Errorf("failed to decode emsg blob: %w", err) + return "", "", nil, fmt.Errorf("failed to decode emsg blob: %w", err) } bodyPlain := string(blob.PlainText) @@ -161,7 +172,43 @@ func (w *WorkerService) fetchEmailBody(ctx context.Context, userID uuid.UUID, s3 } } - return bodyPlain, bodyHTML, nil + return bodyPlain, bodyHTML, blob.Attachments, nil +} + +// fetchAttachments downloads each attachment's bytes from object storage by +// key, returning wmail attachments ready to be MIME-encoded. The bytes are +// stored as-is (not user-encrypted), so no cipher pass is needed here. +func (w *WorkerService) fetchAttachments(ctx context.Context, refs []emsg.Attachment) ([]wmail.Attachment, error) { + if len(refs) == 0 { + return nil, nil + } + if w.Storage == nil { + return nil, fmt.Errorf("storage client not configured") + } + + out := make([]wmail.Attachment, 0, len(refs)) + for _, ref := range refs { + rc, err := w.Storage.Get(ctx, ref.S3Key) + if err != nil { + return nil, fmt.Errorf("get attachment %s: %w", ref.Filename, err) + } + data, readErr := io.ReadAll(rc) + rc.Close() + if readErr != nil { + return nil, fmt.Errorf("read attachment %s: %w", ref.Filename, readErr) + } + + mimeType := ref.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + out = append(out, wmail.Attachment{ + Filename: ref.Filename, + MimeType: mimeType, + Data: data, + }) + } + return out, nil } // sendEmailSuccess sends a success result back to the jobs service diff --git a/internal/app/worker/wmail/send.go b/internal/app/worker/wmail/send.go index 4e736bd0..5bea86cf 100644 --- a/internal/app/worker/wmail/send.go +++ b/internal/app/worker/wmail/send.go @@ -7,11 +7,20 @@ import ( "github.com/google/uuid" "github.com/warmbly/warmbly/internal/client/goog" + "github.com/warmbly/warmbly/internal/client/smtpimap/smtp" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) +// Attachment is a fully-resolved attachment ready to be MIME-encoded: the +// worker has already fetched Data from object storage. +type Attachment struct { + Filename string + MimeType string + Data []byte +} + // SendRequest contains all parameters needed to send an email type SendRequest struct { TaskID uuid.UUID @@ -29,6 +38,9 @@ type SendRequest struct { // UnsubscribeURL, when set (campaign sends with the unsubscribe header // enabled), produces RFC 8058 one-click unsubscribe headers. UnsubscribeURL string + // Attachments, when present, are encoded as multipart/mixed parts after the + // multipart/alternative text body. Warmup sends never carry attachments. + Attachments []Attachment } // buildSendHeaders assembles the outbound custom headers: the warmup @@ -145,6 +157,10 @@ func (w *WMail) sendViaGmail(ctx context.Context, req *SendRequest, bodyHTML str // Build custom headers (warmup token + RFC 8058 one-click unsubscribe). customHeaders := buildSendHeaders(req) + // Convert resolved attachments to the goog transport shape (warmup sends + // carry none, but threading req.Attachments is harmless when empty). + attachments := toGoogAttachments(req.Attachments) + // Send via Gmail API gmailMsg, err := w.GoogleData.Client.SendMessage( ctx, @@ -156,6 +172,7 @@ func (w *WMail) sendViaGmail(ctx context.Context, req *SendRequest, bodyHTML str req.BodyPlain, bodyHTML, parent, + attachments, customHeaders, ) if err != nil { @@ -200,32 +217,23 @@ func (w *WMail) sendViaSMTP(ctx context.Context, req *SendRequest, bodyHTML stri // Build custom headers (warmup token + RFC 8058 one-click unsubscribe). smtpCustomHeaders := buildSendHeaders(req) - // Send via SMTP - var merr *errx.MailError - if smtpCustomHeaders != nil { - merr = w.SmtpImapData.SmtpClient.Send( - ctx, - req.To, - req.Cc, - req.Bcc, - req.Subject, - req.BodyPlain, - bodyHTML, - req.InReplyTo, - smtpCustomHeaders, - ) - } else { - merr = w.SmtpImapData.SmtpClient.Send( - ctx, - req.To, - req.Cc, - req.Bcc, - req.Subject, - req.BodyPlain, - bodyHTML, - req.InReplyTo, - ) - } + // Convert resolved attachments to the SMTP transport shape. + smtpAttachments := toSMTPAttachments(req.Attachments) + + // Send via SMTP. Attachments are passed explicitly (not variadic) so an + // empty list still selects the same code path. + merr := w.SmtpImapData.SmtpClient.Send( + ctx, + req.To, + req.Cc, + req.Bcc, + req.Subject, + req.BodyPlain, + bodyHTML, + req.InReplyTo, + smtpAttachments, + smtpCustomHeaders, + ) if merr != nil { result.Error = merr return result @@ -237,6 +245,38 @@ func (w *WMail) sendViaSMTP(ctx context.Context, req *SendRequest, bodyHTML stri return result } +// toGoogAttachments maps wmail attachments to the Gmail transport shape. +func toGoogAttachments(in []Attachment) []goog.Attachment { + if len(in) == 0 { + return nil + } + out := make([]goog.Attachment, 0, len(in)) + for _, a := range in { + out = append(out, goog.Attachment{ + Filename: a.Filename, + MimeType: a.MimeType, + Data: a.Data, + }) + } + return out +} + +// toSMTPAttachments maps wmail attachments to the SMTP transport shape. +func toSMTPAttachments(in []Attachment) []smtp.Attachment { + if len(in) == 0 { + return nil + } + out := make([]smtp.Attachment, 0, len(in)) + for _, a := range in { + out = append(out, smtp.Attachment{ + Filename: a.Filename, + MimeType: a.MimeType, + Data: a.Data, + }) + } + return out +} + // DetermineErrorEventType maps a MailError to the appropriate JobEventType func DetermineErrorEventType(err *errx.MailError) models.JobEventType { if err == nil { diff --git a/internal/client/goog/send.go b/internal/client/goog/send.go index 9f891f11..a926dea0 100644 --- a/internal/client/goog/send.go +++ b/internal/client/goog/send.go @@ -1,23 +1,47 @@ package goog import ( + "bytes" "context" "encoding/base64" "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/textproto" "strings" "github.com/warmbly/warmbly/internal/models" "google.golang.org/api/gmail/v1" ) +// Attachment is a fully-resolved file ready to be MIME-encoded into an outbound +// message. Data is the raw bytes; MimeType drives the Content-Type. +type Attachment struct { + Filename string + MimeType string + Data []byte +} + func (c *Client) SendMessage( ctx context.Context, to, cc, bcc []string, messageID, subject, bodyPlain, bodyHTML string, parent *models.EmailMessageData, + attachments []Attachment, customHeaders ...map[string]string, ) (*gmail.Message, error) { + // Attachments require a multipart/mixed MIME tree, which the structured + // gmail.MessagePart API does not express well (no per-part raw bytes with + // Content-Disposition). Build a raw RFC 5322 message and submit it as + // base64url Raw. The no-attachment path keeps the existing structured form + // so threading/back-compat behavior is unchanged. + if len(attachments) > 0 { + return c.sendRawWithAttachments(to, cc, bcc, messageID, subject, bodyPlain, bodyHTML, parent, attachments, customHeaders...) + } + // Compose headers headers := []*gmail.MessagePartHeader{ {Name: "From", Value: c.GetAddress()}, @@ -100,3 +124,163 @@ func (c *Client) SendMessage( return sent, nil } + +// sendRawWithAttachments builds a multipart/mixed RFC 5322 message +// (multipart/alternative for text+html, then one application/* part per +// attachment) and submits it via the Gmail API as base64url-encoded Raw. +func (c *Client) sendRawWithAttachments( + to, cc, bcc []string, + messageID, + subject, bodyPlain, bodyHTML string, + parent *models.EmailMessageData, + attachments []Attachment, + customHeaders ...map[string]string, +) (*gmail.Message, error) { + var hdrs []header + hdrs = append(hdrs, + header{"From", c.GetAddress()}, + header{"To", strings.Join(to, ", ")}, + header{"Subject", subject}, + header{"Message-ID", messageID}, + header{"MIME-Version", "1.0"}, + ) + if len(cc) > 0 { + hdrs = append(hdrs, header{"Cc", strings.Join(cc, ", ")}) + } + if len(bcc) > 0 { + hdrs = append(hdrs, header{"Bcc", strings.Join(bcc, ", ")}) + } + if parent != nil && parent.MessageID != "" { + mid := "<" + strings.Trim(parent.MessageID, "<>") + ">" + hdrs = append(hdrs, header{"In-Reply-To", mid}, header{"References", mid}) + } + if len(customHeaders) > 0 { + for k, v := range customHeaders[0] { + hdrs = append(hdrs, header{k, v}) + } + } + + raw, err := buildMixedMIME(hdrs, bodyPlain, bodyHTML, attachments) + if err != nil { + return nil, fmt.Errorf("build mime: %w", err) + } + + msg := &gmail.Message{ + Raw: base64.URLEncoding.EncodeToString(raw), + } + if parent != nil && parent.ThreadID != "" { + msg.ThreadId = parent.ThreadID + } + + sent, err := c.srv.Users.Messages.Send("me", msg).Do() + if err != nil { + return nil, fmt.Errorf("send message failed: %w", err) + } + return sent, nil +} + +type header struct{ name, value string } + +// buildMixedMIME assembles a multipart/mixed message: a multipart/alternative +// (text/plain + optional text/html) followed by one attachment part each. Text +// parts use quoted-printable; attachment parts use base64 with a +// Content-Disposition: attachment header. Shared by the Gmail raw path. +func buildMixedMIME(hdrs []header, bodyPlain, bodyHTML string, attachments []Attachment) ([]byte, error) { + var buf bytes.Buffer + + mixed := multipart.NewWriter(&buf) + + // Top-level headers + the multipart/mixed Content-Type. These must precede + // the first boundary, so write them before any part is created. + for _, h := range hdrs { + fmt.Fprintf(&buf, "%s: %s\r\n", h.name, h.value) + } + fmt.Fprintf(&buf, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mixed.Boundary()) + + // --- multipart/alternative sub-tree for the text bodies --- + var altBuf bytes.Buffer + alt := multipart.NewWriter(&altBuf) + + if err := writeTextPart(alt, "text/plain; charset=UTF-8", bodyPlain); err != nil { + return nil, err + } + if bodyHTML != "" { + if err := writeTextPart(alt, "text/html; charset=UTF-8", bodyHTML); err != nil { + return nil, err + } + } + if err := alt.Close(); err != nil { + return nil, err + } + + altPart, err := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("multipart/alternative; boundary=%s", alt.Boundary())}, + }) + if err != nil { + return nil, err + } + if _, err := altPart.Write(altBuf.Bytes()); err != nil { + return nil, err + } + + // --- one attachment part per file --- + for _, a := range attachments { + mimeType := a.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + // RFC 2047-encode the filename for non-ASCII safety. + fn := mime.QEncoding.Encode("utf-8", a.Filename) + part, perr := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("%s; name=%q", mimeType, fn)}, + "Content-Transfer-Encoding": {"base64"}, + "Content-Disposition": {fmt.Sprintf("attachment; filename=%q", fn)}, + }) + if perr != nil { + return nil, perr + } + if werr := writeBase64Wrapped(part, a.Data); werr != nil { + return nil, werr + } + } + + if err := mixed.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// writeTextPart writes a quoted-printable text body part with the given +// Content-Type into the multipart writer. +func writeTextPart(w *multipart.Writer, contentType, body string) error { + part, err := w.CreatePart(textproto.MIMEHeader{ + "Content-Type": {contentType}, + "Content-Transfer-Encoding": {"quoted-printable"}, + }) + if err != nil { + return err + } + qp := quotedprintable.NewWriter(part) + if _, err := qp.Write([]byte(body)); err != nil { + return err + } + return qp.Close() +} + +// writeBase64Wrapped writes data as base64, hard-wrapped at 76 columns per +// RFC 2045 so strict MTAs accept the message. +func writeBase64Wrapped(w io.Writer, data []byte) error { + encoded := base64.StdEncoding.EncodeToString(data) + const lineLen = 76 + for i := 0; i < len(encoded); i += lineLen { + end := i + lineLen + if end > len(encoded) { + end = len(encoded) + } + if _, err := w.Write([]byte(encoded[i:end] + "\r\n")); err != nil { + return err + } + } + return nil +} diff --git a/internal/client/smtpimap/smtp/client.go b/internal/client/smtpimap/smtp/client.go index 1567fed4..ae4304c0 100644 --- a/internal/client/smtpimap/smtp/client.go +++ b/internal/client/smtpimap/smtp/client.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" "errors" "fmt" + "io" + "mime" "mime/multipart" "mime/quotedprintable" "net" "net/mail" "net/smtp" + "net/textproto" "strings" "time" @@ -35,11 +39,20 @@ type Client struct { BindIP *net.TCPAddr } +// Attachment is a fully-resolved file to encode into an outbound message. Data +// is the raw bytes; MimeType drives the Content-Type. +type Attachment struct { + Filename string + MimeType string + Data []byte +} + func (c *Client) Send( ctx context.Context, to, cc, bcc []string, subject, bodyPlain, bodyHTML, inReplyTo string, + attachments []Attachment, customHeaders ...map[string]string, ) *errx.MailError { from := mail.Address{Address: c.Email, Name: fmt.Sprintf("%s %s", c.FirstName, c.LastName)} @@ -71,20 +84,80 @@ func (c *Client) Send( } } - // ----- Multipart body ----- var msg bytes.Buffer - writer := multipart.NewWriter(&msg) - boundary := writer.Boundary() - headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%s", boundary) + if len(attachments) > 0 { + c.writeMixedBody(&msg, headers, bodyPlain, bodyHTML, attachments) + } else { + c.writeAlternativeBody(&msg, headers, bodyPlain, bodyHTML) + } + + recipients := append(append([]string{}, to...), cc...) + recipients = append(recipients, bcc...) + + return c.sendRaw(ctx, from.Address, recipients, msg.Bytes()) +} + +// writeAlternativeBody writes a multipart/alternative message (text/plain + +// optional text/html) including the top-level headers. +func (c *Client) writeAlternativeBody(msg *bytes.Buffer, headers map[string]string, bodyPlain, bodyHTML string) { + writer := multipart.NewWriter(msg) + headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%s", writer.Boundary()) for k, v := range headers { - fmt.Fprintf(&msg, "%s: %s\r\n", k, v) + fmt.Fprintf(msg, "%s: %s\r\n", k, v) } - fmt.Fprint(&msg, "\r\n") + fmt.Fprint(msg, "\r\n") - // text/plain + writeTextParts(writer, bodyPlain, bodyHTML) + writer.Close() +} + +// writeMixedBody writes a multipart/mixed message: a multipart/alternative +// sub-tree for the text bodies, then one application/* part per attachment with +// a Content-Disposition: attachment header. +func (c *Client) writeMixedBody(msg *bytes.Buffer, headers map[string]string, bodyPlain, bodyHTML string, attachments []Attachment) { + mixed := multipart.NewWriter(msg) + headers["Content-Type"] = fmt.Sprintf("multipart/mixed; boundary=%s", mixed.Boundary()) + + for k, v := range headers { + fmt.Fprintf(msg, "%s: %s\r\n", k, v) + } + fmt.Fprint(msg, "\r\n") + + // multipart/alternative sub-tree for the text bodies. + var altBuf bytes.Buffer + alt := multipart.NewWriter(&altBuf) + writeTextParts(alt, bodyPlain, bodyHTML) + alt.Close() + + altPart, _ := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("multipart/alternative; boundary=%s", alt.Boundary())}, + }) + altPart.Write(altBuf.Bytes()) + + // One attachment part per file. + for _, a := range attachments { + mimeType := a.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + fn := mime.QEncoding.Encode("utf-8", a.Filename) + part, _ := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("%s; name=%q", mimeType, fn)}, + "Content-Transfer-Encoding": {"base64"}, + "Content-Disposition": {fmt.Sprintf("attachment; filename=%q", fn)}, + }) + writeBase64Wrapped(part, a.Data) + } + + mixed.Close() +} + +// writeTextParts writes the text/plain and optional text/html quoted-printable +// parts into the given multipart writer. +func writeTextParts(writer *multipart.Writer, bodyPlain, bodyHTML string) { if bodyPlain != "" { - part, _ := writer.CreatePart(map[string][]string{ + part, _ := writer.CreatePart(textproto.MIMEHeader{ "Content-Type": {"text/plain; charset=UTF-8"}, "Content-Transfer-Encoding": {"quoted-printable"}, }) @@ -92,10 +165,8 @@ func (c *Client) Send( qp.Write([]byte(bodyPlain)) qp.Close() } - - // text/html if bodyHTML != "" { - part, _ := writer.CreatePart(map[string][]string{ + part, _ := writer.CreatePart(textproto.MIMEHeader{ "Content-Type": {"text/html; charset=UTF-8"}, "Content-Transfer-Encoding": {"quoted-printable"}, }) @@ -103,12 +174,20 @@ func (c *Client) Send( qp.Write([]byte(bodyHTML)) qp.Close() } - writer.Close() +} - recipients := append(append([]string{}, to...), cc...) - recipients = append(recipients, bcc...) - - return c.sendRaw(ctx, from.Address, recipients, msg.Bytes()) +// writeBase64Wrapped writes data as base64, hard-wrapped at 76 columns per +// RFC 2045 so strict MTAs accept the message. +func writeBase64Wrapped(w io.Writer, data []byte) { + encoded := base64.StdEncoding.EncodeToString(data) + const lineLen = 76 + for i := 0; i < len(encoded); i += lineLen { + end := i + lineLen + if end > len(encoded) { + end = len(encoded) + } + w.Write([]byte(encoded[i:end] + "\r\n")) + } } // ---------- Internal helpers ---------- diff --git a/internal/config/constants.go b/internal/config/constants.go index 54e1a175..53d1a9c5 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -12,14 +12,52 @@ const ( WarmupMaxDefault = 40 WarmupIncreaseDefault = 1 + // Net-new campaign send controls. The ramp mirrors the warmup ramp shape + // (start, +increment/day, ceiling) but is applied only via min() against + // the per-mailbox cold cap, so it can only lower effective volume. + CampaignSenderWeightDefault = 1 + CampaignSenderWeightMax = 100 + CampaignRampStartDefault = 10 + CampaignRampIncrementDefault = 5 + CampaignRampCeilingDefault = 50 + CampaignMaxNewLeadsMax = 1000 + MaxContactSize = 10240 MaxEmailBodySize = 200 * 1024 // 200 KB MaxEmailFolders = 30 - // Sequences - SequenceDefaultName = "New Sequence" + // 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 = "" SequenceSubjectLimit = 100 SequenceBodyLimit = 30_000 + // SequenceWaitAfterMax bounds a step's per-step delay (in days). Mirrors the + // editor's 0–60 day cap so an API caller can't persist an absurd or negative + // delay that the scheduler would then turn into an unreachable send time. + SequenceWaitAfterMax = 60 + + // Webhook/integration fan-out throttle. Caps how many events of a single + // type one org can fan out to its webhooks + integration sinks + // (Slack/Discord/CRM) per minute — the backstop against a campaign "notify" + // action, or any per-contact event, flooding a customer's endpoints. Over + // the cap, further events of that type in the same minute are dropped + // (logged), not queued. + // + // The effective cap is PLAN-BASED: it scales with the org's resolved mailbox + // allowance (override > plan > hard cap), so bigger plans get more webhook + // throughput. These three knobs are "what we centrally allow": + // + // - Base: a generous floor every org gets, including free/no-plan orgs, so + // normal usage never trips the throttle (good UX by default). + // - PerMailbox: how much each mailbox in the plan's allowance adds, since + // webhook volume tracks sending activity. + // - Max: a hard ceiling so even an "unlimited" plan stays bounded. + // + // Sized far above normally-spaced sending (per-mailbox daily caps + min-gap + // spacing); only a runaway loop or a huge per-contact fan-out approaches it. + WebhookDispatchBasePerMinute = 600 // generous floor for any org (10/s) + WebhookDispatchPerMailboxPerMinute = 30 // added per mailbox the plan allows + WebhookDispatchMaxPerMinute = 6000 // hard ceiling (100/s) for any plan // Unibox UniboxLimitMin = 1 diff --git a/internal/errx/common.go b/internal/errx/common.go index da652e63..fef4ba42 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -99,9 +99,14 @@ var ( ErrCampaignLimit = New(BadRequest, "You reached your limit for campaigns, please try again later.") // Sequence - ErrSequenceName = New(BadRequest, "Sequence name cannot be longer than 50 characters.") - ErrSequenceSubject = New(BadRequest, "Sequence subject cannot be longer than 100 characters.") - ErrSequenceBody = New(BadRequest, fmt.Sprintf("Sequence body cannot be longer than %d characters.", config.SequenceBodyLimit)) + ErrSequenceName = New(BadRequest, "Sequence name cannot be longer than 50 characters.") + ErrSequenceSubject = New(BadRequest, "Sequence subject cannot be longer than 100 characters.") + ErrSequenceBody = New(BadRequest, fmt.Sprintf("Sequence body cannot be longer than %d characters.", config.SequenceBodyLimit)) + ErrSequenceBranch = New(BadRequest, "Invalid branching conditions.") + ErrSequenceBranchTo = New(BadRequest, "Branch target must be another step in the same campaign and cannot create a cycle.") + ErrSequenceKind = New(BadRequest, "Step kind must be email, action, or wait.") + ErrSequenceAction = New(BadRequest, "Invalid action configuration for this step.") + ErrSequenceWaitAfter = New(BadRequest, fmt.Sprintf("Step wait must be between 0 and %d days.", config.SequenceWaitAfterMax)) // Contact ErrContactSerialize = New(BadRequest, "Failed to serialize contact.") diff --git a/internal/events/publisher.go b/internal/events/publisher.go index b74e55c6..d675dcf7 100644 --- a/internal/events/publisher.go +++ b/internal/events/publisher.go @@ -55,6 +55,10 @@ type SendEmailParams struct { TrackingInfo *models.TrackingInfo WarmupToken string UnsubscribeURL string + // Attachments are file refs put into the emsg EmailBlob inside the S3 body + // object (reached by the worker via BodyS3Key). They are deliberately NOT + // added to models.SendEmail / the Avro event — the Kafka contract is fixed. + Attachments []models.AttachmentRef } type publisher struct { @@ -78,8 +82,10 @@ func NewPublisher(bus eventbus.EventBus, storageClient storage.Store, c codec.Co // PublishSendEmail stores email body in S3 and publishes a send email event to the worker func (p *publisher) PublishSendEmail(ctx context.Context, workerID uuid.UUID, params *SendEmailParams) error { - // Store email body in S3 - s3Key, err := p.StoreEmailBody(ctx, params.TaskID, params.UserID, params.BodyPlain, params.BodyHTML) + // Store email body (and attachment refs) in S3. The attachment refs ride + // inside the emsg blob so the worker receives them via BodyS3Key without any + // change to the Avro event contract. + s3Key, err := p.storeEmailBody(ctx, params.TaskID, params.UserID, params.BodyPlain, params.BodyHTML, params.Attachments) if err != nil { return fmt.Errorf("failed to store email body: %w", err) } @@ -126,8 +132,17 @@ func (p *publisher) PublishSendEmail(ctx context.Context, workerID uuid.UUID, pa return p.publish(workerTopic, params.TaskID.String(), workerEvent) } -// StoreEmailBody stores email body in S3 and returns the S3 key +// StoreEmailBody stores email body in S3 and returns the S3 key. It is the +// interface method; the attachment-aware path goes through storeEmailBody. func (p *publisher) StoreEmailBody(ctx context.Context, taskID, userID uuid.UUID, plainText, htmlBody string) (string, error) { + return p.storeEmailBody(ctx, taskID, userID, plainText, htmlBody, nil) +} + +// storeEmailBody encodes the email body plus attachment refs into the emsg blob +// and uploads it to object storage, returning the S3 key. Bodies are encrypted +// per-user before encoding; attachment refs are plaintext metadata (the bytes +// they point to are stored separately and the worker fetches them by key). +func (p *publisher) storeEmailBody(ctx context.Context, taskID, userID uuid.UUID, plainText, htmlBody string, attachments []models.AttachmentRef) (string, error) { if p.storageClient == nil { return "", nil } @@ -153,11 +168,19 @@ func (p *publisher) StoreEmailBody(ctx context.Context, taskID, userID uuid.UUID } } - // Create email blob + // Create email blob. Attachment refs are carried inside the blob so the + // worker can fetch each file's bytes from object storage at send time. blob := &emsg.EmailBlob{ PlainText: []byte(encPlainText), HTMLBody: []byte(encHTMLBody), } + for _, a := range attachments { + blob.Attachments = append(blob.Attachments, emsg.Attachment{ + S3Key: a.S3Key, + Filename: a.Filename, + MimeType: a.MimeType, + }) + } data, err := blob.EncodeBinary() if err != nil { diff --git a/internal/infrastructure/db/migrations/000013_campaign_send_controls.down.sql b/internal/infrastructure/db/migrations/000013_campaign_send_controls.down.sql new file mode 100644 index 00000000..1a8fcd97 --- /dev/null +++ b/internal/infrastructure/db/migrations/000013_campaign_send_controls.down.sql @@ -0,0 +1,23 @@ +-- Reverse FK order: drop the child tables (which reference campaigns) before +-- dropping the campaign columns, then the independent contact columns. + +DROP INDEX IF EXISTS idx_campaign_senders_campaign; +DROP TABLE IF EXISTS campaign_daily_sends; +DROP TABLE IF EXISTS campaign_senders; + +ALTER TABLE contacts DROP COLUMN IF EXISTS esp_resolved_at; +ALTER TABLE contacts DROP COLUMN IF EXISTS esp_provider; + +ALTER TABLE campaigns DROP COLUMN IF EXISTS tracking_domain_verified_at; +ALTER TABLE campaigns DROP COLUMN IF EXISTS tracking_domain_verified; +ALTER TABLE campaigns DROP COLUMN IF EXISTS tracking_domain; +ALTER TABLE campaigns DROP COLUMN IF EXISTS prioritize_new_leads; +ALTER TABLE campaigns DROP COLUMN IF EXISTS max_new_leads_per_day; +ALTER TABLE campaigns DROP COLUMN IF EXISTS esp_match_mode; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_level_date; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_level; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_ceiling; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_increment; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_start; +ALTER TABLE campaigns DROP COLUMN IF EXISTS rotation_mode; +ALTER TABLE campaigns DROP COLUMN IF EXISTS sender_strategy; diff --git a/internal/infrastructure/db/migrations/000013_campaign_send_controls.up.sql b/internal/infrastructure/db/migrations/000013_campaign_send_controls.up.sql new file mode 100644 index 00000000..1552cc7e --- /dev/null +++ b/internal/infrastructure/db/migrations/000013_campaign_send_controls.up.sql @@ -0,0 +1,71 @@ +-- Net-new campaign send controls: sender rotation/weighting, per-campaign daily +-- ramp-up, ESP/provider matching, new-lead cap + priority, and a campaign-scoped +-- tracking-domain override. +-- +-- Every change is ADDITIVE and the defaults reproduce today's behavior exactly: +-- tag-based sender selection, flat daily_limit (ramp off), no ESP matching, +-- unlimited new leads, and the mailbox/default tracking domain. Existing +-- campaigns are therefore unaffected until a control is explicitly turned on. + +-- 1. Sending-account rotation / weighting -------------------------------- +ALTER TABLE campaigns ADD COLUMN sender_strategy text NOT NULL DEFAULT 'tags' + CHECK (sender_strategy IN ('tags', 'explicit')); +ALTER TABLE campaigns ADD COLUMN rotation_mode text NOT NULL DEFAULT 'weighted' + CHECK (rotation_mode IN ('weighted', 'round_robin', 'least_recently_used')); + +-- 2. Per-campaign daily ramp-up ------------------------------------------ +ALTER TABLE campaigns ADD COLUMN ramp_enabled boolean NOT NULL DEFAULT false; +ALTER TABLE campaigns ADD COLUMN ramp_start integer NOT NULL DEFAULT 10 + CHECK (ramp_start >= 1 AND ramp_start <= 100); +ALTER TABLE campaigns ADD COLUMN ramp_increment integer NOT NULL DEFAULT 5 + CHECK (ramp_increment >= 0 AND ramp_increment <= 100); +ALTER TABLE campaigns ADD COLUMN ramp_ceiling integer NOT NULL DEFAULT 50 + CHECK (ramp_ceiling >= 1 AND ramp_ceiling <= 100); +-- ramp_level is the persisted per-mailbox level (0 = not started); pause/resume +-- keeps progress. ramp_level_date day-gates the once-per-UTC-day advance. +ALTER TABLE campaigns ADD COLUMN ramp_level integer NOT NULL DEFAULT 0; +ALTER TABLE campaigns ADD COLUMN ramp_level_date date; + +-- 3. ESP / provider matching --------------------------------------------- +ALTER TABLE campaigns ADD COLUMN esp_match_mode text NOT NULL DEFAULT 'off' + CHECK (esp_match_mode IN ('off', 'prefer', 'strict')); + +-- 4. New-lead cap + priority --------------------------------------------- +ALTER TABLE campaigns ADD COLUMN max_new_leads_per_day integer NOT NULL DEFAULT 0 + CHECK (max_new_leads_per_day >= 0 AND max_new_leads_per_day <= 1000); -- 0 = unlimited +ALTER TABLE campaigns ADD COLUMN prioritize_new_leads boolean NOT NULL DEFAULT false; + +-- 5. Campaign-scoped tracking domain (honored only when verified) --------- +ALTER TABLE campaigns ADD COLUMN tracking_domain text NOT NULL DEFAULT ''; -- '' = mailbox/default fallback +ALTER TABLE campaigns ADD COLUMN tracking_domain_verified boolean NOT NULL DEFAULT false; +ALTER TABLE campaigns ADD COLUMN tracking_domain_verified_at timestamptz; + +-- Explicit per-campaign sender list (feature 1). Used only when +-- sender_strategy = 'explicit'; an empty set makes the scheduler fall back to +-- the existing tag-based selection, preserving backward compatibility. +CREATE TABLE campaign_senders ( + campaign_id uuid NOT NULL REFERENCES campaigns (id) ON DELETE CASCADE, + email_account_id uuid NOT NULL REFERENCES email_accounts (id) ON DELETE CASCADE, + weight integer NOT NULL DEFAULT 1 CHECK (weight >= 1 AND weight <= 100), + rotation_position integer NOT NULL DEFAULT 0, -- monotonic cursor for round_robin + last_sent_at timestamptz, -- drives least_recently_used + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY (campaign_id, email_account_id) +); +CREATE INDEX idx_campaign_senders_campaign ON campaign_senders (campaign_id) WHERE enabled = true; + +-- Per-(campaign, day) counters; back the ramp advance and the new-lead cap +-- (features 2 & 4). A "new lead" is a contact receiving sequence position 1. +CREATE TABLE campaign_daily_sends ( + campaign_id uuid NOT NULL REFERENCES campaigns (id) ON DELETE CASCADE, + send_date date NOT NULL, + emails_sent integer NOT NULL DEFAULT 0, + new_leads_started integer NOT NULL DEFAULT 0, + PRIMARY KEY (campaign_id, send_date) +); + +-- Recipient ESP cache (feature 3). Derived from the recipient domain in the +-- control plane at selection time — never an MX dial on the send hot path. +ALTER TABLE contacts ADD COLUMN esp_provider text NOT NULL DEFAULT ''; -- '' | 'gmail' | 'outlook' | 'other' +ALTER TABLE contacts ADD COLUMN esp_resolved_at timestamptz; diff --git a/internal/infrastructure/db/migrations/000014_lead_sync_sources.down.sql b/internal/infrastructure/db/migrations/000014_lead_sync_sources.down.sql new file mode 100644 index 00000000..60fb2605 --- /dev/null +++ b/internal/infrastructure/db/migrations/000014_lead_sync_sources.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_lead_sync_sources_org_campaign; +DROP INDEX IF EXISTS idx_lead_sync_sources_org; +DROP TABLE IF EXISTS lead_sync_sources; diff --git a/internal/infrastructure/db/migrations/000014_lead_sync_sources.up.sql b/internal/infrastructure/db/migrations/000014_lead_sync_sources.up.sql new file mode 100644 index 00000000..849cb50e --- /dev/null +++ b/internal/infrastructure/db/migrations/000014_lead_sync_sources.up.sql @@ -0,0 +1,40 @@ +-- On-demand Google Sheets -> leads sync sources. +-- +-- A "lead sync source" is a saved, re-runnable binding between a Google Sheet +-- (reached through an existing google_sheets OAuth connection) and Warmbly's +-- contact importer. It is ON-DEMAND only: the user presses "Sync now" and the +-- control plane reads the sheet and upserts contacts by (user, email). There is +-- no background scheduler — nothing here drives the worker. +-- +-- column_mapping / category_ids are JSONB so they reuse the exact contact +-- import column-mapping shape ([]ContactImportColumnMapping) and category id +-- list the /contacts/import/commit path already understands. + +CREATE TABLE lead_sync_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + created_by_user_id uuid NOT NULL, + provider text NOT NULL DEFAULT 'google_sheets', + connection_id uuid NOT NULL, -- integration_connections row used for Google OAuth + sheet_id text NOT NULL, + sheet_title text, + tab_title text, + a1_range text, + has_header boolean NOT NULL DEFAULT true, + column_mapping jsonb NOT NULL DEFAULT '[]', + dedup text NOT NULL DEFAULT 'update' + CHECK (dedup IN ('skip', 'update', 'create_duplicate')), + target_campaign_id uuid, + category_ids jsonb NOT NULL DEFAULT '[]', + subscribed_default boolean NOT NULL DEFAULT true, + label text, + status text NOT NULL DEFAULT 'idle', + last_synced_at timestamptz, + last_result jsonb, + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_lead_sync_sources_org ON lead_sync_sources (organization_id); +CREATE INDEX idx_lead_sync_sources_org_campaign ON lead_sync_sources (organization_id, target_campaign_id); diff --git a/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.down.sql b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.down.sql new file mode 100644 index 00000000..49f185b3 --- /dev/null +++ b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.down.sql @@ -0,0 +1 @@ +ALTER TABLE campaigns DROP COLUMN IF EXISTS schedule_windows; diff --git a/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.up.sql b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.up.sql new file mode 100644 index 00000000..fee8e80a --- /dev/null +++ b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.up.sql @@ -0,0 +1,13 @@ +-- Per-day, multi-interval campaign sending windows. +-- +-- Supersedes the single (days bitmask + start_time + end_time) window: when +-- this column is non-null it is the AUTHORITATIVE sending schedule. NULL means +-- "derive from the legacy days/start_time/end_time columns" (back-compat for +-- campaigns created before this migration). +-- +-- Shape: a jsonb array of exactly 7 elements indexed by weekday +-- (0 = Sunday .. 6 = Saturday, matching Go's time.Weekday). Each element is an +-- array of {"start": , "end": } intervals, e.g. +-- [[], [{"start":480,"end":600},{"start":720,"end":960}], [], [], [], [], []] +-- = Monday 08:00–10:00 and 12:00–16:00, no other day. +ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS schedule_windows jsonb; diff --git a/internal/infrastructure/db/migrations/000016_per_step_ab_variants.down.sql b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.down.sql new file mode 100644 index 00000000..50ba5f7a --- /dev/null +++ b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS campaign_ab_variants_unique_name; +DROP INDEX IF EXISTS idx_campaign_ab_variants_sequence; +ALTER TABLE campaign_ab_variants DROP COLUMN IF EXISTS sequence_id; diff --git a/internal/infrastructure/db/migrations/000016_per_step_ab_variants.up.sql b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.up.sql new file mode 100644 index 00000000..a0cd21d4 --- /dev/null +++ b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.up.sql @@ -0,0 +1,13 @@ +-- Per-step A/B variants. A variant may belong to a specific sequence step +-- (sequence_id). NULL = campaign-level (legacy: applies regardless of step). +-- Step-scoped variants are selected deterministically per (contact, step), so +-- they need no assignment-table changes; campaign-level keeps its assignment. +ALTER TABLE campaign_ab_variants ADD COLUMN IF NOT EXISTS sequence_id uuid REFERENCES sequences(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS idx_campaign_ab_variants_sequence ON campaign_ab_variants (sequence_id); + +-- Variant names are unique per (campaign, step). COALESCE the NULL step to a +-- sentinel so campaign-level names also stay unique (NULLs would otherwise be +-- distinct in a plain unique index). +ALTER TABLE campaign_ab_variants DROP CONSTRAINT IF EXISTS campaign_variant_unique_name; +CREATE UNIQUE INDEX IF NOT EXISTS campaign_ab_variants_unique_name + ON campaign_ab_variants (campaign_id, COALESCE(sequence_id, '00000000-0000-0000-0000-000000000000'::uuid), name); diff --git a/internal/infrastructure/db/migrations/000017_campaign_attachments.down.sql b/internal/infrastructure/db/migrations/000017_campaign_attachments.down.sql new file mode 100644 index 00000000..4598c295 --- /dev/null +++ b/internal/infrastructure/db/migrations/000017_campaign_attachments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS campaign_attachments; diff --git a/internal/infrastructure/db/migrations/000017_campaign_attachments.up.sql b/internal/infrastructure/db/migrations/000017_campaign_attachments.up.sql new file mode 100644 index 00000000..9254afb3 --- /dev/null +++ b/internal/infrastructure/db/migrations/000017_campaign_attachments.up.sql @@ -0,0 +1,16 @@ +-- Campaign email attachments. Binary lives in S3 (s3_key); this row is the +-- metadata + ownership record. sequence_id is nullable: NULL = available to +-- every step of the campaign; set = scoped to one step. +CREATE TABLE IF NOT EXISTS campaign_attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id uuid NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + sequence_id uuid REFERENCES sequences(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename text NOT NULL, + size bigint NOT NULL, + mime_type text NOT NULL, + s3_key text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_campaign_attachments_campaign ON campaign_attachments (campaign_id); +CREATE INDEX IF NOT EXISTS idx_campaign_attachments_sequence ON campaign_attachments (sequence_id); diff --git a/internal/infrastructure/db/migrations/000018_credit_ledger.down.sql b/internal/infrastructure/db/migrations/000018_credit_ledger.down.sql new file mode 100644 index 00000000..4c387674 --- /dev/null +++ b/internal/infrastructure/db/migrations/000018_credit_ledger.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE plans DROP COLUMN IF EXISTS monthly_credits; + +DROP INDEX IF EXISTS uq_credit_txns_idempotency; +DROP INDEX IF EXISTS idx_credit_txns_org; +DROP TABLE IF EXISTS credit_ledger_transactions; +DROP TABLE IF EXISTS credit_ledger; diff --git a/internal/infrastructure/db/migrations/000018_credit_ledger.up.sql b/internal/infrastructure/db/migrations/000018_credit_ledger.up.sql new file mode 100644 index 00000000..d4921916 --- /dev/null +++ b/internal/infrastructure/db/migrations/000018_credit_ledger.up.sql @@ -0,0 +1,44 @@ +-- AI writing-assistant credit system. Two tables plus a per-plan monthly grant. +-- +-- credit_ledger is the authoritative per-organization balance (one row per org). +-- Consumption is a single atomic conditional UPDATE +-- (balance = balance - $n WHERE balance >= $n RETURNING ...) so concurrent +-- generation requests can never drive the balance negative — billing +-- correctness lives in the database, not in application-level read-modify-write. +-- +-- credit_ledger_transactions is the append-only audit trail. idempotency_key is +-- nullable+unique so a retried POST /generation/write (same Idempotency-Key) +-- can be detected and not double-charged. + +CREATE TABLE IF NOT EXISTS credit_ledger ( + org_id uuid PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + balance integer NOT NULL DEFAULT 0 CHECK (balance >= 0), + month_reset_at timestamptz NOT NULL DEFAULT now(), + total_purchased integer NOT NULL DEFAULT 0 CHECK (total_purchased >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS credit_ledger_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + amount integer NOT NULL, -- negative = consumption, positive = grant/purchase + reason text NOT NULL, + model_used text NOT NULL DEFAULT '', + tokens_used integer NOT NULL DEFAULT 0, + balance_after integer NOT NULL, + idempotency_key text, -- nullable; unique when present + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_credit_txns_org ON credit_ledger_transactions (org_id, created_at DESC); + +-- Partial unique index: at most one transaction per idempotency_key, but NULL +-- keys (grants, resets, non-idempotent calls) are unconstrained. +CREATE UNIQUE INDEX IF NOT EXISTS uq_credit_txns_idempotency + ON credit_ledger_transactions (idempotency_key) + WHERE idempotency_key IS NOT NULL; + +-- Per-plan monthly credit grant. Additive with a safe default so existing +-- plans (and the free trial) start at 0 until seeded. +ALTER TABLE plans ADD COLUMN IF NOT EXISTS monthly_credits integer NOT NULL DEFAULT 0; diff --git a/internal/infrastructure/db/migrations/000019_sequence_branching.down.sql b/internal/infrastructure/db/migrations/000019_sequence_branching.down.sql new file mode 100644 index 00000000..1fa60b14 --- /dev/null +++ b/internal/infrastructure/db/migrations/000019_sequence_branching.down.sql @@ -0,0 +1 @@ +ALTER TABLE sequences DROP COLUMN IF EXISTS conditions; diff --git a/internal/infrastructure/db/migrations/000019_sequence_branching.up.sql b/internal/infrastructure/db/migrations/000019_sequence_branching.up.sql new file mode 100644 index 00000000..72074a19 --- /dev/null +++ b/internal/infrastructure/db/migrations/000019_sequence_branching.up.sql @@ -0,0 +1,6 @@ +-- Per-step conditional branching. A sequence step can carry a branching tree +-- that routes a contact to a target step (or stops them) based on whether they +-- opened/clicked/replied within N days. Kept as a single jsonb tree on the +-- step itself — no extra tables. An empty object ('{}') / empty branches list +-- means the step keeps the default linear progression. +ALTER TABLE sequences ADD COLUMN IF NOT EXISTS conditions jsonb NOT NULL DEFAULT '{}'; diff --git a/internal/infrastructure/db/migrations/000020_sequence_action_nodes.down.sql b/internal/infrastructure/db/migrations/000020_sequence_action_nodes.down.sql new file mode 100644 index 00000000..6f005714 --- /dev/null +++ b/internal/infrastructure/db/migrations/000020_sequence_action_nodes.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE sequences DROP CONSTRAINT IF EXISTS sequences_kind_chk; +ALTER TABLE sequences DROP COLUMN IF EXISTS action; +ALTER TABLE sequences DROP COLUMN IF EXISTS kind; diff --git a/internal/infrastructure/db/migrations/000020_sequence_action_nodes.up.sql b/internal/infrastructure/db/migrations/000020_sequence_action_nodes.up.sql new file mode 100644 index 00000000..d7c3fed1 --- /dev/null +++ b/internal/infrastructure/db/migrations/000020_sequence_action_nodes.up.sql @@ -0,0 +1,12 @@ +-- Action / wait sequence nodes. A step can now be an EMAIL node (the existing +-- behaviour) or a control-plane ACTION/WAIT node (tag/unsubscribe/notify/wait/ +-- end) that runs a side effect and routes onward WITHOUT sending mail. Existing +-- rows default to 'email' so routing and sending are unchanged. +ALTER TABLE sequences + ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'email', + ADD COLUMN IF NOT EXISTS action jsonb NOT NULL DEFAULT '{}'; + +UPDATE sequences SET kind = 'email' WHERE kind IS NULL OR kind = ''; + +ALTER TABLE sequences + ADD CONSTRAINT sequences_kind_chk CHECK (kind IN ('email', 'action', 'wait')); diff --git a/internal/models/advanced_outreach.go b/internal/models/advanced_outreach.go index 1ae79ee1..f243c23e 100644 --- a/internal/models/advanced_outreach.go +++ b/internal/models/advanced_outreach.go @@ -138,8 +138,11 @@ type SuppressedRecipient struct { } type CampaignABVariant struct { - ID uuid.UUID `json:"id"` - CampaignID uuid.UUID `json:"campaign_id"` + ID uuid.UUID `json:"id"` + CampaignID uuid.UUID `json:"campaign_id"` + // SequenceID scopes the variant to one step. nil = campaign-level (applies + // to every step, legacy behavior). + SequenceID *uuid.UUID `json:"sequence_id,omitempty"` Name string `json:"name"` Weight int `json:"weight"` Subject string `json:"subject"` @@ -153,14 +156,15 @@ type CampaignABVariant struct { } type CreateCampaignABVariantRequest struct { - Name string `json:"name" binding:"required"` - Weight int `json:"weight"` - Subject string `json:"subject,omitempty"` - BodyHTML string `json:"body_html,omitempty"` - BodyPlain string `json:"body_plain,omitempty"` - IsControl bool `json:"is_control"` - IsActive *bool `json:"is_active,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Name string `json:"name" binding:"required"` + SequenceID *uuid.UUID `json:"sequence_id,omitempty"` + Weight int `json:"weight"` + Subject string `json:"subject,omitempty"` + BodyHTML string `json:"body_html,omitempty"` + BodyPlain string `json:"body_plain,omitempty"` + IsControl bool `json:"is_control"` + IsActive *bool `json:"is_active,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type UpdateCampaignABVariantRequest struct { diff --git a/internal/models/attachment.go b/internal/models/attachment.go new file mode 100644 index 00000000..337d8979 --- /dev/null +++ b/internal/models/attachment.go @@ -0,0 +1,29 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CampaignAttachment is the metadata + ownership record for an email +// attachment. The binary itself lives in object storage at S3Key. +type CampaignAttachment struct { + ID uuid.UUID `json:"id"` + CampaignID uuid.UUID `json:"campaign_id"` + SequenceID *uuid.UUID `json:"sequence_id,omitempty"` + UserID uuid.UUID `json:"user_id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + S3Key string `json:"s3_key"` + CreatedAt time.Time `json:"created_at"` +} + +// AttachmentRef is the lightweight reference carried through the send pipeline +// (backend → Kafka → worker). The worker fetches the bytes from S3 by key. +type AttachmentRef struct { + S3Key string `json:"s3_key"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` +} diff --git a/internal/models/campaign.go b/internal/models/campaign.go index 0a3d892b..39bffd18 100644 --- a/internal/models/campaign.go +++ b/internal/models/campaign.go @@ -1,11 +1,91 @@ package models import ( + "database/sql/driver" + "encoding/json" + "fmt" "time" "github.com/google/uuid" ) +// TimeInterval is a sending window within a single day, expressed in minutes +// since local midnight. End is exclusive-ish and must be > Start, <= 1440. +type TimeInterval struct { + Start int `json:"start"` + End int `json:"end"` +} + +// ScheduleWindows is a campaign's per-day sending schedule, indexed by +// time.Weekday (0=Sunday .. 6=Saturday). A nil/empty day means "no sending that +// day". When non-empty it is the authoritative schedule and supersedes the +// legacy Days/StartTime/EndTime fields. Persisted as a jsonb array-of-7. +type ScheduleWindows [7][]TimeInterval + +// IsEmpty reports whether no day carries any interval (treated as "unset"). +func (w ScheduleWindows) IsEmpty() bool { + for _, day := range w { + if len(day) > 0 { + return false + } + } + return true +} + +// DaySpan returns the earliest start and latest end across a weekday's +// intervals (ok=false when that day has none). Used for even send distribution. +func (w ScheduleWindows) DaySpan(weekday int) (start, end int, ok bool) { + if weekday < 0 || weekday > 6 || len(w[weekday]) == 0 { + return 0, 0, false + } + start, end = w[weekday][0].Start, w[weekday][0].End + for _, iv := range w[weekday][1:] { + if iv.Start < start { + start = iv.Start + } + if iv.End > end { + end = iv.End + } + } + return start, end, true +} + +// Value implements driver.Valuer — marshals to jsonb, or NULL when empty (so an +// empty schedule reverts to the legacy day/time derivation). +func (w ScheduleWindows) Value() (driver.Value, error) { + if w.IsEmpty() { + return nil, nil + } + return json.Marshal(w) +} + +// Scan implements sql.Scanner — reads the jsonb column (NULL → empty). +func (w *ScheduleWindows) Scan(src any) error { + if src == nil { + *w = ScheduleWindows{} + return nil + } + var b []byte + switch v := src.(type) { + case []byte: + b = v + case string: + b = []byte(v) + default: + return fmt.Errorf("ScheduleWindows: unsupported scan type %T", src) + } + if len(b) == 0 { + *w = ScheduleWindows{} + return nil + } + var parsed ScheduleWindows + if err := json.Unmarshal(b, &parsed); err != nil { + return err + } + *w = parsed + return nil +} + type Campaign struct { ID uuid.UUID `json:"id"` UserID string `json:"user_id"` @@ -20,7 +100,7 @@ type Campaign struct { LinkTracking bool `json:"link_tracking"` TextOnly bool `json:"text_only"` DailyLimit int `json:"daily_limit"` - UnsubscribeHeader bool `json:"unscrubscribe_header"` + UnsubscribeHeader bool `json:"unsubscribe_header"` RiskyEmails bool `json:"risky_emails"` CC []string `json:"cc"` @@ -33,6 +113,10 @@ type Campaign struct { StartTime string `json:"start_time"` EndTime string `json:"end_time"` + // ScheduleWindows, when non-empty, is the authoritative per-day sending + // schedule (supersedes Days/StartTime/EndTime). Indexed by time.Weekday. + ScheduleWindows ScheduleWindows `json:"schedule_windows"` + EmailTags []string `json:"email_tags"` Folders []string `json:"folders"` @@ -40,12 +124,57 @@ type Campaign struct { ContactOrderDir string `json:"contact_order_dir"` ContactOrderField *string `json:"contact_order_field,omitempty"` + // Sending-account selection. SenderStrategy is "tags" (default — accounts + // resolved from EmailTags) or "explicit" (the campaign_senders list). + // RotationMode picks how volume spreads across the chosen mailboxes. + SenderStrategy string `json:"sender_strategy"` + RotationMode string `json:"rotation_mode"` + Senders []CampaignSender `json:"senders,omitempty"` // loaded on demand, not in the base SELECT + + // Per-campaign daily ramp-up. Applied only via min() against the per-mailbox + // cap, so it can never raise volume above the cold cap. RampLevel/RampLevelDate + // are server-managed (persisted across pause/resume). + RampEnabled bool `json:"ramp_enabled"` + RampStart int `json:"ramp_start"` + RampIncrement int `json:"ramp_increment"` + RampCeiling int `json:"ramp_ceiling"` + RampLevel int `json:"ramp_level"` + RampLevelDate *time.Time `json:"ramp_level_date,omitempty"` + + // ESP/provider matching: off | prefer | strict. + ESPMatchMode string `json:"esp_match_mode"` + + // New-lead throttle. MaxNewLeadsPerDay 0 = unlimited (current behavior). + MaxNewLeadsPerDay int `json:"max_new_leads_per_day"` + PrioritizeNewLeads bool `json:"prioritize_new_leads"` + + // Campaign-scoped tracking-domain override. Honored only when verified; + // otherwise falls back to the mailbox/default domain. + TrackingDomain string `json:"tracking_domain"` + TrackingDomainVerified bool `json:"tracking_domain_verified"` + TrackingDomainVerifiedAt *time.Time `json:"tracking_domain_verified_at,omitempty"` + LastStatusChangeAt *time.Time `json:"last_status_change_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } +// CampaignSender is one mailbox in an explicit-strategy campaign's sender pool. +type CampaignSender struct { + EmailAccountID uuid.UUID `json:"email_account_id"` + Weight int `json:"weight"` + LastSentAt *time.Time `json:"last_sent_at,omitempty"` + Enabled bool `json:"enabled"` +} + +// CampaignSenderInput is the write shape for PUT /campaigns/:id/senders. +type CampaignSenderInput struct { + EmailAccountID uuid.UUID `json:"email_account_id"` + Weight *int `json:"weight,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + type MiniCampaign struct { ID string `json:"id"` Name string `json:"name"` @@ -79,12 +208,30 @@ type UpdateCampaign struct { StartTime *string `json:"start_time"` EndTime *string `json:"end_time"` + // Authoritative per-day schedule. When sent, supersedes Days/StartTime/EndTime. + ScheduleWindows *ScheduleWindows `json:"schedule_windows,omitempty"` + EmailTags []string `json:"email_tags"` Folders []string `json:"folders"` ContactOrderBy *string `json:"contact_order_by"` ContactOrderDir *string `json:"contact_order_dir"` ContactOrderField *string `json:"contact_order_field"` + + // Net-new send controls. The explicit sender LIST is edited via + // PUT /campaigns/:id/senders; only the strategy/mode toggles ride PATCH. + SenderStrategy *string `json:"sender_strategy,omitempty"` + RotationMode *string `json:"rotation_mode,omitempty"` + + RampEnabled *bool `json:"ramp_enabled,omitempty"` + RampStart *int `json:"ramp_start,omitempty"` + RampIncrement *int `json:"ramp_increment,omitempty"` + RampCeiling *int `json:"ramp_ceiling,omitempty"` + + ESPMatchMode *string `json:"esp_match_mode,omitempty"` + MaxNewLeadsPerDay *int `json:"max_new_leads_per_day,omitempty"` + PrioritizeNewLeads *bool `json:"prioritize_new_leads,omitempty"` + TrackingDomain *string `json:"tracking_domain,omitempty"` } // CreateCampaign is the payload accepted by POST /campaigns. Name is required; @@ -119,6 +266,24 @@ type CreateCampaign struct { EmailTagIDs []string `json:"email_tag_ids,omitempty"` FolderIDs []string `json:"folder_ids,omitempty"` + // Sending-account selection + rotation (net-new). When sender_strategy is + // "explicit", Senders is the mailbox pool; otherwise EmailTagIDs are used. + SenderStrategy *string `json:"sender_strategy,omitempty"` + RotationMode *string `json:"rotation_mode,omitempty"` + Senders []CampaignSenderInput `json:"senders,omitempty"` + + // Per-campaign daily ramp-up (net-new). ramp_level is server-owned. + RampEnabled *bool `json:"ramp_enabled,omitempty"` + RampStart *int `json:"ramp_start,omitempty"` + RampIncrement *int `json:"ramp_increment,omitempty"` + RampCeiling *int `json:"ramp_ceiling,omitempty"` + + // ESP/provider matching + new-lead throttle + tracking-domain override. + ESPMatchMode *string `json:"esp_match_mode,omitempty"` + MaxNewLeadsPerDay *int `json:"max_new_leads_per_day,omitempty"` + PrioritizeNewLeads *bool `json:"prioritize_new_leads,omitempty"` + TrackingDomain *string `json:"tracking_domain,omitempty"` + // Initial sequences (in order) — caller can also create them after. Sequences []CreateSequenceInput `json:"sequences,omitempty"` diff --git a/internal/models/contact.go b/internal/models/contact.go index 97337611..116d998d 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -39,10 +39,54 @@ type Contact struct { IsCatchAll bool `json:"is_catch_all"` VerificationCheckedAt *time.Time `json:"verification_checked_at,omitempty"` + // Recipient ESP/provider, derived in the control plane from the recipient + // domain (never an MX dial on the send hot path). '' | 'gmail' | 'outlook' + // | 'other'. Used by the campaign ESP-matching feature. + ESPProvider string `json:"esp_provider"` + ESPResolvedAt *time.Time `json:"esp_resolved_at,omitempty"` + + // CampaignLead is this contact's processing state WITHIN a single campaign. + // Populated by Search ONLY when the query filters by exactly one campaign + // (the campaign Leads view); nil otherwise. Lets the Leads list show which + // leads are queued, in progress, replied, bounced, or unsubscribed. + CampaignLead *ContactCampaignProgress `json:"campaign_lead,omitempty"` + UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } +// ContactCampaignProgress is a contact's aggregate processing state inside one +// campaign, derived from campaign_contact_progress (across all of the campaign's +// steps) plus the contact's subscription flag. +type ContactCampaignProgress struct { + // Status is the single derived state shown in the Leads list: + // pending — a lead, but no email sent yet (queued) + // active — at least one email sent, still progressing through the flow + // replied — the contact has replied (terminal/positive) + // bounced — a send hard-bounced (terminal/negative) + // unsubscribed — the contact is unsubscribed/suppressed (terminal) + Status string `json:"status"` + Sent int `json:"sent"` + Opened int `json:"opened"` + Clicked int `json:"clicked"` + Replied int `json:"replied"` + Bounced int `json:"bounced"` + LastActivityAt *time.Time `json:"last_activity_at,omitempty"` + // CurrentStep is the label of the step the contact is on now — the latest + // step actually sent ("Email 2", a custom step name, or an action label). + // Empty when nothing has been sent yet (status "pending"). + CurrentStep string `json:"current_step,omitempty"` +} + +// Lead status constants for ContactCampaignProgress.Status. +const ( + LeadStatusPending = "pending" + LeadStatusActive = "active" + LeadStatusReplied = "replied" + LeadStatusBounced = "bounced" + LeadStatusUnsubscribed = "unsubscribed" +) + type ContactsResult struct { Data []Contact `json:"data"` Pagination Pagination `json:"pagination"` diff --git a/internal/models/credits.go b/internal/models/credits.go new file mode 100644 index 00000000..4a7f8028 --- /dev/null +++ b/internal/models/credits.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CreditLedger is the authoritative per-organization AI-credit balance. There +// is exactly one row per organization (org_id is the primary key). Balance is +// mutated only through atomic conditional UPDATEs in the repository so it can +// never go negative under concurrent generation requests. +type CreditLedger struct { + OrgID uuid.UUID `json:"org_id"` + Balance int `json:"balance"` + MonthResetAt time.Time `json:"month_reset_at"` + TotalPurchased int `json:"total_purchased"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreditTransaction is one append-only row in the credit audit trail. Amount is +// negative for consumption and positive for grants/purchases. BalanceAfter is +// the resulting ledger balance captured atomically with the mutation. +// IdempotencyKey is nil for non-idempotent operations (grants, monthly resets) +// and set to the caller's Idempotency-Key for consumption so retries don't +// double-charge. +type CreditTransaction struct { + ID uuid.UUID `json:"id"` + OrgID uuid.UUID `json:"org_id"` + Amount int `json:"amount"` + Reason string `json:"reason"` + ModelUsed string `json:"model_used"` + TokensUsed int `json:"tokens_used"` + BalanceAfter int `json:"balance_after"` + IdempotencyKey *string `json:"idempotency_key,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/models/integration.go b/internal/models/integration.go index 30614a75..0f6d6e63 100644 --- a/internal/models/integration.go +++ b/internal/models/integration.go @@ -228,7 +228,6 @@ const ( IntegrationActionDiscordNotify IntegrationAction = "discord.notify" IntegrationActionHubSpotUpsert IntegrationAction = "hubspot.upsert_contact" IntegrationActionPipedriveUpsert IntegrationAction = "pipedrive.upsert_person" - IntegrationActionSheetsAppend IntegrationAction = "google_sheets.append_row" IntegrationActionGenericWebhookPing IntegrationAction = "webhook.ping" ) diff --git a/internal/models/lead_sync.go b/internal/models/lead_sync.go new file mode 100644 index 00000000..ce92c7d2 --- /dev/null +++ b/internal/models/lead_sync.go @@ -0,0 +1,104 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// LeadSyncStatus is the lifecycle state of a saved lead-sync source. It is +// not a queue state — syncs are on-demand — only a record of how the last +// "Sync now" went. +type LeadSyncStatus string + +const ( + // LeadSyncStatusIdle — never synced, or the last sync succeeded. + LeadSyncStatusIdle LeadSyncStatus = "idle" + // LeadSyncStatusSyncing — a sync is in flight (set for the duration of + // the synchronous SyncNow call). + LeadSyncStatusSyncing LeadSyncStatus = "syncing" + // LeadSyncStatusError — the last sync failed; LastError carries why. + LeadSyncStatusError LeadSyncStatus = "error" +) + +// LeadSyncSource is a saved, re-runnable binding between a Google Sheet (read +// through an existing google_sheets OAuth connection) and Warmbly's contact +// importer. New rows create contacts; rows matching an existing contact by +// email are updated. A source optionally enrols new/updated leads into a +// campaign and/or tags them with categories. +// +// Secrets never live here — the sheet is read using the linked connection's +// envelope-encrypted Google token, resolved at sync time. +type LeadSyncSource struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id"` + Provider string `json:"provider"` + ConnectionID uuid.UUID `json:"connection_id"` + SheetID string `json:"sheet_id"` + SheetTitle string `json:"sheet_title,omitempty"` + TabTitle string `json:"tab_title,omitempty"` + A1Range string `json:"a1_range,omitempty"` + HasHeader bool `json:"has_header"` + + // ColumnMapping reuses the exact contact-import mapping shape so the sheet + // rows flow through the same /contacts/import/commit code path. + ColumnMapping []ContactImportColumnMapping `json:"column_mapping"` + Dedup ContactImportDedupStrategy `json:"dedup"` + + // TargetCampaignID, when set, enrols every new/updated lead into that + // campaign on each sync. + TargetCampaignID *uuid.UUID `json:"target_campaign_id,omitempty"` + CategoryIDs []string `json:"category_ids"` + SubscribedDefault bool `json:"subscribed_default"` + + Label string `json:"label,omitempty"` + Status LeadSyncStatus `json:"status"` + + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` + LastResult *ContactImportResult `json:"last_result,omitempty"` + LastError string `json:"last_error,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateLeadSyncSource is the input for creating a saved source. The connection +// must be the org's google_sheets OAuth connection. +type CreateLeadSyncSource struct { + ConnectionID uuid.UUID `json:"connection_id"` + SheetID string `json:"sheet_id"` + SheetTitle string `json:"sheet_title"` + TabTitle string `json:"tab_title"` + HasHeader bool `json:"has_header"` + ColumnMapping []ContactImportColumnMapping `json:"column_mapping"` + Dedup ContactImportDedupStrategy `json:"dedup"` + TargetCampaignID *uuid.UUID `json:"target_campaign_id"` + CategoryIDs []string `json:"category_ids"` + SubscribedDefault *bool `json:"subscribed_default"` + Label string `json:"label"` +} + +// UpdateLeadSyncSource is the input for editing a saved source. All fields are +// optional pointers so a PATCH can touch a single setting; nil leaves the +// stored value untouched. +type UpdateLeadSyncSource struct { + SheetID *string `json:"sheet_id"` + SheetTitle *string `json:"sheet_title"` + TabTitle *string `json:"tab_title"` + HasHeader *bool `json:"has_header"` + ColumnMapping *[]ContactImportColumnMapping `json:"column_mapping"` + Dedup *ContactImportDedupStrategy `json:"dedup"` + TargetCampaignID *uuid.UUID `json:"target_campaign_id"` + ClearCampaign bool `json:"clear_campaign"` + CategoryIDs *[]string `json:"category_ids"` + SubscribedDefault *bool `json:"subscribed_default"` + Label *string `json:"label"` +} + +// LeadSyncResult is what "Sync now" returns: the underlying contact-import +// counts plus the source id so the dashboard can attribute the run. +type LeadSyncResult struct { + SourceID uuid.UUID `json:"source_id"` + Result *ContactImportResult `json:"result"` +} diff --git a/internal/models/sequence.go b/internal/models/sequence.go index 4e91ea4a..b413d251 100644 --- a/internal/models/sequence.go +++ b/internal/models/sequence.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "time" "github.com/google/uuid" @@ -19,10 +20,41 @@ type Sequence struct { WaitAfter int `json:"wait_after"` Position int `json:"position"` + // Conditions is the per-step branching tree. When empty (`{}` / no + // branches), the step keeps the default linear behaviour (advance to the + // next position). When populated, the scheduler evaluates the contact's + // engagement against these branches at schedule time to decide which step + // (or stop) comes next. Stored as a single jsonb column on `sequences`. + Conditions json.RawMessage `json:"conditions,omitempty"` + + // Kind is "email" (default — subject/body are rendered and sent) or a + // non-email control node: "action" (Action.Type names the side effect) or + // "wait" (delay only). Routing (Conditions) is identical regardless of Kind. + Kind string `json:"kind"` + // Action is the typed config for non-email nodes; an empty object for email + // nodes. Stored in the sequences.action jsonb column. + Action json.RawMessage `json:"action,omitempty"` + UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } +// ActionConfig is the persisted config for a non-email (action/wait) node. Type +// is the switch the task executes on; the remaining fields are type-scoped. +type ActionConfig struct { + Type string `json:"type"` // wait | add_tag | remove_tag | unsubscribe | notify | end + + // wait + WaitMinutes *int `json:"wait_minutes,omitempty"` + + // add_tag / remove_tag — a contact category id (product "tags" == categories) + CategoryID *uuid.UUID `json:"category_id,omitempty"` + + // notify — webhook / integration fan-out + NotifyEvent string `json:"notify_event,omitempty"` + NotifyData map[string]any `json:"notify_data,omitempty"` +} + type UpdateSequence struct { Name *string `json:"name"` Subject *string `json:"subject"` @@ -33,4 +65,56 @@ type UpdateSequence struct { BodyCode *bool `json:"body_code"` WaitAfter *int `json:"wait_after"` + + // Conditions, when non-nil, replaces the step's branching tree. Send `{}` + // (or an object with an empty `branches` array) to clear branching and fall + // back to linear progression. + Conditions *BranchConditions `json:"conditions"` + + // Kind / Action, when non-nil, switch the node between email and action/wait. + Kind *string `json:"kind"` + Action *ActionConfig `json:"action"` +} + +// BranchConditions is the typed branching tree persisted in the sequence +// `conditions` jsonb column. Branches are evaluated in declared order; the first +// branch whose conditions ALL match wins. A winning branch routes the contact to +// its TargetSequenceID (any step in the campaign), or stops them when the target +// is nil. When no branch matches (or Branches is empty) the scheduler keeps the +// default linear progression (advance to the next step by position). +type BranchConditions struct { + Branches []Branch `json:"branches,omitempty"` +} + +// Branch is a single conditional route out of a step ("if -> go to +// target, else stop"). A branch with no conditions is an unconditional catch-all +// ("otherwise"). +type Branch struct { + // BranchID is a stable client-supplied identifier (for editor diffing / + // logging). Kept as a free-form string: the editor uses crypto.randomUUID() + // when available but falls back to a non-UUID token, so this must NOT be a + // strict uuid.UUID or unmarshalling the PATCH body would fail. + BranchID string `json:"branch_id"` + // TargetSequenceID is the step to route to when this branch matches. nil + // means STOP (send the contact no further step). A target that no longer + // exists (a deleted step) is treated as STOP at schedule time. + TargetSequenceID *uuid.UUID `json:"target_sequence_id"` + // Conditions are ANDed together — every condition must hold for the branch + // to match. An empty list is an unconditional/catch-all branch ("otherwise"). + Conditions []BranchCondition `json:"conditions,omitempty"` +} + +// BranchCondition is a single engagement predicate evaluated against the +// contact's campaign_contact_progress row for the current step. +type BranchCondition struct { + // Field is the engagement signal: "opened" | "clicked" | "replied" and + // their negations "not_opened" | "not_clicked" | "not_replied". + Field string `json:"field"` + // Operator is the comparison. Currently "within_days" (the signal occurred + // in the last Value days) and "ever" (the signal occurred at all). For the + // not_* fields the meaning inverts (did NOT happen within / ever). + Operator string `json:"operator"` + // Value is the day window for "within_days". nil for operators that take no + // argument (e.g. "ever"). + Value *int `json:"value"` } diff --git a/internal/models/subscriptions.go b/internal/models/subscriptions.go index 2575b61b..74c1c8fc 100644 --- a/internal/models/subscriptions.go +++ b/internal/models/subscriptions.go @@ -59,6 +59,9 @@ type Plan struct { MaxTeamMembers *int `json:"max_team_members,omitempty"` MaxEmailAccounts *int `json:"max_email_accounts,omitempty"` + // AI writing-assistant monthly credit grant for this plan. + MonthlyCredits int `json:"monthly_credits"` + UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/models/webhook.go b/internal/models/webhook.go index 6258d925..207a9934 100644 --- a/internal/models/webhook.go +++ b/internal/models/webhook.go @@ -32,6 +32,8 @@ const ( // bounce/complaint rate enters the early-warning band (half the pause // threshold) — a graduated signal short of an auto-pause. WebhookEventCampaignDeliverabilityWarning WebhookEventType = "campaign.deliverability_warning" + // campaign.action fires from a "notify" action node in a sequence flow. + WebhookEventCampaignAction WebhookEventType = "campaign.action" // Warmup WebhookEventWarmupEmailSent WebhookEventType = "warmup.email_sent" @@ -61,6 +63,7 @@ var AllWebhookEventTypes = []WebhookEventType{ WebhookEventCampaignPaused, WebhookEventCampaignCompleted, WebhookEventCampaignDeliverabilityWarning, + WebhookEventCampaignAction, WebhookEventWarmupEmailSent, WebhookEventWarmupHealthChanged, WebhookEventWarmupPlacementInSpam, diff --git a/internal/pkg/emsg/emsg.go b/internal/pkg/emsg/emsg.go index 5a5d4eca..8f0cdb57 100644 --- a/internal/pkg/emsg/emsg.go +++ b/internal/pkg/emsg/emsg.go @@ -13,20 +13,40 @@ const Version = 1 // Bitmask flags for sections const ( - FlagPlainText uint32 = 1 << 0 - FlagHTMLBody uint32 = 1 << 1 + FlagPlainText uint32 = 1 << 0 + FlagHTMLBody uint32 = 1 << 1 + FlagAttachments uint32 = 1 << 2 ) +// Attachment is a single attachment reference carried inside the S3 body blob. +// Only the metadata travels here; the worker fetches the bytes from object +// storage by S3Key. Keeping these refs in the blob (not the Avro Kafka event) +// preserves the published worker event contract. +type Attachment struct { + S3Key string + Filename string + MimeType string +} + // EmailBlob represents a binary-encoded email body and metadata. type EmailBlob struct { - PlainText []byte - HTMLBody []byte + PlainText []byte + HTMLBody []byte + Attachments []Attachment } -// EncodeBinary serializes the blob into binary format. +// EncodeBinary serializes the blob into binary format. Layout: +// +// "EMSG" | version(1) | flags(4) | [plain] | [html] | [attachments] +// +// where each body section is uint32-length-prefixed and only present when its +// flag bit is set. The attachments section, when present, is a uint32 count +// followed by that many (s3key, filename, mimetype) triples of length-prefixed +// strings. Attachment metadata travels here (inside the S3 body blob), not in +// the Avro Kafka event, so the published worker event contract is unchanged. func (b *EmailBlob) EncodeBinary() ([]byte, error) { var flags uint32 - parts := make([][]byte, 0, 5) + parts := make([][]byte, 0, 2) if len(b.PlainText) > 0 { flags |= FlagPlainText @@ -36,6 +56,9 @@ func (b *EmailBlob) EncodeBinary() ([]byte, error) { flags |= FlagHTMLBody parts = append(parts, b.HTMLBody) } + if len(b.Attachments) > 0 { + flags |= FlagAttachments + } buf := new(bytes.Buffer) @@ -44,12 +67,26 @@ func (b *EmailBlob) EncodeBinary() ([]byte, error) { buf.WriteByte(Version) // 1 byte binary.Write(buf, binary.BigEndian, flags) // 4 bytes - // Write each section [len][data] + // Write each variable-length body section [len][data] in flag order. for _, p := range parts { binary.Write(buf, binary.BigEndian, uint32(len(p))) buf.Write(p) } + // Attachments section: [count] then [len][str] x3 per attachment. + if flags&FlagAttachments != 0 { + binary.Write(buf, binary.BigEndian, uint32(len(b.Attachments))) + writeStr := func(s string) { + binary.Write(buf, binary.BigEndian, uint32(len(s))) + buf.WriteString(s) + } + for _, a := range b.Attachments { + writeStr(a.S3Key) + writeStr(a.Filename) + writeStr(a.MimeType) + } + } + return buf.Bytes(), nil } @@ -91,5 +128,39 @@ func DecodeBinary(r io.Reader) (*EmailBlob, error) { b.HTMLBody, _ = readSection() } + if flags&FlagAttachments != 0 { + var count uint32 + if err := binary.Read(r, binary.BigEndian, &count); err != nil { + return nil, err + } + readStr := func() (string, error) { + data, err := readSection() + if err != nil { + return "", err + } + return string(data), nil + } + b.Attachments = make([]Attachment, 0, count) + for i := uint32(0); i < count; i++ { + s3Key, err := readStr() + if err != nil { + return nil, err + } + filename, err := readStr() + if err != nil { + return nil, err + } + mimeType, err := readStr() + if err != nil { + return nil, err + } + b.Attachments = append(b.Attachments, Attachment{ + S3Key: s3Key, + Filename: filename, + MimeType: mimeType, + }) + } + } + return b, nil } diff --git a/internal/pkg/emsg/emsg_test.go b/internal/pkg/emsg/emsg_test.go index 09ee75c5..8f27f89a 100644 --- a/internal/pkg/emsg/emsg_test.go +++ b/internal/pkg/emsg/emsg_test.go @@ -71,3 +71,35 @@ func TestEmailBlob_EncodeDecode(t *testing.T) { }) } } + +func TestEmailBlob_Attachments(t *testing.T) { + in := &EmailBlob{ + PlainText: []byte("hi"), + HTMLBody: []byte("hi"), + Attachments: []Attachment{ + {S3Key: "attachments/a/1.pdf", Filename: "report.pdf", MimeType: "application/pdf"}, + {S3Key: "attachments/a/2.png", Filename: "logo.png", MimeType: "image/png"}, + }, + } + + got := roundTrip(t, in) + + if len(got.Attachments) != len(in.Attachments) { + t.Fatalf("attachment count mismatch: got=%d want=%d", len(got.Attachments), len(in.Attachments)) + } + for i, want := range in.Attachments { + if got.Attachments[i] != want { + t.Errorf("attachment[%d] mismatch:\n got=%+v\nwant=%+v", i, got.Attachments[i], want) + } + } + if !bytes.Equal(got.PlainText, in.PlainText) || !bytes.Equal(got.HTMLBody, in.HTMLBody) { + t.Errorf("body sections corrupted when attachments present") + } +} + +func TestEmailBlob_NoAttachments(t *testing.T) { + got := roundTrip(t, &EmailBlob{PlainText: []byte("x")}) + if len(got.Attachments) != 0 { + t.Errorf("expected no attachments, got %d", len(got.Attachments)) + } +} diff --git a/internal/pkg/generation/anthropic.go b/internal/pkg/generation/anthropic.go new file mode 100644 index 00000000..fbd8be7b --- /dev/null +++ b/internal/pkg/generation/anthropic.go @@ -0,0 +1,242 @@ +package generation + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// ErrNotConfigured is returned by WritingGenerator implementations when no +// provider API key is available. Handlers map this to a clear "AI writing +// assistant is not configured" response instead of a generic 500. +var ErrNotConfigured = errors.New("writing assistant not configured: no provider API key") + +// Model tiers for the writing assistant. Free orgs are routed to the cheaper, +// faster Haiku model; paid orgs to Sonnet. The strings are Anthropic model IDs; +// callers should use ModelForTier rather than hardcoding. +const ( + ModelWritingFree = "claude-3-5-haiku-latest" + ModelWritingPaid = "claude-sonnet-4-5" + + // OpenAI fallback models, used only when ANTHROPIC_API_KEY is unset but + // OPENAI_API_KEY is present. + ModelWritingFreeOpenAI = "gpt-4o-mini" + ModelWritingPaidOpenAI = "gpt-4o" + + anthropicMessagesURL = "https://api.anthropic.com/v1/messages" + anthropicVersion = "2023-06-01" + + // writingMaxTokens caps a single writing-assistant completion. Generous for + // an email draft but bounded so a runaway prompt can't burn credits/tokens. + writingMaxTokens = 1024 +) + +// WritingResult is the normalized output of a writing-assistant generation, +// regardless of which provider produced it. +type WritingResult struct { + Text string + Model string + TokensUsed int +} + +// WritingGenerator is the provider-agnostic interface the handler depends on. +// Keeping it behind an interface means a missing API key yields a clear +// ErrNotConfigured at call time, and the provider (Anthropic vs OpenAI +// fallback) can be swapped without touching the handler. +type WritingGenerator interface { + // GenerateWriting produces assistant text for the given model, prompt, and + // optional tone. model is a provider model ID (see ModelForTier). + GenerateWriting(ctx context.Context, model, prompt, tone string) (*WritingResult, error) + + // ModelForTier returns the model ID this provider should use for the org's + // tier (paid → stronger model). The handler calls this rather than knowing + // which concrete provider is active. + ModelForTier(paid bool) string +} + +// ModelForTier returns the Anthropic writing model ID for the org's tier. +func (c *AnthropicClient) ModelForTier(paid bool) string { + if paid { + return ModelWritingPaid + } + return ModelWritingFree +} + +// humanWritingSystemPrompt encodes concrete, researched human-writing rules so +// output reads like a real person typed it fast — NOT the vague "be human". +// Bans em dashes + AI-tell vocabulary, forces sentence-length variation, one +// low-friction ask, <=80 words, and preserves {{.Merge}} variables. +const humanWritingSystemPrompt = `You are Warmbly's cold-outreach email writer. You write very short, first-touch cold emails that sound like a real, busy person typed them in 30 seconds. Your only goal is replies. Sounding human and getting replies are the same goal; do not try to "beat detectors." + +OUTPUT +- Output only the email body (and a subject line if one is requested). No preamble, no explanation, no "Sure, here is". +- Keep it under 80 words and 4 to 6 sentences. Shorter is better. +- If a subject line is requested, make it lowercase, specific, and under 6 words (e.g. "quick q on your onboarding"). Never use Re:/Fwd: fakes, ALL-CAPS, emoji, or "!". + +MERGE VARIABLES +- Preserve any merge variables exactly as written, including dotted Go-template form like {{.FirstName}}, {{.Company}}, {{.Role}}. Never rename, reformat, or invent them. +- Place merge variables inline and naturally. A merge variable is NOT personalization by itself. Given a real signal about the recipient (a recent hire, funding round, launch, pricing change, post), build the first line on that signal, not the merge tag. + +STRUCTURE +1. Open with one specific, earned observation about the recipient or their problem. No "I hope this email finds you well," no "I wanted to reach out," no "my name is." With only a merge tag and no signal, lead with the problem their kind of team feels now. +2. Name a pain they actually feel before mentioning what Warmbly does. Buyer-first, never product- or credentials-first. +3. Make one concrete claim, ideally with a real number, product, or observable fact. +4. End with exactly ONE low-friction, interest-based ask that gives an easy out (e.g. "want the 2-line version of how?", "worth a look, or not a priority right now?"). Never stack asks. Never ask "do you have 30 minutes?". +5. Optional: one casual P.S., one line, a genuine human aside. + +VOICE AND RHYTHM +- Casual founder register. Lowercase openers are fine. Fragments are fine ("makes sense?"). A quick note between meetings, not a press release. +- Use contractions always: it's, don't, you're, we'll, won't, that's. +- Active voice with a concrete subject doing the action. +- Vary sentence length hard. Put a 3-4 word line next to a 20+ word one. Never write three or four sentences of similar length in a row. Let one short line land alone. +- One idea per sentence. Keep most sentences under 20 words. Aim for an 8th-grade reading level. +- Take a position. Make a direct claim and own it. Warm but blunt. Offer an easy "no." + +HARD BANS (never produce these) +- Em dashes. Use a period, comma, or parentheses instead. +- AI vocabulary: delve, leverage, utilize, robust, elevate, seamless(ly), tapestry, underscore, realm, harness, pivotal, comprehensive, foster, showcase, testament, multifaceted, cutting-edge, best-in-class, end-to-end, unlock, empower, streamline, actionable insights, drive value, move the needle, synergy, circle back, low-hanging fruit, touch base. +- Formulaic openers: "I hope this email finds you well," "In today's fast-paced world," "I came across your profile," "Hope you're having a great week," "I wanted to reach out," "just touching base," "love what you're building." +- Hedging filler: "it's important to note," "it's worth mentioning," "generally speaking," "in many cases." +- Rule-of-three triads and neat parallel triplets. +- Summary/inspirational closers: "In conclusion," "At the end of the day," "Looking forward to hearing from you." +- Over-politeness: "Thank you so much for your time," "at your earliest convenience," "Have a wonderful day!" and exclamation-point friendliness. +- Negative parallelism: "It's not just X, it's Y," "not only... but also." Say it plainly. +- Transition scaffolding: "Furthermore," "Moreover," "Additionally," "That said." Cut it or use "so," "but here's the catch." +- Trailing -ing filler: "helping you save time," "underscoring the value." +- Vague claims: "many companies," "leading brands," "significant results," "industry-leading," "studies show." Be specific or cut it. +- Passive voice that hides who acted. Spam triggers: ALL-CAPS, "!!!", "FREE," "GUARANTEED," "risk-free," "ACT NOW," and 2+ links. + +SELF-CHECK before returning: under 80 words? one ask with an easy out? sentence lengths actually vary? zero em dashes? zero banned phrases? merge variables intact? reads like a person typed it fast, not a template? If any answer is no, rewrite.` + +// writingSystemPrompt builds the instruction shared by both providers, folding +// in the optional caller tone on top of the human-writing rules. +func writingSystemPrompt(tone string) string { + base := humanWritingSystemPrompt + tone = strings.TrimSpace(tone) + if tone != "" { + base += fmt.Sprintf("\n\nTONE: match this tone where it doesn't conflict with the rules above: %s.", tone) + } + return base +} + +// AnthropicClient calls the Anthropic Messages API over plain HTTP. We use the +// stdlib rather than adding an SDK dependency so the build stays lean; the +// request shape is the documented v1/messages contract. +type AnthropicClient struct { + apiKey string + http *http.Client +} + +// NewAnthropicClient returns a client, or nil if apiKey is empty so callers can +// fall back to another provider. +func NewAnthropicClient(apiKey string) *AnthropicClient { + if strings.TrimSpace(apiKey) == "" { + return nil + } + return &AnthropicClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 60 * time.Second}, + } +} + +type anthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []anthropicMessage `json:"messages"` +} + +type anthropicResponse struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +// GenerateWriting implements WritingGenerator against the Anthropic API. +func (c *AnthropicClient) GenerateWriting(ctx context.Context, model, prompt, tone string) (*WritingResult, error) { + if c == nil { + return nil, ErrNotConfigured + } + if model == "" { + model = ModelWritingFree + } + + body, err := json.Marshal(anthropicRequest{ + Model: model, + MaxTokens: writingMaxTokens, + System: writingSystemPrompt(tone), + Messages: []anthropicMessage{ + {Role: "user", Content: prompt}, + }, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, anthropicMessagesURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("anthropic-version", anthropicVersion) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + + var parsed anthropicResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("anthropic: decode response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if parsed.Error != nil { + return nil, fmt.Errorf("anthropic: %s: %s", parsed.Error.Type, parsed.Error.Message) + } + return nil, fmt.Errorf("anthropic: unexpected status %d", resp.StatusCode) + } + + var sb strings.Builder + for _, block := range parsed.Content { + if block.Type == "text" { + sb.WriteString(block.Text) + } + } + text := strings.TrimSpace(sb.String()) + if text == "" { + return nil, errors.New("anthropic: empty completion") + } + + return &WritingResult{ + Text: text, + Model: model, + TokensUsed: parsed.Usage.InputTokens + parsed.Usage.OutputTokens, + }, nil +} diff --git a/internal/pkg/generation/writing.go b/internal/pkg/generation/writing.go new file mode 100644 index 00000000..3ed36cb0 --- /dev/null +++ b/internal/pkg/generation/writing.go @@ -0,0 +1,55 @@ +package generation + +import ( + "context" + "errors" + "strings" + + "github.com/openai/openai-go/v2" +) + +// ModelForTier returns the OpenAI fallback writing model ID for the org's tier. +func (c *GenerationClient) ModelForTier(paid bool) string { + if paid { + return ModelWritingPaidOpenAI + } + return ModelWritingFreeOpenAI +} + +// GenerateWriting implements WritingGenerator using the existing OpenAI client. +// It is the fallback provider used only when ANTHROPIC_API_KEY is unset but +// OPENAI_API_KEY is present. The handler depends on the WritingGenerator +// interface, so it never needs to know which provider is active. +func (c *GenerationClient) GenerateWriting(ctx context.Context, model, prompt, tone string) (*WritingResult, error) { + if c == nil { + return nil, ErrNotConfigured + } + chatModel := openai.ChatModel(ModelWritingFreeOpenAI) + if model != "" { + chatModel = openai.ChatModel(model) + } + + resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: chatModel, + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(writingSystemPrompt(tone)), + openai.UserMessage(prompt), + }, + }) + if err != nil { + return nil, err + } + if len(resp.Choices) == 0 { + return nil, errors.New("openai: empty completion") + } + text := strings.TrimSpace(resp.Choices[0].Message.Content) + if text == "" { + return nil, errors.New("openai: empty completion") + } + + return &WritingResult{ + Text: text, + Model: string(chatModel), + TokensUsed: int(resp.Usage.TotalTokens), + }, nil +} diff --git a/internal/repository/branch_conditions.go b/internal/repository/branch_conditions.go new file mode 100644 index 00000000..f62f2e79 --- /dev/null +++ b/internal/repository/branch_conditions.go @@ -0,0 +1,208 @@ +package repository + +import ( + "hash/fnv" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// Branching condition vocabulary. Kept in one place so the shape validator +// (write path) and the resolver (schedule path) agree exactly. +var branchConditionFields = map[string]bool{ + "opened": true, + "clicked": true, + "replied": true, + "not_opened": true, + "not_clicked": true, + "not_replied": true, + // "random" routes a deterministic percentage of contacts down this branch + // (a random split / split-test). Pairs with operator "chance", Value = %. + "random": true, +} + +var branchConditionOperators = map[string]bool{ + "within_days": true, // signal occurred (or not, for not_*) in the last Value days + "ever": true, // signal occurred (or not) at all; Value ignored + // "always" is the frontend editor's label for "ever (any time)" — accepted as + // an exact alias of "ever" so the shipped UI contract stays valid. + "always": true, + // "chance" pairs with field "random": Value is the percent (1-99) of + // contacts that take the branch, chosen deterministically per contact. + "chance": true, +} + +// maxBranchesPerStep / maxConditionsPerBranch bound the tree so a single step +// cannot carry an unreasonable amount of branching logic. +const ( + maxBranchesPerStep = 20 + maxConditionsPerBranch = 20 + maxBranchWithinDays = 365 +) + +// validateBranchConditions checks the per-step shape of a branching tree: +// known fields/operators, sane within_days windows, and bounded fan-out. It +// does NOT check cross-step concerns (target exists / same campaign / no +// cycles) — those need the full sequence set and live in the service layer. +func validateBranchConditions(bc *models.BranchConditions) *errx.Error { + if bc == nil { + return nil + } + if len(bc.Branches) > maxBranchesPerStep { + return errx.ErrSequenceBranch + } + for _, b := range bc.Branches { + if len(b.Conditions) > maxConditionsPerBranch { + return errx.ErrSequenceBranch + } + for _, cond := range b.Conditions { + if !branchConditionFields[cond.Field] { + return errx.ErrSequenceBranch + } + if !branchConditionOperators[cond.Operator] { + return errx.ErrSequenceBranch + } + if cond.Operator == "within_days" { + if cond.Value == nil || *cond.Value < 1 || *cond.Value > maxBranchWithinDays { + return errx.ErrSequenceBranch + } + } + if cond.Field == "random" { + if cond.Operator != "chance" || cond.Value == nil || *cond.Value < 1 || *cond.Value > 99 { + return errx.ErrSequenceBranch + } + } + } + } + return nil +} + +// BranchState is the three-valued result of evaluating a branch (or a single +// condition) at a point in time. Crucially, an engagement window that has not +// elapsed yet is UNDECIDED — neither matched nor not — so the scheduler waits +// and re-checks instead of guessing. This is what makes "if didn't open within +// N days" actually wait N days before firing. +type BranchState int + +const ( + BranchNoMatch BranchState = iota // definitively does not apply + BranchMatch // definitively applies now + BranchUndecided // not knowable yet; re-check at the returned time +) + +// evaluateBranchState evaluates one branch send-relative to when the current +// step was sent. AND semantics: any NoMatch condition fails the branch; any +// Undecided condition leaves the branch Undecided (re-check at the latest +// pending window). An empty condition list is the catch-all (always matches). +func evaluateBranchState(b *models.Branch, prog *CampaignContactProgress, sentAt, now time.Time) (BranchState, time.Time) { + if len(b.Conditions) == 0 { + return BranchMatch, time.Time{} + } + state := BranchMatch + var recheck time.Time + for i := range b.Conditions { + cs, wend := conditionState(b.Conditions[i], prog, b.BranchID, sentAt, now) + if cs == BranchNoMatch { + return BranchNoMatch, time.Time{} + } + if cs == BranchUndecided { + state = BranchUndecided + if wend.After(recheck) { + recheck = wend + } + } + } + return state, recheck +} + +// conditionState evaluates a single predicate send-relative. For "within_days" +// the window is [sentAt, sentAt+N days]: +// - positive (opened/clicked/replied): Match if it happened in the window; +// Undecided while the window is still open; NoMatch once it closes unmet. +// - negative (not_*): NoMatch if it happened in the window; Undecided while the +// window is still open; Match once it closes without the signal. +// +// "ever"/"always" (legacy, no window) decides immediately. random is instant. +func conditionState(cond models.BranchCondition, prog *CampaignContactProgress, branchID string, sentAt, now time.Time) (BranchState, time.Time) { + if cond.Field == "random" { + if randomHolds(cond, prog.ContactID, branchID) { + return BranchMatch, time.Time{} + } + return BranchNoMatch, time.Time{} + } + + var ts *time.Time + negate := false + switch cond.Field { + case "opened": + ts = prog.OpenedAt + case "clicked": + ts = prog.ClickedAt + case "replied": + ts = prog.RepliedAt + case "not_opened": + ts, negate = prog.OpenedAt, true + case "not_clicked": + ts, negate = prog.ClickedAt, true + case "not_replied": + ts, negate = prog.RepliedAt, true + default: + return BranchNoMatch, time.Time{} + } + + if cond.Operator == "within_days" { + days := 0 + if cond.Value != nil { + days = *cond.Value + } + windowEnd := sentAt.Add(time.Hour * 24 * time.Duration(days)) + happened := ts != nil && !ts.After(windowEnd) + if negate { + if happened { + return BranchNoMatch, time.Time{} + } + if now.Before(windowEnd) { + return BranchUndecided, windowEnd + } + return BranchMatch, time.Time{} + } + if happened { + return BranchMatch, time.Time{} + } + if now.Before(windowEnd) { + return BranchUndecided, windowEnd + } + return BranchNoMatch, time.Time{} + } + + // "ever" / "always" / unknown operator: decide immediately, no window. + happened := ts != nil + if negate { + happened = !happened + } + if happened { + return BranchMatch, time.Time{} + } + return BranchNoMatch, time.Time{} +} + +// randomHolds deterministically routes Value% of contacts down a random-split +// branch. Stable per (contact, branch): the same contact always takes the same +// path for this branch, so re-evaluation at each schedule pass is consistent. +func randomHolds(cond models.BranchCondition, contactID uuid.UUID, branchID string) bool { + pct := 0 + if cond.Value != nil { + pct = *cond.Value + } + if pct <= 0 { + return false + } + if pct >= 100 { + return true + } + h := fnv.New32a() + _, _ = h.Write([]byte(contactID.String() + ":" + branchID)) + return int(h.Sum32()%100) < pct +} diff --git a/internal/repository/pg_advanced_outreach.go b/internal/repository/pg_advanced_outreach.go index d1f84dbe..201387c5 100644 --- a/internal/repository/pg_advanced_outreach.go +++ b/internal/repository/pg_advanced_outreach.go @@ -148,6 +148,7 @@ func scanABVariant(rows pgx.Row, v *models.CampaignABVariant) error { if err := rows.Scan( &v.ID, &v.CampaignID, + &v.SequenceID, &v.Name, &v.Weight, &v.Subject, @@ -174,7 +175,7 @@ func scanABVariant(rows pgx.Row, v *models.CampaignABVariant) error { func (r *advancedOutreachRepository) ListABVariants(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignABVariant, error) { query := ` - SELECT id, campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + SELECT id, campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at FROM campaign_ab_variants WHERE campaign_id = $1 ORDER BY is_control DESC, created_at ASC @@ -211,14 +212,15 @@ func (r *advancedOutreachRepository) CreateABVariant(ctx context.Context, campai } query := ` INSERT INTO campaign_ab_variants ( - campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) - RETURNING id, campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) + RETURNING id, campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at ` var out models.CampaignABVariant if err := scanABVariant(r.db.QueryRow(ctx, query, campaignID, + req.SequenceID, req.Name, weight, req.Subject, @@ -292,7 +294,7 @@ func (r *advancedOutreachRepository) UpdateABVariant(ctx context.Context, campai UPDATE campaign_ab_variants SET %s WHERE campaign_id = $1 AND id = $2 - RETURNING id, campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + RETURNING id, campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at `, strings.Join(sets, ", ")) var out models.CampaignABVariant @@ -316,7 +318,7 @@ func (r *advancedOutreachRepository) DeleteABVariant(ctx context.Context, campai func (r *advancedOutreachRepository) GetAssignedVariant(ctx context.Context, campaignID, contactID uuid.UUID) (*models.CampaignABVariant, error) { query := ` - SELECT v.id, v.campaign_id, v.name, v.weight, v.subject, v.body_html, v.body_plain, v.is_control, v.is_active, v.metadata, v.created_at, v.updated_at + SELECT v.id, v.campaign_id, v.sequence_id, v.name, v.weight, v.subject, v.body_html, v.body_plain, v.is_control, v.is_active, v.metadata, v.created_at, v.updated_at FROM campaign_ab_assignments a JOIN campaign_ab_variants v ON v.id = a.variant_id WHERE a.campaign_id = $1 AND a.contact_id = $2 diff --git a/internal/repository/pg_attachment.go b/internal/repository/pg_attachment.go new file mode 100644 index 00000000..764de7e7 --- /dev/null +++ b/internal/repository/pg_attachment.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/infrastructure/db" + "github.com/warmbly/warmbly/internal/models" +) + +// AttachmentRepository persists campaign attachment metadata. Binary content +// lives in object storage; these rows track ownership, size (for quota), and +// the S3 key so the worker can fetch the bytes at send time. +type AttachmentRepository interface { + Create(ctx context.Context, att *models.CampaignAttachment) error + GetByID(ctx context.Context, id uuid.UUID) (*models.CampaignAttachment, error) + ListByCampaign(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignAttachment, error) + Delete(ctx context.Context, id uuid.UUID) error + // SumStorageUsedByOrg totals the bytes of every attachment owned by the org + // (joined through campaigns) — the basis for the per-plan storage quota. + SumStorageUsedByOrg(ctx context.Context, orgID uuid.UUID) (int64, error) +} + +type attachmentRepository struct { + DB *db.DB +} + +func NewAttachmentRepository(database *db.DB) AttachmentRepository { + return &attachmentRepository{DB: database} +} + +const attachmentCols = `id, campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key, created_at` + +func scanAttachment(row pgx.Row, a *models.CampaignAttachment) error { + return row.Scan( + &a.ID, &a.CampaignID, &a.SequenceID, &a.UserID, + &a.Filename, &a.Size, &a.MimeType, &a.S3Key, &a.CreatedAt, + ) +} + +func (r *attachmentRepository) Create(ctx context.Context, att *models.CampaignAttachment) error { + return scanAttachment(r.DB.QueryRow(ctx, ` + INSERT INTO campaign_attachments (campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING `+attachmentCols, + att.CampaignID, att.SequenceID, att.UserID, att.Filename, att.Size, att.MimeType, att.S3Key, + ), att) +} + +func (r *attachmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.CampaignAttachment, error) { + a := &models.CampaignAttachment{} + err := scanAttachment(r.DB.QueryRow(ctx, `SELECT `+attachmentCols+` FROM campaign_attachments WHERE id = $1`, id), a) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return a, nil +} + +func (r *attachmentRepository) ListByCampaign(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignAttachment, error) { + rows, err := r.DB.Query(ctx, `SELECT `+attachmentCols+` FROM campaign_attachments WHERE campaign_id = $1 ORDER BY created_at ASC`, campaignID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.CampaignAttachment, 0) + for rows.Next() { + var a models.CampaignAttachment + if err := scanAttachment(rows, &a); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (r *attachmentRepository) Delete(ctx context.Context, id uuid.UUID) error { + _, err := r.DB.Exec(ctx, `DELETE FROM campaign_attachments WHERE id = $1`, id) + return err +} + +func (r *attachmentRepository) SumStorageUsedByOrg(ctx context.Context, orgID uuid.UUID) (int64, error) { + var total int64 + err := r.DB.QueryRow(ctx, ` + SELECT COALESCE(SUM(ca.size), 0) + FROM campaign_attachments ca + JOIN campaigns c ON c.id = ca.campaign_id + WHERE c.organization_id = $1 + `, orgID).Scan(&total) + return total, err +} diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index 2364f92d..d8b0c414 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -39,10 +40,40 @@ type CampaignRepository interface { CountActiveForOrganization(ctx context.Context, orgID uuid.UUID) (int, error) AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) // CountActiveCampaignsForAccount returns how many active campaigns send - // from the given mailbox (matched through the campaign's email tags). - // Used by the warmup scheduler to keep a low-volume health-check warmup - // running whenever a mailbox is in use by a live campaign. + // from the given mailbox (matched through the campaign's email tags OR an + // explicit campaign_senders row). Used by the warmup scheduler to keep a + // low-volume health-check warmup running whenever a mailbox is in use by a + // live campaign. CountActiveCampaignsForAccount(ctx context.Context, accountID uuid.UUID) (int, error) + + // ── Explicit sender pool (feature 1) ──────────────────────────────── + // GetCampaignSenders returns the campaign's explicit sender rows. + GetCampaignSenders(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignSender, error) + // ReplaceCampaignSenders atomically replaces the explicit sender pool + // (delete-all + multi-insert in one tx). Every account must belong to the + // campaign owner; an empty list clears the pool (the campaign then resolves + // from its email tags, or all active mailboxes when it has none). + ReplaceCampaignSenders(ctx context.Context, campaignID uuid.UUID, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) + // AdvanceCampaignSender bumps the round-robin cursor and stamps last_sent_at + // for the chosen mailbox in a single atomic UPDATE. Fires only on a genuine + // send so the round-robin/LRU cursors stay coherent under concurrency. + AdvanceCampaignSender(ctx context.Context, campaignID, accountID uuid.UUID) error + + // ── Per-campaign ramp + daily counters (features 2 & 4) ───────────── + // AdvanceRampLevel advances the persisted ramp level once per UTC day. + // Idempotent and a no-op when ramp is disabled or already advanced today. + AdvanceRampLevel(ctx context.Context, campaignID uuid.UUID) error + // IncrementCampaignDailySend bumps today's send counters; newLead also + // increments new_leads_started (a position-1 send). + IncrementCampaignDailySend(ctx context.Context, campaignID uuid.UUID, newLead bool) error + // CountNewLeadsStartedToday returns new_leads_started for the current UTC + // day (0 when no row exists yet). + CountNewLeadsStartedToday(ctx context.Context, campaignID uuid.UUID) (int, error) + + // ── Campaign-scoped tracking domain (feature 5) ───────────────────── + // SetCampaignTrackingDomainVerified flips the verified flag / timestamp on + // the campaign-scoped tracking-domain override. + SetCampaignTrackingDomainVerified(ctx context.Context, campaignID uuid.UUID, verified bool, at *time.Time) error } type campaignRepository struct { @@ -55,13 +86,24 @@ func NewCampaignRepostory(db *db.DB) CampaignRepository { } } +// CAMPAIGN_SELECT and its scan dests (getCampaign), CAMPAIGN_SELECT_FULL + +// getCampaignFull, and the hand-written GetByID Scan MUST stay in lockstep: +// the new send-control columns are appended AFTER created_at in the base list +// (and BETWEEN created_at and the tag/folder aggregates in the _FULL list) so +// every scanner reads the same column order. A mismatch is a runtime scan error +// that compiles and lints cleanly — change all four together. const CAMPAIGN_SELECT = `id, name, description, status, stop_on_reply, open_tracking, link_tracking, text_only, daily_limit, unsubscribe_header, risky_emails, cc_addr, bcc_addr, start_date, end_date, timezone, days, start_time, end_time, contact_order_by, contact_order_dir, contact_order_field, - updated_at, created_at` + updated_at, created_at, + sender_strategy, rotation_mode, + ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, ramp_level, ramp_level_date, + esp_match_mode, max_new_leads_per_day, prioritize_new_leads, + tracking_domain, tracking_domain_verified, tracking_domain_verified_at, + schedule_windows` func getCampaign(rows db.Scannable, campaign *models.Campaign, extra ...any) error { var dest []any = []any{ @@ -72,6 +114,11 @@ func getCampaign(rows db.Scannable, campaign *models.Campaign, extra ...any) err &campaign.StartTime, &campaign.EndTime, &campaign.ContactOrderBy, &campaign.ContactOrderDir, &campaign.ContactOrderField, &campaign.UpdatedAt, &campaign.CreatedAt, + &campaign.SenderStrategy, &campaign.RotationMode, + &campaign.RampEnabled, &campaign.RampStart, &campaign.RampIncrement, &campaign.RampCeiling, &campaign.RampLevel, &campaign.RampLevelDate, + &campaign.ESPMatchMode, &campaign.MaxNewLeadsPerDay, &campaign.PrioritizeNewLeads, + &campaign.TrackingDomain, &campaign.TrackingDomainVerified, &campaign.TrackingDomainVerifiedAt, + &campaign.ScheduleWindows, } dest = append(dest, extra...) return rows.Scan( @@ -87,6 +134,11 @@ const CAMPAIGN_SELECT_FULL = ` c.start_time, c.end_time, c.contact_order_by, c.contact_order_dir, c.contact_order_field, c.updated_at, c.created_at, + c.sender_strategy, c.rotation_mode, + c.ramp_enabled, c.ramp_start, c.ramp_increment, c.ramp_ceiling, c.ramp_level, c.ramp_level_date, + c.esp_match_mode, c.max_new_leads_per_day, c.prioritize_new_leads, + c.tracking_domain, c.tracking_domain_verified, c.tracking_domain_verified_at, + c.schedule_windows, COALESCE(array_agg(cet.tag_id) FILTER (WHERE cet.tag_id IS NOT NULL), '{}') AS email_tag_ids, COALESCE(array_agg(cec.folder_id) FILTER (WHERE cec.folder_id IS NOT NULL), '{}') AS email_folder_ids ` @@ -214,6 +266,74 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u riskyEmails = *data.RiskyEmails } + // ── Net-new send controls. Defaults reproduce today's behavior exactly. ── + senderStrategy := "tags" + if data.SenderStrategy != nil { + if err := validate.CampaignSenderStrategy(*data.SenderStrategy); err != nil { + return nil, err + } + senderStrategy = *data.SenderStrategy + } + rotationMode := "weighted" + if data.RotationMode != nil { + if err := validate.CampaignRotationMode(*data.RotationMode); err != nil { + return nil, err + } + rotationMode = *data.RotationMode + } + rampEnabled := false + if data.RampEnabled != nil { + rampEnabled = *data.RampEnabled + } + rampStart := config.CampaignRampStartDefault + if data.RampStart != nil { + rampStart = *data.RampStart + } + rampIncrement := config.CampaignRampIncrementDefault + if data.RampIncrement != nil { + rampIncrement = *data.RampIncrement + } + rampCeiling := config.CampaignRampCeilingDefault + if data.RampCeiling != nil { + rampCeiling = *data.RampCeiling + } + if data.RampEnabled != nil || data.RampStart != nil || data.RampIncrement != nil || data.RampCeiling != nil { + if err := validate.CampaignRamp(rampStart, rampIncrement, rampCeiling); err != nil { + return nil, err + } + } + espMatchMode := "off" + if data.ESPMatchMode != nil { + if err := validate.CampaignESPMatchMode(*data.ESPMatchMode); err != nil { + return nil, err + } + espMatchMode = *data.ESPMatchMode + } + maxNewLeads := 0 + if data.MaxNewLeadsPerDay != nil { + if err := validate.CampaignMaxNewLeads(*data.MaxNewLeadsPerDay); err != nil { + return nil, err + } + maxNewLeads = *data.MaxNewLeadsPerDay + } + prioritizeNewLeads := false + if data.PrioritizeNewLeads != nil { + prioritizeNewLeads = *data.PrioritizeNewLeads + } + trackingDomain := "" + if data.TrackingDomain != nil { + if err := validate.CampaignTrackingDomain(*data.TrackingDomain); err != nil { + return nil, err + } + trackingDomain = strings.TrimSpace(strings.ToLower(*data.TrackingDomain)) + } + // An explicit-strategy campaign must ship at least one sender (the scheduler + // otherwise falls back to tags, but persisting an empty explicit pool is a + // configuration error the caller should fix up front). + if senderStrategy == "explicit" && len(data.Senders) == 0 { + return nil, errx.New(errx.BadRequest, "explicit sender strategy requires at least one sender") + } + tx, err := r.DB.Begin(ctx) if err != nil { db.CaptureError(err, "", nil, "begin") @@ -235,6 +355,10 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u daily_limit, unsubscribe_header, risky_emails, cc_addr, bcc_addr, start_date, end_date, timezone, days, start_time, end_time, + sender_strategy, rotation_mode, + ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, + esp_match_mode, max_new_leads_per_day, prioritize_new_leads, + tracking_domain, created_at, updated_at ) VALUES ( gen_random_uuid(), $1, $2, $3, $4, @@ -242,31 +366,45 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, + $20, $21, + $22, $23, $24, $25, + $26, $27, $28, + $29, NOW(), NOW() ) RETURNING %s `, CAMPAIGN_SELECT) params := []any{ - data.Name, // $1 - data.Description, // $2 - userID, // $3 - orgID, // $4 (nullable) - stopOnReply, // $5 - openTracking, // $6 - linkTracking, // $7 - textOnly, // $8 - dailyLimit, // $9 - unsubHeader, // $10 - riskyEmails, // $11 - cc, // $12 - bcc, // $13 - data.StartDate, // $14 - data.EndDate, // $15 - timezone, // $16 - days, // $17 - startTime, // $18 - endTime, // $19 + data.Name, // $1 + data.Description, // $2 + userID, // $3 + orgID, // $4 (nullable) + stopOnReply, // $5 + openTracking, // $6 + linkTracking, // $7 + textOnly, // $8 + dailyLimit, // $9 + unsubHeader, // $10 + riskyEmails, // $11 + cc, // $12 + bcc, // $13 + data.StartDate, // $14 + data.EndDate, // $15 + timezone, // $16 + days, // $17 + startTime, // $18 + endTime, // $19 + senderStrategy, // $20 + rotationMode, // $21 + rampEnabled, // $22 + rampStart, // $23 + rampIncrement, // $24 + rampCeiling, // $25 + espMatchMode, // $26 + maxNewLeads, // $27 + prioritizeNewLeads, // $28 + trackingDomain, // $29 } row := tx.QueryRow(ctx, insertSQL, params...) @@ -298,6 +436,20 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u campaign.Folders = folders } + // Explicit sender pool (feature 1). Only persisted when the caller passed a + // list; validation/ownership checks live in syncCampaignSendersTx. + if len(data.Senders) > 0 { + orgStr := "" + if orgID != nil { + orgStr = orgID.String() + } + senders, xerr := syncCampaignSendersTx(ctx, tx, campaign.ID, userID, orgStr, data.Senders) + if xerr != nil { + return nil, xerr + } + campaign.Senders = senders + } + // Initial sequences. Position is the array index; wait_after defaults // to 0 for the first step and 3 days for any follow-ups so a default // wizard run still produces something usable. @@ -308,6 +460,9 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u waitAfter = 3 } if seq.WaitAfter != nil { + if *seq.WaitAfter < 0 || *seq.WaitAfter > config.SequenceWaitAfterMax { + return nil, errx.ErrSequenceWaitAfter + } waitAfter = *seq.WaitAfter } bodySync := true @@ -708,6 +863,14 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri args = append(args, *data.EndTime) argPos++ } + if data.ScheduleWindows != nil { + if err := validate.CampaignScheduleWindows(data.ScheduleWindows); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "schedule_windows", argPos)) + args = append(args, *data.ScheduleWindows) + argPos++ + } if data.ContactOrderBy != nil { validOrderBy := map[string]bool{ "created_at": true, "email": true, "name": true, @@ -734,7 +897,89 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri argPos++ } - if argPos == 3 && data.EmailTags == nil { + // ── Net-new send controls ─────────────────────────────────────────── + if data.SenderStrategy != nil { + if err := validate.CampaignSenderStrategy(*data.SenderStrategy); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "sender_strategy", argPos)) + args = append(args, *data.SenderStrategy) + argPos++ + } + if data.RotationMode != nil { + if err := validate.CampaignRotationMode(*data.RotationMode); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "rotation_mode", argPos)) + args = append(args, *data.RotationMode) + argPos++ + } + if data.RampEnabled != nil { + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_enabled", argPos)) + args = append(args, *data.RampEnabled) + argPos++ + } + if data.RampStart != nil { + if *data.RampStart < 1 || *data.RampStart > 100 { + return nil, errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_start", argPos)) + args = append(args, *data.RampStart) + argPos++ + } + if data.RampIncrement != nil { + if *data.RampIncrement < 0 || *data.RampIncrement > 100 { + return nil, errx.New(errx.BadRequest, "ramp increment must be between 0 and 100") + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_increment", argPos)) + args = append(args, *data.RampIncrement) + argPos++ + } + if data.RampCeiling != nil { + if *data.RampCeiling < 1 || *data.RampCeiling > 100 { + return nil, errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_ceiling", argPos)) + args = append(args, *data.RampCeiling) + argPos++ + } + if data.RampStart != nil && data.RampCeiling != nil && *data.RampStart > *data.RampCeiling { + return nil, errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + } + if data.ESPMatchMode != nil { + if err := validate.CampaignESPMatchMode(*data.ESPMatchMode); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "esp_match_mode", argPos)) + args = append(args, *data.ESPMatchMode) + argPos++ + } + if data.MaxNewLeadsPerDay != nil { + if err := validate.CampaignMaxNewLeads(*data.MaxNewLeadsPerDay); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "max_new_leads_per_day", argPos)) + args = append(args, *data.MaxNewLeadsPerDay) + argPos++ + } + if data.PrioritizeNewLeads != nil { + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "prioritize_new_leads", argPos)) + args = append(args, *data.PrioritizeNewLeads) + argPos++ + } + if data.TrackingDomain != nil { + if err := validate.CampaignTrackingDomain(*data.TrackingDomain); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "tracking_domain", argPos)) + args = append(args, *data.TrackingDomain) + argPos++ + // Any change to the override invalidates a prior verification until + // the CNAME is re-resolved (only a verified override is honored). + setClauses = append(setClauses, "tracking_domain_verified = false", "tracking_domain_verified_at = NULL") + } + + if argPos == 3 && data.EmailTags == nil && data.Folders == nil { return nil, errx.ErrNotEnough } @@ -832,6 +1077,11 @@ func (r *campaignRepository) GetByID(ctx context.Context, campaignID uuid.UUID) &campaign.StartTime, &campaign.EndTime, &campaign.ContactOrderBy, &campaign.ContactOrderDir, &campaign.ContactOrderField, &campaign.UpdatedAt, &campaign.CreatedAt, + &campaign.SenderStrategy, &campaign.RotationMode, + &campaign.RampEnabled, &campaign.RampStart, &campaign.RampIncrement, &campaign.RampCeiling, &campaign.RampLevel, &campaign.RampLevelDate, + &campaign.ESPMatchMode, &campaign.MaxNewLeadsPerDay, &campaign.PrioritizeNewLeads, + &campaign.TrackingDomain, &campaign.TrackingDomainVerified, &campaign.TrackingDomainVerifiedAt, + &campaign.ScheduleWindows, &campaign.EmailTags, &campaign.Folders, ) if err != nil { @@ -848,7 +1098,7 @@ func (r *campaignRepository) GetByID(ctx context.Context, campaignID uuid.UUID) // GetSequenceByID retrieves a sequence by ID func (r *campaignRepository) GetSequenceByID(ctx context.Context, sequenceID uuid.UUID) (*models.Sequence, error) { query := ` - SELECT id, name, subject, body_plain, body_html, body_sync, body_code, wait_after, updated_at, created_at + SELECT id, name, subject, body_plain, body_html, body_sync, body_code, wait_after, kind, action, updated_at, created_at FROM sequences WHERE id = $1 ` @@ -856,7 +1106,7 @@ func (r *campaignRepository) GetSequenceByID(ctx context.Context, sequenceID uui var seq models.Sequence err := r.DB.QueryRow(ctx, query, sequenceID).Scan( &seq.ID, &seq.Name, &seq.Subject, &seq.BodyPlain, &seq.BodyHTML, - &seq.BodySync, &seq.BodyCode, &seq.WaitAfter, &seq.UpdatedAt, &seq.CreatedAt, + &seq.BodySync, &seq.BodyCode, &seq.WaitAfter, &seq.Kind, &seq.Action, &seq.UpdatedAt, &seq.CreatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -938,9 +1188,20 @@ func (r *campaignRepository) PauseAllByUserID(ctx context.Context, userID uuid.U return err } -// StartCampaign sets campaign status to active and updates last_status_change_at +// StartCampaign sets campaign status to active and updates last_status_change_at. +// When ramp is enabled and not yet started (ramp_level = 0), it also seeds the +// ramp at ramp_start for today so the first day sends at the ramp floor rather +// than ramp_start+increment. Pause/resume preserves ramp_level, so a resume +// continues from the persisted level (this only fires on a fresh start). func (r *campaignRepository) StartCampaign(ctx context.Context, campaignID uuid.UUID) error { - query := `UPDATE campaigns SET status = 'active', last_status_change_at = NOW(), updated_at = NOW() WHERE id = $1` + query := ` + UPDATE campaigns + SET status = 'active', + last_status_change_at = NOW(), + updated_at = NOW(), + ramp_level = CASE WHEN ramp_enabled AND ramp_level = 0 THEN ramp_start ELSE ramp_level END, + ramp_level_date = CASE WHEN ramp_enabled AND ramp_level = 0 THEN CURRENT_DATE ELSE ramp_level_date END + WHERE id = $1` _, err := r.DB.Exec(ctx, query, campaignID) return err } @@ -974,16 +1235,30 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign return errx.New(errx.BadRequest, "campaign must have at least one contact") } - // Check email tags - var tagCount int - err = r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM campaign_email_tags WHERE campaign_id = $1`, campaignID).Scan(&tagCount) - if err != nil { + // Sender pool (unified): valid if it has any enabled explicit sender OR any + // email tag OR — when neither is selected ("all") — at least one active + // mailbox for the owner to fall back to. + var senderCount int + if err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM campaign_senders WHERE campaign_id = $1 AND enabled`, campaignID).Scan(&senderCount); err != nil { return err } - if tagCount == 0 { - return errx.New(errx.BadRequest, "campaign must have at least one email tag") + var tagCount int + if err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM campaign_email_tags WHERE campaign_id = $1`, campaignID).Scan(&tagCount); err != nil { + return err + } + if senderCount > 0 || tagCount > 0 { + return nil + } + var activeMailboxes int + if err := r.DB.QueryRow(ctx, ` + SELECT COUNT(*) FROM email_accounts + WHERE user_id = (SELECT user_id FROM campaigns WHERE id = $1) AND status = 'active' + `, campaignID).Scan(&activeMailboxes); err != nil { + return err + } + if activeMailboxes == 0 { + return errx.New(errx.BadRequest, "campaign must have at least one active sending mailbox") } - return nil } @@ -1026,9 +1301,15 @@ func (r *campaignRepository) CountActiveForOrganization(ctx context.Context, org return count, err } +// AccountHasActiveCampaign reports whether the mailbox backs at least one active +// campaign, counting BOTH tag-based campaigns AND explicit-sender campaigns +// (campaign_senders). The explicit lane matters for the warmup health-check +// floor: an explicit-sender mailbox sends cold and must keep its in-campaign +// warmup heartbeat just like a tag-based one. func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) { query := ` SELECT EXISTS ( + -- tag-resolved SELECT 1 FROM email_accounts ea JOIN email_tags et ON et.email_id = ea.id @@ -1037,6 +1318,26 @@ func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accou WHERE ea.id = $1 AND ea.status = 'active' AND c.status = 'active' + UNION ALL + -- explicit sender + SELECT 1 + FROM campaign_senders cs + JOIN campaigns c ON c.id = cs.campaign_id + JOIN email_accounts ea ON ea.id = cs.email_account_id + WHERE cs.email_account_id = $1 + AND cs.enabled + AND ea.status = 'active' + AND c.status = 'active' + UNION ALL + -- "all" campaigns (no tags, no enabled senders) back every active mailbox of their owner + SELECT 1 + FROM campaigns c + JOIN email_accounts ea ON ea.user_id = c.user_id + WHERE ea.id = $1 + AND ea.status = 'active' + AND c.status = 'active' + AND NOT EXISTS (SELECT 1 FROM campaign_email_tags cet2 WHERE cet2.campaign_id = c.id) + AND NOT EXISTS (SELECT 1 FROM campaign_senders cs2 WHERE cs2.campaign_id = c.id AND cs2.enabled) ) ` var exists bool @@ -1044,18 +1345,292 @@ func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accou return exists, err } +// CountActiveCampaignsForAccount counts distinct active campaigns the mailbox +// backs, via the campaign's email tags (tag strategy) OR an enabled +// campaign_senders row (explicit strategy). func (r *campaignRepository) CountActiveCampaignsForAccount(ctx context.Context, accountID uuid.UUID) (int, error) { query := ` - SELECT COUNT(DISTINCT c.id) - FROM campaigns c - JOIN campaign_email_tags cet ON cet.campaign_id = c.id - JOIN email_tags et ON et.tag_id = cet.tag_id - WHERE et.email_id = $1 AND c.status = 'active'` + SELECT COUNT(*) FROM ( + -- tag-resolved + SELECT c.id + FROM campaigns c + JOIN campaign_email_tags cet ON cet.campaign_id = c.id + JOIN email_tags et ON et.tag_id = cet.tag_id + WHERE et.email_id = $1 + AND c.status = 'active' + UNION + -- explicit sender + SELECT c.id + FROM campaigns c + JOIN campaign_senders cs ON cs.campaign_id = c.id + WHERE cs.email_account_id = $1 + AND cs.enabled + AND c.status = 'active' + UNION + -- "all" campaigns (no tags, no enabled senders) count for every active mailbox of their owner + SELECT c.id + FROM campaigns c + JOIN email_accounts ea ON ea.user_id = c.user_id + WHERE ea.id = $1 + AND ea.status = 'active' + AND c.status = 'active' + AND NOT EXISTS (SELECT 1 FROM campaign_email_tags cet2 WHERE cet2.campaign_id = c.id) + AND NOT EXISTS (SELECT 1 FROM campaign_senders cs2 WHERE cs2.campaign_id = c.id AND cs2.enabled) + ) AS active_campaigns` var count int err := r.DB.QueryRow(ctx, query, accountID).Scan(&count) return count, err } +// syncCampaignSendersTx replaces the explicit sender pool inside an existing tx. +// It deletes the current rows and multi-inserts the new set, validating that +// every account is reachable in the campaign's context — by the campaign's +// organization (orgID) for org-scoped campaigns, or by the owner (userID) for a +// personal campaign with no org. rotation_position and last_sent_at are +// preserved for accounts that remain in the pool so a rewrite of weights/enabled +// flags does not reset the round-robin/LRU cursors. +func syncCampaignSendersTx(ctx context.Context, tx pgx.Tx, campaignID uuid.UUID, userID, orgID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { + accountIDs := make([]uuid.UUID, 0, len(in)) + weights := make(map[uuid.UUID]int, len(in)) + enabledFor := make(map[uuid.UUID]bool, len(in)) + seen := make(map[uuid.UUID]struct{}, len(in)) + for _, s := range in { + if s.EmailAccountID == uuid.Nil { + return nil, errx.New(errx.BadRequest, "sender email_account_id is required") + } + if _, dup := seen[s.EmailAccountID]; dup { + return nil, errx.New(errx.BadRequest, "duplicate sender email_account_id") + } + seen[s.EmailAccountID] = struct{}{} + weight := config.CampaignSenderWeightDefault + if s.Weight != nil { + if err := validate.CampaignSenderWeight(*s.Weight); err != nil { + return nil, err + } + weight = *s.Weight + } + enabled := true + if s.Enabled != nil { + enabled = *s.Enabled + } + accountIDs = append(accountIDs, s.EmailAccountID) + weights[s.EmailAccountID] = weight + enabledFor[s.EmailAccountID] = enabled + } + + // Ownership check: every referenced mailbox must be reachable in the + // campaign's context. Org-scoped campaigns (the org-gated senders route) + // validate against the organization, so any member with PermManageCampaigns + // can pick the org's mailboxes; a personal campaign with no org falls back to + // the owner's user_id. (ownerCol is a fixed literal, never user input.) + ownerCol, ownerVal := "user_id", userID + if orgID != "" { + ownerCol, ownerVal = "organization_id", orgID + } + var ownedCount int + if err := tx.QueryRow(ctx, + `SELECT COUNT(*) FROM email_accounts WHERE `+ownerCol+` = $1 AND id = ANY($2)`, + ownerVal, accountIDs, + ).Scan(&ownedCount); err != nil { + db.CaptureError(err, "campaign_senders ownership", []any{ownerVal}, "queryrow") + return nil, errx.InternalError() + } + if ownedCount != len(accountIDs) { + return nil, errx.New(errx.BadRequest, "one or more sender mailboxes do not belong to this account") + } + + // Delete rows that are no longer in the set (CASCADE-safe; preserves the + // surviving rows so their cursors carry over). + if _, err := tx.Exec(ctx, + `DELETE FROM campaign_senders WHERE campaign_id = $1 AND email_account_id <> ALL($2)`, + campaignID, accountIDs, + ); err != nil { + db.CaptureError(err, "campaign_senders delete", []any{campaignID}, "exec") + return nil, errx.InternalError() + } + + // Upsert the desired rows. ON CONFLICT updates weight/enabled but leaves + // rotation_position/last_sent_at untouched. + for _, id := range accountIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO campaign_senders (campaign_id, email_account_id, weight, enabled) + VALUES ($1, $2, $3, $4) + ON CONFLICT (campaign_id, email_account_id) + DO UPDATE SET weight = EXCLUDED.weight, enabled = EXCLUDED.enabled + `, campaignID, id, weights[id], enabledFor[id]); err != nil { + db.CaptureError(err, "campaign_senders upsert", []any{campaignID, id}, "exec") + return nil, errx.InternalError() + } + } + + return readCampaignSendersTx(ctx, tx, campaignID) +} + +// campaignSenderQuerier is satisfied by both *db.DB (via the embedded pool) and +// pgx.Tx, so readCampaignSendersTx works inside or outside a transaction. +type campaignSenderQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +// readCampaignSendersTx loads the explicit sender pool inside a tx. +func readCampaignSendersTx(ctx context.Context, q campaignSenderQuerier, campaignID uuid.UUID) ([]models.CampaignSender, *errx.Error) { + rows, err := q.Query(ctx, ` + SELECT email_account_id, weight, last_sent_at, enabled + FROM campaign_senders + WHERE campaign_id = $1 + ORDER BY created_at ASC, email_account_id ASC + `, campaignID) + if err != nil { + db.CaptureError(err, "campaign_senders select", []any{campaignID}, "query") + return nil, errx.InternalError() + } + defer rows.Close() + + senders := make([]models.CampaignSender, 0) + for rows.Next() { + var s models.CampaignSender + if err := rows.Scan(&s.EmailAccountID, &s.Weight, &s.LastSentAt, &s.Enabled); err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + senders = append(senders, s) + } + if err := rows.Err(); err != nil { + db.CaptureError(err, "", nil, "rows") + return nil, errx.InternalError() + } + return senders, nil +} + +// GetCampaignSenders returns the explicit sender pool for a campaign. +func (r *campaignRepository) GetCampaignSenders(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignSender, error) { + senders, xerr := readCampaignSendersTx(ctx, r.DB, campaignID) + if xerr != nil { + return nil, xerr + } + return senders, nil +} + +// ReplaceCampaignSenders atomically swaps the explicit sender pool. An empty +// list is rejected — clearing senders should be done by switching the campaign +// back to sender_strategy='tags'. +func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaignID uuid.UUID, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { + // An empty list is allowed: it clears the explicit sender pool, so the + // campaign falls back to its email tags or, with neither, to every active + // mailbox of the owner. syncCampaignSendersTx handles the empty set safely + // (it deletes all current rows and inserts none). + + // Resolve the campaign owner + organization so we can validate mailbox + // ownership against the org (the senders route is org-scoped). + var userID string + var orgID *uuid.UUID + if err := r.DB.QueryRow(ctx, `SELECT user_id, organization_id FROM campaigns WHERE id = $1`, campaignID).Scan(&userID, &orgID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errx.ErrNotFound + } + db.CaptureError(err, "campaign owner lookup", []any{campaignID}, "queryrow") + return nil, errx.InternalError() + } + orgStr := "" + if orgID != nil { + orgStr = orgID.String() + } + + tx, err := r.DB.Begin(ctx) + if err != nil { + db.CaptureError(err, "", nil, "begin") + return nil, errx.InternalError() + } + var committed bool + defer func() { + if !committed { + tx.Rollback(ctx) + } + }() + + senders, xerr := syncCampaignSendersTx(ctx, tx, campaignID, userID, orgStr, in) + if xerr != nil { + return nil, xerr + } + + if err := tx.Commit(ctx); err != nil { + db.CaptureError(err, "", nil, "commit") + return nil, errx.InternalError() + } + committed = true + return senders, nil +} + +// AdvanceCampaignSender bumps the round-robin cursor and stamps last_sent_at in +// a single atomic UPDATE (no read-modify-write), keeping cursors coherent when +// multiple campaign tasks for the same campaign run concurrently. +func (r *campaignRepository) AdvanceCampaignSender(ctx context.Context, campaignID, accountID uuid.UUID) error { + _, err := r.DB.Exec(ctx, ` + UPDATE campaign_senders + SET rotation_position = rotation_position + 1, last_sent_at = NOW() + WHERE campaign_id = $1 AND email_account_id = $2 + `, campaignID, accountID) + return err +} + +// AdvanceRampLevel advances the persisted ramp level once per UTC day. It is a +// no-op when ramp is off or already advanced today. Applied via min() in the +// scheduler so it can only LOWER the effective per-mailbox cap. +func (r *campaignRepository) AdvanceRampLevel(ctx context.Context, campaignID uuid.UUID) error { + _, err := r.DB.Exec(ctx, ` + UPDATE campaigns + SET ramp_level = LEAST(ramp_ceiling, GREATEST(ramp_level, ramp_start) + ramp_increment), + ramp_level_date = CURRENT_DATE + WHERE id = $1 + AND ramp_enabled + AND (ramp_level_date IS NULL OR ramp_level_date < CURRENT_DATE) + `, campaignID) + return err +} + +// IncrementCampaignDailySend bumps today's per-campaign send counters. newLead +// also increments new_leads_started (a position-1 send) so the new-lead cap can +// read it back. +func (r *campaignRepository) IncrementCampaignDailySend(ctx context.Context, campaignID uuid.UUID, newLead bool) error { + newLeadInc := 0 + if newLead { + newLeadInc = 1 + } + _, err := r.DB.Exec(ctx, ` + INSERT INTO campaign_daily_sends (campaign_id, send_date, emails_sent, new_leads_started) + VALUES ($1, CURRENT_DATE, 1, $2) + ON CONFLICT (campaign_id, send_date) + DO UPDATE SET emails_sent = campaign_daily_sends.emails_sent + 1, + new_leads_started = campaign_daily_sends.new_leads_started + $2 + `, campaignID, newLeadInc) + return err +} + +// CountNewLeadsStartedToday returns new_leads_started for the current UTC day. +func (r *campaignRepository) CountNewLeadsStartedToday(ctx context.Context, campaignID uuid.UUID) (int, error) { + var n int + err := r.DB.QueryRow(ctx, ` + SELECT COALESCE(new_leads_started, 0) + FROM campaign_daily_sends + WHERE campaign_id = $1 AND send_date = CURRENT_DATE + `, campaignID).Scan(&n) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + return n, err +} + +// SetCampaignTrackingDomainVerified flips the verified flag/timestamp on the +// campaign-scoped tracking-domain override. +func (r *campaignRepository) SetCampaignTrackingDomainVerified(ctx context.Context, campaignID uuid.UUID, verified bool, at *time.Time) error { + _, err := r.DB.Exec(ctx, ` + UPDATE campaigns + SET tracking_domain_verified = $2, tracking_domain_verified_at = $3, updated_at = NOW() + WHERE id = $1 + `, campaignID, verified, at) + return err +} + // UpdateStatusWithLock updates campaign status using a PostgreSQL advisory lock to prevent concurrent updates. // The WHERE clause guards against races: only updates if the campaign is currently 'active'. func (r *campaignRepository) UpdateStatusWithLock(ctx context.Context, campaignID uuid.UUID, status string) error { diff --git a/internal/repository/pg_campaign_progress.go b/internal/repository/pg_campaign_progress.go index f3f7058e..6ea70843 100644 --- a/internal/repository/pg_campaign_progress.go +++ b/internal/repository/pg_campaign_progress.go @@ -3,10 +3,12 @@ package repository import ( "context" "database/sql" + "encoding/json" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/warmbly/warmbly/internal/models" ) // CampaignContactProgress represents the progress of a contact in a campaign @@ -48,6 +50,9 @@ type CampaignRollingRates struct { type ContactSequencePair struct { ContactID uuid.UUID SequenceID uuid.UUID + // IsNewLead is true when this pair is the contact's first step (sequence + // position 1). Drives the per-day new-lead counter and cap. + IsNewLead bool } type CampaignSequencePair struct { @@ -74,8 +79,14 @@ type CampaignProgressRepository interface { CountEmailsSentTodayByOrganization(ctx context.Context, organizationID uuid.UUID) (int, error) GetLatestCampaignSequenceForContact(ctx context.Context, contactID uuid.UUID) (*CampaignSequencePair, error) - // Find next email to send - FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string) (*ContactSequencePair, error) + // FindNextRoutedPair selects the next (contact, step) to send by following + // each contact's step rules (the branching tree) rather than a flat position + // order. prioritizeNewLeads sorts first-step pairs first; excludeNewLeads + // drops first-step pairs entirely so the new-lead/day cap can be enforced + // while follow-ups keep flowing. The second return value, when the pair is + // nil, is the soonest time a waiting contact's condition window elapses — the + // scheduler should defer and re-check then rather than completing. + FindNextRoutedPair(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, *time.Time, error) } type campaignProgressRepository struct { @@ -356,12 +367,108 @@ func (r *campaignProgressRepository) GetLatestCampaignSequenceForContact(ctx con return out, nil } -// FindNextContactSequence finds the next contact/sequence pair that needs to be sent -// orderBy: "created_at", "email", "name", "custom_field", "manual" -// orderDir: "asc", "desc" -// orderField: custom field name (used when orderBy is "custom_field") -func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string) (*ContactSequencePair, error) { - // Build the ORDER BY clause based on ordering settings +// FindNextRoutedPair selects the next (contact, step) to send by FOLLOWING THE +// FLOW graph instead of linear position order. For each contact, the next step +// is the route out of their last-sent step: +// 1. conditional branches (first match wins, evaluated against engagement), +// 2. then the explicit "else" catch-all branch (empty conditions; target nil = STOP), +// 3. then linear position+1 — but ONLY when the step defines no branches at all +// (so plain linear campaigns keep working unchanged). +// +// A contact who has never been sent starts at the entry step (position 1). A +// step is sent only if the route reaches it, so branch-only steps are never sent +// linearly, and a routed step that was already sent (a loop) stops the contact. +// +// Conditions are evaluated SEND-RELATIVE with a three-valued result: a contact +// whose next step isn't decidable yet (an engagement window still open) is not +// returned; instead the soonest re-check time is returned (second value) so the +// scheduler defers and checks again exactly then. Returns (nil, nil, nil) when +// the campaign is genuinely complete (no sendable and nothing pending). +func (r *campaignProgressRepository) FindNextRoutedPair(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, *time.Time, error) { + // 1. Load steps (position + branch tree) once, ordered by position. + type stepInfo struct { + id uuid.UUID + bc models.BranchConditions + } + srows, err := r.db.Query(ctx, `SELECT id, conditions FROM sequences WHERE campaign_id = $1 ORDER BY position ASC, created_at ASC`, campaignID) + if err != nil { + return nil, nil, err + } + var steps []stepInfo + idxByID := map[uuid.UUID]int{} + for srows.Next() { + var si stepInfo + var raw []byte + if serr := srows.Scan(&si.id, &raw); serr != nil { + srows.Close() + return nil, nil, serr + } + if len(raw) > 0 { + _ = json.Unmarshal(raw, &si.bc) + } + idxByID[si.id] = len(steps) + steps = append(steps, si) + } + srows.Close() + if serr := srows.Err(); serr != nil { + return nil, nil, serr + } + if len(steps) == 0 { + return nil, nil, nil + } + entry := steps[0].id + now := time.Now() + + // routeResult is the outcome of routing a contact out of their current step: + // send `target`, fully `stop`, or `wait` until a condition window elapses. + type routeResult struct { + target *uuid.UUID + stop bool + wait *time.Time + } + // routeNext follows the first DECIDABLE branch out of fromID. A branch whose + // window is still open leaves the contact waiting (so "if opened within 3d" + // gets its 3 days instead of being judged the instant the step is sent). + routeNext := func(fromID uuid.UUID, prog *CampaignContactProgress, sentAt time.Time) routeResult { + idx, ok := idxByID[fromID] + if !ok { + return routeResult{stop: true} + } + bc := steps[idx].bc + // Routing is purely the connections the user drew. A step with no + // outgoing connection, or whose connections don't match, ends the + // contact (STOP). There is NO implicit "advance to the next step by + // position" — steps are only linked when explicitly connected, and an + // unconditional connection (a branch with no conditions) is the "just go + // there after the wait" default. + if len(bc.Branches) == 0 { + return routeResult{stop: true} + } + for i := range bc.Branches { + b := &bc.Branches[i] + st, recheck := evaluateBranchState(b, prog, sentAt, now) + if st == BranchNoMatch { + continue + } + if st == BranchUndecided { + rc := recheck + return routeResult{wait: &rc} + } + // Matched: a nil / deleted target ends the contact (STOP). + if b.TargetSequenceID == nil { + return routeResult{stop: true} + } + if _, live := idxByID[*b.TargetSequenceID]; !live { + return routeResult{stop: true} + } + t := *b.TargetSequenceID + return routeResult{target: &t} + } + // Nothing matched -> the flow ends with STOP. + return routeResult{stop: true} + } + + // 2. Ordered candidate contacts + their last-sent step (with engagement) + sent set. var contactOrder string switch orderBy { case "email": @@ -376,64 +483,122 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context } case "manual": contactOrder = "cl.position NULLS LAST, c.created_at" - default: // created_at + default: contactOrder = "c.created_at" } - - // Apply direction dir := "ASC" if orderDir == "desc" { dir = "DESC" } - - query := ` - WITH all_pairs AS ( - -- Generate all possible contact-sequence combinations for this campaign - SELECT - cl.contact_id, - s.id as sequence_id, - ROW_NUMBER() OVER (ORDER BY ` + contactOrder + ` ` + dir + `, s.position, s.created_at) as pair_order - FROM campaign_leads cl - JOIN contacts c ON c.id = cl.contact_id - CROSS JOIN sequences s - WHERE cl.campaign_id = $1 - AND s.campaign_id = $1 - -- Skip contacts that bounced in ANY campaign - AND NOT EXISTS ( - SELECT 1 FROM campaign_contact_progress ccp2 - WHERE ccp2.contact_id = cl.contact_id - AND ccp2.bounced_at IS NOT NULL - ) - -- Skip suppressed recipients (bounce, complaint, unsubscribe from deliverability) - AND NOT EXISTS ( - SELECT 1 FROM suppressed_recipients sr - JOIN campaigns camp ON camp.organization_id = sr.organization_id - WHERE camp.id = $1 - AND LOWER(sr.email) = LOWER(c.email) - AND (sr.expires_at IS NULL OR sr.expires_at > NOW()) - ) - ), - sent_pairs AS ( - -- Get all already-sent pairs - SELECT contact_id, sequence_id - FROM campaign_contact_progress - WHERE campaign_id = $1 - AND sent_at IS NOT NULL - ) - SELECT ap.contact_id, ap.sequence_id - FROM all_pairs ap - LEFT JOIN sent_pairs sp ON ap.contact_id = sp.contact_id AND ap.sequence_id = sp.sequence_id - WHERE sp.contact_id IS NULL -- Not yet sent - ORDER BY ap.pair_order - LIMIT 1 - ` - - pair := &ContactSequencePair{} - err := r.db.QueryRow(ctx, query, campaignID).Scan(&pair.ContactID, &pair.SequenceID) - - if err == sql.ErrNoRows { - return nil, nil // No more emails to send + orderPrefix := "" + if prioritizeNewLeads { + // New leads (no last-sent step) first. + orderPrefix = "(lp.sequence_id IS NULL) DESC, " } - return pair, err + query := ` + SELECT cl.contact_id, + lp.sequence_id, lp.sent_at, lp.opened_at, lp.clicked_at, lp.replied_at, + COALESCE(ss.ids, '{}') AS sent_ids + FROM campaign_leads cl + JOIN contacts c ON c.id = cl.contact_id + LEFT JOIN LATERAL ( + SELECT sequence_id, sent_at, opened_at, clicked_at, replied_at + FROM campaign_contact_progress p + WHERE p.campaign_id = $1 AND p.contact_id = cl.contact_id AND p.sent_at IS NOT NULL + ORDER BY p.sent_at DESC LIMIT 1 + ) lp ON true + LEFT JOIN LATERAL ( + SELECT array_agg(sequence_id) AS ids + FROM campaign_contact_progress p2 + WHERE p2.campaign_id = $1 AND p2.contact_id = cl.contact_id AND p2.sent_at IS NOT NULL + ) ss ON true + WHERE cl.campaign_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM campaign_contact_progress b + WHERE b.contact_id = cl.contact_id AND b.bounced_at IS NOT NULL + ) + AND NOT EXISTS ( + -- Global "stop on reply": when the campaign has it on, a contact who + -- has already replied never surfaces as a candidate again. + SELECT 1 FROM campaigns camp_sr + JOIN campaign_contact_progress rp + ON rp.contact_id = cl.contact_id AND rp.campaign_id = $1 + WHERE camp_sr.id = $1 AND camp_sr.stop_on_reply = true AND rp.replied_at IS NOT NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM suppressed_recipients sr + JOIN campaigns camp ON camp.organization_id = sr.organization_id + WHERE camp.id = $1 + AND LOWER(sr.email) = LOWER(c.email) + AND (sr.expires_at IS NULL OR sr.expires_at > NOW()) + ) + ORDER BY ` + orderPrefix + contactOrder + ` ` + dir + ` + ` + + rows, err := r.db.Query(ctx, query, campaignID) + if err != nil { + return nil, nil, err + } + defer rows.Close() + var earliestWait *time.Time + for rows.Next() { + var contactID uuid.UUID + var lastSeq *uuid.UUID + var sentAt, openedAt, clickedAt, repliedAt *time.Time + var sentIDs []uuid.UUID + if serr := rows.Scan(&contactID, &lastSeq, &sentAt, &openedAt, &clickedAt, &repliedAt, &sentIDs); serr != nil { + return nil, nil, serr + } + + isNew := lastSeq == nil + var res routeResult + if isNew { + if excludeNewLeads { + continue + } + e := entry + res = routeResult{target: &e} + } else { + prog := &CampaignContactProgress{ + CampaignID: campaignID, ContactID: contactID, SequenceID: *lastSeq, + SentAt: sentAt, OpenedAt: openedAt, ClickedAt: clickedAt, RepliedAt: repliedAt, + } + sa := time.Time{} + if sentAt != nil { + sa = *sentAt + } + res = routeNext(*lastSeq, prog, sa) + } + + if res.wait != nil { + // Not decidable yet — remember the soonest window so the scheduler + // can re-check exactly then instead of guessing or completing. + if earliestWait == nil || res.wait.Before(*earliestWait) { + earliestWait = res.wait + } + continue + } + if res.stop || res.target == nil { + continue // reached the end / a STOP + } + // Loop guard: never re-send a step the contact already received. + already := false + for _, sid := range sentIDs { + if sid == *res.target { + already = true + break + } + } + if already { + continue + } + return &ContactSequencePair{ContactID: contactID, SequenceID: *res.target, IsNewLead: isNew}, nil, nil + } + if rerr := rows.Err(); rerr != nil { + return nil, nil, rerr + } + // Nobody sendable now. If contacts are waiting on a window, hand back the + // soonest re-check time so the scheduler defers rather than completing. + return nil, earliestWait, nil } diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 6a267e55..031189b8 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -32,6 +32,10 @@ type ContactRepository interface { // the batch scheduler can work them off a cap per tick. UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result) *errx.Error ListUnverifiedContacts(ctx context.Context, limit int) ([]models.Contact, *errx.Error) + // SetContactESP caches the recipient ESP/provider resolved from the contact's + // domain (control-plane only, no MX dial). Best-effort: a failure should not + // block sending. + SetContactESP(ctx context.Context, contactID uuid.UUID, provider string) error GetByEmailsAndUser(ctx context.Context, userID uuid.UUID, emails []string) (map[string]models.Contact, *errx.Error) Search(ctx context.Context, userID string, category, cursor *string, filters models.SearchContacts, limit int32) (*models.ContactsResult, *errx.Error) ExportAll(ctx context.Context, userID string, filters *models.SearchContacts, contactIDs []string, max int) ([]models.Contact, *errx.Error) @@ -340,7 +344,8 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (* SELECT c.id, c.first_name, c.last_name, c.email, c.company, c.phone, c.custom_fields, c.subscribed, c.updated_at, c.created_at, - c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at + c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at, + c.esp_provider, c.esp_resolved_at FROM contacts c WHERE c.id = $1 ` @@ -351,6 +356,7 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (* &contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed, &contact.UpdatedAt, &contact.CreatedAt, &contact.VerificationStatus, &contact.VerificationReason, &contact.IsCatchAll, &contact.VerificationCheckedAt, + &contact.ESPProvider, &contact.ESPResolvedAt, ) if err != nil { if err == pgx.ErrNoRows { @@ -365,6 +371,19 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (* return &contact, nil } +// SetContactESP caches the recipient ESP/provider on the contact row. It is a +// single keyed UPDATE and intentionally tolerant: callers treat any error as a +// best-effort cache miss and fall back to deriving the provider on the fly. +func (r *contactRepository) SetContactESP(ctx context.Context, contactID uuid.UUID, provider string) error { + query := ` + UPDATE contacts + SET esp_provider = $2, esp_resolved_at = NOW() + WHERE id = $1 + ` + _, err := r.DB.Exec(ctx, query, contactID, provider) + return err +} + // UpdateContactVerification stores the outcome of a verification pass on the // contact. It is keyed only by contact id (the verifier runs in the control // plane, not in a user request) and is a no-op-safe single UPDATE. @@ -583,6 +602,11 @@ func (r *contactRepository) Search( // ----------------------------- // Campaign IDs filter (must be in ALL specified campaigns) // ----------------------------- + // When filtering by exactly one campaign (the campaign Leads view), we also + // surface each contact's per-campaign processing state. Capture that single + // campaign's bound placeholder so the progress subquery can reuse it without + // appending another arg. + singleCampaignPlaceholder := "" if len(filters.CampaignIDs) > 0 { placeholders := make([]string, len(filters.CampaignIDs)) for i, id := range filters.CampaignIDs { @@ -590,6 +614,9 @@ func (r *contactRepository) Search( args = append(args, id) argIndex++ } + if len(filters.CampaignIDs) == 1 { + singleCampaignPlaceholder = placeholders[0] + } campaignClause := fmt.Sprintf(` c.id IN ( SELECT contact_id @@ -703,6 +730,52 @@ func (r *contactRepository) Search( } } + // Per-campaign lead progress. Only computed in the single-campaign (Leads + // view) case; otherwise the column is NULL so the scan list stays fixed. + // `last_at` is the latest of any touchpoint timestamp (GREATEST skips NULLs); + // counts aggregate across every step the contact was sent in this campaign. + leadProgressSelect := "NULL::json" + if singleCampaignPlaceholder != "" { + leadProgressSelect = fmt.Sprintf(`( + SELECT json_build_object( + 'sent', COUNT(*) FILTER (WHERE p.sent_at IS NOT NULL), + 'opened', COUNT(*) FILTER (WHERE p.opened_at IS NOT NULL), + 'clicked', COUNT(*) FILTER (WHERE p.clicked_at IS NOT NULL), + 'replied', COUNT(*) FILTER (WHERE p.replied_at IS NOT NULL), + 'bounced', COUNT(*) FILTER (WHERE p.bounced_at IS NOT NULL), + 'last_at', MAX(GREATEST(p.sent_at, p.opened_at, p.clicked_at, p.replied_at, p.bounced_at)), + -- The step the contact is on now = the latest step actually sent. + -- Labelled the same way the canvas does: custom name, else + -- "Email N" (Nth email-kind step by position), else action label. + 'step', ( + SELECT CASE + WHEN NULLIF(BTRIM(s.name), '') IS NOT NULL THEN s.name + WHEN s.kind = 'email' THEN 'Email ' || ( + SELECT COUNT(*) FROM sequences s2 + WHERE s2.campaign_id = s.campaign_id AND s2.kind = 'email' + AND (s2.position < s.position + OR (s2.position = s.position AND s2.created_at <= s.created_at)) + )::text + WHEN s.kind = 'action' THEN (CASE s.action->>'type' + WHEN 'add_tag' THEN 'Add tag' + WHEN 'remove_tag' THEN 'Remove tag' + WHEN 'unsubscribe' THEN 'Unsubscribe' + WHEN 'notify' THEN 'Notify' + ELSE 'Action' END) + ELSE 'Step' + END + FROM campaign_contact_progress lp + JOIN sequences s ON s.id = lp.sequence_id + WHERE lp.campaign_id = %[1]s AND lp.contact_id = c.id AND lp.sent_at IS NOT NULL + ORDER BY lp.sent_at DESC + LIMIT 1 + ) + ) + FROM campaign_contact_progress p + WHERE p.campaign_id = %[1]s AND p.contact_id = c.id + )`, singleCampaignPlaceholder) + } + // Main query. // // Both the `campaigns` and `categories` agg subqueries need the @@ -732,7 +805,8 @@ func (r *contactRepository) Search( WHERE cc.contact_id = c.id AND cat.user_id = $%d ), '[]'::json - ) AS categories + ) AS categories, + %s AS lead_progress FROM contacts c LEFT JOIN ( SELECT contact_id, COUNT(campaign_id) AS campaign_count @@ -742,7 +816,7 @@ func (r *contactRepository) Search( %s ORDER BY %s %s, c.id ASC LIMIT $%d - `, argIndex, argIndex, whereSQL, sortBy, direction, argIndex+1) + `, argIndex, argIndex, leadProgressSelect, whereSQL, sortBy, direction, argIndex+1) args = append(args, userID, limit+1) @@ -787,16 +861,60 @@ func (r *contactRepository) Search( var campaignCount int var campaignsJSON []byte var categoriesJSON []byte + var leadProgressJSON []byte if err := rows.Scan( &c.ID, &c.FirstName, &c.LastName, &c.Email, &c.Company, &c.Phone, &c.CustomFields, &c.Subscribed, - &c.UpdatedAt, &c.CreatedAt, &campaignCount, &campaignsJSON, &categoriesJSON, + &c.UpdatedAt, &c.CreatedAt, &campaignCount, &campaignsJSON, &categoriesJSON, &leadProgressJSON, ); err != nil { db.CaptureError(err, "", nil, "scan") return nil, errx.InternalError() } + // Per-campaign lead progress (single-campaign Leads view only). Derive + // the single display status from the counts + subscription flag. + if len(leadProgressJSON) > 0 { + var lp struct { + Sent int `json:"sent"` + Opened int `json:"opened"` + Clicked int `json:"clicked"` + Replied int `json:"replied"` + Bounced int `json:"bounced"` + LastAt *time.Time `json:"last_at"` + Step *string `json:"step"` + } + if err := json.Unmarshal(leadProgressJSON, &lp); err != nil { + sentry.CaptureException(err) + return nil, errx.InternalError() + } + status := models.LeadStatusPending + switch { + case !c.Subscribed: + status = models.LeadStatusUnsubscribed + case lp.Bounced > 0: + status = models.LeadStatusBounced + case lp.Replied > 0: + status = models.LeadStatusReplied + case lp.Sent > 0: + status = models.LeadStatusActive + } + currentStep := "" + if lp.Step != nil { + currentStep = *lp.Step + } + c.CampaignLead = &models.ContactCampaignProgress{ + Status: status, + Sent: lp.Sent, + Opened: lp.Opened, + Clicked: lp.Clicked, + Replied: lp.Replied, + Bounced: lp.Bounced, + LastActivityAt: lp.LastAt, + CurrentStep: currentStep, + } + } + if len(campaignsJSON) > 0 { var campaigns []struct { ID string `json:"id"` diff --git a/internal/repository/pg_credit.go b/internal/repository/pg_credit.go new file mode 100644 index 00000000..2698be3a --- /dev/null +++ b/internal/repository/pg_credit.go @@ -0,0 +1,226 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/infrastructure/db" + "github.com/warmbly/warmbly/internal/models" +) + +// ErrInsufficientCredits is returned by Consume when the org's balance is lower +// than the requested amount. Callers map this to a 402. +var ErrInsufficientCredits = errors.New("insufficient credits") + +// CreditRepository persists the AI-credit ledger and its append-only +// transaction log. All balance mutations are performed with atomic, conditional +// SQL so the balance can never go negative under concurrency — this is the +// billing-correctness boundary and must not be reimplemented as an +// application-level read-modify-write. +type CreditRepository interface { + // GetBalance returns the ledger for an org, or nil if it has none yet. + GetBalance(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) + + // EnsureLedger creates the org's ledger row if absent (idempotent), then + // returns it. + EnsureLedger(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) + + // Consume atomically debits `amount` credits from the org, writing a + // negative transaction row in the same transaction. Returns the resulting + // balance, the recorded transaction, and whether this was an idempotent + // replay (true = no new debit happened, the prior transaction was returned). + // + // If idempotencyKey is non-empty and a transaction already exists for it, + // Consume is a no-op debit and returns that prior transaction with + // replayed=true (so retries are safe). If the balance is too low, it returns + // ErrInsufficientCredits and debits nothing. + Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (balance int, txn *models.CreditTransaction, replayed bool, err error) + + // Grant atomically credits `amount` to the org (creating the ledger if + // needed) and writes a positive transaction row. Used for monthly plan + // grants and credit purchases. + Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *models.CreditTransaction, error) + + // ListTransactions returns the org's transaction history, newest first. + ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, error) +} + +type creditRepository struct { + DB *db.DB +} + +func NewCreditRepository(database *db.DB) CreditRepository { + return &creditRepository{DB: database} +} + +const creditLedgerCols = `org_id, balance, month_reset_at, total_purchased, created_at, updated_at` + +func scanLedger(row pgx.Row, l *models.CreditLedger) error { + return row.Scan(&l.OrgID, &l.Balance, &l.MonthResetAt, &l.TotalPurchased, &l.CreatedAt, &l.UpdatedAt) +} + +const creditTxnCols = `id, org_id, amount, reason, model_used, tokens_used, balance_after, idempotency_key, created_at` + +func scanTxn(row pgx.Row, t *models.CreditTransaction) error { + return row.Scan( + &t.ID, &t.OrgID, &t.Amount, &t.Reason, &t.ModelUsed, + &t.TokensUsed, &t.BalanceAfter, &t.IdempotencyKey, &t.CreatedAt, + ) +} + +func (r *creditRepository) GetBalance(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) { + l := &models.CreditLedger{} + err := scanLedger(r.DB.QueryRow(ctx, `SELECT `+creditLedgerCols+` FROM credit_ledger WHERE org_id = $1`, orgID), l) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return l, nil +} + +func (r *creditRepository) EnsureLedger(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) { + l := &models.CreditLedger{} + err := scanLedger(r.DB.QueryRow(ctx, ` + INSERT INTO credit_ledger (org_id) VALUES ($1) + ON CONFLICT (org_id) DO UPDATE SET org_id = EXCLUDED.org_id + RETURNING `+creditLedgerCols, orgID), l) + if err != nil { + return nil, err + } + return l, nil +} + +func (r *creditRepository) Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, *models.CreditTransaction, bool, error) { + if amount <= 0 { + return 0, nil, false, errors.New("consume amount must be positive") + } + + tx, err := r.DB.Begin(ctx) + if err != nil { + return 0, nil, false, err + } + defer tx.Rollback(ctx) + + // Idempotency short-circuit: if this key already produced a transaction, + // return it unchanged rather than debiting again. + if idempotencyKey != "" { + existing := &models.CreditTransaction{} + err := scanTxn(tx.QueryRow(ctx, + `SELECT `+creditTxnCols+` FROM credit_ledger_transactions WHERE idempotency_key = $1`, idempotencyKey), existing) + if err == nil { + if cerr := tx.Commit(ctx); cerr != nil { + return 0, nil, false, cerr + } + return existing.BalanceAfter, existing, true, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return 0, nil, false, err + } + } + + // Atomic conditional debit. The WHERE balance >= amount guard means a + // concurrent request can never drive the balance below zero, and a missing + // ledger row yields no rows (treated as insufficient). + var newBalance int + err = tx.QueryRow(ctx, ` + UPDATE credit_ledger + SET balance = balance - $2, updated_at = now() + WHERE org_id = $1 AND balance >= $2 + RETURNING balance + `, orgID, amount).Scan(&newBalance) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil, false, ErrInsufficientCredits + } + if err != nil { + return 0, nil, false, err + } + + var keyArg *string + if idempotencyKey != "" { + keyArg = &idempotencyKey + } + txn := &models.CreditTransaction{} + err = scanTxn(tx.QueryRow(ctx, ` + INSERT INTO credit_ledger_transactions + (org_id, amount, reason, model_used, tokens_used, balance_after, idempotency_key) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING `+creditTxnCols, + orgID, -amount, reason, model, tokens, newBalance, keyArg), txn) + if err != nil { + return 0, nil, false, err + } + + if err := tx.Commit(ctx); err != nil { + return 0, nil, false, err + } + return newBalance, txn, false, nil +} + +func (r *creditRepository) Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *models.CreditTransaction, error) { + if amount <= 0 { + return 0, nil, errors.New("grant amount must be positive") + } + + tx, err := r.DB.Begin(ctx) + if err != nil { + return 0, nil, err + } + defer tx.Rollback(ctx) + + var newBalance int + err = tx.QueryRow(ctx, ` + INSERT INTO credit_ledger (org_id, balance) VALUES ($1, $2) + ON CONFLICT (org_id) DO UPDATE + SET balance = credit_ledger.balance + EXCLUDED.balance, updated_at = now() + RETURNING balance + `, orgID, amount).Scan(&newBalance) + if err != nil { + return 0, nil, err + } + + txn := &models.CreditTransaction{} + err = scanTxn(tx.QueryRow(ctx, ` + INSERT INTO credit_ledger_transactions + (org_id, amount, reason, model_used, tokens_used, balance_after, idempotency_key) + VALUES ($1, $2, $3, '', 0, $4, NULL) + RETURNING `+creditTxnCols, + orgID, amount, reason, newBalance), txn) + if err != nil { + return 0, nil, err + } + + if err := tx.Commit(ctx); err != nil { + return 0, nil, err + } + return newBalance, txn, nil +} + +func (r *creditRepository) ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := r.DB.Query(ctx, ` + SELECT `+creditTxnCols+` + FROM credit_ledger_transactions + WHERE org_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, orgID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.CreditTransaction, 0) + for rows.Next() { + var t models.CreditTransaction + if err := scanTxn(rows, &t); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index 53542ec0..92a718ec 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -43,6 +43,14 @@ type EmailRepository interface { Get(ctx context.Context, userID, emailAccountID string) (*models.Email, *errx.Error) GetByID(ctx context.Context, emailAccountID uuid.UUID) (*models.Email, *errx.Error) GetByTags(ctx context.Context, userID string, tags []string) ([]models.Email, *errx.Error) + // GetAllActiveByUser returns every active mailbox for a user (no tag/sender + // filter) — the "all" sender pool used when a campaign picks neither tags nor + // explicit accounts. + GetAllActiveByUser(ctx context.Context, userID string) ([]models.Email, *errx.Error) + // GetByCampaignSenders returns the active mailboxes in a campaign's explicit + // sender pool, carrying each sender's rotation metadata (weight, + // rotation_position, last_sent_at) for the scheduler's rotation modes. + GetByCampaignSenders(ctx context.Context, userID string, campaignID uuid.UUID) ([]CampaignSenderAccount, *errx.Error) GetSMTPCredentials(ctx context.Context, emailAccountID uuid.UUID) (*SMTPCredentials, *errx.Error) GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*OAuthCredentials, *errx.Error) GetWorkerID(ctx context.Context, emailAccountID uuid.UUID) (*uuid.UUID, *errx.Error) @@ -982,6 +990,121 @@ func (r *emailRepository) GetByTags(ctx context.Context, userID string, tags []s return emails, nil } +// GetAllActiveByUser returns every active mailbox for a user (the "all" sender +// pool). Same projection as GetByTags, without the tag join. +func (r *emailRepository) GetAllActiveByUser(ctx context.Context, userID string) ([]models.Email, *errx.Error) { + query := ` + SELECT + ea.id, ea.user_id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code, + ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit, + ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.warmup, ea.warmup_paused_at, ea.warmup_base, + ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, + ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone, + ea.created_at, ea.updated_at + FROM email_accounts ea + WHERE ea.user_id = $1 + AND ea.status = 'active' + ORDER BY ea.id + ` + + rows, err := r.DB.Query(ctx, query, userID) + if err != nil { + db.CaptureError(err, query, []any{userID}, "query") + return nil, errx.InternalError() + } + defer rows.Close() + + var emails []models.Email + for rows.Next() { + var i models.Email + err := rows.Scan( + &i.ID, &i.UserID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode, + &i.Provider, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit, + &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, + &i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, + &i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone, + &i.CreatedAt, &i.UpdatedAt, + ) + if err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + i.Tags = []string{} + emails = append(emails, i) + } + + return emails, nil +} + +// CampaignSenderAccount pairs an active sender mailbox with its per-campaign +// rotation metadata, so the scheduler's rotation modes (weighted / round_robin +// / least_recently_used) can pick among them without a second query. +type CampaignSenderAccount struct { + Account models.Email + Weight int + RotationPosition int + LastSentAt *time.Time +} + +// GetByCampaignSenders mirrors GetByTags but resolves accounts through the +// explicit campaign_senders pool instead of email tags. Only enabled senders +// backing an active mailbox are returned; the per-sender weight/cursor/last-send +// ride along for rotation. +func (r *emailRepository) GetByCampaignSenders(ctx context.Context, userID string, campaignID uuid.UUID) ([]CampaignSenderAccount, *errx.Error) { + query := ` + SELECT + ea.id, ea.user_id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code, + ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit, + ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.warmup, ea.warmup_paused_at, ea.warmup_base, + ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, + ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone, + ea.created_at, ea.updated_at, + cs.weight, cs.rotation_position, cs.last_sent_at + FROM email_accounts ea + JOIN campaign_senders cs ON cs.email_account_id = ea.id + WHERE cs.campaign_id = $2 + AND cs.enabled + AND ea.user_id = $1 + AND ea.status = 'active' + ORDER BY ea.id + ` + + rows, err := r.DB.Query(ctx, query, userID, campaignID) + if err != nil { + db.CaptureError(err, query, []any{userID, campaignID}, "query") + return nil, errx.InternalError() + } + defer rows.Close() + + var out []CampaignSenderAccount + for rows.Next() { + var i models.Email + var sender CampaignSenderAccount + err := rows.Scan( + &i.ID, &i.UserID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode, + &i.Provider, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit, + &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, + &i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, + &i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone, + &i.CreatedAt, &i.UpdatedAt, + &sender.Weight, &sender.RotationPosition, &sender.LastSentAt, + ) + if err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + i.Tags = []string{} + sender.Account = i + out = append(out, sender) + } + if err := rows.Err(); err != nil { + db.CaptureError(err, "", nil, "rows") + return nil, errx.InternalError() + } + + return out, nil +} + // GetSMTPCredentials retrieves SMTP/IMAP credentials for an email account func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID uuid.UUID) (*SMTPCredentials, *errx.Error) { query := ` diff --git a/internal/repository/pg_lead_sync.go b/internal/repository/pg_lead_sync.go new file mode 100644 index 00000000..69db1343 --- /dev/null +++ b/internal/repository/pg_lead_sync.go @@ -0,0 +1,199 @@ +package repository + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/warmbly/warmbly/internal/models" +) + +// LeadSyncRepository owns persistence for on-demand Google Sheets -> leads +// sync sources. Every query is organization-scoped; a source is only ever +// reachable by the org that created it. +type LeadSyncRepository interface { + Create(ctx context.Context, src *models.LeadSyncSource) error + List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) + Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, error) + Update(ctx context.Context, src *models.LeadSyncSource) error + Delete(ctx context.Context, orgID, id uuid.UUID) error + // SetResult records the outcome of a "Sync now" run. resultJSON is the + // marshalled *models.ContactImportResult (nil-safe). + SetResult(ctx context.Context, id uuid.UUID, status models.LeadSyncStatus, lastSyncedAt *time.Time, resultJSON []byte, lastErr string) error +} + +type leadSyncRepository struct { + db *pgxpool.Pool +} + +func NewLeadSyncRepository(db *pgxpool.Pool) LeadSyncRepository { + return &leadSyncRepository{db: db} +} + +const leadSyncCols = ` + id, organization_id, created_by_user_id, provider, connection_id, + sheet_id, COALESCE(sheet_title, ''), COALESCE(tab_title, ''), COALESCE(a1_range, ''), + has_header, column_mapping, dedup, target_campaign_id, category_ids, + subscribed_default, COALESCE(label, ''), status, last_synced_at, last_result, + COALESCE(last_error, ''), created_at, updated_at` + +func (r *leadSyncRepository) Create(ctx context.Context, src *models.LeadSyncSource) error { + if src.ID == uuid.Nil { + src.ID = uuid.New() + } + now := time.Now().UTC() + src.CreatedAt = now + src.UpdatedAt = now + if src.Provider == "" { + src.Provider = string(models.IntegrationGoogleSheets) + } + if src.Status == "" { + src.Status = models.LeadSyncStatusIdle + } + + mapping := marshalJSONDefault(src.ColumnMapping, "[]") + cats := marshalJSONDefault(src.CategoryIDs, "[]") + + _, err := r.db.Exec(ctx, ` + INSERT INTO lead_sync_sources ( + id, organization_id, created_by_user_id, provider, connection_id, + sheet_id, sheet_title, tab_title, a1_range, has_header, + column_mapping, dedup, target_campaign_id, category_ids, + subscribed_default, label, status, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12, $13, $14, + $15, $16, $17, $18, $18 + )`, + src.ID, src.OrganizationID, src.CreatedByUserID, src.Provider, src.ConnectionID, + src.SheetID, nullIfEmptyStr(src.SheetTitle), nullIfEmptyStr(src.TabTitle), nullIfEmptyStr(src.A1Range), src.HasHeader, + mapping, string(src.Dedup), src.TargetCampaignID, cats, + src.SubscribedDefault, nullIfEmptyStr(src.Label), string(src.Status), now, + ) + return err +} + +func (r *leadSyncRepository) List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) { + rows, err := r.db.Query(ctx, `SELECT `+leadSyncCols+` + FROM lead_sync_sources + WHERE organization_id = $1 AND ($2::uuid IS NULL OR target_campaign_id = $2) + ORDER BY created_at DESC`, orgID, campaignID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.LeadSyncSource{} + for rows.Next() { + var s models.LeadSyncSource + if err := scanLeadSyncInto(rows, &s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (r *leadSyncRepository) Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, error) { + row := r.db.QueryRow(ctx, `SELECT `+leadSyncCols+` + FROM lead_sync_sources WHERE organization_id = $1 AND id = $2`, orgID, id) + var s models.LeadSyncSource + if err := scanLeadSyncInto(row, &s); err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, err + } + return &s, nil +} + +func (r *leadSyncRepository) Update(ctx context.Context, src *models.LeadSyncSource) error { + now := time.Now().UTC() + src.UpdatedAt = now + mapping := marshalJSONDefault(src.ColumnMapping, "[]") + cats := marshalJSONDefault(src.CategoryIDs, "[]") + + _, err := r.db.Exec(ctx, ` + UPDATE lead_sync_sources SET + sheet_id = $1, sheet_title = $2, tab_title = $3, a1_range = $4, + has_header = $5, column_mapping = $6, dedup = $7, target_campaign_id = $8, + category_ids = $9, subscribed_default = $10, label = $11, updated_at = $12 + WHERE organization_id = $13 AND id = $14`, + src.SheetID, nullIfEmptyStr(src.SheetTitle), nullIfEmptyStr(src.TabTitle), nullIfEmptyStr(src.A1Range), + src.HasHeader, mapping, string(src.Dedup), src.TargetCampaignID, + cats, src.SubscribedDefault, nullIfEmptyStr(src.Label), now, + src.OrganizationID, src.ID, + ) + return err +} + +func (r *leadSyncRepository) Delete(ctx context.Context, orgID, id uuid.UUID) error { + _, err := r.db.Exec(ctx, + `DELETE FROM lead_sync_sources WHERE organization_id = $1 AND id = $2`, orgID, id) + return err +} + +func (r *leadSyncRepository) SetResult(ctx context.Context, id uuid.UUID, status models.LeadSyncStatus, lastSyncedAt *time.Time, resultJSON []byte, lastErr string) error { + now := time.Now().UTC() + _, err := r.db.Exec(ctx, ` + UPDATE lead_sync_sources SET + status = $1, last_synced_at = $2, last_result = $3, last_error = $4, updated_at = $5 + WHERE id = $6`, + string(status), lastSyncedAt, nullIfEmptyBytes(resultJSON), nullIfEmptyStr(lastErr), now, id) + return err +} + +// marshalJSONDefault marshals v to JSON bytes, falling back to a literal +// default (e.g. "[]") if v is nil or marshalling fails — JSONB columns are +// NOT NULL with a '[]' default, so we never write a SQL NULL there. +func marshalJSONDefault(v any, def string) []byte { + if v == nil { + return []byte(def) + } + b, err := json.Marshal(v) + if err != nil || len(b) == 0 || string(b) == "null" { + return []byte(def) + } + return b +} + +func scanLeadSyncInto(row scanner, s *models.LeadSyncSource) error { + var ( + mapping []byte + cats []byte + lastResult []byte + status string + dedup string + ) + if err := row.Scan( + &s.ID, &s.OrganizationID, &s.CreatedByUserID, &s.Provider, &s.ConnectionID, + &s.SheetID, &s.SheetTitle, &s.TabTitle, &s.A1Range, + &s.HasHeader, &mapping, &dedup, &s.TargetCampaignID, &cats, + &s.SubscribedDefault, &s.Label, &status, &s.LastSyncedAt, &lastResult, + &s.LastError, &s.CreatedAt, &s.UpdatedAt, + ); err != nil { + return err + } + s.Status = models.LeadSyncStatus(status) + s.Dedup = models.ContactImportDedupStrategy(dedup) + + s.ColumnMapping = []models.ContactImportColumnMapping{} + if len(mapping) > 0 { + _ = json.Unmarshal(mapping, &s.ColumnMapping) + } + s.CategoryIDs = []string{} + if len(cats) > 0 { + _ = json.Unmarshal(cats, &s.CategoryIDs) + } + if len(lastResult) > 0 { + var res models.ContactImportResult + if err := json.Unmarshal(lastResult, &res); err == nil { + s.LastResult = &res + } + } + return nil +} diff --git a/internal/repository/pg_relation_sync.go b/internal/repository/pg_relation_sync.go index 8428888b..d2cf1486 100644 --- a/internal/repository/pg_relation_sync.go +++ b/internal/repository/pg_relation_sync.go @@ -20,8 +20,14 @@ type RelationSyncInput struct { NewValues []string } +// SyncRelation diffs the desired related-id set against what's stored and +// applies the minimal insert/delete. All relation join tables here key on +// uuid↔uuid (campaign_id/email_id ↔ tag_id/folder_id), and Postgres has NO +// implicit text→uuid assignment cast, so the id params (sent by pgx as text) +// are cast to uuid explicitly. The SELECT casts the related id back to ::text +// so it scans cleanly into a Go string. func SyncRelation(input RelationSyncInput) ([]string, *errx.Error) { - querySelect := fmt.Sprintf(`SELECT %s FROM %s WHERE %s = $1`, + querySelect := fmt.Sprintf(`SELECT %s::text FROM %s WHERE %s = $1::uuid`, input.ColRelated, input.Table, input.ColMain) params := []any{ @@ -53,7 +59,7 @@ func SyncRelation(input RelationSyncInput) ([]string, *errx.Error) { toDelete := utils.Difference(current, input.NewValues) if len(toDelete) > 0 { - queryDel := fmt.Sprintf(`DELETE FROM %s WHERE %s = $1 AND %s = ANY($2)`, + queryDel := fmt.Sprintf(`DELETE FROM %s WHERE %s = $1::uuid AND %s = ANY($2::uuid[])`, input.Table, input.ColMain, input.ColRelated) params = []any{ @@ -73,7 +79,7 @@ func SyncRelation(input RelationSyncInput) ([]string, *errx.Error) { if len(toInsert) > 0 { queryIns := fmt.Sprintf(`INSERT INTO %s (%s, %s) - SELECT $1, unnest($2::text[])`, + SELECT $1::uuid, unnest($2::uuid[])`, input.Table, input.ColMain, input.ColRelated) params = []any{ diff --git a/internal/repository/pg_sequence.go b/internal/repository/pg_sequence.go index 9c32b462..69c04845 100644 --- a/internal/repository/pg_sequence.go +++ b/internal/repository/pg_sequence.go @@ -2,6 +2,7 @@ package repository import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -42,6 +43,9 @@ var SequenceSelections []string = []string{ "body_code", "wait_after", "position", + "conditions", + "kind", + "action", "updated_at", "created_at", } @@ -64,7 +68,8 @@ var ( func GetSequence(row db.Scannable, seq *models.Sequence) error { return row.Scan( &seq.ID, &seq.Name, &seq.Subject, &seq.BodyPlain, &seq.BodyHTML, &seq.BodySync, - &seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.UpdatedAt, &seq.CreatedAt, + &seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.Conditions, &seq.Kind, &seq.Action, + &seq.UpdatedAt, &seq.CreatedAt, ) } @@ -233,10 +238,50 @@ func (r *sequenceRepository) Update(ctx context.Context, userID, campaignID, seq argPos++ } if data.WaitAfter != nil { + if *data.WaitAfter < 0 || *data.WaitAfter > config.SequenceWaitAfterMax { + return nil, errx.ErrSequenceWaitAfter + } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "wait_after", argPos)) args = append(args, *data.WaitAfter) argPos++ } + if data.Conditions != nil { + // Validate shape (operators/fields/values) before persisting. Cross-step + // validation (targets-in-campaign + no cycles) happens in the service + // layer where the full sequence set is available. + if verr := validateBranchConditions(data.Conditions); verr != nil { + return nil, verr + } + raw, merr := json.Marshal(data.Conditions) + if merr != nil { + db.CaptureError(merr, "", nil, "marshal_conditions") + return nil, errx.InternalError() + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "conditions", argPos)) + args = append(args, raw) + argPos++ + } + if data.Kind != nil { + if *data.Kind != "email" && *data.Kind != "action" && *data.Kind != "wait" { + return nil, errx.ErrSequenceKind + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "kind", argPos)) + args = append(args, *data.Kind) + argPos++ + } + if data.Action != nil { + if verr := validateActionConfig(data.Action); verr != nil { + return nil, verr + } + raw, merr := json.Marshal(data.Action) + if merr != nil { + db.CaptureError(merr, "", nil, "marshal_action") + return nil, errx.InternalError() + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "action", argPos)) + args = append(args, raw) + argPos++ + } if argPos == 4 { return nil, errx.ErrNotEnough diff --git a/internal/repository/pg_user.go b/internal/repository/pg_user.go index c5dff61a..19ed29ec 100644 --- a/internal/repository/pg_user.go +++ b/internal/repository/pg_user.go @@ -22,6 +22,7 @@ type UserRepository interface { GetUserByEmail(ctx context.Context, email string) (*models.User, error) SetFreeTrialUsed(ctx context.Context, userID uuid.UUID) error UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) error + UpdateProfile(ctx context.Context, userID uuid.UUID, firstName, lastName string) error UpdateAvatar(ctx context.Context, userID uuid.UUID, avatarURL *string) error // GetBanState returns the user's ban_scope bitmask (0 = not @@ -162,6 +163,12 @@ func (r *userRepository) UpdateOnboarding(ctx context.Context, userID uuid.UUID, return err } +func (r *userRepository) UpdateProfile(ctx context.Context, userID uuid.UUID, firstName, lastName string) error { + const q = `UPDATE users SET first_name=$2, last_name=$3, updated_at=NOW() WHERE id=$1` + _, err := r.DB.Exec(ctx, q, userID, firstName, lastName) + return err +} + func (r *userRepository) UpdateAvatar(ctx context.Context, userID uuid.UUID, avatarURL *string) error { const q = `UPDATE users SET avatar_url=$2, updated_at=NOW() WHERE id=$1` _, err := r.DB.Exec(ctx, q, userID, avatarURL) diff --git a/internal/repository/sequence_actions.go b/internal/repository/sequence_actions.go new file mode 100644 index 00000000..630092c2 --- /dev/null +++ b/internal/repository/sequence_actions.go @@ -0,0 +1,25 @@ +package repository + +import ( + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// validateActionConfig checks a non-email (action/wait) node's typed config +// before it is persisted. This is a shape gate only; the kind↔action pairing and +// cross-step routing targets are handled elsewhere (the canvas sets kind+action +// together, and dangling routes resolve to "stop" at schedule time). +func validateActionConfig(a *models.ActionConfig) *errx.Error { + if a == nil { + return nil + } + switch a.Type { + case "wait", "add_tag", "remove_tag", "unsubscribe", "notify", "end": + // Type must be known. Sub-config (wait minutes, tag) is filled in the + // editor; an unconfigured node is a harmless no-op at send time, so we + // don't block creating a draft node here. + return nil + default: + return errx.ErrSequenceAction + } +} diff --git a/internal/scheduler/campaign_scheduler.go b/internal/scheduler/campaign_scheduler.go index 05cb24b1..b1259976 100644 --- a/internal/scheduler/campaign_scheduler.go +++ b/internal/scheduler/campaign_scheduler.go @@ -22,36 +22,160 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return time.Time{}, nil, uuid.Nil, ErrCampaignNotActive } - // STEP 2: Get all email accounts assigned to this campaign (via tags) - accounts, err := s.emailRepo.GetByTags(ctx, campaign.UserID, campaign.EmailTags) - if err != nil { - return time.Time{}, nil, uuid.Nil, err + // STEP 1.5: Advance the per-campaign daily ramp level (idempotent, once per + // UTC day; no-op when ramp is disabled). Re-load so campaign.RampLevel + // reflects today's level before any capacity math. Failing open here keeps + // scheduling running (the worst case is today's ramp not advancing). + if campaign.RampEnabled { + if aerr := s.campaignRepo.AdvanceRampLevel(ctx, campaignID); aerr == nil { + if reloaded, rerr := s.campaignRepo.GetByID(ctx, campaignID); rerr == nil && reloaded != nil { + campaign = reloaded + } + } + } + + // STEP 2: Resolve the campaign's sending mailboxes. Explicit strategy uses + // the campaign_senders pool (carrying per-sender rotation metadata); tags + // strategy keeps the existing tag-based resolution. An empty explicit pool + // falls back to tags so a misconfigured campaign still sends. + type senderMeta struct { + weight int + rotationPosition int + lastSentAt *time.Time + hasMeta bool + } + accounts := []models.Email{} + senderMetaByID := map[uuid.UUID]senderMeta{} + seen := map[uuid.UUID]bool{} + // UNION of the explicit campaign_senders pool and the tag-resolved mailboxes + // (one dropdown picks both — they're no longer mutually exclusive). When the + // campaign selects NEITHER tags nor explicit accounts, it sends from ALL of + // the owner's active mailboxes ("all"). + senders, serr := s.emailRepo.GetByCampaignSenders(ctx, campaign.UserID, campaignID) + if serr != nil { + return time.Time{}, nil, uuid.Nil, serr + } + for _, snd := range senders { + accounts = append(accounts, snd.Account) + seen[snd.Account.ID] = true + senderMetaByID[snd.Account.ID] = senderMeta{ + weight: snd.Weight, + rotationPosition: snd.RotationPosition, + lastSentAt: snd.LastSentAt, + hasMeta: true, + } + } + if len(campaign.EmailTags) > 0 { + tagAccounts, terr := s.emailRepo.GetByTags(ctx, campaign.UserID, campaign.EmailTags) + if terr != nil { + return time.Time{}, nil, uuid.Nil, terr + } + for _, ta := range tagAccounts { + if !seen[ta.ID] { + accounts = append(accounts, ta) + seen[ta.ID] = true + } + } + } + if len(senders) == 0 && len(campaign.EmailTags) == 0 { + allAccts, aerr := s.emailRepo.GetAllActiveByUser(ctx, campaign.UserID) + if aerr != nil { + return time.Time{}, nil, uuid.Nil, aerr + } + accounts = allAccts } if len(accounts) == 0 { return time.Time{}, nil, uuid.Nil, ErrNoEmailAccounts } - // STEP 3: Get campaign progress - find next contact/sequence to send + // STEP 3: Get campaign progress - find next contact/sequence to send. + // Honor the new-lead-per-day cap and the prioritize-new-leads ordering. orderField := "" if campaign.ContactOrderField != nil { orderField = *campaign.ContactOrderField } - nextPair, err := s.campaignProgressRepo.FindNextContactSequence( + excludeNewLeads := false + if campaign.MaxNewLeadsPerDay > 0 { + newLeadsToday, nlerr := s.campaignRepo.CountNewLeadsStartedToday(ctx, campaignID) + if nlerr != nil { + return time.Time{}, nil, uuid.Nil, nlerr + } + if newLeadsToday >= campaign.MaxNewLeadsPerDay { + excludeNewLeads = true + } + } + nextPair, recheckAt, err := s.campaignProgressRepo.FindNextRoutedPair( ctx, campaignID, campaign.ContactOrderBy, campaign.ContactOrderDir, orderField, + campaign.PrioritizeNewLeads, + excludeNewLeads, ) if err != nil { return time.Time{}, nil, uuid.Nil, err } if nextPair == nil { + // When the new-lead cap is active and only new-lead pairs remain, + // FindNextRoutedPair returns nil with exclude on but WOULD return a pair + // without it. In that case defer to the next day so follow-ups keep + // progressing and new leads resume tomorrow — do NOT complete. + if excludeNewLeads { + if again, _, aerr := s.campaignProgressRepo.FindNextRoutedPair( + ctx, campaignID, campaign.ContactOrderBy, campaign.ContactOrderDir, orderField, + campaign.PrioritizeNewLeads, false, + ); aerr == nil && again != nil { + s.logCampaignDecision(ctx, campaignID, "new_lead_cap_reached", + "Daily new-lead cap reached; deferring remaining new leads to tomorrow", + map[string]interface{}{"max_new_leads_per_day": campaign.MaxNewLeadsPerDay}) + deferTime := s.deferToNextDay(campaign) + // Return a DEFERRAL, never a sendable pair: the caller only checks + // err for deferrals, so a nil-error here would send a new lead and + // blow past the cap. nil pair + sentinel = reschedule, don't send. + return deferTime, nil, accounts[0].ID, ErrCampaignDeferred + } + } + // Some contacts are waiting on a condition window (e.g. "if didn't open + // within 3 days"). Defer and re-check exactly when the soonest window + // elapses, instead of marking the campaign complete. + if recheckAt != nil { + s.logCampaignDecision(ctx, campaignID, "awaiting_condition_window", + "Waiting on a branch condition window; re-checking when it elapses", + map[string]interface{}{"recheck_at": recheckAt.UTC().Format(time.RFC3339)}) + return *recheckAt, nil, accounts[0].ID, ErrCampaignDeferred + } return time.Time{}, nil, uuid.Nil, ErrCampaignCompleted } + // Branch routing is resolved inside FindNextRoutedPair: the chosen step is the + // route out of the contact's last-sent step — conditional branches first + // (first match wins, evaluated against opened/clicked/replied), then the + // explicit "else" catch-all, then linear position+1 only when a step defines + // no branches. A step is sent only if the flow reaches it; STOP/end and + // already-sent loops drop the contact in the finder. Conditions are evaluated + // at schedule time (a known, accepted race vs. last-moment engagement). + + // STEP 3.5: Resolve the recipient ESP/provider for ESP matching. Cheap: + // prefer the cached contact.esp_provider, else derive from the domain + // string. NEVER dial MX on the hot path. Empty => unknown => wildcard. + recipientProvider := "" + if campaign.ESPMatchMode != "off" && s.contactRepo != nil { + if contact, cerr := s.contactRepo.GetByID(ctx, nextPair.ContactID); cerr == nil && contact != nil { + if contact.ESPProvider != "" { + recipientProvider = contact.ESPProvider + } else { + recipientProvider = providerForEmailDomain(contact.Email) + // Opportunistically cache the derived provider (best-effort). + if recipientProvider != "" { + _ = s.contactRepo.SetContactESP(ctx, contact.ID, recipientProvider) + } + } + } + } + // STEP 4: Calculate base time from sequence wait_after baseTime := time.Now() @@ -77,6 +201,9 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // Fall back to UTC if campaign has no timezone set (account timezone checked later) campaignTZName := campaign.Timezone campaignTZ := loadLocation(campaignTZName) + // Authoritative per-day sending windows (or derived from the legacy + // days/start/end fields). Drives every day-of-week + time-window gate below. + windows := effectiveWindows(campaign) candidateTime := baseTime // Check campaign date range @@ -88,11 +215,39 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return time.Time{}, nil, uuid.Nil, ErrCampaignEnded } - // STEP 6: Find next valid day of week (campaign.Days is bitmask) - candidateTime = findNextValidDay(candidateTime, uint8(campaign.Days), campaignTZ) + // STEP 6+7: Snap to the next allowed per-day sending window (handles both + // the day-of-week gate and the time-of-day window, including multiple + // intervals per day). + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) - // STEP 7: Ensure within campaign time window (start_time to end_time) - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + // effectiveCap is the per-mailbox cold cap for THIS campaign, after the ramp + // clamp. It is min(per-mailbox cold cap, campaign daily limit) further min()'d + // with the day's ramp ceiling. Applied via min() only — it can never RAISE a + // mailbox above its cold cap (the mailbox-first safety invariant). + effectiveCap := func(acct models.Email) int { + lim := min(acct.CampaignLimit, campaign.DailyLimit) + if campaign.RampEnabled { + lim = min(lim, campaignRampCeiling(true, campaign.RampStart, campaign.RampIncrement, campaign.RampCeiling, campaign.RampLevel)) + } + return lim + } + + // providerMatches reports whether a mailbox's provider satisfies the + // recipient ESP under the current match mode. An unknown recipient provider + // (non-Google/Outlook domain) is always a wildcard so matching never blocks + // first contact. An smtp_imap mailbox has no known ESP: under PREFER it acts + // as a wildcard so matching never starves, but under STRICT "same provider" + // means exactly that — an smtp_imap mailbox is NOT treated as a Gmail/Outlook + // match (it only carries unknown/other-domain recipients, handled above). + providerMatches := func(acctProvider string) bool { + if campaign.ESPMatchMode == "off" || recipientProvider == "" { + return true + } + if acctProvider == "smtp_imap" { + return campaign.ESPMatchMode != "strict" + } + return acctProvider == recipientProvider + } // STEP 8: Build weighted account candidates // Skip accounts whose local time falls outside business hours (8am-8pm) @@ -103,7 +258,7 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return time.Time{}, nil, uuid.Nil, err } - acctLimit := min(acct.CampaignLimit, campaign.DailyLimit) + acctLimit := effectiveCap(acct) remaining := acctLimit - sentToday // Skip accounts that have reached their daily limit @@ -116,6 +271,8 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // cold volume (the concentration risk the safety policy warns about): // - quarantined/blocked (still within blocked_until) → don't send at all // - throttled → halve today's budget (and the wider min-gap still applies) + // This gate runs FIRST, before any rotation/ESP logic, so a degraded + // mailbox is always dropped regardless of weighting. if state, blockedUntil, herr := s.warmupRepo.GetHealthState(ctx, acct.ID); herr == nil { switch state { case models.WarmupHealthQuarantined, models.WarmupHealthBlocked: @@ -145,59 +302,126 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai warmupAgeDays = int(time.Since(*acct.Warmup).Hours() / 24) } - candidates = append(candidates, AccountCandidate{ + cand := AccountCandidate{ Account: acct, RemainingToday: remaining, WarmupAgeDays: warmupAgeDays, Weight: computeWeight(remaining, warmupAgeDays), - }) + ProviderMatch: providerMatches(acct.Provider), + } + if meta, ok := senderMetaByID[acct.ID]; ok { + cand.HasSenderMetadata = true + cand.SenderWeight = meta.weight + cand.RotationPosition = meta.rotationPosition + cand.SenderLastSentAt = meta.lastSentAt + } + candidates = append(candidates, cand) } - // STEP 8.5: Select best account via weighted random selection - selected := selectAccountWeighted(candidates) - if selected == nil { - // ALL accounts at capacity today — push to next day and pick highest-weight account (all reset tomorrow) - candidateTime = candidateTime.Add(24 * time.Hour) - candidateTime = findNextValidDay(candidateTime, uint8(campaign.Days), campaignTZ) - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) - - // Recompute with full capacity for tomorrow - var bestCandidate *AccountCandidate - for i := range candidates { - warmupAgeDays := candidates[i].WarmupAgeDays - acctLimit := min(candidates[i].Account.CampaignLimit, campaign.DailyLimit) - candidates[i].RemainingToday = acctLimit - candidates[i].Weight = computeWeight(acctLimit, warmupAgeDays) - if bestCandidate == nil || candidates[i].Weight > bestCandidate.Weight { - bestCandidate = &candidates[i] + // STEP 8.25: Apply ESP matching to the under-budget candidate set. + // strict → only matching mailboxes are eligible; if none, DEFER (never + // send cross-provider). + // prefer → restrict to matching mailboxes when at least one has capacity, + // otherwise fall back to the full eligible set (never starves). + if campaign.ESPMatchMode != "off" && recipientProvider != "" { + matching := make([]AccountCandidate, 0, len(candidates)) + for _, c := range candidates { + if c.ProviderMatch { + matching = append(matching, c) } } - if bestCandidate == nil { + switch campaign.ESPMatchMode { + case "strict": + if len(matching) == 0 { + // No matching mailbox under budget today: defer to the next slot + // rather than complete or send cross-provider. + s.logCampaignDecision(ctx, campaignID, "provider_match_deferred", + "No same-provider mailbox available; deferring to next slot", + map[string]interface{}{"recipient_provider": recipientProvider}) + // Deferral, not a send: nil pair + sentinel so the caller reschedules + // instead of sending this contact from a cross-provider mailbox. + return s.deferToNextDay(campaign), nil, accounts[0].ID, ErrCampaignDeferred + } + candidates = matching + case "prefer": + if len(matching) > 0 { + candidates = matching + } + } + } + + // STEP 8.5: Select best account per the campaign's rotation mode. + selected := selectAccountByRotationMode(campaign.RotationMode, candidates) + if selected == nil { + // ALL accounts at capacity today — push to next day and recompute with + // tomorrow's full (ramp-clamped) capacity. The ramp clamp AND the ESP + // filter MUST be re-applied here, or tomorrow's recompute over-budgets a + // mailbox past its ramp ceiling / picks a cross-provider sender. + candidateTime = candidateTime.Add(24 * time.Hour) + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) + + var tomorrow []AccountCandidate + for i := range candidates { + acct := candidates[i].Account + // ESP-strict: keep only matching mailboxes for tomorrow too. + if campaign.ESPMatchMode == "strict" && recipientProvider != "" && !candidates[i].ProviderMatch { + continue + } + acctLimit := effectiveCap(acct) // same ramp clamp as STEP 8 + c := candidates[i] + c.RemainingToday = acctLimit + c.Weight = computeWeight(acctLimit, candidates[i].WarmupAgeDays) + tomorrow = append(tomorrow, c) + } + // ESP-prefer: restrict tomorrow to matching mailboxes when any exist. + if campaign.ESPMatchMode == "prefer" && recipientProvider != "" { + var matchingTomorrow []AccountCandidate + for _, c := range tomorrow { + if c.ProviderMatch { + matchingTomorrow = append(matchingTomorrow, c) + } + } + if len(matchingTomorrow) > 0 { + tomorrow = matchingTomorrow + } + } + + selected = selectAccountByRotationMode(campaign.RotationMode, tomorrow) + if selected == nil { + // ESP-strict with no matching mailbox at all: defer rather than + // complete or send cross-provider. + if campaign.ESPMatchMode == "strict" && recipientProvider != "" { + s.logCampaignDecision(ctx, campaignID, "provider_match_deferred", + "No same-provider mailbox available tomorrow; deferring", + map[string]interface{}{"recipient_provider": recipientProvider}) + // Deferral, not a send (see above). + return s.deferToNextDay(campaign), nil, accounts[0].ID, ErrCampaignDeferred + } return time.Time{}, nil, uuid.Nil, ErrNoEmailAccounts } - selected = bestCandidate } account := &selected.Account - // STEP 9: Even distribution across today's time window - endMinutes := parseTimeOfDay(campaign.EndTime) - nowLocal := time.Now().In(campaignTZ) - currentMinutes := nowLocal.Hour()*60 + nowLocal.Minute() - + // STEP 9: Even distribution across the candidate day's sending window. Uses + // the span (earliest start → latest end) of that weekday's intervals. remainingEmails := selected.RemainingToday if remainingEmails > 0 { - startMinutes := parseTimeOfDay(campaign.StartTime) - remainingMinutes := endMinutes - max(currentMinutes, startMinutes) - if remainingMinutes > 0 { - idealInterval := time.Minute * time.Duration(remainingMinutes/remainingEmails) - minInterval := time.Second * time.Duration(account.MinWaitTime) - if idealInterval < minInterval { - idealInterval = minInterval - } - distributedTime := time.Now().Add(idealInterval) - if distributedTime.After(candidateTime) { - candidateTime = distributedTime + wd := int(candidateTime.In(campaignTZ).Weekday()) + if dayStart, dayEnd, ok := windows.DaySpan(wd); ok { + nowLocal := time.Now().In(campaignTZ) + currentMinutes := nowLocal.Hour()*60 + nowLocal.Minute() + remainingMinutes := dayEnd - max(currentMinutes, dayStart) + if remainingMinutes > 0 { + idealInterval := time.Minute * time.Duration(remainingMinutes/remainingEmails) + minInterval := time.Second * time.Duration(account.MinWaitTime) + if idealInterval < minInterval { + idealInterval = minInterval + } + distributedTime := time.Now().Add(idealInterval) + if distributedTime.After(candidateTime) { + candidateTime = distributedTime + } } } } @@ -214,8 +438,8 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai if candidateTime.Before(earliestNext) { candidateTime = earliestNext - // Re-apply time window after adjusting for min wait - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + // Re-snap into a sending window after adjusting for min wait. + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) } } @@ -236,8 +460,34 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // STEP 13: Apply human-like distribution (favor morning/afternoon peaks) candidateTime = applyDistributionCurve(candidateTime, campaignTZ) - // STEP 14: Ensure still within campaign window after all adjustments - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + // STEP 14: Ensure still within a sending window after all adjustments + // (jitter/conflict/distribution can push into a gap between intervals). + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) return candidateTime, nextPair, account.ID, nil } + +// deferToNextDay pushes a candidate time to the next valid campaign day within +// the campaign's send window. Used by the ESP-strict and new-lead-cap deferral +// paths so a campaign reschedules instead of completing or busy-looping. +func (s *schedulerService) deferToNextDay(campaign *models.Campaign) time.Time { + tz := loadLocation(campaign.Timezone) + t := nextScheduleSlot(time.Now().Add(24*time.Hour), effectiveWindows(campaign), tz) + // Add a small jitter so deferred tasks don't all wake at the same instant. + return t.Add(time.Minute * time.Duration(randomJitter(0, 30))) +} + +// logCampaignDecision records a send-path decision (ESP defer, new-lead cap) to +// the campaign activity log. Best-effort and nil-safe — a logging miss never +// blocks scheduling. +func (s *schedulerService) logCampaignDecision(ctx context.Context, campaignID uuid.UUID, eventType, message string, metadata map[string]interface{}) { + if s.campaignLogRepo == nil { + return + } + _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaignID, + EventType: eventType, + Message: message, + Metadata: metadata, + }) +} diff --git a/internal/scheduler/errors.go b/internal/scheduler/errors.go index 44110c37..f26be723 100644 --- a/internal/scheduler/errors.go +++ b/internal/scheduler/errors.go @@ -20,4 +20,13 @@ var ( // ErrDailyLimitReached is returned when the daily limit has been reached ErrDailyLimitReached = errors.New("daily email limit reached") + + // ErrCampaignDeferred is returned when there IS a valid contact to send but + // no eligible mailbox right now — ESP-strict has no same-provider mailbox + // under budget, or the daily new-lead cap is reached. The caller must + // reschedule at the returned (defer) time WITHOUT sending. The returned pair + // is always nil on this path so it can never be mistaken for a sendable + // contact; the returned accountID is a nominal pool mailbox for the wakeup + // task only (the next invocation re-evaluates selection from scratch). + ErrCampaignDeferred = errors.New("campaign send deferred - no eligible mailbox for this contact right now") ) diff --git a/internal/scheduler/helpers.go b/internal/scheduler/helpers.go index 0e3e3ce0..d16641f4 100644 --- a/internal/scheduler/helpers.go +++ b/internal/scheduler/helpers.go @@ -4,6 +4,7 @@ import ( "math" "math/rand" "sort" + "strings" "time" "github.com/warmbly/warmbly/internal/models" @@ -62,6 +63,68 @@ func ensureTimeWindow(t time.Time, startTime, endTime string, tz *time.Location) return t } +// effectiveWindows returns the campaign's authoritative per-day sending +// schedule. When ScheduleWindows is set it is used as-is; otherwise it is +// DERIVED from the legacy days bitmask + start/end time (one interval per active +// day) so campaigns created before the multi-window feature still schedule +// correctly. The stored days bitmask is Monday-indexed (bit 0 = Monday, as the +// bitmask package and dashboard write it), so it is mapped to time.Weekday +// (Sun=0) via (bit+1)%7 — which also corrects the historical off-by-one in the +// legacy day check. +func effectiveWindows(c *models.Campaign) models.ScheduleWindows { + if !c.ScheduleWindows.IsEmpty() { + return c.ScheduleWindows + } + start := parseTimeOfDay(c.StartTime) + end := parseTimeOfDay(c.EndTime) + if end <= start { + // No usable legacy window → unconstrained (any time allowed). + return models.ScheduleWindows{} + } + var sw models.ScheduleWindows + for bit := 0; bit < 7; bit++ { + if c.Days == 0 || c.Days&(1<= from that falls inside one of +// the campaign's per-day sending windows, searching up to 8 days ahead. An +// empty schedule means "unconstrained" and returns from unchanged. When from is +// already inside a window its exact instant is preserved (so jitter/min-wait +// adjustments survive); otherwise it advances to the next interval's start. +func nextScheduleSlot(from time.Time, sw models.ScheduleWindows, tz *time.Location) time.Time { + if sw.IsEmpty() { + return from + } + cur := from.In(tz) + for i := 0; i < 8; i++ { + y, m, d := cur.Date() + nowMin := cur.Hour()*60 + cur.Minute() + + ivs := append([]models.TimeInterval(nil), sw[int(cur.Weekday())]...) + sort.Slice(ivs, func(a, b int) bool { return ivs[a].Start < ivs[b].Start }) + + for _, iv := range ivs { + if nowMin < iv.Start { + return time.Date(y, m, d, iv.Start/60, iv.Start%60, 0, 0, tz) + } + if nowMin < iv.End { + if i == 0 { + return from // already inside a window — keep the exact instant + } + return cur + } + } + // No interval left today — jump to the start of the next day. + cur = time.Date(y, m, d, 0, 0, 0, 0, tz).Add(24 * time.Hour) + } + return from +} + // ensureBusinessHours ensures time is within business hours (8am-8pm) func ensureBusinessHours(t time.Time, timezone string) time.Time { loc := loadLocation(timezone) @@ -192,6 +255,63 @@ type AccountCandidate struct { RemainingToday int WarmupAgeDays int Weight float64 + + // Per-sender rotation metadata (explicit sender strategy only). SenderWeight + // multiplies the base Weight in weighted mode; RotationPosition drives + // round_robin; SenderLastSentAt drives least_recently_used. Defaults + // (weight 1, nil last-sent, position 0) make tag-strategy candidates behave + // exactly as before. + SenderWeight int + RotationPosition int + SenderLastSentAt *time.Time + HasSenderMetadata bool + + // ProviderMatch reflects whether this mailbox's provider matches the + // recipient ESP under ESP matching. Always true when ESP matching is off or + // the recipient provider is unknown. + ProviderMatch bool +} + +// campaignRampCeiling returns the day's effective ramp ceiling. When ramp is +// disabled it returns the campaign ceiling unchanged (the caller still min()s +// against the per-mailbox cap). When enabled it returns the already-advanced +// level clamped into [start, ceiling]. The scheduler applies this ONLY via +// min() against the per-mailbox cold cap, so it can only LOWER volume. +func campaignRampCeiling(enabled bool, start, increment, ceiling, level int) int { + _ = increment // the increment is applied by AdvanceRampLevel; level is already advanced + if !enabled { + return ceiling + } + v := level + if v < start { + v = start + } + if v > ceiling { + v = ceiling + } + return v +} + +// providerForEmailDomain maps a recipient email address to a coarse ESP bucket +// for provider matching. Pure string work — never dials MX. Unknown/other +// domains return "" so matching never blocks the first contact. +func providerForEmailDomain(email string) string { + at := strings.LastIndex(email, "@") + if at < 0 || at == len(email)-1 { + return "" + } + domain := strings.ToLower(strings.TrimSpace(email[at+1:])) + switch domain { + case "gmail.com", "googlemail.com": + return "gmail" + case "outlook.com", "hotmail.com", "live.com", "msn.com", "office365.com", "microsoft.com": + return "outlook" + } + // Subdomain / suffix heuristics for hosted Google/Microsoft mail. + if strings.HasSuffix(domain, ".onmicrosoft.com") { + return "outlook" + } + return "" } // computeWeight calculates a scheduling weight for an account based on remaining capacity and warmup age. @@ -205,13 +325,18 @@ func computeWeight(remaining int, warmupAgeDays int) float64 { } // selectAccountWeighted picks an account using weighted random selection. -// Returns nil if all candidates have zero weight. +// Returns nil if all candidates have zero weight. The per-sender weight (1..100, +// default 1 for tag-strategy candidates) multiplies the base scheduling weight, +// so an operator can bias rotation toward specific mailboxes without ever +// raising any mailbox above its already-clamped per-mailbox cap. func selectAccountWeighted(candidates []AccountCandidate) *AccountCandidate { var totalWeight float64 var viable []AccountCandidate for _, c := range candidates { - if c.Weight > 0 { - totalWeight += c.Weight + w := effectiveWeight(c) + if w > 0 { + totalWeight += w + c.Weight = w viable = append(viable, c) } } @@ -232,3 +357,76 @@ func selectAccountWeighted(candidates []AccountCandidate) *AccountCandidate { // Fallback to last viable candidate return &viable[len(viable)-1] } + +// effectiveWeight folds the per-sender weight into the base scheduling weight. +// Tag-strategy candidates carry SenderWeight 0 (no metadata) and so use the +// base weight unchanged. +func effectiveWeight(c AccountCandidate) float64 { + if c.HasSenderMetadata && c.SenderWeight > 0 { + return c.Weight * float64(c.SenderWeight) + } + return c.Weight +} + +// selectAccountLeastRecentlyUsed picks the viable candidate (Weight>0) whose +// sender last_sent_at is oldest; a nil last_sent_at (never used) sorts first. +// Ties broken by account id for determinism. +func selectAccountLeastRecentlyUsed(candidates []AccountCandidate) *AccountCandidate { + var best *AccountCandidate + for i := range candidates { + if candidates[i].Weight <= 0 { + continue + } + if best == nil || lruLess(&candidates[i], best) { + best = &candidates[i] + } + } + return best +} + +func lruLess(a, b *AccountCandidate) bool { + switch { + case a.SenderLastSentAt == nil && b.SenderLastSentAt == nil: + return a.Account.ID.String() < b.Account.ID.String() + case a.SenderLastSentAt == nil: + return true + case b.SenderLastSentAt == nil: + return false + case a.SenderLastSentAt.Equal(*b.SenderLastSentAt): + return a.Account.ID.String() < b.Account.ID.String() + default: + return a.SenderLastSentAt.Before(*b.SenderLastSentAt) + } +} + +// selectAccountRoundRobin picks the viable candidate (Weight>0) with the lowest +// rotation_position; ties broken by account id for determinism. +func selectAccountRoundRobin(candidates []AccountCandidate) *AccountCandidate { + var best *AccountCandidate + for i := range candidates { + if candidates[i].Weight <= 0 { + continue + } + if best == nil || + candidates[i].RotationPosition < best.RotationPosition || + (candidates[i].RotationPosition == best.RotationPosition && + candidates[i].Account.ID.String() < best.Account.ID.String()) { + best = &candidates[i] + } + } + return best +} + +// selectAccountByRotationMode dispatches to the chosen rotation strategy. For +// explicit-strategy campaigns rotationMode is one of weighted / round_robin / +// least_recently_used; anything else falls back to weighted. +func selectAccountByRotationMode(rotationMode string, candidates []AccountCandidate) *AccountCandidate { + switch rotationMode { + case "round_robin": + return selectAccountRoundRobin(candidates) + case "least_recently_used": + return selectAccountLeastRecentlyUsed(candidates) + default: + return selectAccountWeighted(candidates) + } +} diff --git a/internal/scheduler/service.go b/internal/scheduler/service.go index 0927f74b..6391a43f 100644 --- a/internal/scheduler/service.go +++ b/internal/scheduler/service.go @@ -27,6 +27,12 @@ type schedulerService struct { campaignProgressRepo repository.CampaignProgressRepository emailRepo repository.EmailRepository campaignRepo repository.CampaignRepository + // contactRepo is used only to read/cache the recipient ESP/provider for + // ESP matching (no MX dial on the hot path). nil-safe. + contactRepo repository.ContactRepository + // campaignLogRepo records send-path decision logs (e.g. ESP defer, + // new-lead cap). Optional/nil-safe so the scheduler keeps working without it. + campaignLogRepo repository.CampaignLogRepository } // NewSchedulerService creates a new scheduler service @@ -36,6 +42,8 @@ func NewSchedulerService( campaignProgressRepo repository.CampaignProgressRepository, emailRepo repository.EmailRepository, campaignRepo repository.CampaignRepository, + contactRepo repository.ContactRepository, + campaignLogRepo repository.CampaignLogRepository, ) SchedulerService { return &schedulerService{ taskRepo: taskRepo, @@ -43,6 +51,8 @@ func NewSchedulerService( campaignProgressRepo: campaignProgressRepo, emailRepo: emailRepo, campaignRepo: campaignRepo, + contactRepo: contactRepo, + campaignLogRepo: campaignLogRepo, } } diff --git a/internal/seed/plans.go b/internal/seed/plans.go index f80ca997..e29ec7d3 100644 --- a/internal/seed/plans.go +++ b/internal/seed/plans.go @@ -52,6 +52,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { maxActiveCampaigns *int maxTeamMembers *int maxEmailAccounts *int + monthlyCredits int } plans := []plan{ @@ -62,6 +63,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 0, dailyCampaignLimit: intPtr(20), maxCampaigns: intPtr(2), maxActiveCampaigns: intPtr(1), maxTeamMembers: intPtr(1), maxEmailAccounts: intPtr(2), + monthlyCredits: 50, }, { id: PlanStarterID, name: "Starter", maxContacts: 1_000, dailyEmails: 100, @@ -70,6 +72,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 0, dailyCampaignLimit: intPtr(100), maxCampaigns: intPtr(5), maxActiveCampaigns: intPtr(2), maxTeamMembers: intPtr(2), maxEmailAccounts: intPtr(3), + monthlyCredits: 250, }, { id: PlanProMonthlyID, name: "Pro", maxContacts: 25_000, dailyEmails: 1_000, @@ -78,6 +81,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 1, dailyCampaignLimit: intPtr(1_000), maxCampaigns: intPtr(50), maxActiveCampaigns: intPtr(20), maxTeamMembers: intPtr(10), maxEmailAccounts: intPtr(20), + monthlyCredits: 2_000, }, { id: PlanProYearlyID, name: "Pro (Annual)", maxContacts: 25_000, dailyEmails: 1_000, @@ -86,6 +90,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 1, dailyCampaignLimit: intPtr(1_000), maxCampaigns: intPtr(50), maxActiveCampaigns: intPtr(20), maxTeamMembers: intPtr(10), maxEmailAccounts: intPtr(20), + monthlyCredits: 2_000, }, { id: PlanEnterpriseID, name: "Enterprise", maxContacts: 1_000_000, dailyEmails: 10_000, @@ -94,6 +99,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 3, dailyCampaignLimit: intPtr(10_000), maxCampaigns: nil, maxActiveCampaigns: nil, maxTeamMembers: nil, maxEmailAccounts: nil, + monthlyCredits: 25_000, }, } @@ -103,8 +109,9 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { id, name, max_contacts, daily_emails, ai_generation, account_limit, price, discounted_price, duration_id, savings, public, dedicated_workers, daily_campaign_limit, - max_campaigns, max_active_campaigns, max_team_members, max_email_accounts - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + max_campaigns, max_active_campaigns, max_team_members, max_email_accounts, + monthly_credits + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, max_contacts = EXCLUDED.max_contacts, @@ -122,12 +129,14 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { max_active_campaigns = EXCLUDED.max_active_campaigns, max_team_members = EXCLUDED.max_team_members, max_email_accounts = EXCLUDED.max_email_accounts, + monthly_credits = EXCLUDED.monthly_credits, updated_at = NOW() `, p.id, p.name, p.maxContacts, p.dailyEmails, p.ai, p.accountLimit, p.price, p.discounted, p.duration, p.savings, p.public, p.dedicatedWorkers, p.dailyCampaignLimit, p.maxCampaigns, p.maxActiveCampaigns, p.maxTeamMembers, p.maxEmailAccounts, + p.monthlyCredits, ) if err != nil { return err diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 50eabb6b..f9ba84a7 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -156,7 +157,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // Reschedule to the next day to keep campaign progression alive. nextDay := time.Now().UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(5 * time.Minute) _, _, nextAccountID, calcErr := s.scheduler.CalculateNextCampaignTime(ctx, *campaignTask.CampaignID) - if calcErr == nil { + if calcErr == nil || errors.Is(calcErr, scheduler.ErrCampaignDeferred) { if err := s.createCampaignTask(ctx, campaign.ID, nextAccountID, nextDay); err != nil { log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to create next campaign task after daily limit") } @@ -175,6 +176,22 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { executionStatus = "completed" return nil } + if errors.Is(err, scheduler.ErrCampaignDeferred) { + // A valid contact exists but no eligible mailbox right now (ESP-strict + // has no same-provider mailbox, or the daily new-lead cap is reached). + // Reschedule at the deferred slot WITHOUT sending and WITHOUT touching + // progress / daily counters / rotation — mirrors the daily-limit path. + scheduledNext := nextTime + if scheduledNext.IsZero() { + scheduledNext = time.Now().UTC().Add(1 * time.Hour) + } + if cerr := s.createCampaignTask(ctx, campaign.ID, accountID, scheduledNext); cerr != nil { + log.Warn().Err(cerr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to schedule deferred campaign task") + } + s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") + executionStatus = "completed" + return nil + } if errors.Is(err, scheduler.ErrCampaignCompleted) { s.campaignRepo.UpdateStatus(ctx, campaign.ID, "completed") if s.campaignLogRepo != nil { @@ -184,6 +201,19 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { Message: "Campaign completed: all emails sent", }) } + // Broadcast live so the dashboard (and the sidebar campaign counters) + // flip from "sending" to "finished" without a manual refresh. + if s.streamingPublisher != nil { + s.streamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{ + BaseEvent: pubsub.BaseEvent{ + EventType: pubsub.EventCampaignCompleted, + UserID: campaign.UserID, + }, + CampaignID: campaign.ID.String(), + Name: campaign.Name, + Status: "completed", + }) + } } s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") executionStatus = "completed" @@ -221,7 +251,9 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // Pre-send verification gate: drop addresses already known to be invalid // (bad syntax / no MX / 550 RCPT) before a worker sends and earns a hard - // bounce. Only 'invalid' is dropped — 'risky'/'unknown'/'valid' still send. + // bounce. 'invalid' is always dropped; 'risky' is dropped only when the + // campaign's "send to risky emails" toggle is off (see the next gate). + // 'unknown'/'valid' always send. if contact.VerificationStatus == "invalid" { _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_suppressed") if s.campaignLogRepo != nil { @@ -237,12 +269,86 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { return nil } + // Risky-recipient gate: when "send to risky emails" is off, also drop + // addresses verification flagged 'risky' (catch-all / role / low-quality), + // which raise bounce risk. Enforces the campaign.RiskyEmails toggle that the + // settings UI exposes — without this the toggle is stored but inert. + if !campaign.RiskyEmails && contact.VerificationStatus == "risky" { + _ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_suppressed") + if s.campaignLogRepo != nil { + _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaign.ID, + EventType: "suppressed", + Message: fmt.Sprintf("Risky recipient skipped (send to risky emails is off): %s", contact.Email), + Metadata: map[string]interface{}{"reason": contact.VerificationReason}, + }) + } + _ = s.createCampaignTask(ctx, campaign.ID, accountID, nextTime) + executionStatus = "completed" + return nil + } + sequence, err := s.campaignRepo.GetSequenceByID(ctx, nextPair.SequenceID) if err != nil { sentry.CaptureException(err) return errx.InternalError() } + // STEP 7.6: Non-email nodes (action / wait). These run a control-plane side + // effect and route onward WITHOUT sending mail — the render/send block below + // is reached only for email nodes. We stamp the node visited so routing + // advances past it next tick, then schedule the next campaign tick (now for + // instant actions and "end", now+wait for a wait node). An "end" node has no + // outgoing connection, so the contact drops out of routing afterwards while + // the campaign keeps processing other contacts. + if sequence.Kind != "email" { + var cfg models.ActionConfig + if len(sequence.Action) > 0 { + _ = json.Unmarshal(sequence.Action, &cfg) + } + if aerr := s.executeActionNode(ctx, campaign, contact, &cfg); aerr != nil { + log.Warn().Err(aerr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Str("action", cfg.Type).Msg("Action node execution failed") + } + resumeAt := nextTime + if cfg.Type == "wait" && cfg.WaitMinutes != nil && *cfg.WaitMinutes > 0 { + resumeAt = time.Now().UTC().Add(time.Duration(*cfg.WaitMinutes) * time.Minute) + } + if rerr := s.campaignProgressRepo.RecordEmailSent(ctx, campaign.ID, contact.ID, sequence.ID); rerr != nil { + log.Warn().Err(rerr).Str("campaign_id", campaign.ID.String()).Msg("Failed to record action node progress") + } + if cerr := s.createCampaignTask(ctx, campaign.ID, accountID, resumeAt); cerr != nil { + log.Warn().Err(cerr).Str("campaign_id", campaign.ID.String()).Msg("Failed to schedule next task after action node") + } + if s.campaignLogRepo != nil { + _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaign.ID, + EventType: "action", + Message: fmt.Sprintf("Ran '%s' action for %s", cfg.Type, contact.Email), + }) + } + s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") + executionStatus = "completed" + return nil + } + + // Load campaign attachments (campaign-wide; metadata only — the worker + // fetches the bytes from object storage by S3 key at send time). + var attachmentRefs []models.AttachmentRef + if s.attachmentRepo != nil { + atts, attErr := s.attachmentRepo.ListByCampaign(ctx, campaign.ID) + if attErr != nil { + log.Warn().Err(attErr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to load campaign attachments") + } else { + for _, a := range atts { + attachmentRefs = append(attachmentRefs, models.AttachmentRef{ + S3Key: a.S3Key, + Filename: a.Filename, + MimeType: a.MimeType, + }) + } + } + } + // STEP 7.5: Update campaign task with contact_id and sequence_id for tracking // This allows the tracking consumer to find the correct contact/sequence when // processing open/click events from the tracking pixel service @@ -281,7 +387,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } if s.advanced != nil && campaign.OrganizationID != nil { - selection, sxerr := s.advanced.SelectVariant(ctx, *campaign.OrganizationID, campaign.ID, contact.ID, subject, bodyHTML, bodyPlain) + selection, sxerr := s.advanced.SelectVariant(ctx, *campaign.OrganizationID, campaign.ID, contact.ID, sequence.ID, subject, bodyHTML, bodyPlain) if sxerr != nil { return sxerr } @@ -292,13 +398,31 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } } - // STEP 11: Add tracking + // STEP 11: Add tracking. Resolve the tracking host once: a VERIFIED + // campaign-scoped override wins, otherwise the mailbox/default domain. + // Only a verified override is honored (an unresolved/unverified host could + // point tracking at a hijackable target — SSRF-adjacent), matching the + // webhook-safety posture. + trackingDomain := account.TrackingDomain + if campaign.TrackingDomain != "" { + if campaign.TrackingDomainVerified { + trackingDomain = campaign.TrackingDomain + } else if s.campaignLogRepo != nil { + s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaign.ID, + EventType: "tracking_domain_unverified", + Message: "Campaign tracking domain not verified; using mailbox default", + Metadata: map[string]interface{}{"tracking_domain": campaign.TrackingDomain}, + }) + } + } + if campaign.OpenTracking && bodyHTML != "" { - bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, account.TrackingDomain) + bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, trackingDomain) } if campaign.LinkTracking && bodyHTML != "" { - bodyHTML = WrapLinksForTracking(bodyHTML, taskID, account.TrackingDomain) + bodyHTML = WrapLinksForTracking(bodyHTML, taskID, trackingDomain) } // STEP 12: Add signature @@ -339,13 +463,13 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // STEP 14: Generate Message-ID messageID := generateMessageID(account.Email) - // STEP 15: Build tracking info + // STEP 15: Build tracking info (worker receives the already-resolved host). var tracking *models.TrackingInfo if campaign.OpenTracking || campaign.LinkTracking { tracking = &models.TrackingInfo{ OpenTracking: campaign.OpenTracking, LinkTracking: campaign.LinkTracking, - TrackingDomain: account.TrackingDomain, + TrackingDomain: trackingDomain, } } @@ -370,6 +494,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { Tracking: tracking, UserID: userUUID, UnsubscribeURL: unsubscribeURL, + Attachments: attachmentRefs, } if err := s.emailSender.Send(ctx, taskID, emailMsg, *account); err != nil { @@ -440,6 +565,13 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to record email sent") } + // Bump today's per-campaign counters. newLead counts ONLY a genuinely-sent + // position-1 (first-step) email, so the new-lead/day cap can never under-count + // and over-send. Skipped/suppressed/failed tasks never reach this point. + if err := s.campaignRepo.IncrementCampaignDailySend(ctx, campaign.ID, nextPair.IsNewLead); err != nil { + log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to increment campaign daily send counter") + } + // Publish campaign progress summary to Pub/Sub for real-time dashboard updates if s.streamingPublisher != nil { if progress, pErr := s.campaignProgressRepo.GetCampaignProgress(ctx, campaign.ID); pErr == nil && progress != nil { @@ -467,6 +599,15 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { return errx.InternalError() } + // STEP 18.5: Advance the explicit-sender rotation cursor on a GENUINE send + // only (single atomic UPDATE), so round_robin/least_recently_used cursors + // stay coherent and a send-failure/skip never bumps them. The UPDATE is + // scoped to (campaign_id, email_account_id), so it's a harmless no-op for + // tag/all-resolved mailboxes that have no campaign_senders row. + if err := s.campaignRepo.AdvanceCampaignSender(ctx, campaign.ID, account.ID); err != nil { + log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to advance campaign sender cursor") + } + // Publish task completion to Pub/Sub if s.streamingPublisher != nil { s.streamingPublisher.PublishTaskStatus(ctx, campaign.UserID, taskID, pubsub.EventTaskCompleted, "Email sent successfully", map[string]string{ @@ -544,6 +685,62 @@ func (s *tasksService) autoPauseCampaign(ctx context.Context, campaignID, taskID } } +// executeActionNode runs the control-plane side effect for a non-email node. +// "wait" and "end" have no side effect (their behaviour is timing / routing +// only); the others reuse existing repos/services. Everything here is +// control-plane — the worker is never involved for an action node. +func (s *tasksService) executeActionNode(ctx context.Context, campaign *models.Campaign, contact *models.Contact, cfg *models.ActionConfig) error { + switch cfg.Type { + case "wait", "end", "": + return nil + case "add_tag": + if cfg.CategoryID == nil { + return nil + } + if _, xerr := s.contactRepo.Update(ctx, campaign.UserID, contact.ID.String(), &models.UpdateContact{ + AddCategories: []string{cfg.CategoryID.String()}, + }); xerr != nil { + return xerr + } + return nil + case "remove_tag": + if cfg.CategoryID == nil { + return nil + } + if _, xerr := s.contactRepo.Update(ctx, campaign.UserID, contact.ID.String(), &models.UpdateContact{ + RemoveCategories: []string{cfg.CategoryID.String()}, + }); xerr != nil { + return xerr + } + return nil + case "unsubscribe": + if xerr := s.advanced.Unsubscribe(ctx, campaign.ID, contact.ID); xerr != nil { + return xerr + } + return nil + case "notify": + if s.advanced == nil || campaign.OrganizationID == nil { + return nil + } + event := models.WebhookEventCampaignAction + if cfg.NotifyEvent != "" { + event = models.WebhookEventType(cfg.NotifyEvent) + } + data := map[string]any{ + "campaign_id": campaign.ID.String(), + "contact_id": contact.ID.String(), + "contact_email": contact.Email, + } + for k, v := range cfg.NotifyData { + data[k] = v + } + s.advanced.EmitCampaignEvent(ctx, *campaign.OrganizationID, event, data) + return nil + default: + return nil + } +} + // createCampaignTask creates a new campaign task in GCP Cloud Tasks func (s *tasksService) createCampaignTask(ctx context.Context, campaignID, accountID uuid.UUID, scheduleTime time.Time) error { // Create task in database with advisory lock diff --git a/internal/tasks/email_sender.go b/internal/tasks/email_sender.go index 5da997c2..989ad278 100644 --- a/internal/tasks/email_sender.go +++ b/internal/tasks/email_sender.go @@ -27,6 +27,10 @@ type EmailMessage struct { WarmupToken string UserID uuid.UUID UnsubscribeURL string + // Attachments are file refs (S3 key + metadata). The worker fetches the + // bytes from object storage at send time. Refs travel inside the S3 body + // blob, never the Avro Kafka event. + Attachments []models.AttachmentRef } // EmailSender interface for sending emails via workers @@ -78,6 +82,7 @@ func (s *emailSender) Send(ctx context.Context, taskID uuid.UUID, msg EmailMessa TrackingInfo: msg.Tracking, WarmupToken: msg.WarmupToken, UnsubscribeURL: msg.UnsubscribeURL, + Attachments: msg.Attachments, } // Publish send email event to worker diff --git a/internal/tasks/service.go b/internal/tasks/service.go index f0e50d77..ae46490e 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -78,6 +78,7 @@ type tasksService struct { campaignRepo repository.CampaignRepository contactRepo repository.ContactRepository campaignLogRepo repository.CampaignLogRepository + attachmentRepo repository.AttachmentRepository // warmupSettings caches the warmup generation settings in-process so the // per-send AI-vs-static decision doesn't hit Postgres on every warmup. @@ -112,6 +113,7 @@ func NewService( contactRepo repository.ContactRepository, campaignLogRepo repository.CampaignLogRepository, advanced advanced.Service, + attachmentRepo repository.AttachmentRepository, ) TasksService { return &tasksService{ tasksClient: tasksClient, @@ -134,6 +136,7 @@ func NewService( campaignRepo: campaignRepo, contactRepo: contactRepo, campaignLogRepo: campaignLogRepo, + attachmentRepo: attachmentRepo, warmupSettings: &warmupSettingsCache{}, } } diff --git a/internal/tasks/template.go b/internal/tasks/template.go index 4dde13f5..d83dd5af 100644 --- a/internal/tasks/template.go +++ b/internal/tasks/template.go @@ -6,6 +6,8 @@ import ( "net/url" "regexp" "strings" + "sync" + "text/template" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/models" @@ -30,37 +32,141 @@ type TemplateVariables struct { Custom map[string]string } -// RenderTemplate renders a template string with contact variables -func RenderTemplate(template string, contact models.Contact) string { - vars := TemplateVariables{ - FirstName: contact.FirstName, - LastName: contact.LastName, - Email: contact.Email, - Company: contact.Company, - Phone: contact.Phone, - Custom: make(map[string]string), +// identifierKey matches a Go-template-safe selector key: a leading letter or +// underscore followed by letters, digits, or underscores. Only keys matching +// this can be referenced via the {{.Key}} selector syntax; non-identifier +// custom-field keys (e.g. "job title", "first-name") are substituted literally +// by a pre-pass before the template engine parses the body. +var identifierKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// legacyDotToken matches a single {{.}} token. Used by the +// pre-pass to find tokens whose key is a known but non-identifier custom field. +var legacyDotToken = regexp.MustCompile(`\{\{\.([^{}]+)\}\}`) + +// tmplCache caches parsed templates keyed by the raw template string. A stored +// nil *template.Template is a "known-bad" sentinel: that body failed to parse, +// so future renders skip straight to the naive fallback instead of re-parsing +// on every recipient. *template.Template is safe for concurrent Execute once +// parsed, so a single cached instance is reused across the whole send loop. +var tmplCache sync.Map // map[string]*template.Template ; nil value = known-bad + +// buildTemplateData flattens the contact into the single map[string]string root +// the template engine executes against. Standard fields use their established +// dot-names so {{.FirstName}} keeps working; custom fields are merged in, with +// standard fields winning a name collision. +func buildTemplateData(contact models.Contact) map[string]string { + data := make(map[string]string, len(contact.CustomFields)+5) + for k, v := range contact.CustomFields { + data[k] = v + } + data["FirstName"] = contact.FirstName + data["LastName"] = contact.LastName + data["Email"] = contact.Email + data["Company"] = contact.Company + data["Phone"] = contact.Phone + return data +} + +// rewriteNonIdentifierTokens substitutes {{.}} tokens whose key exists in +// data but is NOT a valid Go-template identifier (so the selector syntax can't +// reference it, e.g. "job title"). Identifier tokens are left for the engine so +// they remain usable inside {{if}}/{{eq}}. +func rewriteNonIdentifierTokens(tmpl string, data map[string]string) string { + if !strings.Contains(tmpl, "{{.") { + return tmpl + } + return legacyDotToken.ReplaceAllStringFunc(tmpl, func(match string) string { + key := strings.TrimSpace(legacyDotToken.FindStringSubmatch(match)[1]) + if identifierKey.MatchString(key) { + return match // engine resolves it (and it may be used in if/eq) + } + if v, ok := data[key]; ok { + return v // legacy literal substitution for non-identifier keys + } + return match // unknown + non-identifier: leave for engine/missingkey + }) +} + +// compiledTemplate returns a parsed, cached template for tmpl, or nil if the +// body is known-bad (caller falls back to naiveRenderTemplate). missingkey=zero +// makes absent map keys render as "" and test false in {{if .X}}. text/template +// (not html/template) performs no escaping, so the author's HTML body is emitted +// verbatim. +func compiledTemplate(tmpl string) *template.Template { + if v, ok := tmplCache.Load(tmpl); ok { + t, _ := v.(*template.Template) + return t // may be nil (known-bad) + } + t, err := template.New("body").Option("missingkey=zero").Parse(tmpl) + if err != nil { + tmplCache.Store(tmpl, (*template.Template)(nil)) + return nil + } + tmplCache.Store(tmpl, t) + return t +} + +// TemplateError returns a parse error when a template's control syntax is +// malformed (e.g. an {{if}} with no {{end}}, or a bad {{eq}}), or nil when it is +// valid. Non-identifier {{.key}} tokens (custom fields with spaces) are +// neutralized first — the renderer substitutes those per contact, so they must +// not false-fail validation. Used to block starting a campaign with a template +// that would otherwise degrade to literal {{if}} text in the sent email. +func TemplateError(tmpl string) error { + if tmpl == "" { + return nil + } + prepared := legacyDotToken.ReplaceAllStringFunc(tmpl, func(match string) string { + key := strings.TrimSpace(legacyDotToken.FindStringSubmatch(match)[1]) + if identifierKey.MatchString(key) { + return match + } + return "" // non-identifier custom key: substituted per contact at render + }) + _, err := template.New("validate").Option("missingkey=zero").Parse(prepared) + return err +} + +// RenderTemplate renders a sequence template against a contact, supporting Go +// text/template conditionals ({{if}}/{{else}}/{{eq}}), standard variables, and +// custom fields. It NEVER hard-fails: any parse or execution error falls back to +// the naive replacement path so a send always produces a body. Spintax is +// intentionally left untouched here (single-brace {a|b} survives the template +// pass) and expanded later in the pipeline where applicable. +func RenderTemplate(tmpl string, contact models.Contact) string { + if tmpl == "" { + return tmpl } - // Parse custom fields if present - if contact.CustomFields != nil { - vars.Custom = contact.CustomFields + data := buildTemplateData(contact) + prepared := rewriteNonIdentifierTokens(tmpl, data) + + t := compiledTemplate(prepared) + if t == nil { + return naiveRenderTemplate(tmpl, contact) // known-bad -> legacy path } - result := template - - // Replace standard variables - result = strings.ReplaceAll(result, "{{.FirstName}}", vars.FirstName) - result = strings.ReplaceAll(result, "{{.LastName}}", vars.LastName) - result = strings.ReplaceAll(result, "{{.Email}}", vars.Email) - result = strings.ReplaceAll(result, "{{.Company}}", vars.Company) - result = strings.ReplaceAll(result, "{{.Phone}}", vars.Phone) - - // Replace custom variables - for k, v := range vars.Custom { - placeholder := fmt.Sprintf("{{.%s}}", k) - result = strings.ReplaceAll(result, placeholder, v) + var b strings.Builder + if err := t.Execute(&b, data); err != nil { + return naiveRenderTemplate(tmpl, contact) } + return b.String() +} +// naiveRenderTemplate is the legacy renderer: a literal {{.Key}} -> value +// substitution for the standard fields and every custom field. It is the +// graceful fallback when text/template parsing or execution fails, so a body +// always renders even for malformed conditional syntax. +func naiveRenderTemplate(tmpl string, contact models.Contact) string { + result := tmpl + result = strings.ReplaceAll(result, "{{.FirstName}}", contact.FirstName) + result = strings.ReplaceAll(result, "{{.LastName}}", contact.LastName) + result = strings.ReplaceAll(result, "{{.Email}}", contact.Email) + result = strings.ReplaceAll(result, "{{.Company}}", contact.Company) + result = strings.ReplaceAll(result, "{{.Phone}}", contact.Phone) + for k, v := range contact.CustomFields { + result = strings.ReplaceAll(result, fmt.Sprintf("{{.%s}}", k), v) + } return result } diff --git a/internal/tasks/template_test.go b/internal/tasks/template_test.go index 7c6e4bf6..52492dee 100644 --- a/internal/tasks/template_test.go +++ b/internal/tasks/template_test.go @@ -59,6 +59,72 @@ func TestRenderTemplate_NoPlaceholders(t *testing.T) { } } +func TestRenderTemplate_ConditionalIfSet(t *testing.T) { + tmpl := "Hi {{.FirstName}},{{if .Company}} saw {{.Company}} is hiring.{{end}}" + + with := RenderTemplate(tmpl, models.Contact{FirstName: "Alex", Company: "Acme"}) + if with != "Hi Alex, saw Acme is hiring." { + t.Errorf("if-set (present) wrong: %q", with) + } + + without := RenderTemplate(tmpl, models.Contact{FirstName: "Alex"}) + if without != "Hi Alex," { + t.Errorf("if-set (absent) wrong: %q", without) + } +} + +func TestRenderTemplate_IfElse(t *testing.T) { + tmpl := "{{if .FirstName}}Hi {{.FirstName}}{{else}}Hi there{{end}}," + + if got := RenderTemplate(tmpl, models.Contact{FirstName: "Sam"}); got != "Hi Sam," { + t.Errorf("if branch wrong: %q", got) + } + if got := RenderTemplate(tmpl, models.Contact{}); got != "Hi there," { + t.Errorf("else branch wrong: %q", got) + } +} + +func TestRenderTemplate_EqOnCustomField(t *testing.T) { + tmpl := `{{if eq .city "Berlin"}}in town{{else}}remote{{end}}` + + yes := RenderTemplate(tmpl, models.Contact{CustomFields: map[string]string{"city": "Berlin"}}) + if yes != "in town" { + t.Errorf("eq match wrong: %q", yes) + } + no := RenderTemplate(tmpl, models.Contact{CustomFields: map[string]string{"city": "Paris"}}) + if no != "remote" { + t.Errorf("eq non-match wrong: %q", no) + } +} + +func TestRenderTemplate_MissingKeyRendersEmpty(t *testing.T) { + // An unknown token renders empty (missingkey=zero) rather than leaking. + got := RenderTemplate("X{{.Nope}}Y", models.Contact{FirstName: "A"}) + if got != "XY" { + t.Errorf("missing key should be empty: %q", got) + } +} + +func TestRenderTemplate_MalformedFallsBack(t *testing.T) { + // An {{if}} with no {{end}} must not hard-fail; it falls back to naive + // substitution so standard variables still resolve. + got := RenderTemplate("Hi {{.FirstName}} {{if .Company}}oops", models.Contact{FirstName: "Bo", Company: "Acme"}) + if !strings.Contains(got, "Bo") { + t.Errorf("malformed template should still substitute variables: %q", got) + } +} + +func TestRenderTemplate_NonIdentifierCustomKey(t *testing.T) { + // Custom keys with spaces can't use selector syntax; the pre-pass substitutes + // {{.Job Title}} literally so it still renders. + got := RenderTemplate("Role: {{.Job Title}}", models.Contact{ + CustomFields: map[string]string{"Job Title": "CTO"}, + }) + if got != "Role: CTO" { + t.Errorf("non-identifier custom key wrong: %q", got) + } +} + func TestGenerateConversationEmail_NewEmail(t *testing.T) { conv := Conversation{ Theme: "test", diff --git a/internal/utils/validate/campaign.go b/internal/utils/validate/campaign.go index dc328beb..179eb691 100644 --- a/internal/utils/validate/campaign.go +++ b/internal/utils/validate/campaign.go @@ -1,10 +1,13 @@ package validate import ( + "net" + "strings" "time" "github.com/warmbly/warmbly/internal/bitmask" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" ) func CampaignName(name string) *errx.Error { @@ -57,3 +60,117 @@ func CampaignTime(input string) *errx.Error { } return nil } + +// CampaignScheduleWindows validates a per-day sending schedule: each interval +// must sit within the day (0..1440 minutes) with start < end, and no day may +// carry an unreasonable number of windows. Overlaps are allowed (the scheduler +// resolves them); ordering is not required. +func CampaignScheduleWindows(w *models.ScheduleWindows) *errx.Error { + if w == nil { + return nil + } + for _, day := range w { + if len(day) > 8 { + return errx.New(errx.BadRequest, "a day may have at most 8 sending windows") + } + for _, iv := range day { + if iv.Start < 0 || iv.End > 1440 || iv.Start >= iv.End { + return errx.New(errx.BadRequest, "invalid sending window: 0 <= start < end <= 1440") + } + } + } + return nil +} + +// ── Net-new send-control validators ────────────────────────────────────── + +func CampaignSenderStrategy(s string) *errx.Error { + if s != "tags" && s != "explicit" { + return errx.ErrInvalid + } + return nil +} + +func CampaignRotationMode(s string) *errx.Error { + switch s { + case "weighted", "round_robin", "least_recently_used": + return nil + } + return errx.ErrInvalid +} + +func CampaignSenderWeight(w int) *errx.Error { + if w < 1 || w > 100 { + return errx.New(errx.BadRequest, "sender weight must be between 1 and 100") + } + return nil +} + +// CampaignRamp validates the ramp config. The ceiling<=daily_limit cross-check +// is intentionally NOT enforced here: the scheduler applies the ramp via +// min(daily_limit, ramp_ceiling, per-mailbox cap), so a ceiling above the +// daily limit can only be clamped down, never over-send. +func CampaignRamp(start, increment, ceiling int) *errx.Error { + if start < 1 || start > 100 { + return errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + } + if increment < 0 || increment > 100 { + return errx.New(errx.BadRequest, "ramp increment must be between 0 and 100") + } + if ceiling < 1 || ceiling > 100 { + return errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + } + if start > ceiling { + return errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + } + return nil +} + +func CampaignESPMatchMode(s string) *errx.Error { + switch s { + case "off", "prefer", "strict": + return nil + } + return errx.ErrInvalid +} + +func CampaignMaxNewLeads(v int) *errx.Error { + if v < 0 || v > 1000 { + return errx.New(errx.BadRequest, "max new leads per day must be between 0 and 1000") + } + return nil +} + +// CampaignTrackingDomain validates a campaign-scoped tracking-domain override. +// Empty means "fall back to the mailbox/default domain". Otherwise it must be a +// bare hostname: no scheme, no path, no raw IP literal, and no internal/metadata +// host — mirroring the mailbox tracking-domain rules and the webhook-SSRF posture. +func CampaignTrackingDomain(host string) *errx.Error { + if host == "" { + return nil + } + if len(host) > 253 || strings.Contains(host, "://") || strings.ContainsAny(host, " \t\r\n/\\?#@:") { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + if net.ParseIP(host) != nil { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + lower := strings.ToLower(host) + if lower == "localhost" || strings.HasSuffix(lower, ".localhost") || lower == "metadata.google.internal" { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + if !strings.Contains(host, ".") { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + for _, c := range label { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + } + } + return nil +} diff --git a/realtime/lib/realtime/subscription.ex b/realtime/lib/realtime/subscription.ex index bb65e3f2..7fa055e1 100644 --- a/realtime/lib/realtime/subscription.ex +++ b/realtime/lib/realtime/subscription.ex @@ -120,7 +120,7 @@ defmodule Realtime.Subscription do WHERE s.user_id = $1 """ - case Repo.query(query, [Ecto.UUID.dump!(user_id) |> elem(1)]) do + case Repo.query(query, [Ecto.UUID.dump!(user_id)]) do {:ok, %{rows: [[ws_message, ws_join, ws_event, max_conn]]}} -> %{ limit_ws_message_pm: ws_message || 120, diff --git a/schema.sql b/schema.sql new file mode 100644 index 00000000..e69de29b diff --git a/site/public/apple-touch-icon.png b/site/public/apple-touch-icon.png index 492364a8..3ab55a4e 100644 Binary files a/site/public/apple-touch-icon.png and b/site/public/apple-touch-icon.png differ diff --git a/site/public/favicon-16x16.png b/site/public/favicon-16x16.png index d547f4f6..ac23522f 100644 Binary files a/site/public/favicon-16x16.png and b/site/public/favicon-16x16.png differ diff --git a/site/public/favicon-32x32.png b/site/public/favicon-32x32.png index d2d7e664..56cc8a0a 100644 Binary files a/site/public/favicon-32x32.png and b/site/public/favicon-32x32.png differ diff --git a/site/public/favicon-48x48.png b/site/public/favicon-48x48.png index b9325ffa..2701e19e 100644 Binary files a/site/public/favicon-48x48.png and b/site/public/favicon-48x48.png differ diff --git a/site/public/favicon-96x96.png b/site/public/favicon-96x96.png index 0cf2c1b7..a1f394bb 100644 Binary files a/site/public/favicon-96x96.png and b/site/public/favicon-96x96.png differ diff --git a/site/public/favicon.ico b/site/public/favicon.ico index ff8734da..e2beeae1 100644 Binary files a/site/public/favicon.ico and b/site/public/favicon.ico differ diff --git a/site/public/web-app-manifest-192x192.png b/site/public/web-app-manifest-192x192.png index 8f84d0e3..2c41b2f3 100644 Binary files a/site/public/web-app-manifest-192x192.png and b/site/public/web-app-manifest-192x192.png differ diff --git a/site/public/web-app-manifest-512x512.png b/site/public/web-app-manifest-512x512.png index 82a76efb..730e3502 100644 Binary files a/site/public/web-app-manifest-512x512.png and b/site/public/web-app-manifest-512x512.png differ diff --git a/site/src/layouts/Learn.astro b/site/src/layouts/Learn.astro index ae9c4f8c..4682a75c 100644 --- a/site/src/layouts/Learn.astro +++ b/site/src/layouts/Learn.astro @@ -26,6 +26,7 @@ const related = [ { href: '/learn/cold-email-rules/', title: 'Cold email rules across regions', read: '10 min' }, { href: '/learn/warmup-pools/', title: 'Warmup pools: free, premium, dedicated', read: '7 min' }, { href: '/learn/reputation-recovery/', title: 'Recovering a burned mailbox', read: '9 min' }, + { href: '/learn/personalization/', title: 'Personalization & conditionals', read: '6 min' }, ]; const path = Astro.url.pathname; const next = related.filter((r) => r.href !== path).slice(0, 2); diff --git a/site/src/pages/learn/index.astro b/site/src/pages/learn/index.astro index bbd81140..001068e8 100644 --- a/site/src/pages/learn/index.astro +++ b/site/src/pages/learn/index.astro @@ -70,6 +70,15 @@ const articles = [ body: 'A step-by-step plan to bring a quarantined mailbox back to healthy.', icon: 'workflow', }, + { + href: '/learn/personalization/', + title: 'Personalization & conditionals', + topic: 'Sending well', + level: 'Practical', + read: 6, + body: 'Merge variables, custom fields, and if/else conditionals so one template reads naturally for every recipient.', + icon: 'list', + }, ]; const featured = articles.find((a) => a.featured)!; diff --git a/site/src/pages/learn/personalization.astro b/site/src/pages/learn/personalization.astro new file mode 100644 index 00000000..dd138658 --- /dev/null +++ b/site/src/pages/learn/personalization.astro @@ -0,0 +1,155 @@ +--- +import Learn from '../../layouts/Learn.astro'; + +// Template syntax is kept in consts so Astro renders it literally (in .astro +// markup a bare "{" starts an expression). +const t = { + first: '{{.FirstName}}', + company: '{{.Company}}', + role: '{{.role}}', + index: '{{index . "Job Title"}}', + if: '{{if}}', + end: '{{end}}', + else: '{{else}}', + elseif: '{{else if}}', + eq: '{{eq}}', +}; + +const exVars = `Hi {{.FirstName}}, + +Thanks for the work you're doing at {{.Company}}.`; + +const exCustom = `You're the {{.role}} at {{.Company}}, right?`; + +const exIndex = `Role: {{index . "Job Title"}}`; + +const exIf = `Hi {{.FirstName}},{{if .Company}} I saw {{.Company}} is hiring.{{end}}`; + +const exIfElse = `{{if .FirstName}}Hi {{.FirstName}}{{else}}Hi there{{end}},`; + +const exEq = `{{if eq .city "Berlin"}}I'll be in Berlin next week.{{else}}Let's set up a call.{{end}}`; + +const exChain = `{{if eq .plan "enterprise"}}your team's size{{else if eq .plan "pro"}}your growing team{{else}}teams like yours{{end}}`; + +const exAnd = `{{if and (.FirstName) (eq .Company "Acme")}}Hi {{.FirstName}} at Acme{{end}}`; + +// The "way more complex" example: one template that adapts the subject, the +// greeting, the opening line (by role), a city aside, and the call to action +// (by plan) — all from variables + custom fields. +const exAdvanced = `Subject: {{if .Company}}quick idea for {{.Company}}{{else}}a quick idea{{end}} + +Hi {{if .FirstName}}{{.FirstName}}{{else}}there{{end}}, + +{{if eq .role "Founder"}}As the founder, you probably feel {{else if eq .role "Marketing"}}On the marketing side, you've likely noticed {{else}}You've probably run into {{end}}the cost of {{if .Company}}{{.Company}}'s{{else}}your{{end}} cold email landing in spam instead of the inbox. + +{{if and (.city) (eq .city "Berlin")}}I'm in Berlin next week and happy to grab a coffee. {{end}}We get teams to the inbox with gradual warmup and per-mailbox limits, no gimmicks. + +{{if eq .plan "enterprise"}}Given your volume, worth a 15-minute call?{{else if eq .plan "pro"}}Want the 2-line version of how it works?{{else}}Open to a quick look, or not a priority right now?{{end}} + +Best, +Sam`; + +const preClass = "not-prose my-4 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-1)] p-4 text-[12.5px] leading-relaxed text-foreground/80 whitespace-pre-wrap"; +const preStyle = "font-family: var(--font-mono)"; +--- + +

+ Every subject line and email body in Warmbly supports merge variables and simple conditionals. + They're rendered separately for each recipient when the email actually sends, so a single template + can read naturally for your whole list. +

+ +

Variables

+

Insert a variable and it's replaced with that contact's detail on send. The standard contact fields are:

+
    +
  • {t.first} — the contact's first name
  • +
  • {'{{.LastName}}'} — the contact's last name
  • +
  • {'{{.Email}}'} — the contact's email address
  • +
  • {t.company} — where the contact works
  • +
  • {'{{.Phone}}'} — the contact's phone number
  • +
+
{exVars}
+

+ If a contact is missing a value, it renders as nothing (blank). Write so the sentence still reads, or guard + it with a conditional (below). +

+ +

Custom fields

+

+ Any custom field on your contacts works too — reference it by its exact name. For a field called + role: +

+
{exCustom}
+

+ If the field name has spaces or punctuation (for example Job Title), use the + index form instead: +

+
{exIndex}
+

+ The name must match a field that exists on the contact; if it doesn't, the value is blank. So pair custom + fields with a conditional whenever the surrounding text depends on them. +

+ +

Conditionals (if / else)

+

Wrap text so it only appears when a field has a value — or matches a specific value.

+ +

Show text only when a field is set

+
{exIf}
+

→ “Hi Alex, I saw Acme is hiring.” when Company is set; “Hi Alex,” when it's blank.

+ +

Provide a fallback with else

+
{exIfElse}
+ +

Match a specific value with eq

+
{exEq}
+

+ eq compares exact text and is case-sensitive — {'eq .Company "acme"'} + will not match “Acme”. +

+ +

Chain options and combine checks

+
{exChain}
+
{exAnd}
+

+ Supported: {t.if}, {t.else}, {t.elseif}, + {t.end}, eq, and, and + or. A missing or empty field counts as “not set” (false). +

+ +

A complete, complex template

+

+ Here's everything together — one template whose subject, greeting, opening line (by role), city aside, and + call to action (by plan) all adapt per recipient. It uses three custom fields (role, + city, plan) alongside the standard ones: +

+
{exAdvanced}
+

+ Build these up gradually and lean on the editor's Preview tab — it evaluates your conditionals against sample + data so you can see each branch before you send. +

+ +

If something goes wrong

+

Warmbly guards against broken templates at three points:

+
    +
  • While editing, an unbalanced conditional (an {t.if} with no {t.end}) shows an inline warning.
  • +
  • A campaign won't start if any step's template is malformed — you get an error naming the step, so recipients never receive raw {'{{if ...}}'} text.
  • +
  • If a broken template ever reaches sending, it falls back to plain variable substitution rather than failing the send.
  • +
+

Always check Preview and send yourself a test email before launching.

+ +

Quick reference

+
    +
  • {t.first} — a contact field
  • +
  • {'{{.your_field}}'} — a custom field by name
  • +
  • {t.index} — a custom field whose name has spaces
  • +
  • {'{{if .Field}} … {{end}}'} — show only when the field is set
  • +
  • {'{{if .Field}} … {{else}} … {{end}}'} — with a fallback
  • +
  • {'{{if eq .Field "value"}} … {{end}}'} — when the field equals a value
  • +
+
diff --git a/web/package.json b/web/package.json index 9cdf4df5..0d973482 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -69,6 +70,7 @@ "@tiptap/pm": "^3.9.1", "@tiptap/react": "^3.8.0", "@types/papaparse": "^5.3.16", + "@xyflow/react": "^12.11.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -78,6 +80,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", + "html-to-image": "^1.11.13", "immer": "^11.1.3", "input-otp": "^1.4.2", "lucide-react": "^0.563.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9af70661..7a598daf 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@dagrejs/dagre': + specifier: ^3.0.0 + version: 3.0.0 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -173,6 +176,9 @@ importers: '@types/papaparse': specifier: ^5.3.16 version: 5.5.0 + '@xyflow/react': + specifier: ^12.11.0 + version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(immer@11.1.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@zxcvbn-ts/core': specifier: ^3.0.4 version: 3.0.4 @@ -200,6 +206,9 @@ importers: framer-motion: specifier: ^12.23.24 version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 immer: specifier: ^11.1.3 version: 11.1.3 @@ -457,6 +466,12 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1874,6 +1889,24 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2012,6 +2045,22 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@xyflow/react@12.11.0': + resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.77': + resolution: {integrity: sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==} + '@zxcvbn-ts/core@3.0.4': resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} @@ -2132,6 +2181,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2184,6 +2236,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-urls@6.0.1: resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} engines: {node: '>=20'} @@ -2508,6 +2598,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3428,6 +3521,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.10: resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==} engines: {node: '>=12.20.0'} @@ -3616,6 +3724,12 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': dependencies: react: 19.2.0 @@ -4891,6 +5005,27 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -5082,6 +5217,31 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(immer@11.1.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@xyflow/system': 0.0.77 + classcat: 5.0.5 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + zustand: 4.5.7(@types/react@19.2.7)(immer@11.1.3)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.77': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@zxcvbn-ts/core@3.0.4': dependencies: fastest-levenshtein: 1.0.16 @@ -5202,6 +5362,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -5256,6 +5418,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-urls@6.0.1: dependencies: whatwg-mimetype: 5.0.0 @@ -5577,6 +5775,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.13: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -6407,6 +6607,14 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.7)(immer@11.1.3)(react@19.2.0): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.7 + immer: 11.1.3 + react: 19.2.0 + zustand@5.0.10(@types/react@19.2.7)(immer@11.1.3)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): optionalDependencies: '@types/react': 19.2.7 diff --git a/web/public/apple-icon.png b/web/public/apple-icon.png deleted file mode 100644 index b8ee964a..00000000 Binary files a/web/public/apple-icon.png and /dev/null differ diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png index 492364a8..3ab55a4e 100644 Binary files a/web/public/apple-touch-icon.png and b/web/public/apple-touch-icon.png differ diff --git a/web/public/favicon-16x16.png b/web/public/favicon-16x16.png index d547f4f6..ac23522f 100644 Binary files a/web/public/favicon-16x16.png and b/web/public/favicon-16x16.png differ diff --git a/web/public/favicon-32x32.png b/web/public/favicon-32x32.png index d2d7e664..56cc8a0a 100644 Binary files a/web/public/favicon-32x32.png and b/web/public/favicon-32x32.png differ diff --git a/web/public/favicon-48x48.png b/web/public/favicon-48x48.png index b9325ffa..2701e19e 100644 Binary files a/web/public/favicon-48x48.png and b/web/public/favicon-48x48.png differ diff --git a/web/public/favicon-96x96.png b/web/public/favicon-96x96.png index 0cf2c1b7..a1f394bb 100644 Binary files a/web/public/favicon-96x96.png and b/web/public/favicon-96x96.png differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico index ff8734da..e2beeae1 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/public/icon0.svg b/web/public/icon0.svg deleted file mode 100644 index 469be09d..00000000 --- a/web/public/icon0.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/public/icon1.png b/web/public/icon1.png deleted file mode 100644 index d2ea5664..00000000 Binary files a/web/public/icon1.png and /dev/null differ diff --git a/web/public/manifest.json b/web/public/manifest.json deleted file mode 100644 index 9f3ae485..00000000 --- a/web/public/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "Warmbly", - "short_name": "Warmbly", - "icons": [ - { - "src": "/web-app-manifest-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/web-app-manifest-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} \ No newline at end of file diff --git a/web/public/web-app-manifest-192x192.png b/web/public/web-app-manifest-192x192.png index 8f84d0e3..2c41b2f3 100644 Binary files a/web/public/web-app-manifest-192x192.png and b/web/public/web-app-manifest-192x192.png differ diff --git a/web/public/web-app-manifest-512x512.png b/web/public/web-app-manifest-512x512.png index 82a76efb..730e3502 100644 Binary files a/web/public/web-app-manifest-512x512.png and b/web/public/web-app-manifest-512x512.png differ diff --git a/web/src/app/app/admin/_components/WorkerLabels.tsx b/web/src/app/app/admin/_components/WorkerLabels.tsx index 893b9d51..d7339a1d 100644 --- a/web/src/app/app/admin/_components/WorkerLabels.tsx +++ b/web/src/app/app/admin/_components/WorkerLabels.tsx @@ -8,6 +8,11 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import { listAllWorkerTags, setWorkerTags } from "@/lib/api/client/app/admin/workers"; import type { ManagedWorker } from "@/lib/api/models/app/admin/Worker"; @@ -113,11 +118,18 @@ export function TagEditor({ const [input, setInput] = useState(""); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); + // The suggestion list opens whenever typing produces matches, and can be + // dismissed (click-outside / Esc); typing again clears the dismissal. + const [dismissed, setDismissed] = useState(false); useEffect(() => { setDraft(worker.tags ?? []); }, [worker.tags]); + useEffect(() => { + setDismissed(false); + }, [input]); + const allTagsQ = useQuery({ queryKey: ["admin", "worker-tags"], queryFn: listAllWorkerTags }); const suggestions = useMemo(() => { const q = input.trim().toLowerCase(); @@ -127,6 +139,8 @@ export function TagEditor({ .slice(0, 6); }, [input, allTagsQ.data, draft]); + const suggestOpen = suggestions.length > 0 && !dismissed; + function normalize(t: string): string | null { const v = t.trim().toLowerCase(); if (!v) return null; @@ -176,36 +190,42 @@ export function TagEditor({ remove(t)} /> ))} -
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - add(input); - } - if (e.key === "Backspace" && input === "" && draft.length > 0) { - remove(draft[draft.length - 1]); - } - }} - placeholder="add a tag (eu-west, hetzner, warmup-only) and press enter" - className="w-full border rounded px-3 py-1.5 text-sm" - /> - {suggestions.length > 0 && ( -
- {suggestions.map((s) => ( - - ))} -
- )} -
+ { + if (!o) setDismissed(true); + }} + align="start" + > + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + add(input); + } + if (e.key === "Backspace" && input === "" && draft.length > 0) { + remove(draft[draft.length - 1]); + } + }} + placeholder="add a tag (eu-west, hetzner, warmup-only) and press enter" + className="w-full border rounded px-3 py-1.5 text-sm" + /> + + + {suggestions.map((s) => ( + + ))} + +
+ ))} +
+ +
+ {dash.isPending ? ( +
+ ) : ( + m.key === metric)?.bar} + emptyLabel="No sends in this window yet" /> + )} +
+ + + +
+ + + + All campaigns + + + + {dash.isPending ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
))}
-
- 7d ago - today + ) : (d?.top_campaigns?.length ?? 0) === 0 ? ( + + ) : ( +
+ {d!.top_campaigns.map((c) => { + const dot = + c.status === "active" ? "bg-emerald-500" : c.status === "paused" ? "bg-amber-500" : "bg-slate-300"; + return ( + + + {c.name} + + {num(c.emails_sent)} sent + {pct(c.open_rate)} open + {pct(c.reply_rate)} reply + + + ); + })}
-
- + )} - -
- - - - All accounts - - - - - - + ) : (d?.recent_activity?.length ?? 0) === 0 ? ( + + ) : ( +
+ {d!.recent_activity.map((a, i) => ( + + ))} +
+ )} + + + )} ); } -function RangeTabs({ value, onChange }: { value: "7d" | "30d" | "90d"; onChange: (v: "7d" | "30d" | "90d") => void }) { - const opts: ("7d" | "30d" | "90d")[] = ["7d", "30d", "90d"]; +function HealthCell({ n, label, tone }: { n: number; label: string; tone: string }) { + return ( +
+
{n}
+
{label}
+
+ ); +} + +const ACTIVITY_TONE: Record = { + sent: { tone: "text-slate-500", verb: "Sent to" }, + opened: { tone: "text-emerald-600", verb: "Opened by" }, + clicked: { tone: "text-violet-600", verb: "Clicked by" }, + replied: { tone: "text-amber-600", verb: "Reply from" }, + bounced: { tone: "text-rose-600", verb: "Bounced" }, +}; + +function ActivityRow({ a }: { a: { type: string; campaign_name: string; contact_email: string; timestamp: string } }) { + const meta = ACTIVITY_TONE[a.type] ?? { tone: "text-slate-500", verb: a.type }; + return ( +
+ + + {meta.verb} {a.contact_email} + + {a.campaign_name} + + {new Date(a.timestamp).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} + +
+ ); +} + +function RangeTabs({ value, onChange }: { value: Range; onChange: (v: Range) => void }) { + const opts: Range[] = ["7d", "30d", "90d"]; return (
{opts.map((o) => ( @@ -116,9 +273,7 @@ function RangeTabs({ value, onChange }: { value: "7d" | "30d" | "90d"; onChange: key={o} onClick={() => onChange(o)} className={`h-6 px-2 rounded text-[11px] font-medium tabular-nums transition-colors ${ - value === o - ? "bg-slate-900 text-white" - : "text-slate-500 hover:text-slate-900" + value === o ? "bg-slate-900 text-white" : "text-slate-500 hover:text-slate-900" }`} > {o} @@ -127,3 +282,26 @@ function RangeTabs({ value, onChange }: { value: "7d" | "30d" | "90d"; onChange:
); } + +function ErrorState({ onRetry, isRefetching }: { onRetry: () => void; isRefetching: boolean }) { + return ( +
+
+ +
+

Couldn't load analytics

+

+ The request failed. The backend may be down or returning an error. +

+ +
+ ); +} diff --git a/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx b/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx index 4c2e1968..25594909 100644 --- a/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx +++ b/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx @@ -35,6 +35,7 @@ import useAPIPermissions from "@/lib/api/hooks/app/api-keys/useAPIPermissions"; import useRevokeAPIKey from "@/lib/api/hooks/app/api-keys/useRevokeAPIKey"; import useUpdateAPIKey from "@/lib/api/hooks/app/api-keys/useUpdateAPIKey"; import { StackedBars } from "./Sparkline"; +import { useConfirm } from "@/hooks/context/confirm"; export default function KeyDetailDrawer({ apiKey, @@ -84,6 +85,7 @@ function Inner({ apiKey, onClose }: { apiKey: APIKey; onClose: () => void }) { const perms = useAPIPermissions(); const revoke = useRevokeAPIKey(); const update = useUpdateAPIKey(); + const confirm = useConfirm(); const [editing, setEditing] = React.useState(false); const [name, setName] = React.useState(apiKey.name); @@ -115,15 +117,16 @@ function Inner({ apiKey, onClose }: { apiKey: APIKey; onClose: () => void }) { } function confirmRevoke() { - if (!window.confirm(`Revoke "${apiKey.name}"? Requests authenticating with this key will start failing immediately.`)) return; - revoke.mutate( - { id: apiKey.id, reason: "Revoked by user" }, - { - onSuccess: () => { + confirm.show( + `Revoke "${apiKey.name}"? Requests authenticating with this key will start failing immediately.`, + async () => { + try { + await revoke.mutateAsync({ id: apiKey.id, reason: "Revoked by user" }); toast.success("Key revoked"); onClose(); - }, - onError: () => toast.error("Failed to revoke"), + } catch { + toast.error("Failed to revoke"); + } }, ); } diff --git a/web/src/app/app/audit/page.tsx b/web/src/app/app/audit/page.tsx index 6504b914..20a95790 100644 --- a/web/src/app/app/audit/page.tsx +++ b/web/src/app/app/audit/page.tsx @@ -36,8 +36,12 @@ import { } from "@/components/layout/Page"; import useFeatureAccess from "@/hooks/useFeatureAccess"; import useAuditLogs from "@/lib/api/hooks/app/audit/useAuditLogs"; -import useClickOutside from "@/hooks/useClickOutside"; -import { AnimatePresence, motion } from "framer-motion"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import type AuditLog from "@/lib/api/models/app/audit/AuditLog"; import type { AuditAction, AuditEntityType } from "@/lib/api/models/app/audit/AuditLog"; @@ -453,70 +457,38 @@ function FilterPopover({ onChange: (v: string) => void; }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); return ( -
- - - {open && ( - - - {options.map((o) => ( - - ))} - - )} - -
+ + + + + + onChange("")} selected={value === ""}> + All + + {options.map((o) => ( + onChange(o)} selected={value === o}> + {o} + + ))} + + ); } diff --git a/web/src/app/app/campaigns/[id]/layout.tsx b/web/src/app/app/campaigns/[id]/layout.tsx index a2da319b..b9a7e445 100644 --- a/web/src/app/app/campaigns/[id]/layout.tsx +++ b/web/src/app/app/campaigns/[id]/layout.tsx @@ -1,27 +1,59 @@ +import { useState } from "react"; import { Link, Outlet, useLocation, useParams } from "react-router-dom"; +import { motion } from "framer-motion"; +import { + BarChart3Icon, + CalendarIcon, + ListChecksIcon, + Loader2Icon, + PauseIcon, + PlayIcon, + Settings2Icon, + UsersIcon, +} from "lucide-react"; import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign"; +import useStartCampaign from "@/lib/api/hooks/app/campaigns/useStartCampaign"; +import useStopCampaign from "@/lib/api/hooks/app/campaigns/useStopCampaign"; import { CampaignContext } from "@/hooks/context/campaign"; +import { useConfirm } from "@/hooks/context/confirm"; +import LaunchCampaignDialog from "@/components/app/campaigns/LaunchCampaignDialog"; + +const TABS = [ + { label: "Overview", path: "", Icon: BarChart3Icon }, + { label: "Leads", path: "/leads", Icon: UsersIcon }, + { label: "Steps", path: "/sequences", Icon: ListChecksIcon }, + { label: "Schedule", path: "/schedule", Icon: CalendarIcon }, + { label: "Settings", path: "/preferences", Icon: Settings2Icon }, +] as const; + +const STATUS_PILL: Record = { + active: "bg-emerald-50 text-emerald-700 border-emerald-200", + paused: "bg-amber-50 text-amber-700 border-amber-200", + draft: "bg-slate-100 text-slate-600 border-slate-200", + completed: "bg-slate-100 text-slate-600 border-slate-200", +}; export default function CampaignLayout() { - const location = useLocation() - const { id } = useParams() - const { pathname } = location; - const campaignData = useCampaign(id ?? "") - - const tabData = { - "Analytics": "", - "Leads": "/leads", - "Sequences": "/sequences", - "Schedule": "/schedule", - "Preferences": "/preferences" - } + const { pathname } = useLocation(); + const { id } = useParams(); + const campaignData = useCampaign(id ?? ""); + const confirm = useConfirm(); + const startCampaign = useStartCampaign(); + const stopCampaign = useStopCampaign(); + const [launchOpen, setLaunchOpen] = useState(false); if (campaignData.isLoading) { return ( -
- {[...Array(5)].map((_, i) => ( -
- ))} +
+
+
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
); } @@ -43,38 +75,99 @@ export default function CampaignLayout() { ); } + const campaign = campaignData.data; + const status = campaign.status; + const pill = STATUS_PILL[status] ?? STATUS_PILL.draft; + + const isActive = status === "active"; + const canStart = status === "draft" || status === "paused"; + const canToggle = isActive || canStart; + const pending = isActive ? stopCampaign.isPending : startCampaign.isPending; + + const onToggle = () => { + if (isActive) { + confirm?.show(`Pause ${campaign.name}?`, () => { + stopCampaign.mutate(campaign.id); + }); + } else { + setLaunchOpen(true); + } + }; + return ( - -
-
-

{campaignData.data.name}

-

{campaignData.data.id}

+ +
+
+
+
+

{campaign.name}

+ + {status} + +
+

{campaign.id}

+
+ + {canToggle && ( +
+ +
+ )}
-
- {Object.entries(tabData).map(([label, path]) => { + +
+ {TABS.map(({ label, path, Icon }) => { const fullPath = `/app/campaigns/${id}${path}`; - const isActive = pathname.replaceAll("/", "") === fullPath.replaceAll("/", ""); + const isTabActive = pathname.replace(/\/$/, "") === fullPath.replace(/\/$/, ""); return ( + {label} - {isActive && ( - + {isTabActive && ( + )} ); })}
+
+ setLaunchOpen(false)} + onConfirm={(cid) => startCampaign.mutateAsync(cid)} + /> - ) + ); } diff --git a/web/src/app/app/campaigns/[id]/page.tsx b/web/src/app/app/campaigns/[id]/page.tsx index 9f5335ce..b5c7008b 100644 --- a/web/src/app/app/campaigns/[id]/page.tsx +++ b/web/src/app/app/campaigns/[id]/page.tsx @@ -1,37 +1,283 @@ +import { useMemo, useState } from "react"; +import { + MailCheckIcon, + MousePointerClickIcon, + ReplyIcon, + SendIcon, + TriangleAlertIcon, +} from "lucide-react"; import { useCampaign } from "@/hooks/context/campaign"; +import useCampaignAnalytics from "@/lib/api/hooks/app/analytics/useCampaignAnalytics"; +import useCampaignDailyStats from "@/lib/api/hooks/app/analytics/useCampaignDailyStats"; +import { SectionBar, Stat, StatStrip } from "@/components/layout/Page"; +import { DailyBars, type ChartPoint } from "@/components/ui/charts"; +import AnalyticsShareButton from "@/components/app/analytics/AnalyticsShareButton"; import TaskPreview from "@/components/app/campaigns/TaskPreview"; -import { BarChart3Icon } from "lucide-react"; +import AnimatedNumber from "@/components/ui/AnimatedNumber"; -export default function CampaignPreview() { +const pctFmt = (v: number) => `${v.toFixed(1)}%`; + +type Metric = "sent" | "opens" | "clicks" | "replies"; + +const METRICS: { key: Metric; label: string; bar: string }[] = [ + { key: "sent", label: "Sent", bar: "bg-sky-500" }, + { key: "opens", label: "Opens", bar: "bg-emerald-500" }, + { key: "clicks", label: "Clicks", bar: "bg-violet-500" }, + { key: "replies", label: "Replies", bar: "bg-amber-500" }, +]; + +function pct(v: number | undefined): string { + return v == null ? "—" : `${v.toFixed(1)}%`; +} +function num(v: number | undefined): string { + return (v ?? 0).toLocaleString(); +} + +export default function CampaignOverview() { const campaign = useCampaign(); + const id = campaign?.id ?? ""; + + const analytics = useCampaignAnalytics(id); + const daily = useCampaignDailyStats(id); + + const [metric, setMetric] = useState("sent"); + + const summary = analytics.data?.summary; + const sequences = analytics.data?.sequences ?? []; + const dailyStats = daily.data ?? []; + + const series: ChartPoint[] = useMemo( + () => (daily.data ?? []).map((d) => ({ label: d.date, value: d[metric] ?? 0 })), + [daily.data, metric], + ); + + const loading = analytics.isPending || daily.isPending; + const hasSends = (summary?.emails_sent ?? 0) > 0; + + const shareData = { + title: campaign?.name ?? "Campaign", + subtitle: "Campaign", + metrics: [ + { label: "Sent", value: num(summary?.emails_sent), sub: "emails" }, + { label: "Open rate", value: pct(summary?.open_rate) }, + { label: "Reply rate", value: pct(summary?.reply_rate) }, + { label: "Bounce rate", value: pct(summary?.bounce_rate) }, + ], + daily: dailyStats.map((d) => ({ label: d.date, value: d.sent })), + }; if (!campaign) { return ( -
- {[...Array(3)].map((_, i) => ( -
- ))} +
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
); } - return ( -
-
-
-
- -
-

Campaign Analytics

-

Data will appear here once the campaign starts sending.

-
-
+ const breakdown = [ + { label: "Sent", value: summary?.emails_sent, icon: SendIcon, dot: "bg-slate-400" }, + { label: "Opens", value: summary?.unique_opens, icon: MailCheckIcon, dot: "bg-emerald-500" }, + { label: "Clicks", value: summary?.unique_clicks, icon: MousePointerClickIcon, dot: "bg-violet-500" }, + { label: "Replies", value: summary?.replies, icon: ReplyIcon, dot: "bg-amber-500" }, + { label: "Bounces", value: summary?.bounces, icon: TriangleAlertIcon, dot: "bg-rose-500" }, + ]; -
- + return ( +
+
+ {/* Main analytics column */} +
+
+ + {campaign.status === "active" && ( + + + + + + Live + + )} + + + + } + sub="emails" + accent={hasSends} + /> + } + sub="after delivery" + /> + } + sub="of delivered" + /> + } + sub="incl. positive" + /> + } + sub="hard + soft" + last + /> + +
+ + {analytics.isError ? ( +
+

Couldn't load analytics

+

The request failed — try refreshing.

+
+ ) : ( +
+ +
+ {METRICS.map((m) => ( + + ))} +
+
+
+ {loading ? ( +
+ ) : ( + m.key === metric)?.bar} + emptyLabel={ + hasSends + ? "No activity in this window yet" + : "No sends yet — start the campaign to see performance" + } + /> + )} +
+
+ )} + +
+ + {loading ? ( +
+ {[...Array(2)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : sequences.length === 0 ? ( +
+

No step data yet

+

+ Once steps start sending, per-step opens, clicks, and replies show up here. +

+
+ ) : ( +
+ {/* header row */} +
+ Step + Sent + Opens + Clicks + Replies + Bounces +
+ {sequences.map((s) => ( +
+ + + {s.position} + + {s.name} + + + + + + + + + + + + + + + + +
+ ))} +
+ )} +
+ + {/* quick breakdown strip below sequence table, mobile-friendly summary */} +
+ +
+ {breakdown.map((q) => ( +
+ + {q.label} + + {loading ? "—" : } + +
+ ))} +
+
+
+ + {/* Live panel */} +
); diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index f1715c00..7b7628cd 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -1,126 +1,432 @@ import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { Loading } from "@/components/loader"; import { useCampaign } from "@/hooks/context/campaign"; import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; -import CampaignAppearance from "@/components/app/campaigns/preferences/CampaignAppearance"; +import { + DeliverabilitySection, + GeneralSection, + SendingAccountsSection, +} from "@/components/app/campaigns/preferences/CampaignAppearance"; +import { + CcBccSection, + EspMatchingSection, + LeadFlowSection, + RotationRampSection, +} from "@/components/app/campaigns/preferences/CampaignEmails"; +import CampaignContactOrder from "@/components/app/campaigns/preferences/CampaignContactOrder"; +import CampaignFolderField from "@/components/app/campaigns/CampaignFolderField"; import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign"; import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import CampaignEmails from "@/components/app/campaigns/preferences/CampaignEmails"; -import CampaignContactOrder from "@/components/app/campaigns/preferences/CampaignContactOrder"; +import useCampaignSenders from "@/lib/api/hooks/app/campaigns/useCampaignSenders"; +import useReplaceCampaignSenders from "@/lib/api/hooks/app/campaigns/useReplaceCampaignSenders"; + +const DAILY_MIN = 3; +const DAILY_MAX = 100; + +// One scrolling page — every section stacks in order and the left nav is a +// scrollspy over these ids. +const SECTIONS = [ + { id: "general", label: "General", description: "Name and description for this campaign." }, + { id: "folders", label: "Folders", description: "Organize this campaign into folders." }, + { + id: "senders", + label: "Sending accounts", + description: "Which mailboxes send this campaign — by tag, individually, or both — and the per-mailbox daily cap.", + }, + { + id: "deliverability", + label: "Deliverability", + description: "Reply handling, open/link tracking, and the unsubscribe header.", + }, + { + id: "rotation", + label: "Rotation & ramp-up", + description: "How volume is distributed across mailboxes and ramped over time.", + }, + { + id: "matching", + label: "ESP matching", + description: "Align the sending mailbox provider with each recipient's provider.", + }, + { + id: "leadflow", + label: "Lead flow", + description: "New-lead throttle, prioritization, and risky-address policy.", + }, + { + id: "ccbcc", + label: "CC & BCC", + description: "Copy extra addresses on every email sent by this campaign.", + }, + { id: "order", label: "Contact order", description: "The order contacts are sent in." }, +] as const; + +type SectionId = (typeof SECTIONS)[number]["id"]; + +// Walk up from an element to the nearest scrollable ancestor (the app shell +// scrolls an inner overflow-auto container, not the window). +function findScrollParent(el: HTMLElement | null): HTMLElement | Window { + let node = el?.parentElement ?? null; + while (node) { + const oy = getComputedStyle(node).overflowY; + if ((oy === "auto" || oy === "scroll") && node.scrollHeight > node.clientHeight) return node; + node = node.parentElement; + } + return window; +} + +// useScrollSpy — the active section is the LAST one whose top has scrolled above +// a reading line near the top of the viewport. Because the sections are ordered, +// this also correctly highlights the final section once you reach the bottom +// (where a thin-band observer would never fire). A short click-lock keeps a nav +// click's smooth scroll from flickering the highlight mid-animation. +function useScrollSpy(ids: readonly string[]) { + const [activeId, setActiveId] = React.useState(ids[0]); + const lockUntilRef = React.useRef(0); + + React.useEffect(() => { + const READING_LINE = 140; // px from the top of the viewport + const first = document.getElementById(ids[0]); + const target = findScrollParent(first); + + const compute = () => { + if (Date.now() < lockUntilRef.current) return; + let current = ids[0]; + for (const id of ids) { + const el = document.getElementById(id); + if (!el) continue; + if (el.getBoundingClientRect().top - READING_LINE <= 0) current = id; + else break; + } + setActiveId(current); + }; + + let raf = 0; + const onScroll = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(compute); + }; + compute(); + target.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + return () => { + cancelAnimationFrame(raf); + target.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + }; + }, [ids]); + + const scrollTo = React.useCallback((id: string) => { + setActiveId(id); + lockUntilRef.current = Date.now() + 700; + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + return { activeId, scrollTo }; +} + +// Order-independent set equality for id arrays. Toggling a folder/tag on then +// off produces a new array reference with the same contents — a plain `!==` +// would flag that as a phantom unsaved change, so compare by membership. +function sameIdSet(a: string[] = [], b: string[] = []): boolean { + if (a.length !== b.length) return false; + const set = new Set(a); + return b.every((id) => set.has(id)); +} export default function CampaignPreferences() { - const campaign = useCampaign() + const campaign = useCampaign(); if (!campaign) { - throw new Error("CampaignPreferences cannot be rendered without a campaign") + throw new Error("CampaignPreferences cannot be rendered without a campaign"); } const updateCampaign = useUpdateCampaign(campaign.id); + const replaceSenders = useReplaceCampaignSenders(campaign.id); - const [loading, setLoading] = React.useState(false); - const [activeTab, setActiveTab] = React.useState("tab1"); + // The explicit sender pool is managed by the senders endpoint (not PATCH). + // Always loaded: the picker mixes tags + specific mailboxes in one control, + // so we need the current explicit pool regardless of how senders were chosen. + const { data: senders } = useCampaignSenders(campaign.id, true); + + const [loading, setLoading] = React.useState(false); const [newData, setNewData] = React.useState(campaign); + // Selected mailbox ids for individually-picked senders + the saved baseline + // we diff against. Seeded from the campaign's current senders. + const [explicitAccounts, setExplicitAccounts] = React.useState([]); + const [savedAccounts, setSavedAccounts] = React.useState([]); + + const ids = React.useMemo(() => SECTIONS.map((s) => s.id), []); + const { activeId, scrollTo } = useScrollSpy(ids); + React.useEffect(() => { if (!campaign) return; setNewData(campaign); - }, [campaign]) + }, [campaign]); - const tabData = { - ...(campaign && { - tab1: { - title: "Appearance", - content: , - }, - tab2: { - title: "Campaign Emails", - content: , - }, - tab3: { - title: "Contact Order", - content: , - }, - }), - }; + React.useEffect(() => { + if (!senders) return; + const accountIds = senders.map((s) => s.email_account_id); + setExplicitAccounts(accountIds); + setSavedAccounts(accountIds); + }, [senders]); - const getChanges = () => { + const getChanges = (): Partial => { if (!newData) return {}; return { ...(newData.name !== campaign.name && { name: newData.name }), ...(newData.description !== campaign.description && { description: newData.description }), + ...(!sameIdSet(newData.folders ?? [], campaign.folders ?? []) && { + folders: newData.folders ?? [], + }), + // Sending + deliverability + ...(!sameIdSet(newData.email_tags ?? [], campaign.email_tags ?? []) && { + email_tags: newData.email_tags, + }), + ...(newData.daily_limit !== campaign.daily_limit && { daily_limit: newData.daily_limit }), + ...(newData.stop_on_reply !== campaign.stop_on_reply && { stop_on_reply: newData.stop_on_reply }), ...(newData.text_only !== campaign.text_only && { text_only: newData.text_only }), ...(newData.open_tracking !== campaign.open_tracking && { open_tracking: newData.open_tracking }), ...(newData.link_tracking !== campaign.link_tracking && { link_tracking: newData.link_tracking }), + ...(newData.unsubscribe_header !== campaign.unsubscribe_header && { + unsubscribe_header: newData.unsubscribe_header, + }), - ...(newData.email_tags !== campaign.email_tags && { email_tags: newData.email_tags }), - ...(newData.daily_limit !== campaign.daily_limit && { daily_limit: newData.daily_limit }), + // Rotation + ...(newData.rotation_mode !== campaign.rotation_mode && { rotation_mode: newData.rotation_mode }), - ...(newData.unsubscribe_header !== campaign.unsubscribe_header && { unsubscribe_header: newData.unsubscribe_header }), + // Ramp-up + ...(newData.ramp_enabled !== campaign.ramp_enabled && { ramp_enabled: newData.ramp_enabled }), + ...(newData.ramp_start !== campaign.ramp_start && { ramp_start: newData.ramp_start }), + ...(newData.ramp_increment !== campaign.ramp_increment && { ramp_increment: newData.ramp_increment }), + ...(newData.ramp_ceiling !== campaign.ramp_ceiling && { ramp_ceiling: newData.ramp_ceiling }), + + // ESP matching + new-lead throttle + ...(newData.esp_match_mode !== campaign.esp_match_mode && { esp_match_mode: newData.esp_match_mode }), + ...(newData.max_new_leads_per_day !== campaign.max_new_leads_per_day && { + max_new_leads_per_day: newData.max_new_leads_per_day, + }), + ...(newData.prioritize_new_leads !== campaign.prioritize_new_leads && { + prioritize_new_leads: newData.prioritize_new_leads, + }), ...(newData.risky_emails !== campaign.risky_emails && { risky_emails: newData.risky_emails }), + // cc/bcc ...(newData.cc !== campaign.cc && { cc: newData.cc }), - ...(newData.bcc !== campaign.bcc && { cc: newData.bcc }), + ...(newData.bcc !== campaign.bcc && { bcc: newData.bcc }), - ...(newData.contact_order_by !== campaign.contact_order_by && { contact_order_by: newData.contact_order_by }), - ...(newData.contact_order_dir !== campaign.contact_order_dir && { contact_order_dir: newData.contact_order_dir }), - ...(newData.contact_order_field !== campaign.contact_order_field && { contact_order_field: newData.contact_order_field }), + // Contact order + ...(newData.contact_order_by !== campaign.contact_order_by && { + contact_order_by: newData.contact_order_by, + }), + ...(newData.contact_order_dir !== campaign.contact_order_dir && { + contact_order_dir: newData.contact_order_dir, + }), + ...(newData.contact_order_field !== campaign.contact_order_field && { + contact_order_field: newData.contact_order_field, + }), + }; + }; + + // Whether the explicit sender pool changed (mailboxes picked individually). + // Tags are diffed separately via getChanges → email_tags. + const accountsDirty = React.useMemo(() => { + if (explicitAccounts.length !== savedAccounts.length) return true; + const a = new Set(savedAccounts); + return explicitAccounts.some((id) => !a.has(id)); + }, [explicitAccounts, savedAccounts]); + + const validationError = (): string | null => { + if (newData.daily_limit < DAILY_MIN || newData.daily_limit > DAILY_MAX) { + return `Daily limit must be between ${DAILY_MIN} and ${DAILY_MAX}.`; } - } + if (newData.ramp_enabled && newData.ramp_start > newData.ramp_ceiling) { + return "Ramp start must be less than or equal to the ramp ceiling."; + } + // Sending accounts: nothing selected is valid — it means "all active + // mailboxes", so there is no minimum-selection requirement anymore. + return null; + }; async function submit() { if (loading) return; + const err = validationError(); + if (err) { + toast.error(err); + return; + } try { - setLoading(true) + setLoading(true); const data = getChanges(); - toast.promise( - updateCampaign.mutateAsync(data), + // Persist the explicit sender pool through its own endpoint (it is + // not part of the campaign PATCH body). Map each picked mailbox to a + // CampaignSender with weight 1 so volume splits evenly. An empty list + // clears the explicit pool (falling back to tags / all mailboxes). + const writeSenders = accountsDirty; + await toast.promise( + (async () => { + if (Object.keys(data).length > 0) { + await updateCampaign.mutateAsync(data); + } + if (writeSenders) { + await replaceSenders.mutateAsync( + explicitAccounts.map((id) => ({ email_account_id: id, weight: 1 })), + ); + setSavedAccounts(explicitAccounts); + } + })(), { - loading: "Saving...", + loading: "Saving…", success: "Campaign successfully updated.", - error: (err: AppError) => buildError(err), - } - ) + error: (e: AppError) => buildError(e), + }, + ); } finally { setLoading(false); } } - const hasChanges = Object.keys(getChanges()).length > 0; + const renderSection = (id: SectionId): React.ReactNode => { + switch (id) { + case "general": + return ; + case "folders": + return ( + + setNewData({ + ...newData, + folders: (newData.folders ?? []).includes(id) + ? (newData.folders ?? []).filter((x) => x !== id) + : [...(newData.folders ?? []), id], + }) + } + /> + ); + case "senders": + return ( + + ); + case "deliverability": + return ; + case "rotation": + return ; + case "matching": + return ( + + ); + case "leadflow": + return ; + case "ccbcc": + return ; + case "order": + return ; + } + }; + + const hasChanges = Object.keys(getChanges()).length > 0 || accountsDirty; + const blocked = validationError() !== null; return ( -
-
-
- {Object.keys(tabData).map((key) => ( - +
+ {/* Scrollspy nav */} + + + {/* Stacked sections */} +
+
+ {SECTIONS.map(({ id, label, description }) => ( +
+
+

{label}

+ {description && ( +

{description}

+ )} +
+ {renderSection(id)} +
))}
-
- {tabData[activeTab as keyof typeof tabData]?.content} -
-
-
- - + + {/* Floating save bar — only present when there are unsaved changes. */} + + {hasChanges && ( + + {blocked && ( + {validationError()} + )} + + + + )} +
- ) + ); } diff --git a/web/src/app/app/campaigns/[id]/schedule/page.tsx b/web/src/app/app/campaigns/[id]/schedule/page.tsx index b9df4a1b..3d342fbd 100644 --- a/web/src/app/app/campaigns/[id]/schedule/page.tsx +++ b/web/src/app/app/campaigns/[id]/schedule/page.tsx @@ -1,207 +1,288 @@ import React from "react"; +import { ArrowRightIcon, CalendarClockIcon, CalendarRangeIcon, GlobeIcon } from "lucide-react"; +import { differenceInCalendarDays, format } from "date-fns"; import DateSelect from "@/components/app/campaigns/schedule/ScheduleDateSelect"; -import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask"; +import WeekScheduleGrid, { type Interval } from "@/components/app/campaigns/schedule/WeekScheduleGrid"; import { Loading } from "@/components/loader"; -import { RiHistoryLine } from "@remixicon/react"; -import Selector from "@/components/app/popup/select/Selector"; -import SelectMenu from "@/components/app/popup/select/SelectMenu"; -import { twColors } from "tailwindv4-colors"; -import SelectOption from "@/components/app/popup/select/SelectOption"; -import TimeSelector from "@/components/app/popup/select/TimeSelector"; -import SubTitle from "@/components/app/text/SubTitle"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; import { useCampaign } from "@/hooks/context/campaign"; import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import type { ScheduleInterval } from "@/lib/api/models/app/campaigns/Campaign"; import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign"; import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import { useUserProfile } from "@/hooks/context/user"; +// ── Wire ↔ display conversion ──────────────────────────────────────────── +// Wire (schedule_windows) is a 7-array indexed by weekday 0=Sun..6=Sat (the +// backend's time.Weekday). The grid is Monday-first (column 0 = Mon), so +// display index i maps to wire weekday (i+1)%7. +const parseHHMM = (t: string): number => { + const [h, m] = (t || "0:0").split(":").map(Number); + return (h || 0) * 60 + (m || 0); +}; + +function wireToDisplay(wire: ScheduleInterval[][] | null | undefined): Interval[][] { + return Array.from({ length: 7 }, (_, i) => { + const day = wire?.[(i + 1) % 7] ?? []; + return day.map((iv) => ({ start: iv.start, end: iv.end })); + }); +} + +function displayToWire(display: Interval[][]): ScheduleInterval[][] { + const wire: ScheduleInterval[][] = Array.from({ length: 7 }, () => []); + for (let i = 0; i < 7; i++) wire[(i + 1) % 7] = display[i].map((iv) => ({ start: iv.start, end: iv.end })); + return wire; +} + +const hasAnyWindow = (w: ScheduleInterval[][] | null | undefined): boolean => + !!w && w.some((d) => (d?.length ?? 0) > 0); + +// Seed the editor: prefer schedule_windows; otherwise derive one window per +// active legacy day (days bitmask is Monday-indexed, matching the grid). +function seedWindows(c: Campaign): Interval[][] { + if (hasAnyWindow(c.schedule_windows)) return wireToDisplay(c.schedule_windows); + const start = parseHHMM(c.start_time); + const end = parseHHMM(c.end_time); + return Array.from({ length: 7 }, (_, i) => { + const active = (c.days & (1 << i)) !== 0; + return active && end > start ? [{ start, end }] : []; + }); +} + +const PRESETS: { label: string; build: () => Interval[][] }[] = [ + { + label: "Mon–Fri 9–5", + build: () => Array.from({ length: 7 }, (_, i) => (i < 5 ? [{ start: 540, end: 1020 }] : [])), + }, + { + label: "Every day 9–5", + build: () => Array.from({ length: 7 }, () => [{ start: 540, end: 1020 }]), + }, + { label: "Clear", build: () => Array.from({ length: 7 }, () => []) }, +]; + export default function CampaignSchedule() { const campaign = useCampaign(); if (!campaign) { - throw new Error("CampaignSchedule cannot be rendered without a campaign") + throw new Error("CampaignSchedule cannot be rendered without a campaign"); } + const u = useUserProfile(); const updateCampaign = useUpdateCampaign(campaign.id); - const [loading, setLoading] = React.useState(false); - const [newData, setNewData] = React.useState(campaign) + const [loading, setLoading] = React.useState(false); + const [newData, setNewData] = React.useState(campaign); // timezone + dates draft + const [windows, setWindows] = React.useState(() => seedWindows(campaign)); + + const baseline = React.useMemo(() => seedWindows(campaign), [campaign]); React.useEffect(() => { - if (!campaign) return; - setNewData(campaign) - }, [campaign]) + setNewData(campaign); + setWindows(seedWindows(campaign)); + }, [campaign]); + const windowsChanged = React.useMemo( + () => JSON.stringify(windows) !== JSON.stringify(baseline), + [windows, baseline], + ); - const getChanges = () => { - if (!newData || !campaign) return {}; - return { - ...(newData.start_date !== campaign.start_date && { start_date: newData.start_date }), - ...(newData.end_date !== campaign.end_date && { end_date: newData.end_date }), - ...(newData.timezone !== campaign.timezone && { timezone: newData.timezone }), - ...(newData.days !== campaign.days && { days: newData.days }), - ...(newData.start_time !== campaign.start_time && { start_time: newData.start_time }), - ...(newData.end_time !== campaign.end_time && { end_time: newData.end_time }) - } - } + const fieldChanges = (): Partial => ({ + ...(newData.start_date !== campaign.start_date && { start_date: newData.start_date }), + ...(newData.end_date !== campaign.end_date && { end_date: newData.end_date }), + ...(newData.timezone !== campaign.timezone && { timezone: newData.timezone }), + }); + + const hasChanges = windowsChanged || Object.keys(fieldChanges()).length > 0; + + const totalWindows = windows.reduce((s, d) => s + d.length, 0); + const activeDays = windows.filter((d) => d.length > 0).length; async function submit() { - if (loading || !campaign) return; + if (loading) return; + if (totalWindows === 0) { + toast.error("Add at least one sending window."); + return; + } + const patch: Partial = { + ...fieldChanges(), + ...(windowsChanged && { schedule_windows: displayToWire(windows) }), + }; try { setLoading(true); - const data = getChanges(); - await toast.promise( - updateCampaign.mutateAsync(data), - { - loading: "Saving...", - success: "Campaign successfully updated.", - error: (err: AppError) => buildError(err), - } - ) + await toast.promise(updateCampaign.mutateAsync(patch), { + loading: "Saving…", + success: "Schedule updated.", + error: (err: AppError) => buildError(err), + }); } finally { setLoading(false); } } - const u = useUserProfile(); - const tzRef = React.useRef(null); - const [tzDrop, setTzDrop] = React.useState(false); + const tzLabel = u.timezones.find((tz) => tz.name === newData.timezone)?.display_name ?? newData.timezone; - React.useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (tzRef.current && !tzRef.current.contains(event.target as Node)) { - setTzDrop(false); - } - }; - if (tzDrop) { - document.addEventListener('mousedown', handleClickOutside); - } + const startDate = newData.start_date instanceof Date ? newData.start_date : null; + const endDate = newData.end_date instanceof Date ? newData.end_date : null; + const durationLabel = + startDate && endDate ? `${differenceInCalendarDays(endDate, startDate)} days` : "Open-ended"; + const datesHint = startDate + ? endDate + ? `${format(startDate, "MMM d")} – ${format(endDate, "MMM d, yyyy")}` + : `from ${format(startDate, "MMM d, yyyy")} onward` + : endDate + ? `until ${format(endDate, "MMM d, yyyy")}` + : "Runs continuously while active"; - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [tzDrop]); - - if (!campaign || !newData) return null; - - const hasChanges = Object.keys(getChanges() || {}).length > 0; - - return (<> -
-
- setNewData(bef => bef ? ({ - ...bef, - startDate: v, - }) : null)} - /> - setNewData(bef => bef ? ({ - ...bef, - endDate: v, - }) : null)} - /> -
-
-
- Timezone -
- {((() => { - const v = u.timezones.find((tz) => tz.name === newData.timezone) - if (v) { - return v.display_name - } else { - return newData.timezone - } - })())} - - {u.timezones ? (
+ return ( +
+ {/* Hero scheduler card */} +
+
+
+ + + +
+
+ Weekly sending windows +
+ {totalWindows === 0 ? ( +

+ No windows yet — drag on a day to add one. +

+ ) : ( +

+ {totalWindows} window + {totalWindows === 1 ? "" : "s"} across{" "} + {activeDays} day + {activeDays === 1 ? "" : "s"} +

+ )} +
+
+
+ + + } + label={tzLabel} + className="w-full justify-between sm:w-auto sm:max-w-[230px]" + /> + + {u.timezones.map((tz) => ( - { - setNewData(bef => bef ? ({ - ...bef, - timezone: tz.name, - }) : null) - }} selected={tz.name === newData.timezone} + onSelect={() => setNewData((bef) => ({ ...bef, timezone: tz.name }))} > - - {tz.display_name} - + {tz.display_name} + ))} -
) : ( -
- -
- )} - + +
-
-
- Active Days -
- setNewData(bef => bef ? ({ - ...bef, - days: v, - }) : null)} - /> -
-
-
-
- Start Time - setNewData(bef => bef ? ({ - ...bef, - start_time: v, - }) : null)} - /> -
-
- End Time - setNewData(bef => bef ? ({ - ...bef, - end_time: v, - }) : null)} - /> -
-
+ + {/* presets */} +
+ + Presets + + {PRESETS.map((p) => ( + + ))} + + Drag to add · drag a block to move · edges to resize · ⧉ copies a day to all +
+ + {/* the visual week grid (scrolls horizontally on narrow screens + so the 7 day columns stay tappable) */} +
+
+ +
+

+ Each day is independent — set different windows per day, or several windows in one day. Sends + are scheduled in {tzLabel}; worker IPs spread distribution naturally. +

+
+ + + {/* Run dates */} +
+
+ + + Campaign dates + + + {datesHint} + + {durationLabel} + + +
+
+
+ setNewData((b) => ({ ...b, start_date: v }))} + /> +
+ +
+ setNewData((b) => ({ ...b, end_date: v }))} + /> +
+

+ Optional bounds for when the campaign may send. Leave both blank to run open-ended. +

+
+
+ + {/* Save / Reset */} +
+ +
-
- - -
- ); + ); } diff --git a/web/src/app/app/campaigns/[id]/sequences/page.tsx b/web/src/app/app/campaigns/[id]/sequences/page.tsx index 4299fac8..0199f635 100644 --- a/web/src/app/app/campaigns/[id]/sequences/page.tsx +++ b/web/src/app/app/campaigns/[id]/sequences/page.tsx @@ -1,147 +1,77 @@ import React from "react"; +import { LayersIcon, Loader2Icon, PlusIcon } from "lucide-react"; +import toast from "react-hot-toast"; import { useCampaign } from "@/hooks/context/campaign"; -import { Loading } from "@/components/loader"; -import SequenceBox from "@/components/app/campaigns/sequences/SequenceBox"; -import SequenceView from "@/components/app/campaigns/sequences/SequenceView"; +import CampaignFlow from "@/components/app/campaigns/sequences/CampaignFlow"; import useSequences from "@/lib/api/hooks/app/campaigns/sequences/useSequences"; import useCreateSequence from "@/lib/api/hooks/app/campaigns/sequences/useCreateSequence"; -import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; -import { FileTextIcon, PlusIcon } from "lucide-react"; export default function CampaignSequences() { const campaign = useCampaign(); if (!campaign) { - throw new Error("CampaignSequences cannot be rendered without a campaign") + throw new Error("CampaignSequences cannot be rendered without a campaign"); } - const [load, setLoad] = React.useState(false); - const [select, setSelect] = React.useState(""); - const [newSequences, setNewSequences] = React.useState() - const sequencesData = useSequences(campaign.id); + return ( + }> + + + ); +} - const createSequence = useCreateSequence(campaign.id); +function SequencesBuilder({ campaignId }: { campaignId: string }) { + const { data: sequences } = useSequences(campaignId); + const createSequence = useCreateSequence(campaignId); + const [creating, setCreating] = React.useState(false); - async function CreateSequence() { - if (load) return; - setLoad(true); + async function create() { + if (creating) return; + setCreating(true); try { - const resp = await toast.promise( - createSequence.mutateAsync, - { - loading: "Creating sequence...", - success: "Sequence successfully created.", - error: (err: AppError) => buildError(err), - } - ) - setNewSequences(bef => bef ? [...bef, resp] : [resp]) + await toast.promise(createSequence.mutateAsync(), { + loading: "Adding step…", + success: "Step added.", + error: (err: AppError) => buildError(err), + }); } finally { - setLoad(false); + setCreating(false); } } - return !sequencesData.isLoading ? ( -
-
- {sequencesData.data.map((seq, i) => { - if (!campaign.sequences || !newSequences || campaign.sequences.length !== newSequences.length) return null; - return ( - setNewSequences(bef => bef ? bef.map((s) => s.id === seq.id ? { - ...s, - wait_after: v, - } : s) : null)} - onClick={() => setSelect(seq.id)} - >{seq.name} - ) - })} - {sequencesData.data.length === 0 && ( - { }} - onClick={() => { }} - >New Sequence - )} - {sequencesData.data.length < 5 && ( - - )} + if (sequences.length === 0) { + return ( +
+
+ +
+

Build your flow

+

+ Add your first step, then drag from a step to branch on opens, clicks, or + replies. The first email sends immediately; later steps wait and thread as + follow-ups. +

+
-
- {(() => { - const seq = sequencesData.data.find((v) => v.id === select) - const seq2 = newSequences?.find((v) => v.id === select) - if (!seq || !seq2 || !campaign.sequences) { - return ( -
-
- -
-

Create your first sequence

-

- Add email sequences to automate your outreach flow. -

- -
- ) - } - return setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - name: v, - } : s) : null)} - setSubject={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - subject: v, - } : s) : null)} - setBodyPlain={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_plain: v, - } : s) : null)} - setBodyHTML={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_html: v, - } : s) : null)} - setBodySync={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_sync: v, - } : s) : null)} - setBodyCode={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_code: v, - } : s) : null)} - onUpdate={(s) => setNewSequences(bef => bef ? bef.map((seq) => seq.id === s.id ? s : seq) : null)} - /> - })()} -
-
- ) : ( -
- {[...Array(3)].map((_, i) => ( -
- ))} -
- ) + ); + } + + return ; +} + +function SequencesSkeleton() { + return
; } diff --git a/web/src/app/app/campaigns/page.tsx b/web/src/app/app/campaigns/page.tsx index df404c66..b4c35df5 100644 --- a/web/src/app/app/campaigns/page.tsx +++ b/web/src/app/app/campaigns/page.tsx @@ -2,19 +2,28 @@ import { useUserProfile } from "@/hooks/context/user"; import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; import useStartCampaign from "@/lib/api/hooks/app/campaigns/useStartCampaign"; import useStopCampaign from "@/lib/api/hooks/app/campaigns/useStopCampaign"; +import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign"; import { useConfirm } from "@/hooks/context/confirm"; import { NewCampaignDialog } from "@/components/app/campaigns/NewCampaignDialog"; +import LaunchCampaignDialog from "@/components/app/campaigns/LaunchCampaignDialog"; import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import type Folder from "@/lib/api/models/app/Folder"; +import { cn, hexToRgba } from "@/lib/utils"; import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { AlertTriangleIcon, CalendarIcon, + CheckIcon, + CheckCircle2Icon, + FileTextIcon, FilterIcon, FolderIcon, Loader2Icon, + type LucideIcon, PauseIcon, PlayIcon, PlusIcon, @@ -42,9 +51,204 @@ import { SelectButton, } from "@/components/ui/popover-menu"; -type StatusFilter = "all" | "active" | "paused" | "draft"; +type StatusFilter = "all" | "active" | "paused" | "draft" | "completed"; type SortMode = "newest" | "oldest" | "name"; +// Collapse the backend's raw campaign statuses into the buckets the list +// filters/counts by. paused_no_accounts / paused_trial_expired are auto-pause +// variants, so they belong with "paused"; anything unknown reads as draft. +function statusBucket(s?: string): "active" | "paused" | "completed" | "draft" { + if (s === "active") return "active"; + if (s === "completed") return "completed"; + if (s && s.startsWith("paused")) return "paused"; + return "draft"; +} + +// Per-state label + leading mark for a campaign row. "active" renders the +// animated dot-grid loader; every other state is a 14px lucide icon so the +// fixed-width leading slot keeps each row's name aligned. +const STATUS_LABEL: Record = { + active: "running", + paused: "paused", + paused_no_accounts: "no accounts", + paused_trial_expired: "trial expired", + completed: "finished", + draft: "draft", +}; + +// Single source of truth for a status's color — drives BOTH the leading mark +// and the right-side text label so they always agree. emerald = live/done, +// amber = paused/needs-attention, slate = not started. +const STATUS_TONE: Record = { + active: "text-emerald-600", + completed: "text-emerald-600", + paused: "text-amber-600", + paused_no_accounts: "text-amber-600", + paused_trial_expired: "text-amber-600", + draft: "text-slate-500", +}; + +function statusTone(status: string): string { + return STATUS_TONE[status] ?? STATUS_TONE.draft; +} + +function CampaignStatusMark({ status }: { status: string }) { + const tone = statusTone(status); + if (status === "active") { + return ; + } + let Icon: LucideIcon = FileTextIcon; + let title = "Draft — not started"; + if (status === "completed") { + Icon = CheckCircle2Icon; + title = "Finished"; + } else if (status === "paused") { + Icon = PauseIcon; + title = "Paused"; + } else if (status === "paused_no_accounts" || status === "paused_trial_expired") { + Icon = AlertTriangleIcon; + title = status === "paused_no_accounts" ? "Paused — no sending accounts" : "Paused — trial expired"; + } + return ; +} + +// Read-only chips showing which folders a campaign belongs to — resolves the +// campaign's folder ids against the user's folders, shows up to 2 then "+N". +// Each chip is tinted with the folder's own color for a quick visual read. +function CampaignFolderChips({ campaign, folders }: { campaign: Campaign; folders: Folder[] }) { + const mine = (campaign.folders ?? []) + .map((id) => folders.find((f) => f.id === id)) + .filter((f): f is Folder => !!f); + if (mine.length === 0) return null; + const shown = mine.slice(0, 2); + const extra = mine.length - shown.length; + return ( + + {shown.map((f) => ( + + + {f.title} + + ))} + {extra > 0 && ( + f.title).join(", ")} + > + +{extra} + + )} + + ); +} + +// Per-row "move to folder" control: a folder button (revealed on hover, or +// kept visible + sky when the campaign is already filed) that opens a popover +// of the user's folders. Toggling an item PATCHes the campaign's `folders` +// array; the menu stays open so several folders can be toggled at once. +function CampaignFolderMenu({ campaign, folders }: { campaign: Campaign; folders: Folder[] }) { + const p = useUserProfile(); + const update = useUpdateCampaign(campaign.id); + const current = campaign.folders ?? []; + const inCount = current.length; + + function setFolders(next: string[]) { + update.mutate( + { folders: next }, + { onError: (e) => toast.error(buildError(e as unknown as AppError)) }, + ); + } + + return ( + + + + + + Folders + {folders.length === 0 ? ( + p.setFoldersEdit(true)} + icon={} + > + Create a folder + + ) : ( + folders.map((f) => { + const isIn = current.includes(f.id); + return ( + + setFolders( + isIn + ? current.filter((x) => x !== f.id) + : [...current, f.id], + ) + } + icon={ + + } + trailing={ + isIn ? ( + + ) : null + } + > + {f.title} + + ); + }) + )} + {inCount > 0 && ( + <> + + setFolders([])}> + Remove from all + + + )} + + p.setFoldersEdit(true)} + icon={} + > + Manage folders + + + + ); +} + export default function CampaignsPage() { const p = useUserProfile(); const confirm = useConfirm(); @@ -55,6 +259,7 @@ export default function CampaignsPage() { const [status, setStatus] = useState("all"); const [sort, setSort] = useState("newest"); const [newOpen, setNewOpen] = useState(false); + const [launchTarget, setLaunchTarget] = useState(null); async function toggleCampaign(id: string, currentStatus: string) { try { @@ -85,7 +290,7 @@ export default function CampaignsPage() { const filtered = useMemo(() => { const base = status === "all" ? campaigns - : campaigns.filter((c) => (c.status ?? "draft") === status); + : campaigns.filter((c) => statusBucket(c.status) === status); const sorted = [...base]; if (sort === "newest") { sorted.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); @@ -98,12 +303,9 @@ export default function CampaignsPage() { }, [campaigns, status, sort]); const counts = useMemo(() => { - const stats = { total: campaigns.length, active: 0, paused: 0, draft: 0 }; + const stats = { total: campaigns.length, active: 0, paused: 0, draft: 0, completed: 0 }; for (const c of campaigns) { - const s = c.status ?? "draft"; - if (s === "active") stats.active++; - else if (s === "paused") stats.paused++; - else stats.draft++; + stats[statusBucket(c.status)]++; } return stats; }, [campaigns]); @@ -135,7 +337,7 @@ export default function CampaignsPage() { - + setStatus("draft")} /> + setStatus("completed")} + /> setFolder(folder === f.id ? "" : f.id)} - icon={} + icon={} selected={folder === f.id} > {f.title} @@ -288,14 +496,7 @@ export default function CampaignsPage() {
{filtered.map((c) => { const cstatus = c.status ?? "draft"; - const dot = - cstatus === "active" - ? "bg-emerald-500" - : cstatus === "paused" - ? "bg-amber-500" - : "bg-slate-300"; - const stateLabel = - cstatus === "active" ? "running" : cstatus; + const stateLabel = STATUS_LABEL[cstatus] ?? cstatus; const StateIcon = cstatus === "active" ? PauseIcon : PlayIcon; return ( @@ -304,19 +505,24 @@ export default function CampaignsPage() { to={`/app/campaigns/${c.id}`} className="group h-11 px-5 flex items-center gap-3 hover:bg-slate-50 transition-colors" > - + {/* Fixed-width leading slot so every row's name aligns, + whatever state mark (loader or icon) sits in it. */} + + + {c.name} {c.id.slice(0, 8)} + {c.description && ( {c.description} )} - + {stateLabel} @@ -328,23 +534,26 @@ export default function CampaignsPage() { }) : "—"} + - - {open && ( - + + + + + {pipelines.map((p) => ( + onChange(p.id)} + selected={p.id === currentId} > - {pipelines.map((p) => ( - - ))} - - )} - -
+ {p.name} + + ))} + + ); } @@ -680,64 +666,47 @@ function StagePill({ onChange: (id: string) => void; }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const cur = stages.find((s) => s.id === value); return ( -
- - - {open && ( - + + + + + {stages.map((s) => ( + onChange(s.id)} + selected={s.id === value} + icon={ + + } > - {stages.map((s) => ( - - ))} - - )} - -
+ {s.name} + + ))} + + ); } diff --git a/web/src/app/app/crm/pipelines/page.tsx b/web/src/app/app/crm/pipelines/page.tsx index 310a02c9..4d1c507d 100644 --- a/web/src/app/app/crm/pipelines/page.tsx +++ b/web/src/app/app/crm/pipelines/page.tsx @@ -32,6 +32,12 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines"; import useCreatePipeline from "@/lib/api/hooks/app/crm/pipelines/useCreatePipeline"; import useDeletePipeline from "@/lib/api/hooks/app/crm/pipelines/useDeletePipeline"; @@ -44,7 +50,6 @@ import type Pipeline from "@/lib/api/models/app/crm/Pipeline"; import type { Stage } from "@/lib/api/models/app/crm/Pipeline"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import useClickOutside from "@/hooks/useClickOutside"; const STAGE_COLORS = [ { id: "slate", bg: "bg-slate-400", hex: "#94a3b8" }, @@ -166,8 +171,6 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) { const [name, setName] = React.useState(pipeline.name); const [menuOpen, setMenuOpen] = React.useState(false); const [addStageOpen, setAddStageOpen] = React.useState(false); - const menuRef = React.useRef(null); - useClickOutside(menuRef, () => setMenuOpen(false)); React.useEffect(() => setName(pipeline.name), [pipeline.name]); @@ -268,50 +271,32 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) { Stage -
- - - {menuOpen && ( - - - - - )} - -
+ + + + + + setRenaming(true)} + icon={} + > + Rename + + } + danger + > + Delete pipeline + + +
@@ -368,8 +353,6 @@ function StageCell({ stage, pipelineId: _pipelineId }: { stage: Stage; pipelineI const [editing, setEditing] = React.useState(false); const [name, setName] = React.useState(stage.name); const [colorOpen, setColorOpen] = React.useState(false); - const colorRef = React.useRef(null); - useClickOutside(colorRef, () => setColorOpen(false)); const color = colorForHex(stage.color); React.useEffect(() => setName(stage.name), [stage.name]); @@ -422,39 +405,32 @@ function StageCell({ stage, pipelineId: _pipelineId }: { stage: Stage; pipelineI return (
-
-
+ + +
+ + {editing ? ( @@ -782,44 +758,35 @@ function AddStageDialog({ function ColorDot({ hex, onChange }: { hex: string; onChange: (hex: string) => void }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const color = colorForHex(hex); return ( -
- - - {open && ( - - {STAGE_COLORS.map((c) => ( -
+ + + + + +
+ {STAGE_COLORS.map((c) => ( +
+
+
); } diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx index 26faa86b..2a7d8dec 100644 --- a/web/src/app/app/crm/tasks/page.tsx +++ b/web/src/app/app/crm/tasks/page.tsx @@ -33,12 +33,17 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import useCRMTasks from "@/lib/api/hooks/app/crm/tasks/useCRMTasks"; import useCreateCRMTask from "@/lib/api/hooks/app/crm/tasks/useCreateCRMTask"; import useUpdateCRMTask from "@/lib/api/hooks/app/crm/tasks/useUpdateCRMTask"; import useDeleteCRMTask from "@/lib/api/hooks/app/crm/tasks/useDeleteCRMTask"; import { useConfirm } from "@/hooks/context/confirm"; -import useClickOutside from "@/hooks/useClickOutside"; import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; import type { CRMTaskPriority, CRMTaskStatus } from "@/lib/api/models/app/crm/CRMTask"; import type { AppError } from "@/lib/api/client/normalizeError"; @@ -591,50 +596,33 @@ function PriorityPill({ onChange: (p: CRMTaskPriority) => void; }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const cur = PRIORITIES.find((p) => p.id === value)!; return ( -
- - - {open && ( - + + + + + {PRIORITIES.map((p) => ( + onChange(p.id)} + selected={p.id === value} + icon={} > - {PRIORITIES.map((p) => ( - - ))} - - )} - -
+ {p.label} + + ))} + + ); } diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 449a2cb9..bd815f7b 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -9,7 +9,9 @@ import useFeatureStatus from "@/lib/api/hooks/app/subscription/useFeatureStatus" import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle"; import removeEmail from "@/lib/api/client/app/emails/removeEmail"; import { useUserProfile } from "@/hooks/context/user"; +import { useConfirm } from "@/hooks/context/confirm"; import InboxDetails from "@/components/app/emails/InboxDetails"; +import BulkWarmupDialog from "@/components/app/emails/BulkWarmupDialog"; import type Tag from "@/lib/api/models/app/Tag"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; @@ -27,6 +29,7 @@ import { XIcon, } from "lucide-react"; import { SearchInput } from "@/components/ui/field"; +import AnimatedNumber from "@/components/ui/AnimatedNumber"; import { PopoverMenu, PopoverMenuContent, @@ -58,16 +61,17 @@ const DefaultFolder = { // can proactively toast the user (continuous health reporting). const HEALTH_RANK: Record = { healthy: 0, warning: 1, error: 2 }; -function healthTone(status?: AccountStatus): { dot: string; text: string; label: string } { +function healthTone(status?: AccountStatus): { dot: string; text: string; label: string; pulse: boolean } { const h = status?.health; - if (!h) return { dot: "bg-slate-300", text: "text-slate-500", label: "—" }; - if (h.status === "healthy") return { dot: "bg-emerald-500", text: "text-emerald-600", label: `Healthy ${h.score}` }; - if (h.status === "warning") return { dot: "bg-amber-500", text: "text-amber-600", label: `At risk ${h.score}` }; - return { dot: "bg-rose-500", text: "text-rose-600", label: `Issue ${h.score}` }; + if (!h) return { dot: "bg-slate-300", text: "text-slate-500", label: "—", pulse: false }; + if (h.status === "healthy") return { dot: "bg-emerald-500", text: "text-emerald-600", label: `Healthy ${h.score}`, pulse: false }; + if (h.status === "warning") return { dot: "bg-amber-500", text: "text-amber-600", label: `At risk ${h.score}`, pulse: true }; + return { dot: "bg-rose-500", text: "text-rose-600", label: `Issue ${h.score}`, pulse: true }; } export default function AddressesPage() { const p = useUserProfile(); + const confirm = useConfirm(); const [query, setQuery] = React.useState(""); const [tag, setTag] = React.useState(""); @@ -76,6 +80,7 @@ export default function AddressesPage() { const [view, setView] = React.useState(""); const [viewTab, setViewTab] = React.useState("overview"); const [removing, setRemoving] = React.useState(false); + const [bulkStart, setBulkStart] = React.useState(false); const queryClient = useQueryClient(); // Warmup is a paid/trial feature; gate the start controls when the org @@ -87,19 +92,25 @@ export default function AddressesPage() { // One query feeds live health for every row; the realtime layer already // invalidates ["analytics","accounts",…] on warmup/account events. const statuses = useAccountStatuses(); + // Coerce to an array defensively: a wrong-shape (non-array) response must + // never reach a `for…of`, which would throw "{} is not iterable". + const accountStatuses = useMemo( + () => (Array.isArray(statuses.data) ? statuses.data : []), + [statuses.data], + ); const statusById = useMemo(() => { const m = new Map(); - for (const s of statuses.data ?? []) m.set(s.id, s); + for (const s of accountStatuses) m.set(s.id, s); return m; - }, [statuses.data]); + }, [accountStatuses]); // Proactively notify the user when a mailbox's health drops. const prevHealth = useRef>(new Map()); useEffect(() => { - if (!statuses.data) return; + if (accountStatuses.length === 0) return; const prev = prevHealth.current; const next = new Map(); - for (const s of statuses.data) { + for (const s of accountStatuses) { const cur = s.health?.status ?? "healthy"; next.set(s.id, cur); const before = prev.get(s.id); @@ -109,20 +120,24 @@ export default function AddressesPage() { } } prevHealth.current = next; - }, [statuses.data]); + }, [accountStatuses]); - const removeSelected = async () => { + const removeSelected = () => { if (selected.length === 0 || removing) return; const n = selected.length; - if (!window.confirm(`Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`)) return; - setRemoving(true); - const results = await Promise.allSettled(selected.map((id) => removeEmail(id))); - const failed = results.filter((r) => r.status === "rejected").length; - await queryClient.invalidateQueries({ queryKey: ["emails"] }); - setSelected([]); - setRemoving(false); - if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`); - else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`); + confirm.show( + `Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`, + async () => { + setRemoving(true); + const results = await Promise.allSettled(selected.map((id) => removeEmail(id))); + const failed = results.filter((r) => r.status === "rejected").length; + await queryClient.invalidateQueries({ queryKey: ["emails"] }); + setSelected([]); + setRemoving(false); + if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`); + else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`); + }, + ); }; const bulkWarmup = async (action: "start" | "pause") => { @@ -185,10 +200,10 @@ export default function AddressesPage() { - - 0} /> - - + } sub="connected" /> + } sub="sending now" accent={stats.healthy > 0} /> + } sub="ramping up" /> + } sub="paused or failing" last /> @@ -319,7 +334,7 @@ export default function AddressesPage() { {canWarmup && ( - {warmupLabel} + + {active ? ( + + + + / + {ws?.target_volume ?? box.warmup_base} + + + ) : ( + warmupLabel + )} + @@ -505,8 +555,8 @@ function MailboxRow({ diff --git a/web/src/app/app/integrations/_components/ConnectDrawer.tsx b/web/src/app/app/integrations/_components/ConnectDrawer.tsx index 0bf395c4..25e93d70 100644 --- a/web/src/app/app/integrations/_components/ConnectDrawer.tsx +++ b/web/src/app/app/integrations/_components/ConnectDrawer.tsx @@ -1,6 +1,6 @@ // Multi-step connect drawer. The connect path depends on the provider's // auth method: -// - oauth (HubSpot, Slack, Google Sheets, Pipedrive, Salesforce): one-click +// - oauth (HubSpot, Slack, Pipedrive, Salesforce): one-click // "Connect with X" → provider popup → encrypted tokens stored server-side. // No credentials are ever pasted. Providers without server credentials // render as "coming soon". @@ -102,14 +102,6 @@ const FIELDS_BY_PROVIDER: Record = { helper: "Edit Channel → Integrations → Webhooks → New Webhook → Copy URL.", }, ], - google_sheets: [ - { - key: "sheet_id", - label: "Sheet ID", - placeholder: "1AbC…XyZ", - helper: "The long ID in the sheet URL between /d/ and /edit. Optional — set per automation later.", - }, - ], }; export default function ConnectDrawer({ diff --git a/web/src/app/app/integrations/_components/ConnectionDetail.tsx b/web/src/app/app/integrations/_components/ConnectionDetail.tsx index 0bacfb1e..5a7cf5df 100644 --- a/web/src/app/app/integrations/_components/ConnectionDetail.tsx +++ b/web/src/app/app/integrations/_components/ConnectionDetail.tsx @@ -27,6 +27,7 @@ import { import toast from "react-hot-toast"; import { Label, TextInput } from "@/components/ui/field"; +import { useConfirm } from "@/hooks/context/confirm"; import useConnectionDetail from "@/lib/api/hooks/app/integrations/useConnectionDetail"; import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration"; import { @@ -70,6 +71,7 @@ export default function ConnectionDetail({ const [adding, setAdding] = React.useState(false); const [busy, setBusy] = React.useState(false); + const confirm = useConfirm(); const conn = detail.data?.connection ?? connection; const events = detail.data?.events ?? []; @@ -94,15 +96,16 @@ export default function ConnectionDetail({ } } - async function handleDisconnect() { - if (!window.confirm(`Disconnect ${conn.label}? Automations using it will stop.`)) return; - try { - await disconnect.mutateAsync(conn.id); - toast.success("Disconnected"); - onClose(); - } catch { - toast.error("Disconnect failed"); - } + function handleDisconnect() { + confirm.show(`Disconnect ${conn.label}? Automations using it will stop.`, async () => { + try { + await disconnect.mutateAsync(conn.id); + toast.success("Disconnected"); + onClose(); + } catch { + toast.error("Disconnect failed"); + } + }); } async function addAutomation(eventType: string, config: Record) { @@ -284,7 +287,7 @@ function AutomationRow({ sub, onDelete }: { sub: IntegrationEventSubscription; o const intents = Array.isArray(cfg.intents) ? (cfg.intents as string[]) : []; const minConf = typeof cfg.min_confidence === "number" ? cfg.min_confidence : undefined; const dest = - (cfg.channel as string) || (cfg.sheet_id as string) || (cfg.url as string) || (cfg.webhook_url as string) || ""; + (cfg.channel as string) || (cfg.url as string) || (cfg.webhook_url as string) || ""; const tmpl = (cfg.message_template as string) || ""; const filters: string[] = []; @@ -345,8 +348,7 @@ function AddAutomation({ const [template, setTemplate] = React.useState(""); const needsChannel = provider === "slack"; - const needsSheet = provider === "google_sheets"; - const needsURL = provider !== "slack" && provider !== "google_sheets" && provider !== "discord" && + const needsURL = provider !== "slack" && provider !== "discord" && provider !== "hubspot" && provider !== "pipedrive"; const isReplyTrigger = eventType === REPLY_EVENT; const destRequired = needsChannel || needsURL; @@ -358,7 +360,6 @@ function AddAutomation({ function buildConfig(): Record { const cfg: Record = {}; if (needsChannel && dest.trim()) cfg.channel = dest.trim(); - if (needsSheet && dest.trim()) cfg.sheet_id = dest.trim(); if (needsURL && dest.trim()) cfg.url = dest.trim(); if (isReplyTrigger && intents.length) cfg.intents = intents; if (isReplyTrigger && minConf > 0) cfg.min_confidence = minConf; @@ -366,8 +367,8 @@ function AddAutomation({ return cfg; } - const destLabel = needsChannel ? "Channel" : needsSheet ? "Sheet ID (optional)" : "Destination URL"; - const destPlaceholder = needsChannel ? "#sales" : needsSheet ? "1AbC…XyZ" : "https://…"; + const destLabel = needsChannel ? "Channel" : "Destination URL"; + const destPlaceholder = needsChannel ? "#sales" : "https://…"; const canSubmit = !busy && !(destRequired && !dest.trim()); return ( @@ -426,14 +427,14 @@ function AddAutomation({
)} - {(needsChannel || needsSheet || needsURL) && ( + {(needsChannel || needsURL) && (
)} @@ -500,8 +501,6 @@ function actionForProvider(provider: string): string { return "hubspot.upsert_contact"; case "pipedrive": return "pipedrive.upsert_person"; - case "google_sheets": - return "google_sheets.append_row"; default: return "webhook.ping"; } diff --git a/web/src/app/app/integrations/_components/ProviderGlyph.tsx b/web/src/app/app/integrations/_components/ProviderGlyph.tsx index 5157ad6b..8e78b4b4 100644 --- a/web/src/app/app/integrations/_components/ProviderGlyph.tsx +++ b/web/src/app/app/integrations/_components/ProviderGlyph.tsx @@ -16,7 +16,6 @@ const BRAND: Record = { discord: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" }, calendly: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" }, cal_com: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-800" }, - google_sheets: { bg: "bg-emerald-50", ring: "ring-emerald-200", text: "text-emerald-600" }, }; export default function ProviderGlyph({ diff --git a/web/src/app/app/not-found.tsx b/web/src/app/app/not-found.tsx new file mode 100644 index 00000000..00af2263 --- /dev/null +++ b/web/src/app/app/not-found.tsx @@ -0,0 +1,144 @@ +import { Link, useNavigate, useLocation } from "react-router-dom"; +import { motion } from "framer-motion"; +import { + MailX, + ArrowLeft, + LayoutDashboard, + Search, + ArrowRight, + MailIcon, + MegaphoneIcon, + UsersIcon, + BarChart3Icon, +} from "lucide-react"; +import { useAppStore } from "@/stores"; +import { cn } from "@/lib/utils"; + +const dests = [ + { title: "Accounts", url: "/app/emails", icon: MailIcon, hint: "mailboxes & senders" }, + { title: "Campaigns", url: "/app/campaigns", icon: MegaphoneIcon, hint: "sequences & sends" }, + { title: "Contacts", url: "/app/contacts", icon: UsersIcon, hint: "people & lists" }, + { title: "Analytics", url: "/app/analytics", icon: BarChart3Icon, hint: "opens, clicks, replies" }, +]; + +export default function DashboardNotFound() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen); + + const goBack = () => { + if (window.history.state?.idx > 0) navigate(-1); + else navigate("/app"); + }; + + return ( +
+ + {/* Glyph: MailX in a slate ring + one sky stamp dot */} +
+
+ +
+ +
+ + {/* Eyebrow */} + + Undeliverable · 404 + + + {/* Headline */} +

+ This page bounced +

+ + {/* Subhead */} +

+ We couldn't find a page at that address. The link may be broken, or the page may have + moved. Nothing was lost — pick up where you left off below. +

+ + {/* Bounce-report chip */} +
+
+ + to + + {pathname} +
+
+ 404 + · + no_such_route +
+
+ + {/* Actions */} +
+ + + Dashboard + + +
+ + {/* Popular destinations */} +
+ + Popular destinations + +
+ {dests.map((d, i) => ( + + + + + + {d.title} + + {d.hint} + + + ))} +
+
+
+
+ ); +} diff --git a/web/src/app/app/settings/billing/page.tsx b/web/src/app/app/settings/billing/page.tsx index 56603a8b..da3cbefb 100644 --- a/web/src/app/app/settings/billing/page.tsx +++ b/web/src/app/app/settings/billing/page.tsx @@ -27,6 +27,7 @@ import useValidateDiscountCode from "@/lib/api/hooks/app/subscription/useValidat import useCreateCheckoutSession from "@/lib/api/hooks/app/subscription/useCreateCheckoutSession"; import useChangePlan from "@/lib/api/hooks/app/subscription/useChangePlan"; import usePlans from "@/lib/api/hooks/app/subscription/usePlans"; +import useUsageOverview from "@/lib/api/hooks/app/analytics/useUsageOverview"; import { useAppStore } from "@/stores"; import type { AppError } from "@/lib/api/client/normalizeError"; import type DiscountPreview from "@/lib/api/models/app/subscription/DiscountPreview"; @@ -47,6 +48,7 @@ export default function BillingSettingsPage() { const checkout = useCreateCheckoutSession(); const changePlan = useChangePlan(); const plansQuery = usePlans(); + const usage = useUsageOverview().data; const [codeInput, setCodeInput] = React.useState(""); const [applied, setApplied] = React.useState(null); const [billingInterval, setBillingInterval] = @@ -352,17 +354,17 @@ export default function BillingSettingsPage() { eyebrow="Usage" description="What this workspace is consuming this period." > - + - + doRemove(m.user_id, email)} aria-label="Remove member" - className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100" + className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors opacity-100 md:opacity-0 md:group-hover:opacity-100" > @@ -284,7 +287,7 @@ export default function MembersSettingsPage() { {access.isOwner && ( -
+
- - {open && ( - - {assignable.map((r) => { - const selected = r.id === value; - return ( - - ); - })} - - )} - -
+ + + + + + {assignable.map((r) => { + const selected = r.id === value; + return ( + + ); + })} + + ); } diff --git a/web/src/app/app/settings/profile/page.tsx b/web/src/app/app/settings/profile/page.tsx index c73900bd..83888260 100644 --- a/web/src/app/app/settings/profile/page.tsx +++ b/web/src/app/app/settings/profile/page.tsx @@ -1,8 +1,11 @@ import React from "react"; +import toast from "react-hot-toast"; import { useUserProfile } from "@/hooks/context/user"; import { TextInput } from "@/components/ui/field"; import { TopbarAction } from "@/components/layout/Page"; -import { comingSoon } from "@/lib/helper/comingSoon"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import useUpdateProfile from "@/lib/api/hooks/auth/useUpdateProfile"; import { AvatarUploader } from "@/components/app/avatar/AvatarUploader"; import { useDeleteUserAvatar, @@ -19,6 +22,24 @@ export default function ProfileSettingsPage() { const uploadAvatar = useUploadUserAvatar(); const removeAvatar = useDeleteUserAvatar(); + const updateProfile = useUpdateProfile(); + + async function save() { + if (!dirty || updateProfile.isPending) return; + if (!firstName.trim() || !lastName.trim()) { + toast.error("First and last name are required."); + return; + } + try { + await updateProfile.mutateAsync({ + first_name: firstName.trim(), + last_name: lastName.trim(), + }); + toast.success("Profile saved"); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } return ( Discard - comingSoon("Profile editing")}> - Save profile + + {updateProfile.isPending ? "Saving…" : "Save profile"} ) : null diff --git a/web/src/app/app/settings/workspace/page.tsx b/web/src/app/app/settings/workspace/page.tsx index a3eae98a..e17cccd3 100644 --- a/web/src/app/app/settings/workspace/page.tsx +++ b/web/src/app/app/settings/workspace/page.tsx @@ -1,8 +1,11 @@ import React from "react"; +import toast from "react-hot-toast"; import { useAppStore } from "@/stores"; import { TextInput } from "@/components/ui/field"; import { TopbarAction } from "@/components/layout/Page"; -import { comingSoon } from "@/lib/helper/comingSoon"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import useUpdateOrganization from "@/lib/api/hooks/app/organizations/useUpdateOrganization"; import { AvatarUploader } from "@/components/app/avatar/AvatarUploader"; import { useDeleteOrgAvatar, @@ -13,19 +16,28 @@ import { Row, Section, SectionShell, ToggleRow } from "../_components/SectionShe export default function WorkspaceSettingsPage() { const currentOrg = useAppStore((s) => s.currentOrganization); const [name, setName] = React.useState(currentOrg?.name ?? ""); - const [domain, setDomain] = React.useState(""); const uploadOrgAvatar = useUploadOrgAvatar(); const removeOrgAvatar = useDeleteOrgAvatar(); + const updateOrg = useUpdateOrganization(); // Avatar changes are committed immediately by the uploader, so // they don't count toward the dirty flag — only fields that need // a "Save workspace" action do. - const dirty = name !== (currentOrg?.name ?? "") || domain !== ""; + const dirty = name.trim() !== (currentOrg?.name ?? ""); function discard() { setName(currentOrg?.name ?? ""); - setDomain(""); + } + + async function save() { + if (!dirty || updateOrg.isPending) return; + try { + await updateOrg.mutateAsync({ name: name.trim() }); + toast.success("Workspace saved"); + } catch (err) { + toast.error(buildError(err as AppError)); + } } return ( @@ -38,8 +50,8 @@ export default function WorkspaceSettingsPage() { Discard - comingSoon("Workspace settings")}> - Save workspace + + {updateOrg.isPending ? "Saving…" : "Save workspace"} ) : null @@ -87,23 +99,15 @@ export default function WorkspaceSettingsPage() { eyebrow="Sending defaults" description="Used by new campaigns unless overridden." > - - - - undefined} - type="number" - className="w-full max-w-[120px]" + diff --git a/web/src/app/app/templates/page.tsx b/web/src/app/app/templates/page.tsx index 55e9e038..dd1ebc5c 100644 --- a/web/src/app/app/templates/page.tsx +++ b/web/src/app/app/templates/page.tsx @@ -31,13 +31,19 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuSeparator, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import useTemplates from "@/lib/api/hooks/app/templates/useTemplates"; import useCreateTemplate from "@/lib/api/hooks/app/templates/useCreateTemplate"; import useUpdateTemplate from "@/lib/api/hooks/app/templates/useUpdateTemplate"; import useDeleteTemplate from "@/lib/api/hooks/app/templates/useDeleteTemplate"; import useDuplicateTemplate from "@/lib/api/hooks/app/templates/useDuplicateTemplate"; import useReorderTemplates from "@/lib/api/hooks/app/templates/useReorderTemplates"; -import useClickOutside from "@/hooks/useClickOutside"; import { useConfirm } from "@/hooks/context/confirm"; import type Template from "@/lib/api/models/app/templates/Template"; import type { AppError } from "@/lib/api/client/normalizeError"; @@ -271,13 +277,10 @@ function TemplateRow({ const del = useDeleteTemplate(); const confirm = useConfirm(); const [menuOpen, setMenuOpen] = React.useState(false); - const menuRef = React.useRef(null); - useClickOutside(menuRef, () => setMenuOpen(false)); const preview = previewText(template); async function doDuplicate() { - setMenuOpen(false); try { await toast.promise(duplicate.mutateAsync(template.id), { loading: "Duplicating…", @@ -290,7 +293,6 @@ function TemplateRow({ } function doDelete() { - setMenuOpen(false); confirm?.show(`Delete template "${template.name}"? This can't be undone.`, async () => { try { await toast.promise(del.mutateAsync(template.id), { @@ -356,57 +358,42 @@ function TemplateRow({ )} -
- - - {menuOpen && ( - + + + - -
- - - )} - + + + + + } + > + Edit + + } + > + Duplicate + + + } + > + Delete + + +
); @@ -458,6 +445,7 @@ function TemplateEditor({ }) { const create = useCreateTemplate(); const update = useUpdateTemplate(); + const confirm = useConfirm(); const [name, setName] = React.useState(""); const [subject, setSubject] = React.useState(""); @@ -486,19 +474,22 @@ function TemplateEditor({ }, [state]); function applyPreset(p: TemplatePreset) { - // Only fill empty fields if the user has already started typing, - // so picking a preset by accident doesn't trash their work. + const apply = () => { + setName(p.name); + setSubject(p.subject); + setBodyPlain(p.body_plain); + setBodyHTML(""); + setShowHTML(false); + setActivePresetId(p.id); + }; + // Only warn if the user has already started typing, so picking a preset + // by accident doesn't trash their work. const dirty = name.trim() !== "" || subject.trim() !== "" || bodyPlain.trim() !== "" || bodyHTML.trim() !== ""; if (dirty && activePresetId !== p.id) { - const ok = window.confirm("Replace what you have so far with this template?"); - if (!ok) return; + confirm.show("Replace what you have so far with this template?", apply); + return; } - setName(p.name); - setSubject(p.subject); - setBodyPlain(p.body_plain); - setBodyHTML(""); - setShowHTML(false); - setActivePresetId(p.id); + apply(); } function clearPreset() { @@ -722,44 +713,25 @@ function TemplateEditor({ function VariableMenu({ onPick }: { onPick: (token: string) => void }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); return ( -
- - - {open && ( - - {VARIABLE_HINTS.map((v) => ( - - ))} - - )} - -
+ + + + + + {VARIABLE_HINTS.map((v) => ( + onPick(v)}> + {v} + + ))} + + ); } diff --git a/web/src/app/auth/hooks/useLoginConfirmForm.ts b/web/src/app/auth/hooks/useLoginConfirmForm.ts index 79fa4ff1..b3edb673 100644 --- a/web/src/app/auth/hooks/useLoginConfirmForm.ts +++ b/web/src/app/auth/hooks/useLoginConfirmForm.ts @@ -1,8 +1,10 @@ import type React from "react"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import useLoginConfirm from "@/lib/api/hooks/auth/useLoginConfirm"; import { saveTokens } from "@/lib/auth"; +import getUser from "@/lib/api/client/auth/getUser"; import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -10,6 +12,7 @@ import buildError from "@/lib/helper/buildError"; export function useLoginConfirmForm() { const params = useParams(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const loginConfirm = useLoginConfirm(); const mail = params["to"] ?? ""; @@ -26,6 +29,16 @@ export function useLoginConfirmForm() { { loading: "Loading...", success: "Successfully authorized.", error: (err: AppError) => buildError(err) } ); saveTokens(Object.fromEntries(Object.entries(r).map(([k, v]) => [k, String(v)]))); + // Drop any logged-out cache, then prime the profile with the NEW token + // BEFORE navigating into the gated shell. Navigating immediately raced + // UserProvider's mount (useUser is refetchOnMount:false) and left the + // loader spinning until a manual reload / window refocus. + queryClient.clear(); + try { + await queryClient.fetchQuery({ queryKey: ["auth", "me"], queryFn: getUser }); + } catch { + // UserProvider re-attempts and redirects to login on a real failure. + } navigate("/app/emails"); } finally { setPending(false); } }; diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index b6ded89f..0523e450 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -19,6 +19,7 @@ import useLoginConfirm from "@/lib/api/hooks/auth/useLoginConfirm"; import useRegister from "@/lib/api/hooks/auth/useRegister"; import useRegisterConfirm from "@/lib/api/hooks/auth/useRegisterConfirm"; import { saveTokens } from "@/lib/auth"; +import getUser from "@/lib/api/client/auth/getUser"; import { WEBSITE_URL } from "@/lib/information"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -198,13 +199,24 @@ export default function LoginPage() { const explicitPasskeyChallengeRef = useRef(null); const explicitPasskeyChallengePendingRef = useRef(false); - const completeSession = useCallback((token: Token) => { + const completeSession = useCallback(async (token: Token) => { saveTokens(token as unknown as Record); // Drop any cache from the logged-out state. `useUser` is // `refetchOnMount: false`, so a stale/errored ["auth","me"] entry would - // otherwise survive into the app shell and leave every gated page empty - // until a manual reload or window-focus refetch. Mirrors useLogout(). + // otherwise survive into the app shell. Mirrors useLogout(). queryClient.clear(); + // Prime identity with the NEW token BEFORE entering the gated shell. + // Navigating the instant tokens are saved raced UserProvider's mount: + // with no cached profile and refetchOnMount:false, the loader could spin + // until a manual reload / window refocus (the reported infinite load). + // Fetching /auth/me here resolves it deterministically — the network and + // token are known-good (login just succeeded) — and a genuine auth + // failure surfaces as a redirect rather than a hang. + try { + await queryClient.fetchQuery({ queryKey: ["auth", "me"], queryFn: getUser }); + } catch { + // UserProvider re-attempts and redirects to login on a real failure. + } navigate("/app/emails"); }, [navigate, queryClient]); @@ -218,7 +230,7 @@ export default function LoginPage() { try { const token = await passkeyLogin({ conditional: true }); toast.success("Welcome back!"); - completeSession(token); + await completeSession(token); } catch (e) { // Cancel / no-passkey is expected here; report only real failures. if (!(e instanceof PasskeyCancelled)) Sentry.captureException(e); @@ -280,7 +292,7 @@ export default function LoginPage() { const token = await finishPasskeyLogin(challenge); signedIn = true; toast.success("Welcome back!"); - completeSession(token); + await completeSession(token); } catch (e) { if (e instanceof PasskeyCancelled) { // WebAuthn can't distinguish "no passkey on this device" from @@ -419,13 +431,12 @@ export default function LoginPage() { if (mode === "signin") { const res = await loginConfirmMutation.mutateAsync({ session, code, turnstile: token }); toast.success("Welcome back!"); - saveTokens(res as unknown as Record); - // See completeSession: clear stale logged-out cache so the - // app shell refetches identity with the new token. - queryClient.clear(); // Nudge passwordless enrollment once the dashboard loads. try { sessionStorage.setItem(SUGGEST_PASSKEY_FLAG, "1"); } catch { /* storage unavailable */ } - navigate("/app/emails"); + // completeSession saves tokens, clears stale cache, primes the + // profile with the new token, then navigates — so the gated + // shell never mounts without identity (no infinite loader). + await completeSession(res as unknown as Token); } else { await registerConfirmMutation.mutateAsync({ session, code, turnstile: token }); toast.success("Account created! Please sign in."); diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx index 7192b293..e47eebfb 100644 --- a/web/src/app/not-found.tsx +++ b/web/src/app/not-found.tsx @@ -1,13 +1,71 @@ import { Link } from "react-router-dom"; +import { motion } from "framer-motion"; +import { MailX, LayoutDashboard, LogIn } from "lucide-react"; +import { Logo } from "@/components/svg"; export default function NotFound() { return ( -
-
-

404 Error

-

Page not found.

- Go back +
+ + {/* Brand wordmark */} +
+ + + Warmbly +
+ + {/* Glyph: MailX in a slate ring + one sky stamp dot */} +
+
+ +
+ +
+ + {/* Eyebrow */} + + Undeliverable · 404 + + + {/* Headline */} +

+ This page bounced +

+ + {/* Subhead */} +

+ We couldn't find a page at that address. The link may be broken, or the page may have + moved. Head back to a safe place below. +

+ + {/* Actions */} +
+ + Back to dashboard + + + Sign in + +
+
); -} \ No newline at end of file +} diff --git a/web/src/components/app/Calendar.tsx b/web/src/components/app/Calendar.tsx index 76fde1ea..f6d5ad57 100644 --- a/web/src/components/app/Calendar.tsx +++ b/web/src/components/app/Calendar.tsx @@ -1,7 +1,20 @@ import { AnimatePresence, motion } from "framer-motion"; import React from "react"; -import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay, isToday } from 'date-fns'; -import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react"; +import { createPortal } from "react-dom"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; export default function Calendar({ date, @@ -16,8 +29,17 @@ export default function Calendar({ }) { const [currentMonth, setCurrentMonth] = React.useState(date || new Date()); - const handleDateSelect = (date: Date) => { - onSubmit(date); + // Zero-size anchor rendered at the calendar's natural DOM position (inside + // the caller's `relative` trigger wrapper). We measure its parent — the + // trigger box — to position the portaled panel, instead of relying on + // `absolute top-full`, which clips inside overflow-hidden/scroll ancestors + // (modals, scroll areas, cards) and never flips up when there's no room. + const anchorRef = React.useRef(null); + const panelRef = React.useRef(null); + const [pos, setPos] = React.useState<{ top: number; left: number } | null>(null); + + const handleDateSelect = (d: Date) => { + onSubmit(d); close(); }; @@ -28,88 +50,180 @@ export default function Calendar({ const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); - const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd }); - - return days; + return eachDayOfInterval({ start: calendarStart, end: calendarEnd }); }; + // Position the portaled panel fixed at the trigger rect, flipping above the + // trigger when there isn't room below in the viewport. + React.useLayoutEffect(() => { + if (!active) { + setPos(null); + return; + } + const compute = () => { + const trigger = anchorRef.current?.parentElement; + const panel = panelRef.current; + if (!trigger) return; + const r = trigger.getBoundingClientRect(); + const sideOffset = 8; + const ch = panel?.offsetHeight ?? 0; + const cw = panel?.offsetWidth ?? 264; + let top = r.bottom + sideOffset; + if (ch && top + ch > window.innerHeight - 8) { + const above = r.top - ch - sideOffset; + if (above >= 8) top = above; + } + let left = r.left; + if (cw && left + cw > window.innerWidth - 8) left = window.innerWidth - 8 - cw; + if (left < 8) left = 8; + setPos({ top, left }); + }; + compute(); + window.addEventListener("resize", compute); + window.addEventListener("scroll", compute, true); + return () => { + window.removeEventListener("resize", compute); + window.removeEventListener("scroll", compute, true); + }; + }, [active, currentMonth]); + + // The panel is portaled to , outside the caller's wrapper ref. The + // callers dismiss on a document "mousedown" that checks ref.contains(target), + // so a mousedown inside the (portaled) panel would otherwise close the + // calendar on month-nav. Stop mousedown/touchstart at the panel via a NATIVE + // listener (reliable regardless of React's event delegation) — "click" is not + // stopped, so date selection and month navigation still fire. + React.useEffect(() => { + const el = panelRef.current; + if (!active || !el) return; + const stop = (e: Event) => e.stopPropagation(); + el.addEventListener("mousedown", stop); + el.addEventListener("touchstart", stop); + return () => { + el.removeEventListener("mousedown", stop); + el.removeEventListener("touchstart", stop); + }; + }, [active, pos]); + const calendarDays = generateCalendarDays(); return ( - - {active && ( - -
-
-
setCurrentMonth(subMonths(currentMonth, 1))} - className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" + <> +
+ {/* month header */} +
+ + + {format(currentMonth, "MMMM yyyy")} + + +
- - {format(currentMonth, 'yyyy. MMMM')} - - -
setCurrentMonth(addMonths(currentMonth, 1))} - className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" - > - -
-
- -
-
- {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map(day => ( -
- {day} +
+ {/* weekday labels */} +
+ {["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].map((day) => ( +
+ {day} +
+ ))}
- ))} -
-
- {calendarDays.map((day) => { - const isSelected = date && isSameDay(day, date); - const isCurrentDay = isToday(day); - const isCurrentMonth = isSameMonth(day, currentMonth); + {/* day grid */} +
+ {calendarDays.map((day) => { + const isSelected = date && isSameDay(day, date); + const isCurrentDay = isToday(day); + const isCurrentMonth = isSameMonth(day, currentMonth); - return ( -
{ - handleDateSelect(day) - if (!isCurrentMonth) { - setCurrentMonth(day) - } - }} - className={` - relative cursor-pointer ripple w-10 h-10 flex items-center justify-center text-sm rounded-lg transition-[background] - ${isSelected - ? 'bg-blue-500 text-white shadow-md' - : isCurrentDay - ? 'bg-blue-50 text-blue-600 font-semibold' - : isCurrentMonth - ? 'text-gray-700 hover:bg-gray-100' - : 'text-gray-200 hover:bg-gray-50' - } - `} - > - {format(day, 'd')} -
- ); - })} -
-
-
- - )} - - ) + return ( + + ); + })} +
+
+ + {/* footer: clear + today */} +
+ + +
+ + )} + , + document.body, + )} + + ); } diff --git a/web/src/components/app/analytics/AnalyticsShareButton.tsx b/web/src/components/app/analytics/AnalyticsShareButton.tsx new file mode 100644 index 00000000..0a5e1da8 --- /dev/null +++ b/web/src/components/app/analytics/AnalyticsShareButton.tsx @@ -0,0 +1,234 @@ +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, CopyIcon, DownloadIcon, ImageIcon, Loader2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import useExportCard from "@/hooks/useExportCard"; +import StatsShareCard, { type ShareAspect, type ShareCardData } from "./StatsShareCard"; + +// Drops a "Share image" button next to any analytics surface. It keeps a +// branded mounted off-viewport (real layout, so the capture +// isn't blank) and, on click, opens a preview modal that rasterizes the card to +// a PNG. The user picks an aspect preset (1:1 / 3:2 / 16:9) and previews it +// before choosing to download or copy. + +const ASPECTS: { value: ShareAspect; label: string; ratio: string; suffix: string }[] = [ + { value: "1:1", label: "1:1", ratio: "1 / 1", suffix: "1x1" }, + { value: "3:2", label: "3:2", ratio: "3 / 2", suffix: "3x2" }, + { value: "16:9", label: "16:9", ratio: "16 / 9", suffix: "16x9" }, +]; + +// Apply a preset suffix before the ".png" extension (e.g. "warmbly-x.png" -> +// "warmbly-x-16x9.png"). Falls back to appending if there's no .png. +function withPresetSuffix(filename: string, suffix: string): string { + const i = filename.toLowerCase().lastIndexOf(".png"); + if (i === -1) return `${filename}-${suffix}.png`; + return `${filename.slice(0, i)}-${suffix}${filename.slice(i)}`; +} + +export default function AnalyticsShareButton({ + data, + filename, + label = "Share image", +}: { + data: ShareCardData; + filename: string; + label?: string; +}) { + const ref = useRef(null); + const { renderPng, downloadPng } = useExportCard(); + const [open, setOpen] = useState(false); + const [aspect, setAspect] = useState("1:1"); + const [url, setUrl] = useState(null); + const [copied, setCopied] = useState(false); + + // Render the PNG when the preview opens AND whenever the aspect changes; + // reset when it closes. The capture is heavy, so defer a frame to let the + // off-viewport card relayout for the new aspect first. Square stays crisp at + // pixelRatio 3; wider presets drop to 2 so the (much larger) capture is + // snappy. + useEffect(() => { + if (!open) { + setUrl(null); + setCopied(false); + return; + } + let alive = true; + setUrl(null); // show the spinner while re-capturing for the new aspect + // Double rAF: the off-viewport card re-lays-out for the new aspect on + // the next frame; capturing in a second frame guarantees we rasterize + // the committed-and-painted new size, not the previous one. + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(async () => { + const out = await renderPng(ref.current, { + pixelRatio: aspect === "1:1" ? 3 : 2, + backgroundColor: "#18abed", + }); + if (alive) setUrl(out); + }); + }); + return () => { + alive = false; + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [open, aspect, renderPng]); + + // Esc closes the preview. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + async function copy() { + if (!url) return; + try { + // Pass a Promise to ClipboardItem rather than awaiting the blob + // first: browsers (especially Safari) require clipboard.write() to run + // within the click's user-gesture, and an `await fetch(...)` beforehand + // forfeits it → NotAllowedError. The Promise form keeps the gesture. + const item = new ClipboardItem({ "image/png": fetch(url).then((r) => r.blob()) }); + await navigator.clipboard.write([item]); + setCopied(true); + toast.success("Image copied to clipboard"); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error("Couldn't copy — download instead"); + } + } + + const current = ASPECTS.find((a) => a.value === aspect) ?? ASPECTS[0]; + + return ( + <> + + + + {open && ( + setOpen(false)} + > + e.stopPropagation()} + className="w-full max-w-[min(94vw,860px)] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden" + > +
+ + Share image + +
+ + Preview before download + + +
+ + {/* aspect preset selector (house theme segmented control) */} +
+ Aspect +
+ {ASPECTS.map((a) => { + const active = a.value === aspect; + return ( + + ); + })} +
+
+ +
+
+ {url ? ( + Analytics share preview + ) : ( +
+ + Rendering… +
+ )} +
+
+ +
+ + +
+ + + )} + + + {/* Off-viewport but laid out, so html-to-image captures real pixels. */} +
+ +
+ + ); +} diff --git a/web/src/components/app/analytics/StatsShareCard.tsx b/web/src/components/app/analytics/StatsShareCard.tsx new file mode 100644 index 00000000..ad4bdb9a --- /dev/null +++ b/web/src/components/app/analytics/StatsShareCard.tsx @@ -0,0 +1,257 @@ +import React from "react"; +import { Logo } from "@/components/svg"; +import { type ChartPoint } from "@/components/ui/charts"; + +// A branded analytics card rasterized to a shareable PNG (see useExportCard). +// Design: a flat branded sky background, the Warmbly logo white on the sky, and +// one clean white panel holding the title, metrics, and area chart. +// +// Capture-safe for html-to-image (SVG foreignObject): +// - sky / glow / haze are pure CSS gradients set inline. +// - NO CSS filter: blur() (unreliable in capture) and NO mix-blend-mode. +// - the chart is an inline SVG with an internal . + +export interface ShareMetric { + label: string; + value: string; + sub?: string; +} + +export interface ShareCardData { + title: string; + subtitle?: string; + metrics: ShareMetric[]; // up to 4 + daily: ChartPoint[]; // primary "sent" series +} + +export type ShareAspect = "1:1" | "3:2" | "16:9"; + +const DIMENSIONS: Record = { + "1:1": { width: 1080, height: 1080 }, + "3:2": { width: 1620, height: 1080 }, + "16:9": { width: 1920, height: 1080 }, +}; + +// Marketing-site hero sky palette, but SYMMETRIC for a standalone card: a soft +// top-center radial in the bright sky-300 → sky-400 band (never the hero's +// sky-800 navy). Centered at 50%, so neither side is darker than the other — +// no "dark on the right". Bright at top, gently deeper toward the bottom. +const SKY_BASE = + "radial-gradient(ellipse 125% 105% at 50% -12%," + + " #9fe0fb 0%, #74cef7 30%, #4ec1f6 58%, #2fb6f2 82%, #18abed 100%)"; + +// Additive light wash (the .sky-breathe layer, baked static) — centered to match. +const SKY_BREATHE = + "radial-gradient(ellipse 125% 105% at 50% -12%," + + " rgba(224,242,254,0.85) 0%, rgba(125,211,252,0.38) 24%, rgba(56,189,248,0.14) 46%, transparent 64%)"; + +// Lifts the exposed bottom band toward light sky so it stays airy. +const HAZE_BOTTOM = + "linear-gradient(to top, rgba(186,230,253,0.40) 0%, rgba(125,211,252,0.16) 42%, transparent 100%)"; + +const PANEL: React.CSSProperties = { + background: "#ffffff", + borderRadius: 26, + border: "1px solid rgba(255,255,255,0.85)", +}; + +function SkyBackdrop() { + return ( +
+ {/* additive light wash */} +
+ {/* lift the bottom band */} +
+
+ ); +} + +function Stat({ metric, valueSize }: { metric: ShareMetric; valueSize: number }) { + return ( +
+
{metric.label}
+
+ {metric.value} +
+ {metric.sub &&
{metric.sub}
} +
+ ); +} + +// Long area chart — smooth sky line + gradient fill, ALWAYS with a bottom +// baseline (so it reads as a chart even with no data). Fills its flex parent. +function ShareAreaChart({ points }: { points: ChartPoint[] }) { + const vals = points.map((p) => p.value || 0); + const hasData = points.length > 0 && vals.reduce((a, b) => a + b, 0) > 0; + + const W = 1000; + const H = 320; + const padX = 4; + const padTop = 14; + const baseY = H - 6; + const max = Math.max(1, ...vals); + const w = W - padX * 2; + const h = baseY - padTop; + const step = vals.length > 1 ? w / (vals.length - 1) : 0; + const pts = vals.map((v, i) => [padX + i * step, baseY - (v / max) * h] as const); + const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`).join(" "); + const lastX = padX + Math.max(0, vals.length - 1) * step; + const area = `${line} L ${lastX.toFixed(1)} ${baseY} L ${padX} ${baseY} Z`; + + return ( +
+
+ + + + + + + + {hasData && } + {hasData && ( + + )} + {/* baseline — always present */} + + + {!hasData && ( +
+ No sends in this window yet +
+ )} +
+
+ {hasData ? shortDate(points[0].label) : ""} + {hasData ? shortDate(points[points.length - 1].label) : ""} +
+
+ ); +} + +const StatsShareCard = React.forwardRef( + function StatsShareCard({ data, aspect = "1:1" }, ref) { + const { width, height } = DIMENSIONS[aspect]; + const metrics = data.metrics.slice(0, 4); + const landscape = aspect !== "1:1"; + + const pad = aspect === "16:9" ? 64 : aspect === "3:2" ? 56 : 48; + const logoClass = landscape ? "w-[60px] h-[60px]" : "w-[54px] h-[54px]"; + const wordmarkSize = landscape ? 40 : 36; + const titleSize = aspect === "16:9" ? 50 : aspect === "3:2" ? 46 : 40; + const valueSize = landscape ? 50 : 46; + + return ( +
+ + +
+ {/* logo on the sky */} +
+
+ + + Warmbly + +
+ + {todayLabel()} + +
+ + {/* single white panel */} +
+
+ {/* title */} + {data.subtitle && ( +
+ {data.subtitle} +
+ )} +

+ {data.title} +

+ + {/* divided metric row */} +
+ {metrics.map((m) => ( + + ))} +
+ +
+ + {/* long area chart */} +
+ + Sends over time + +
+
+ +
+
+
+ + {/* footer on the sky */} +
+ + warmbly.com + + + Cold email, warmed up. + +
+
+
+ ); + }, +); + +function shortDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } catch { + return iso; + } +} + +function todayLabel(): string { + return new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); +} + +export default StatsShareCard; diff --git a/web/src/components/app/campaigns/CampaignFolderField.tsx b/web/src/components/app/campaigns/CampaignFolderField.tsx new file mode 100644 index 00000000..7a760f15 --- /dev/null +++ b/web/src/components/app/campaigns/CampaignFolderField.tsx @@ -0,0 +1,77 @@ +// Themed folder multi-select for the campaign settings page. +// +// Replaces the off-theme chip-box FolderSelector with toggle chips in our own +// slate/sky language: each folder is a pill tinted with its own color, a solid +// color dot, and a check when selected. Clicking toggles membership. + +import { CheckIcon, PlusIcon, Settings2Icon } from "lucide-react"; +import { useUserProfile } from "@/hooks/context/user"; +import { cn, hexToRgba } from "@/lib/utils"; + +export default function CampaignFolderField({ + selected, + onToggle, +}: { + selected: string[]; + onToggle: (id: string) => void; +}) { + const p = useUserProfile(); + const folders = [...(p.user.folders ?? [])].sort((a, b) => a.position - b.position); + + if (folders.length === 0) { + return ( +
+

No folders yet

+

+ Create folders to organize your campaigns. +

+ +
+ ); + } + + return ( +
+ {folders.map((f) => { + const on = selected.includes(f.id); + return ( + + ); + })} + +
+ ); +} diff --git a/web/src/components/app/campaigns/LaunchCampaignDialog.tsx b/web/src/components/app/campaigns/LaunchCampaignDialog.tsx new file mode 100644 index 00000000..b493602f --- /dev/null +++ b/web/src/components/app/campaigns/LaunchCampaignDialog.tsx @@ -0,0 +1,291 @@ +// Launch campaign dialog. +// +// A purpose-built, animated launch experience that replaces the generic +// "Start X?" confirm. It shows a short pre-flight summary (daily cap, +// schedule, tracking, steps) derived from the campaign, then runs through +// idle → launching → live with motion: the launching state reuses the same +// dot-grid loader as the running indicator, and success springs a check in +// before auto-closing. Errors surface inline so a failed start (e.g. a +// template error) is readable instead of a toast that vanishes. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + AlertTriangleIcon, + CalendarClockIcon, + CheckIcon, + EyeIcon, + GaugeIcon, + ListChecksIcon, + RocketIcon, + XIcon, +} from "lucide-react"; +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign"; + +type Phase = "idle" | "launching" | "done"; + +function bitCount(n: number): number { + let c = 0; + let v = n & 0xff; + while (v) { + v &= v - 1; + c++; + } + return c; +} + +function scheduleSummary(c: Campaign): string { + const hasWindows = + Array.isArray(c.schedule_windows) && + c.schedule_windows.some((d) => Array.isArray(d) && d.length > 0); + if (hasWindows) return "Custom windows"; + const days = bitCount(c.days ?? 0); + const time = + c.start_time && c.end_time ? `${c.start_time}–${c.end_time}` : "all day"; + return `${days || 7} day${days === 1 ? "" : "s"} · ${time}`; +} + +function trackingSummary(c: Campaign): string { + if (c.open_tracking && c.link_tracking) return "Opens + links"; + if (c.open_tracking) return "Opens"; + if (c.link_tracking) return "Links"; + return "Off"; +} + +function SummaryChip({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+ {icon} +
+
+ {label} +
+
+ {value} +
+
+
+ ); +} + +export default function LaunchCampaignDialog({ + campaign, + onClose, + onConfirm, +}: { + campaign: Campaign | null; + onClose: () => void; + onConfirm: (id: string) => Promise; +}) { + const [phase, setPhase] = React.useState("idle"); + const [error, setError] = React.useState(null); + const timer = React.useRef(null); + + // The list passes a campaign whose `sequences` is null (the list endpoint + // omits them), so fetch the full record to show an accurate step count and + // settings. Falls back to the passed campaign until it resolves. + const full = useCampaign(campaign?.id ?? ""); + const c = full.data ?? campaign; + + // Reset to a clean state whenever a new campaign is opened. + React.useEffect(() => { + if (campaign) { + setPhase("idle"); + setError(null); + } + }, [campaign]); + + React.useEffect(() => { + return () => { + if (timer.current) window.clearTimeout(timer.current); + }; + }, []); + + // Esc closes (unless mid-launch). + React.useEffect(() => { + if (!campaign) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && phase !== "launching") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [campaign, phase, onClose]); + + async function launch() { + if (!campaign || phase === "launching") return; + setError(null); + setPhase("launching"); + try { + await onConfirm(campaign.id); + setPhase("done"); + timer.current = window.setTimeout(onClose, 1200); + } catch (e) { + setError(buildError(e as unknown as AppError)); + setPhase("idle"); + } + } + + return ( + + {c && ( + phase !== "launching" && onClose()} + className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4" + > + e.stopPropagation()} + className="w-full max-w-[440px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden" + > + + {phase === "done" ? ( + + + + +

+ You're live +

+

+ {c.name} is now sending +

+
+ ) : ( + + {/* Header */} +
+ + + +
+

+ Launch campaign +

+

+ {c.name} +

+
+ +
+ + {/* Pre-flight summary */} +
+ } + label="Daily cap" + value={`${c.daily_limit}/mailbox`} + /> + } + label="Schedule" + value={scheduleSummary(c)} + /> + } + label="Tracking" + value={trackingSummary(c)} + /> + } + label="Steps" + value={ + full.isLoading && !c.sequences + ? "…" + : c.sequences + ? `${c.sequences.length} step${c.sequences.length === 1 ? "" : "s"}` + : "0 steps" + } + /> +
+ +

+ Sending begins immediately, paced to the schedule and your + mailbox guardrails. You can pause anytime. +

+ + {error && ( +
+ +

+ {error} +

+
+ )} + + {/* Footer */} +
+ + +
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/web/src/components/app/campaigns/TaskPreview.tsx b/web/src/components/app/campaigns/TaskPreview.tsx index fc26f47b..d6af914b 100644 --- a/web/src/components/app/campaigns/TaskPreview.tsx +++ b/web/src/components/app/campaigns/TaskPreview.tsx @@ -1,206 +1,247 @@ -import { useMemo } from 'react'; -import { useCampaignChannel, type ActivityItem } from '@/hooks/useCampaignChannel'; +import { useMemo } from "react"; +import { + MailOpenIcon, + MousePointerClickIcon, + ReplyIcon, + SendIcon, + TriangleAlertIcon, + XCircleIcon, + type LucideIcon, +} from "lucide-react"; +import { useCampaignChannel, type ActivityItem } from "@/hooks/useCampaignChannel"; +import useCampaignLogs from "@/lib/api/hooks/app/campaigns/useCampaignLogs"; interface TaskPreviewProps { campaignId: string; campaignStatus?: string; } -// Status badge colors -const statusColors: Record = { - active: 'bg-green-100 text-green-800', - paused: 'bg-yellow-100 text-yellow-800', - draft: 'bg-gray-100 text-gray-800', - completed: 'bg-blue-100 text-blue-800', +// ── Status pill tones ─────────────────────────────────────────────── +const STATUS_TONE: Record = { + active: "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200", + paused: "bg-amber-50 text-amber-700 ring-1 ring-amber-200", + completed: "bg-sky-50 text-sky-700 ring-1 ring-sky-200", + draft: "bg-slate-100 text-slate-600 ring-1 ring-slate-200", }; -// Activity type colors -const activityColors: Record = { - sent: 'text-blue-600', - opened: 'text-green-600', - clicked: 'text-purple-600', - replied: 'text-indigo-600', - bounced: 'text-orange-600', - failed: 'text-red-600', +// ── Activity-type icon + tone ─────────────────────────────────────── +const ACTIVITY_META: Record = { + sent: { icon: SendIcon, tone: "text-slate-500" }, + opened: { icon: MailOpenIcon, tone: "text-emerald-600" }, + clicked: { icon: MousePointerClickIcon, tone: "text-violet-600" }, + replied: { icon: ReplyIcon, tone: "text-amber-600" }, + bounced: { icon: TriangleAlertIcon, tone: "text-rose-600" }, + failed: { icon: XCircleIcon, tone: "text-rose-600" }, }; -// Activity type icons -const activityIcons: Record = { - sent: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', - opened: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z', - clicked: 'M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122', - replied: 'M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6', - bounced: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', - failed: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z', -}; - -function formatTime(date: Date): string { - return date.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - }); +function statusLabel(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); } -function ActivityIcon({ type }: { type: string }) { - const path = activityIcons[type] || activityIcons.sent; - return ( - - - - ); +function initials(name?: string, email?: string): string { + const source = (name || email || "").trim(); + if (!source) return "?"; + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return source[0].toUpperCase(); } -function ActivityFeedItem({ activity }: { activity: ActivityItem }) { +function relativeTime(date: Date): string { + const diff = Date.now() - date.getTime(); + const sec = Math.round(diff / 1000); + if (sec < 5) return "just now"; + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); +} + +function ActivityRow({ activity }: { activity: ActivityItem }) { + const meta = ACTIVITY_META[activity.type] ?? ACTIVITY_META.sent; + const Icon = meta.icon; return ( -
-
- -
-
-

{activity.message}

-

{formatTime(activity.timestamp)}

-
+
+ +

+ {activity.message} +

+ + {relativeTime(activity.timestamp)} +
); } export default function TaskPreview({ campaignId, campaignStatus: initialStatus }: TaskPreviewProps) { - const { - isConnected, - channelState, - campaignStatus: realtimeStatus, - taskProgress, - activities, - } = useCampaignChannel(campaignId); + const { isConnected, channelState, campaignStatus: realtimeStatus, taskProgress, activities } = + useCampaignChannel(campaignId); - // Use realtime status if available, otherwise fall back to initial - const currentStatus = realtimeStatus?.status || initialStatus || 'draft'; + // Durable log tail — best effort; never breaks the live panel if it errors. + const logs = useCampaignLogs(campaignId); - // Calculate estimated time remaining - const estimatedTimeRemaining = useMemo(() => { - if (!taskProgress || taskProgress.processed_count === 0) return null; + const currentStatus = realtimeStatus?.status || initialStatus || "draft"; + const isActive = currentStatus === "active"; - const remaining = taskProgress.total_contacts - taskProgress.processed_count; + const connectionLabel = isConnected + ? "Connected" + : channelState === "joining" + ? "Connecting…" + : "Disconnected"; + + const showNowSending = !!taskProgress && isActive && taskProgress.status === "active"; + + const progress = Math.min(100, Math.max(0, taskProgress?.progress ?? 0)); + const processed = taskProgress?.processed_count ?? 0; + const total = taskProgress?.total_contacts ?? 0; + + // Conservative estimate: remaining contacts paced roughly one per minute. + const remainingHint = useMemo(() => { + if (!taskProgress || total <= 0) return null; + const remaining = total - processed; if (remaining <= 0) return null; + if (remaining < 60) return `~${remaining} min left`; + const hours = Math.floor(remaining / 60); + const mins = remaining % 60; + return mins > 0 ? `~${hours}h ${mins}m left` : `~${hours}h left`; + }, [taskProgress, total, processed]); - // Estimate based on recent activity (assuming ~1 email per minute average) - const minutes = remaining; - if (minutes < 60) return `~${minutes} min`; - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return `~${hours}h ${mins}m`; - }, [taskProgress]); + const recentLogs = useMemo(() => { + const all = logs.data?.logs ?? []; + return all.slice(-6).reverse(); + }, [logs.data]); + + const hasActivity = activities.length > 0; return ( -
- {/* Header */} -
-
-

Live Preview

- - {currentStatus.charAt(0).toUpperCase() + currentStatus.slice(1)} - -
-
- - - {isConnected ? 'Connected' : channelState === 'joining' ? 'Connecting...' : 'Disconnected'} +
+ {/* ── Header ─────────────────────────────────────────────── */} +
+ + Live activity + + + {statusLabel(currentStatus)} + +
+ + {isConnected && ( + + )} + + {connectionLabel}
- {/* Current Task Card */} - {taskProgress && currentStatus === 'active' && ( -
-
- {/* Contact Avatar */} -
- {taskProgress.contact_name?.[0]?.toUpperCase() || taskProgress.contact_email?.[0]?.toUpperCase() || '?'} + {/* ── Now sending ────────────────────────────────────────── */} + {showNowSending && ( +
+
+
+ {initials(taskProgress.contact_name, taskProgress.contact_email)}
-

- {taskProgress.contact_name || 'Unknown Contact'} +

+ {taskProgress.contact_name || taskProgress.contact_email || "Unknown contact"}

-

{taskProgress.contact_email}

+ {taskProgress.contact_name && ( +

+ {taskProgress.contact_email} +

+ )} {taskProgress.sequence_name && ( -

+

{taskProgress.sequence_name} - {taskProgress.sequence_index > 0 && ` (Step ${taskProgress.sequence_index})`} + {taskProgress.sequence_index > 0 && ` · Step ${taskProgress.sequence_index}`}

)}
-
- - {taskProgress.status === 'active' ? 'Sending...' : taskProgress.status} - -
+ + + Sending… +
)} - {/* Progress Section */} -
-
- Progress - - {taskProgress?.progress ?? 0}% - -
-
-
-
-
- - {taskProgress?.processed_count ?? 0} of {taskProgress?.total_contacts ?? 0} contacts - - {estimatedTimeRemaining && ( - - {estimatedTimeRemaining} remaining + {/* ── Progress ───────────────────────────────────────────── */} + {(showNowSending || (isActive && total > 0)) && ( +
+
+ + Progress - )} + {progress}% +
+
+
+
+
+ + {processed.toLocaleString()} of {total.toLocaleString()} contacts + + {remainingHint && ( + {remainingHint} + )} +
-
+ )} - {/* Activity Feed */} -
- {activities.length > 0 ? ( -
+ {/* ── Activity feed ──────────────────────────────────────── */} +
+ {hasActivity ? ( +
{activities.map((activity) => ( - + ))}
+ ) : recentLogs.length > 0 ? ( + <> +
+ Recent log +
+
+ {recentLogs.map((log, i) => ( +
+ +

+ {log.message} +

+ + {relativeTime(new Date(log.timestamp))} + +
+ ))} +
+ ) : ( -
- - - -

- {currentStatus === 'active' - ? 'Waiting for activity...' - : 'Start the campaign to see live updates'} +

+

+ {isActive ? "Waiting for the next send…" : "Nothing sending yet"} +

+

+ {isActive + ? "Opens, clicks, replies and bounces will stream in here live as your campaign sends." + : "Start the campaign to watch it send live."}

)} diff --git a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx index e6ce55be..3a050142 100644 --- a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx @@ -1,86 +1,168 @@ -import type Campaign from "@/lib/api/models/app/campaigns/Campaign" -import SubTitle from "../../text/SubTitle" -import Title from "../../text/Title" -import MiniInput from "../../popup/MiniInput" -import MiniTextArea from "../../popup/MiniTextArea" -import CampaignPreferenceBoolBox from "./components/CampaignPreferenceBoolBox" -import Switch from "../../Switch" +// Standard campaign settings, split into the sections rendered on the +// single-scroll preferences page: identity, sending accounts, and the +// deliverability toggles. Each export returns ONLY its controls — the page's +// SettingsSection wrapper supplies the heading, icon and anchor. +// On-theme: slate/sky, rounded-md, 12.5px base. -export default function CampaignAppearance({ +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import { Label, NumberInput, TextInput } from "@/components/ui/field"; +import SenderSelector from "./SenderSelector"; +import { SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; + +const DAILY_MIN = 3; +const DAILY_MAX = 100; + +type SetCampaign = React.Dispatch>; + +/** General — campaign name + description. */ +export function GeneralSection({ campaign, newCampaign, setNewCampaign, }: { - campaign: Campaign, - newCampaign: Campaign, - setNewCampaign: React.Dispatch>, + campaign: Campaign; + newCampaign: Campaign; + setNewCampaign: SetCampaign; }) { return ( -
+
- Campaign Name - Campaign name + setNewCampaign(bef => ({ - ...bef, - name: e.target.value, - }))} + onChange={(v) => setNewCampaign((bef) => ({ ...bef, name: v }))} + className="w-full max-w-[420px]" />
- Campaign Description - Description + setNewCampaign(bef => ({ - ...bef, - description: e.target.value, - }))} + placeholder={campaign.description || "Optional — what this targets"} + onChange={(v) => setNewCampaign((bef) => ({ ...bef, description: v }))} + className="w-full max-w-[420px]" />
- -
- Plain Text Only - Send as simple text email for the best deliverability (disables tracking) -
- setNewCampaign(bef => ({ - ...bef, - text_only: e, - }))} - /> -
- -
- Open Tracking - Track email opens, but may slightly reduce deliverability -
- setNewCampaign(bef => ({ - ...bef, - open_tracking: e, - }))} - /> -
- -
- Link Tracking - Track clicks on links to measure engagement (click-through rate) -
- setNewCampaign(bef => ({ - ...bef, - link_tracking: e, - }))} - /> -
- ) + ); +} + +/** Sending accounts — the unified tag/mailbox picker + per-mailbox daily cap. */ +export function SendingAccountsSection({ + newCampaign, + setNewCampaign, + explicitAccounts, + setExplicitAccounts, +}: { + newCampaign: Campaign; + setNewCampaign: SetCampaign; + explicitAccounts: string[]; + setExplicitAccounts: React.Dispatch>; +}) { + const dailyInvalid = newCampaign.daily_limit < DAILY_MIN || newCampaign.daily_limit > DAILY_MAX; + return ( +
+
+ + setNewCampaign((bef) => ({ ...bef, email_tags: next }))} + selectedAccounts={explicitAccounts} + onAccountsChange={setExplicitAccounts} + /> +

+ Pick tags, specific mailboxes, or both — volume is split evenly across the resolved pool. + Leave empty to send from every active mailbox. +

+
+
+ + setNewCampaign((bef) => ({ ...bef, daily_limit: v }))} + suffix="emails / day" + className="w-48" + /> +

+ {dailyInvalid + ? `Must be between ${DAILY_MIN} and ${DAILY_MAX}.` + : `${DAILY_MIN}–${DAILY_MAX}. Default 50 — stay conservative until reputation is proven.`} +

+
+
+ ); +} + +/** Deliverability — the per-campaign send/tracking toggles. */ +export function DeliverabilitySection({ + newCampaign, + setNewCampaign, +}: { + newCampaign: Campaign; + setNewCampaign: SetCampaign; +}) { + return ( +
+ setNewCampaign((bef) => ({ ...bef, stop_on_reply: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, text_only: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, open_tracking: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, link_tracking: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, unsubscribe_header: v }))} + /> + } + /> +
+ ); } diff --git a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx index 545d1c76..f4e57986 100644 --- a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx @@ -1,269 +1,74 @@ -import { useState, useCallback } from 'react'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { RiDraggable } from '@remixicon/react'; -import type Campaign from '@/lib/api/models/app/campaigns/Campaign'; -import SubTitle from '../../text/SubTitle'; -import Title from '../../text/Title'; -import Selector from '../../popup/select/Selector'; -import SelectMenu from '../../popup/select/SelectMenu'; -import SelectOption from '../../popup/select/SelectOption'; -import MiniInput from '../../popup/MiniInput'; -import Switch from '../../Switch'; -import CampaignPreferenceBoolBox from './components/CampaignPreferenceBoolBox'; +// Contact ordering settings — how the campaign picks who to send to next. +// On-theme: reuses the shared OptionSelect / Segmented / SettingRow primitives +// so it matches the rest of the settings page. -// Order by options -const ORDER_OPTIONS = [ - { value: 'created_at', label: 'Creation Time', description: 'Order by when contacts were added' }, - { value: 'email', label: 'Email', description: 'Alphabetical by email address' }, - { value: 'name', label: 'Name', description: 'Alphabetical by first name, then last name' }, - { value: 'custom_field', label: 'Custom Field', description: 'Order by a custom contact field' }, - { value: 'manual', label: 'Manual', description: 'Drag and drop to set custom order' }, -] as const; +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import { Label, TextInput } from "@/components/ui/field"; +import { OptionSelect, Segmented, SettingRow } from "./components/CampaignPreferenceBoolBox"; -interface ContactItem { - id: string; - email: string; - firstName: string; - lastName: string; - position?: number; -} - -interface SortableContactProps { - contact: ContactItem; - index: number; -} - -function SortableContact({ contact, index }: SortableContactProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: contact.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- - {index + 1}. -
-

- {contact.firstName || contact.lastName - ? `${contact.firstName} ${contact.lastName}`.trim() - : 'Unknown'} -

-

{contact.email}

-
-
- ); -} +const ORDER_OPTIONS: { value: Campaign["contact_order_by"]; label: string; hint: string }[] = [ + { value: "created_at", label: "Creation time", hint: "When the contact was added" }, + { value: "email", label: "Email", hint: "Alphabetical by email address" }, + { value: "name", label: "Name", hint: "Alphabetical by first, then last name" }, + { value: "custom_field", label: "Custom field", hint: "Order by a custom contact field" }, +]; interface CampaignContactOrderProps { campaign: Campaign; newCampaign: Campaign; setNewCampaign: React.Dispatch>; - contacts?: ContactItem[]; - onContactsReorder?: (contacts: ContactItem[]) => void; } -export default function CampaignContactOrder({ - campaign, - newCampaign, - setNewCampaign, - contacts = [], - onContactsReorder, -}: CampaignContactOrderProps) { - const [showOrderMenu, setShowOrderMenu] = useState(false); - const [orderedContacts, setOrderedContacts] = useState( - [...contacts].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) - ); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - const selectedOption = ORDER_OPTIONS.find(o => o.value === newCampaign.contact_order_by) || ORDER_OPTIONS[0]; - - const handleDragEnd = useCallback((event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - setOrderedContacts((items) => { - const oldIndex = items.findIndex((i) => i.id === active.id); - const newIndex = items.findIndex((i) => i.id === over.id); - - const newItems = arrayMove(items, oldIndex, newIndex); - - // Update positions - const updatedItems = newItems.map((item, index) => ({ - ...item, - position: index, - })); - - // Notify parent - onContactsReorder?.(updatedItems); - - return updatedItems; - }); - } - }, [onContactsReorder]); - +export default function CampaignContactOrder({ newCampaign, setNewCampaign }: CampaignContactOrderProps) { return ( -
- {/* Order By Selection */} +
+ {/* Order by — same card picker as the rest of settings */}
- Order Contacts By -
- - {selectedOption.label} - - - {ORDER_OPTIONS.map((option) => ( - { - setNewCampaign((prev) => ({ - ...prev, - contact_order_by: option.value, - })); - setShowOrderMenu(false); - }} - > -
-

{option.label}

-

{option.description}

-
-
- ))} -
-
+ + setNewCampaign((prev) => ({ ...prev, contact_order_by: v }))} + options={ORDER_OPTIONS} + />
- {/* Direction Toggle (not shown for manual) */} - {newCampaign.contact_order_by !== 'manual' && ( - -
- Descending Order - - {newCampaign.contact_order_dir === 'desc' - ? 'Contacts will be processed from Z to A / newest to oldest' - : 'Contacts will be processed from A to Z / oldest to newest'} - -
- - setNewCampaign((prev) => ({ - ...prev, - contact_order_dir: v ? 'desc' : 'asc', - })) - } + {/* Direction */} + setNewCampaign((prev) => ({ ...prev, contact_order_dir: v }))} + options={[ + { value: "asc", label: "Ascending" }, + { value: "desc", label: "Descending" }, + ]} /> -
- )} + } + /> - {/* Custom Field Input */} - {newCampaign.contact_order_by === 'custom_field' && ( + {/* Custom field name */} + {newCampaign.contact_order_by === "custom_field" && (
- Custom Field Name - - setNewCampaign((prev) => ({ - ...prev, - contact_order_field: e.target.value, - })) - } + + setNewCampaign((prev) => ({ ...prev, contact_order_field: v }))} + className="w-full max-w-[280px]" /> -

- Enter the name of a custom field from your contacts +

+ Enter the name of a custom field from your contacts.

)} - - {/* Manual Drag-and-Drop */} - {newCampaign.contact_order_by === 'manual' && ( -
- Drag to Reorder Contacts - {orderedContacts.length > 0 ? ( - - c.id)} - strategy={verticalListSortingStrategy} - > -
- {orderedContacts.map((contact, index) => ( - - ))} -
-
-
- ) : ( -
-

No contacts in this campaign

-

Add contacts to enable manual ordering

-
- )} -
- )} - - {/* Info Box */} -
-

- How contact ordering works: When processing emails, - contacts will be selected in the order you specify here. This determines - who receives emails first when your campaign is running. -

-
); } diff --git a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx index dbe68e1c..fdf5a570 100644 --- a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx @@ -1,101 +1,374 @@ -import type Campaign from "@/lib/api/models/app/campaigns/Campaign" -import SubTitle from "../../text/SubTitle" -import TagSelector from "../../popup/select/TagSelector" -import MiniNumberInput from "../../popup/MiniNumberInput" -import CampaignPreferenceBoolBox from "./components/CampaignPreferenceBoolBox" -import Title from "../../text/Title" -import Switch from "../../Switch" -import MultiInput from "../../MultiInput" +// Advanced campaign settings, split into the sections rendered on the +// single-scroll preferences page: rotation + ramp-up, ESP matching (with the +// visual coverage panel), lead flow, and tracking/headers. Each export returns +// ONLY its controls — the page's SettingsSection wrapper supplies the heading, +// icon and anchor. On-theme: slate/sky, rounded-md, 12.5px base, NumberInput +// for every number. -export default function CampaignEmails({ - campaign, +import { useState } from "react"; +import { AlertCircleIcon } from "lucide-react"; +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import { Label, NumberInput } from "@/components/ui/field"; +import { EmailListInput, OptionSelect, SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; +import EspCoveragePanel from "./EspCoveragePanel"; + +type SetCampaign = React.Dispatch>; + +/** RampPreview — a small day-by-day projection of the per-mailbox ramp curve + * (Day 1 = start, +increment each day, capped at the ceiling). Highlights the + * bar nearest today's live cap (ramp_level). */ +function RampPreview({ + start, + increment, + ceiling, + level, +}: { + start: number; + increment: number; + ceiling: number; + level: number; +}) { + if (start <= 0 || ceiling <= 0 || start > ceiling) return null; + const days: number[] = []; + let v = start; + let guard = 0; + while (guard < 60) { + const capped = Math.min(v, ceiling); + days.push(capped); + if (capped >= ceiling) break; + v += Math.max(1, increment); + guard++; + } + const MAX_BARS = 14; + const shown = days.slice(0, MAX_BARS); + const more = days.length - shown.length; + const todayIdx = days.findIndex((d) => d >= level); + + return ( +
+
+ {shown.map((val, i) => { + const today = i === todayIdx; + return ( +
+ ); + })} +
+
+ + Day 1 · {start}/day + + + {more > 0 ? `+${more} more · ` : ""}Day {days.length} · {ceiling}/day, then steady + +
+
+ ); +} + +/** Inbox rotation — distribution mode + per-mailbox daily ramp-up. */ +export function RotationRampSection({ newCampaign, setNewCampaign, }: { - campaign: Campaign, - newCampaign: Campaign, - setNewCampaign: React.Dispatch>, + newCampaign: Campaign; + setNewCampaign: SetCampaign; +}) { + const rampInvalid = newCampaign.ramp_enabled && newCampaign.ramp_start > newCampaign.ramp_ceiling; + return ( +
+ {/* Inbox rotation */} +
+ setNewCampaign((bef) => ({ ...bef, rotation_mode: v }))} + options={[ + { + value: "least_recently_used", + label: "Even spacing", + hint: "Recommended — always picks the mailbox that's been idle longest, for the most natural send pattern.", + }, + { + value: "round_robin", + label: "Round-robin", + hint: "Cycles through your mailboxes in order (A → B → C → A). A simple, even split.", + }, + { + value: "weighted", + label: "Weighted", + hint: "Sends more from your healthiest, higher-limit mailboxes.", + }, + ]} + /> + } + /> +

+ Each mailbox stays within its own daily limit, and follow-ups always come from the mailbox that + sent the first email — so every thread stays consistent. +

+
+ + {/* Daily ramp-up */} + setNewCampaign((bef) => ({ ...bef, ramp_enabled: v }))} + /> + } + /> + {newCampaign.ramp_enabled && ( +
+
+
+ + setNewCampaign((bef) => ({ ...bef, ramp_start: v }))} + suffix="/ day" + className="w-36" + /> +
+
+ + setNewCampaign((bef) => ({ ...bef, ramp_increment: v }))} + suffix="/ day" + className="w-36" + /> +
+
+ + setNewCampaign((bef) => ({ ...bef, ramp_ceiling: v }))} + suffix="/ day" + className="w-36" + /> +
+ + Today's cap + {newCampaign.ramp_level} + +
+ {rampInvalid ? ( +

+ + Start must be less than or equal to the ceiling. +

+ ) : ( + + )} +
+ )} +
+ ); +} + +/** ESP matching — the mode control + the visual coverage panel. */ +export function EspMatchingSection({ + newCampaign, + setNewCampaign, + explicitAccounts, +}: { + newCampaign: Campaign; + setNewCampaign: SetCampaign; + explicitAccounts: string[]; }) { return ( -
-
- Email Accounts - setNewCampaign(bef => ({ - ...bef, - email_tags: [...bef.email_tags, t] - }))} - onRemove={(t) => setNewCampaign(bef => ({ - ...bef, - email_tags: bef.email_tags.filter((e) => e !== t) - }))} - /> -
-
- Daily Limit - setNewCampaign(bef => ({ - ...bef, - daily_limit: e.target.valueAsNumber - }))} - /> -
- -
- Unsubscribe Header - Add an unsubscribe link in the email header for compliance and better deliverability -
- setNewCampaign(bef => ({ - ...bef, - unsubscribe_header: e, - }))} - /> -
- -
- Risky Emails - Attempt sending to risky addresses (may increase bounces) -
- setNewCampaign(bef => ({ - ...bef, - risky_emails: e, - }))} - /> -
-
-
-
- CC receipments - Send a copy of each email to additional recipients (visible to others) - setNewCampaign(bef => ({ - ...bef, - cc: v, - }))} +
+ setNewCampaign((bef) => ({ ...bef, esp_match_mode: v }))} + options={[ + { value: "off", label: "Off", hint: "Ignore provider when choosing a mailbox" }, + { value: "prefer", label: "Prefer same", hint: "Use a same-provider mailbox when free" }, + { value: "strict", label: "Strict same", hint: "Only send from a same-provider mailbox" }, + ]} /> -
-
- BCC receipments - Send a blind copy of each email to hidden recipients (not visible to others) - setNewCampaign(bef => ({ - ...bef, - bcc: v, - }))} - /> -
-
+ } + /> +
- ) + ); +} + +/** Lead flow — new-lead throttle, prioritization, and risky-address policy. */ +export function LeadFlowSection({ + newCampaign, + setNewCampaign, +}: { + newCampaign: Campaign; + setNewCampaign: SetCampaign; +}) { + return ( +
+
+ + setNewCampaign((bef) => ({ ...bef, max_new_leads_per_day: v }))} + suffix={newCampaign.max_new_leads_per_day === 0 ? "= no limit" : "leads / day"} + className="w-48" + /> +

+ Limits how many brand-new leads get their very first{" "} + email from this campaign each day, so a fresh list goes out as a steady trickle instead of one + big blast. Follow-ups to people already in the campaign still send and don't count against this. +

+
+ setNewCampaign((bef) => ({ ...bef, prioritize_new_leads: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, risky_emails: v }))} + /> + } + /> +
+ ); +} + +/** CC & BCC — extra recipients copied on every send. Gmail-style: the fields + * stay hidden behind "Add CC / Add BCC" until needed, so the section is calm + * by default. (Tracking domain is a per-mailbox setting and lives on the + * mailbox, not the campaign.) */ +export function CcBccSection({ + newCampaign, + setNewCampaign, +}: { + newCampaign: Campaign; + setNewCampaign: SetCampaign; +}) { + const [showCc, setShowCc] = useState(newCampaign.cc.length > 0); + const [showBcc, setShowBcc] = useState(newCampaign.bcc.length > 0); + + const addBtn = + "inline-flex items-center h-7 px-2.5 rounded-md border border-dashed border-slate-300 text-slate-500 hover:border-slate-400 hover:text-slate-700 text-[12px] font-medium transition-colors"; + + return ( +
+ {showCc && ( +
+
+ + +
+ setNewCampaign((bef) => ({ ...bef, cc: v }))} + /> +

+ Visible to recipients. Paste or type several — separate with a comma, space, or Enter. +

+
+ )} + + {showBcc && ( +
+
+ + +
+ setNewCampaign((bef) => ({ ...bef, bcc: v }))} + /> +

Hidden from recipients.

+
+ )} + + {(!showCc || !showBcc) && ( +
+ {!showCc && ( + + )} + {!showBcc && ( + + )} + {!showCc && !showBcc && ( + + Optionally copy extra addresses on every email this campaign sends. + + )} +
+ )} +
+ ); } diff --git a/web/src/components/app/campaigns/preferences/EspCoveragePanel.tsx b/web/src/components/app/campaigns/preferences/EspCoveragePanel.tsx new file mode 100644 index 00000000..22cca0b8 --- /dev/null +++ b/web/src/components/app/campaigns/preferences/EspCoveragePanel.tsx @@ -0,0 +1,281 @@ +// EspCoveragePanel — the "provider matching" visual under the ESP matching +// control. For each recipient provider it shows which of this campaign's +// mailboxes serve it, ranked so the difference between a true same-provider +// match and an SMTP wildcard is obvious. No connector lines, no new API. +// +// Grounded in the scheduler (internal/scheduler/campaign_scheduler.go): +// • recipient provider is derived from the domain → "gmail" | "outlook" | +// unknown (everything else). +// • a mailbox matches a recipient when: mailbox is smtp_imap (WILDCARD — +// serves any provider, even in strict), OR the recipient is unknown +// (wildcard), OR provider equality (gmail↔gmail, outlook↔outlook). +// • strict + no matching mailbox → the recipient is DEFERRED (held to the next +// slot), never sent cross-provider and never skipped. +// • prefer + no matching mailbox → falls back to a cross-provider mailbox. +// +// Pool mirrors the backend resolution: explicit senders ∪ tag mailboxes, else +// all active mailboxes. Only healthy (active) mailboxes count toward coverage. + +import React from "react"; +import useEmails from "@/lib/api/hooks/app/emails/useEmails"; +import ProviderLogo from "./ProviderLogo"; + +type Mode = "off" | "prefer" | "strict"; +type Status = "ok" | "warn" | "blocked" | "any"; + +const LABEL: Record = { + gmail: "Google", + outlook: "Outlook", + smtp_imap: "Other / SMTP", + other: "Other domains", +}; + +export default function EspCoveragePanel({ + mode, + emailTags, + explicitAccounts, +}: { + mode: Mode; + emailTags: string[]; + explicitAccounts: string[]; +}) { + const { emails, isLoading } = useEmails({ query: "", tag: "", limit: 200 }); + + const pool = React.useMemo(() => { + const explicit = new Set(explicitAccounts); + const tags = new Set(emailTags); + const picked = emails.filter((e) => { + if (explicit.size && explicit.has(e.id)) return true; + if (tags.size && (e.tags ?? []).some((t) => tags.has(t))) return true; + return false; + }); + const resolved = explicit.size || tags.size ? picked : emails; + return resolved.filter((e) => e.status === "active"); + }, [emails, emailTags, explicitAccounts]); + + const counts = React.useMemo(() => { + let gmail = 0; + let outlook = 0; + let smtp = 0; + for (const e of pool) { + if (e.provider === "gmail") gmail++; + else if (e.provider === "outlook") outlook++; + else smtp++; + } + return { gmail, outlook, smtp, total: pool.length }; + }, [pool]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (counts.total === 0) { + return ( +
+

+ No active mailboxes resolve for this campaign yet, so there's nothing to match against. Add + mailboxes or tags under Sending accounts. +

+
+ ); + } + + const poolChips = ( + [ + { key: "gmail", count: counts.gmail }, + { key: "outlook", count: counts.outlook }, + { key: "smtp_imap", count: counts.smtp }, + ] as const + ).filter((p) => p.count > 0); + + // Pool is all wildcard mailboxes (SMTP only) → even strict can't narrow. + const onlyWildcard = counts.smtp > 0 && counts.gmail === 0 && counts.outlook === 0; + + return ( +
+ {/* Pool summary + active mode */} +
+ Your mailboxes + +
+
+ {poolChips.map((p) => ( + + + + {LABEL[p.key]} {p.count} + + + ))} +
+ + {/* Recipient → serving mailboxes, ranked (match vs wildcard vs catch-all) */} +
+ {(["gmail", "outlook"] as const).map((r) => { + const sameCount = r === "gmail" ? counts.gmail : counts.outlook; + const hasSame = sameCount > 0; + // SMTP/IMAP is a wildcard for Gmail/Outlook recipients only OUTSIDE + // strict — under strict, "same provider" excludes unknown-ESP SMTP. + const wildServes = counts.smtp > 0 && mode !== "strict"; + // prefer falls back cross-provider when no same + no wildcard. + const crossCount = (r === "gmail" ? counts.outlook : counts.gmail); + + let status: Status; + if (mode === "off") status = "any"; + else if (hasSame || wildServes) status = "ok"; + else if (mode === "prefer" && crossCount > 0) status = "warn"; + else status = "blocked"; + + const tint = + status === "blocked" + ? "border-rose-200 bg-rose-50/40" + : status === "warn" + ? "border-amber-200 bg-amber-50/40" + : "border-slate-200 bg-white"; + + return ( +
+ +
+
+ {LABEL[r]} recipients +
+
+ via + {mode === "off" ? ( + + ) : ( + <> + {hasSame && ( + + )} + {wildServes && ( + + )} + {!hasSame && !wildServes && mode === "prefer" && crossCount > 0 && ( + + )} + {status === "blocked" && ( + + No matching mailbox + + )} + + )} +
+
+ +
+ ); + })} + + {/* Unknown-domain recipients always use any mailbox (wildcard), every mode. */} +
+ +
+
Other domains
+
+ via + +
+
+ +
+
+ + {mode === "strict" && onlyWildcard && ( +

+ All your mailboxes are SMTP/IMAP, which can send to any provider — so Strict can't narrow by + provider here and behaves like Off. Connect a Google or Outlook mailbox to truly restrict + same-provider sending. +

+ )} + +

+ {mode === "off" + ? "Provider matching is off — any recipient can be sent from any mailbox in the pool." + : mode === "strict" + ? "Strict sends Google and Outlook recipients only from a same-provider mailbox (the sky match) — a recipient with no same-provider mailbox is held (deferred) until one frees up, never sent cross-provider. SMTP/IMAP mailboxes only carry non-Google/Outlook (“other”) domains under strict." + : "Prefer uses a same-provider mailbox when one has capacity (the sky match), otherwise it falls back to another provider (the amber chip) — it never holds a recipient. SMTP/IMAP mailboxes can carry any provider."} +

+
+ ); +} + +function AnyMailbox() { + return Any mailbox in the pool; +} + +function Chip({ + provider, + count, + variant, +}: { + provider: string; + count: number; + variant: "match" | "wildcard" | "fallback"; +}) { + const cls = + variant === "match" + ? "border-sky-200 bg-sky-50/60" + : variant === "fallback" + ? "border-amber-200 bg-amber-50/60 border-dashed" + : "border-slate-200 bg-slate-50"; + return ( + + + + ×{count} + + {variant === "wildcard" && ( + any + )} + {variant === "fallback" && ( + fallback + )} + + ); +} + +function StatusBadge({ status }: { status: Status }) { + const map = { + ok: { text: "Covered", cls: "bg-emerald-50 text-emerald-700", dot: "bg-emerald-500" }, + warn: { text: "Fallback", cls: "bg-amber-50 text-amber-700", dot: "bg-amber-500" }, + blocked: { text: "Deferred", cls: "bg-rose-50 text-rose-700", dot: "bg-rose-500" }, + any: { text: "Any", cls: "bg-slate-100 text-slate-500", dot: "bg-slate-400" }, + }[status]; + return ( + + + {map.text} + + ); +} + +function ModePill({ mode }: { mode: Mode }) { + const m = { + off: { t: "Matching off", c: "bg-slate-100 text-slate-500" }, + prefer: { t: "Prefer same", c: "bg-sky-50 text-sky-700" }, + strict: { t: "Strict same", c: "bg-sky-50 text-sky-700" }, + }[mode]; + return ( + + {m.t} + + ); +} diff --git a/web/src/components/app/campaigns/preferences/ProviderLogo.tsx b/web/src/components/app/campaigns/preferences/ProviderLogo.tsx new file mode 100644 index 00000000..3041c549 --- /dev/null +++ b/web/src/components/app/campaigns/preferences/ProviderLogo.tsx @@ -0,0 +1,53 @@ +// ProviderLogo — provider brand marks for the matching visual. Reuses the real, +// official Google + Outlook SVGs the app already ships (the same ones the Add +// Account flow uses, @/components/svg), so the logos are genuine and consistent +// rather than hand-drawn. SMTP/IMAP and unknown domains get a neutral mail mark. + +import { Google, Outlook } from "@/components/svg"; + +export default function ProviderLogo({ + provider, + className = "size-6", + muted = false, +}: { + provider: string; + className?: string; + muted?: boolean; +}) { + const wrap = `inline-flex items-center justify-center shrink-0 ${muted ? "opacity-40 grayscale" : ""}`; + + if (provider === "gmail") { + return ( + + + + ); + } + + if (provider === "outlook") { + return ( + + + + ); + } + + // smtp_imap / other / unknown — neutral mail mark. + return ( + + + + ); +} diff --git a/web/src/components/app/campaigns/preferences/SenderSelector.tsx b/web/src/components/app/campaigns/preferences/SenderSelector.tsx new file mode 100644 index 00000000..e5b8ba62 --- /dev/null +++ b/web/src/components/app/campaigns/preferences/SenderSelector.tsx @@ -0,0 +1,286 @@ +// SenderSelector — the single, unified sending-account picker. One dropdown +// holds BOTH mailbox tags and individual mailboxes; you can mix them freely. +// There is no by-tag / by-account switcher anymore: selecting nothing means +// "all active mailboxes", a tag means "every mailbox in that tag", and a +// mailbox means "exactly that mailbox". The resolved pool on the backend is the +// union of the picked tags and the picked mailboxes (and falls back to all +// active mailboxes when nothing is picked). +// +// Visual language mirrors the contacts CategoryPicker / mailbox TagSelector: +// a bordered chip box + framer-motion dropdown with a search header and +// checkbox-square rows, grouped into Tags and Mailboxes sections. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, PlusIcon, XIcon, MailIcon, TagIcon } from "lucide-react"; +import { useUserProfile } from "@/hooks/context/user"; +import useClickOutside from "@/hooks/useClickOutside"; +import useFlipPlacement from "@/hooks/useFlipPlacement"; +import useEmails from "@/lib/api/hooks/app/emails/useEmails"; + +// A health dot color from the mailbox status, mirroring InboxDetails.statusTone. +function healthDot(status: string): string { + const s = status?.toLowerCase(); + if (s === "active" || s === "healthy") return "bg-emerald-500"; + if (s === "warming" || s === "warning") return "bg-amber-500"; + if (s === "revoked" || s === "error" || s === "inactive") return "bg-rose-500"; + return "bg-slate-300"; +} + +// hexToRgba converts "#rrggbb" to "rgba(...)"; non-hex falls back to a slate +// tint so the chip stays visible. +function hexToRgba(hex: string, alpha: number): string { + const m = /^#([0-9a-f]{6})$/i.exec(hex); + if (!m) return `rgba(100,116,139,${alpha})`; + const v = m[1]; + return `rgba(${parseInt(v.slice(0, 2), 16)},${parseInt(v.slice(2, 4), 16)},${parseInt(v.slice(4, 6), 16)},${alpha})`; +} + +export default function SenderSelector({ + selectedTags, + onTagsChange, + selectedAccounts, + onAccountsChange, +}: { + selectedTags: string[]; + onTagsChange: (next: string[]) => void; + selectedAccounts: string[]; + onAccountsChange: (next: string[]) => void; +}) { + const profile = useUserProfile(); + const tags = React.useMemo( + () => [...(profile?.user.tags ?? [])].sort((a, b) => a.position - b.position), + [profile?.user.tags], + ); + const { emails, isLoading } = useEmails({ query: "", tag: "", limit: 200 }); + + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const ref = React.useRef(null); + const triggerRef = React.useRef(null); + useClickOutside(ref, () => setOpen(false)); + const placement = useFlipPlacement(triggerRef, open, 320); + + const tagById = React.useMemo(() => { + const m = new Map(); + for (const t of tags) m.set(t.id, t); + return m; + }, [tags]); + const mailboxById = React.useMemo(() => { + const m = new Map(); + for (const e of emails) m.set(e.id, e); + return m; + }, [emails]); + + const tagChips = selectedTags + .map((id) => tagById.get(id)) + .filter((t): t is NonNullable => !!t); + // Keep unknown/stale account ids around so a selection still renders + can be removed. + const accountChips = selectedAccounts.map((id) => ({ id, inbox: mailboxById.get(id) })); + + const q = query.trim().toLowerCase(); + const filteredTags = React.useMemo( + () => (!q ? tags : tags.filter((t) => t.title.toLowerCase().includes(q))), + [tags, q], + ); + const filteredMailboxes = React.useMemo( + () => + !q + ? emails + : emails.filter( + (e) => + e.email.toLowerCase().includes(q) || + (e.name ?? "").toLowerCase().includes(q) || + (e.provider ?? "").toLowerCase().includes(q), + ), + [emails, q], + ); + + function toggleTag(id: string) { + if (selectedTags.includes(id)) onTagsChange(selectedTags.filter((v) => v !== id)); + else onTagsChange([...selectedTags, id]); + } + function toggleAccount(id: string) { + if (selectedAccounts.includes(id)) onAccountsChange(selectedAccounts.filter((v) => v !== id)); + else onAccountsChange([...selectedAccounts, id]); + } + + const totalSelected = selectedTags.length + selectedAccounts.length; + const hasChips = tagChips.length > 0 || accountChips.length > 0; + + return ( +
+
+ {!hasChips ? ( +
setOpen((o) => !o)} + className="px-3 py-2 text-[11.5px] text-slate-400 cursor-pointer hover:text-slate-600" + > + All active mailboxes — click to narrow by tag or pick specific ones… +
+ ) : ( +
+ {tagChips.map((t) => ( + + + {t.title} + + + ))} + {accountChips.map(({ id, inbox }) => ( + + + {inbox?.email ?? id} + + + ))} + +
+ )} +
+ + + {open && ( + +
+ setQuery(e.target.value)} + placeholder="Search tags or mailboxes…" + autoFocus + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+
+ {/* Tags */} + {filteredTags.length > 0 && ( + <> +
+ Tags +
+ {filteredTags.map((t) => { + const checked = selectedTags.includes(t.id); + return ( + + ); + })} + + )} + + {/* Mailboxes */} +
+ Mailboxes +
+ {isLoading && ( +
Loading…
+ )} + {!isLoading && filteredMailboxes.length === 0 && ( +
+ {emails.length === 0 ? "No mailboxes connected yet." : "No matches."} +
+ )} + {filteredMailboxes.map((e) => { + const checked = selectedAccounts.includes(e.id); + return ( + + ); + })} +
+
+ + {totalSelected === 0 + ? "Nothing selected — all active mailboxes" + : `${selectedTags.length} tag${selectedTags.length === 1 ? "" : "s"} · ${selectedAccounts.length} mailbox${selectedAccounts.length === 1 ? "" : "es"}`} +
+
+ )} +
+
+ ); +} diff --git a/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx b/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx index d77c5620..30c96ca5 100644 --- a/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx +++ b/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx @@ -1,7 +1,326 @@ +// On-theme primitives shared across the campaign settings tabs. +// +// Replaces the legacy off-theme Switch / Title / SubTitle usage. Everything +// here is slate/sky, rounded-md, 12.5px base, h-7 controls — matching the +// rebuilt analytics + campaign-overview chrome. + +import React from "react"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * SettingRow — a labelled setting line. Title + helper text on the left, + * an arbitrary control (Toggle, Segmented, NumberInput…) on the right. + * + * Mobile-first: by default the control stacks UNDER the title on phones and + * moves to the classic right-justified position from sm+ (good for compact + * controls: Toggle, NumberInput, the 2-option Segmented). + * + * `stack` forces the control onto its own full-width line beneath the title at + * EVERY width. Use it for wide, long-label controls (the 3-option OptionSelect + * for Rotation / ESP matching) so the control owns the full content width and + * never competes with the label for horizontal space. + */ +export function SettingRow({ + title, + description, + control, + children, + stack = false, +}: { + title: string; + description?: React.ReactNode; + control?: React.ReactNode; + children?: React.ReactNode; + stack?: boolean; +}) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} + {children} +
+ {control &&
{control}
} +
+ ); +} + +/** + * Legacy default export kept so any stray importer still compiles. Plain + * justified flex row. + */ export default function CampaignPreferenceBoolBox({ children }: { children: React.ReactNode }) { return (
{children}
- ) + ); +} + +/** + * Toggle — our small on-theme switch. Sky-600 when on, slate when off. + * 28px wide track, no library defaults. + */ +export function Toggle({ + value, + onChange, + disabled, + id, +}: { + value: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; + id?: string; +}) { + return ( + + ); +} + +/** + * Segmented — a small on-theme pill group. Active option = bg-sky-600 white; + * the rest are muted slate. Generic over the option value. + */ +export function Segmented({ + value, + onChange, + options, + className, +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string }[]; + className?: string; +}) { + return ( +
+ {options.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
+ ); +} + +/** + * OptionSelect — a themed, mutually-exclusive option group (a styled radio + * group) for 3+ choices whose labels can be long ("Least-recently-used"). + * + * Layout: a vertical stack of full-width selectable rows on mobile, so any + * label length fits at 360px with ZERO horizontal overflow — the label lives in + * a min-w-0 flex-1 cell and wraps if it ever needs to, while the check + * indicator stays shrink-0 on the right. On sm+ it optionally becomes an even + * multi-column grid (`cols`) for a compact, intentional desktop layout; pass + * cols={1} (default) to keep a single column everywhere. + * + * Active row = sky tint + sky border + filled sky check. Idle = slate border on + * white. Mirrors Segmented's value / onChange / options API for a near drop-in + * swap; `hint` is optional one-line helper text per option. Prefer Segmented + * for compact 2-option choices and OptionSelect for wide/long-label ones. + */ +export function OptionSelect({ + value, + onChange, + options, + cols = 1, + className, + "aria-label": ariaLabel, +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string; hint?: string }[]; + /** Columns from the sm breakpoint up. Mobile is always a single column. */ + cols?: 1 | 2 | 3; + className?: string; + "aria-label"?: string; +}) { + // Static class strings so Tailwind's JIT keeps them. + const smCols = cols === 3 ? "sm:grid-cols-3" : cols === 2 ? "sm:grid-cols-2" : ""; + return ( +
+ {options.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
+ ); +} + +/** + * EmailListInput — chip input for cc/bcc recipients, on-theme. Type an + * address and press Enter/Tab/comma to add; backspace on an empty field + * removes the last chip. + */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function EmailListInput({ + values, + onChange, + placeholder = "name@example.com", +}: { + values: string[]; + onChange: (values: string[]) => void; + placeholder?: string; +}) { + const [draft, setDraft] = React.useState(""); + + // Split on whitespace/comma/semicolon so a pasted or typed list of addresses + // becomes individual chips, de-duplicated against what's already there. + const addTokens = (text: string) => { + const tokens = text + .split(/[\s,;]+/) + .map((t) => t.trim()) + .filter(Boolean); + if (tokens.length === 0) return; + const next = [...values]; + for (const t of tokens) if (!next.includes(t)) next.push(t); + onChange(next); + }; + + const commit = () => { + if (!draft.trim()) return; + addTokens(draft); + setDraft(""); + }; + + return ( +
+ {values.map((v, i) => { + const invalid = !EMAIL_RE.test(v); + return ( + + {v} + + + ); + })} + setDraft(e.target.value)} + onPaste={(e) => { + const text = e.clipboardData.getData("text"); + if (/[\s,;]/.test(text)) { + e.preventDefault(); + addTokens(text); + setDraft(""); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === ";") { + if (draft.trim()) { + e.preventDefault(); + commit(); + } + } else if (e.key === "Backspace" && draft === "" && values.length > 0) { + e.preventDefault(); + onChange(values.slice(0, -1)); + } + }} + onBlur={commit} + placeholder={values.length === 0 ? placeholder : ""} + className="flex-1 min-w-[120px] h-5 bg-transparent text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ ); } diff --git a/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx b/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx index 812614f4..7ede98c0 100644 --- a/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx +++ b/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx @@ -1,18 +1,19 @@ import React from "react"; -import { RiCloseLine, RiCalendarScheduleLine } from "@remixicon/react"; +import { CalendarIcon, XIcon } from "lucide-react"; import Calendar from "../../Calendar"; import { format } from "date-fns"; +import { Label } from "@/components/ui/field"; export default function DateSelect({ title, value, onChange, }: { - title: string, - value: Date | null, - onChange: (v: Date | null) => void, + title: string; + value: Date | null; + onChange: (v: Date | null) => void; }) { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = React.useState(false); const modalRef = React.useRef(null); React.useEffect(() => { @@ -22,42 +23,47 @@ export default function DateSelect({ } }; if (open) { - document.addEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); } - return () => { - document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener("mousedown", handleClickOutside); }; }, [open]); return (
-

- {title} - onChange(null)} - > - - -

+
+ + {value && ( + + )} +
- setOpen(false)} - onSubmit={onChange} - /> + setOpen(false)} onSubmit={onChange} />
- ) + ); } diff --git a/web/src/components/app/campaigns/schedule/WeekScheduleGrid.tsx b/web/src/components/app/campaigns/schedule/WeekScheduleGrid.tsx new file mode 100644 index 00000000..c5d67323 --- /dev/null +++ b/web/src/components/app/campaigns/schedule/WeekScheduleGrid.tsx @@ -0,0 +1,361 @@ +// Calendar-style weekly sending editor with FULL per-day flexibility. Each +// Mon–Sun column owns an independent list of sending windows: +// • drag on empty space in a column to draw a new window +// • drag a window's body to move it, or its top/bottom edge to resize +// • × removes a window; the ⧉ in a day header copies that day to every day +// Everything snaps to 30-min steps. Windows here are in DISPLAY order (index +// 0 = Monday); the page converts to/from the Sun=0 wire format. + +import React from "react"; +import { CopyIcon, PlusIcon, XIcon } from "lucide-react"; + +export interface Interval { + start: number; // minutes since midnight [0,1440) + end: number; // minutes since midnight (start,1440] +} + +const ABBR = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +const HOURS = [0, 3, 6, 9, 12, 15, 18, 21, 24]; +const BODY_H = 420; +const GUTTER = 46; +const SNAP = 30; +const MIN_DRAW = 60; +const DAY = 1440; + +const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); +const snap = (n: number) => Math.round(n / SNAP) * SNAP; +const pct = (min: number) => `${(min / DAY) * 100}%`; +const hourLabel = (h: number) => { + const hh = h % 24; + const ampm = hh < 12 ? "a" : "p"; + const h12 = hh % 12 === 0 ? 12 : hh % 12; + return `${h12}${ampm}`; +}; +const fmt = (min: number) => { + const h = Math.floor(min / 60); + const m = min % 60; + const ampm = h < 12 ? "am" : "pm"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2, "0")}${ampm}`; +}; + +function mergeIntervals(ivs: Interval[]): Interval[] { + const sorted = ivs.filter((iv) => iv.end > iv.start).sort((a, b) => a.start - b.start); + const out: Interval[] = []; + for (const iv of sorted) { + const last = out[out.length - 1]; + if (last && iv.start <= last.end) last.end = Math.max(last.end, iv.end); + else out.push({ ...iv }); + } + return out; +} + +type DragMode = "move" | "start" | "end" | "draw"; +interface DragState { + day: number; + idx: number; + mode: DragMode; + startY: number; + anchorMin: number; // draw: the fixed edge + origStart: number; + origEnd: number; + rectTop: number; + rectH: number; +} + +export default function WeekScheduleGrid({ + windows, + onChange, +}: { + windows: Interval[][]; // length 7, display order (Mon=0) + onChange: (next: Interval[][]) => void; +}) { + const todayIdx = (new Date().getDay() + 6) % 7; // Mon=0..Sun=6 + const [drag, setDrag] = React.useState(null); + + // Refs keep the move/up listeners reading the freshest state without + // re-subscribing mid-gesture. + const winRef = React.useRef(windows); + winRef.current = windows; + const changeRef = React.useRef(onChange); + changeRef.current = onChange; + + const setDay = (day: number, next: Interval[]) => + changeRef.current(winRef.current.map((d, i) => (i === day ? next : d))); + const setInterval = (day: number, idx: number, iv: Interval) => + setDay( + day, + winRef.current[day].map((x, i) => (i === idx ? iv : x)), + ); + + React.useEffect(() => { + if (!drag) return; + const yToMin = (clientY: number) => clamp(((clientY - drag.rectTop) / drag.rectH) * DAY, 0, DAY); + const move = (e: PointerEvent) => { + if (drag.mode === "draw") { + const cur = snap(yToMin(e.clientY)); + const s = clamp(Math.min(drag.anchorMin, cur), 0, DAY - SNAP); + let en = clamp(Math.max(drag.anchorMin, cur), s + SNAP, DAY); + if (en - s < SNAP) en = s + SNAP; + setInterval(drag.day, drag.idx, { start: s, end: en }); + return; + } + const deltaMin = ((e.clientY - drag.startY) / drag.rectH) * DAY; + const dur = drag.origEnd - drag.origStart; + if (drag.mode === "move") { + const s = clamp(snap(drag.origStart + deltaMin), 0, DAY - dur); + setInterval(drag.day, drag.idx, { start: s, end: s + dur }); + } else if (drag.mode === "start") { + const s = clamp(snap(drag.origStart + deltaMin), 0, drag.origEnd - SNAP); + setInterval(drag.day, drag.idx, { start: s, end: drag.origEnd }); + } else { + const en = clamp(snap(drag.origEnd + deltaMin), drag.origStart + SNAP, DAY); + setInterval(drag.day, drag.idx, { start: drag.origStart, end: en }); + } + }; + const up = () => { + let dayIvs = winRef.current[drag.day]; + if (drag.mode === "draw") { + const iv = dayIvs[drag.idx]; + if (iv && iv.end - iv.start < MIN_DRAW) { + let s = iv.start; + const en = Math.min(iv.start + MIN_DRAW, DAY); + if (en - s < MIN_DRAW) s = en - MIN_DRAW; + dayIvs = dayIvs.map((x, i) => (i === drag.idx ? { start: s, end: en } : x)); + } + } + setDay(drag.day, mergeIntervals(dayIvs)); + setDrag(null); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + const prevCursor = document.body.style.cursor; + document.body.style.cursor = drag.mode === "move" ? "grabbing" : "ns-resize"; + document.body.style.userSelect = "none"; + return () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + document.body.style.cursor = prevCursor; + document.body.style.userSelect = ""; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [drag]); + + const beginBlockDrag = (e: React.PointerEvent, day: number, idx: number, mode: DragMode) => { + e.preventDefault(); + e.stopPropagation(); + const col = e.currentTarget.closest("[data-col-body]") as HTMLElement | null; + const rect = (col ?? (e.currentTarget as HTMLElement)).getBoundingClientRect(); + const iv = windows[day][idx]; + setDrag({ + day, + idx, + mode, + startY: e.clientY, + anchorMin: 0, + origStart: iv.start, + origEnd: iv.end, + rectTop: rect.top, + rectH: rect.height, + }); + }; + + const beginDraw = (e: React.PointerEvent, day: number) => { + // Touch/pen: do NOT draw — let the column scroll the page. Add via the + // "+" in the day header instead. Drawing-by-drag stays a mouse gesture. + if (e.pointerType !== "mouse") return; + // Only the empty column surface starts a draw (blocks stopPropagation). + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const anchor = clamp(snap(((e.clientY - rect.top) / rect.height) * DAY), 0, DAY - SNAP); + const newIv: Interval = { start: anchor, end: anchor + SNAP }; + const idx = winRef.current[day].length; + setDay(day, [...winRef.current[day], newIv]); + setDrag({ + day, + idx, + mode: "draw", + startY: e.clientY, + anchorMin: anchor, + origStart: anchor, + origEnd: anchor + SNAP, + rectTop: rect.top, + rectH: rect.height, + }); + }; + + const removeInterval = (day: number, idx: number) => + setDay(day, windows[day].filter((_, i) => i !== idx)); + // Touch-friendly add (no drag needed): drop a default 9–5 window, then drag + // the block to adjust. + const addDefault = (day: number) => + setDay(day, mergeIntervals([...winRef.current[day], { start: 9 * 60, end: 17 * 60 }])); + const copyToAll = (day: number) => { + const src = windows[day].map((iv) => ({ ...iv })); + onChange(windows.map(() => src.map((iv) => ({ ...iv })))); + }; + + return ( +
+ {/* day headers */} +
+
+
+ {ABBR.map((d, i) => { + const active = windows[i].length > 0; + return ( +
+ {d} + {i === todayIdx && ( + + )} + + {active && ( + + )} +
+ ); + })} +
+
+ + {/* grid body */} +
+ {HOURS.map((h) => ( + + {hourLabel(h)} + + ))} + {HOURS.map((h) => ( +
+ ))} + +
+ {ABBR.map((d, i) => { + const active = windows[i].length > 0; + const isToday = i === todayIdx; + return ( +
beginDraw(e, i)} + className={`relative flex-1 cursor-crosshair touch-pan-y ${ + active ? "bg-sky-50/30" : "bg-slate-50/40 hover:bg-slate-100/50" + } ${isToday ? "ring-1 ring-inset ring-sky-100" : ""}`} + > + {windows[i].length === 0 && ( + + drag, or tap + + + )} + {windows[i].map((iv, idx) => ( + beginBlockDrag(e, i, idx, "move")} + onTop={(e) => beginBlockDrag(e, i, idx, "start")} + onBottom={(e) => beginBlockDrag(e, i, idx, "end")} + onRemove={() => removeInterval(i, idx)} + /> + ))} +
+ ); + })} +
+
+
+ ); +} + +function Block({ + iv, + dragging, + onBody, + onTop, + onBottom, + onRemove, +}: { + iv: Interval; + dragging: boolean; + onBody: (e: React.PointerEvent) => void; + onTop: (e: React.PointerEvent) => void; + onBottom: (e: React.PointerEvent) => void; + onRemove: () => void; +}) { + const tall = iv.end - iv.start >= 90; + return ( +
+
{ + e.stopPropagation(); + onTop(e); + }} + className="absolute -top-1 inset-x-0 h-3 cursor-ns-resize touch-pan-x" + > +
+
+
{ + e.stopPropagation(); + onBottom(e); + }} + className="absolute -bottom-1 inset-x-0 h-3 cursor-ns-resize touch-pan-x" + > +
+
+ + + {fmt(iv.start)} + {tall ?
: "–"} + {fmt(iv.end)} +
+
+ ); +} diff --git a/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx b/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx index cd783a14..dea4849a 100644 --- a/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx +++ b/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx @@ -1,37 +1,45 @@ -import Checkbox from "../../Checkbox"; +// Active-days picker — a roomier grid of 7 day cells backed by a uint8 +// day-of-week bitmask (bit i = weekday i, 0=Mon..6=Sun). On-theme: sky +// active, slate idle, h-12 rounded-md cells with a 3-letter label over a +// status dot. Logic is identical to the bitmask contract — only the cell +// presentation changed. export default function WeekdayBitmask({ weekdays, value, setValue, }: { - weekdays: string[], - value: number, - setValue: (v: number) => void, + weekdays: string[]; + value: number; + setValue: (v: number) => void; }) { - const toggleDay = (index: number) => { - const mask = 1 << index; - setValue(value ^ mask); // XOR toggles the bit - }; - - return weekdays.map((day, index) => { - const mask = 1 << index; - const isChecked = (value & mask) !== 0; - - return ( - - ); - }) -}; + return ( +
+ {weekdays.map((day, index) => { + const mask = 1 << index; + const active = (value & mask) !== 0; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx new file mode 100644 index 00000000..2b407c21 --- /dev/null +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -0,0 +1,1610 @@ +// Visual flow canvas for a campaign's steps (React Flow) — a branching tree +// where each condition is its own "IF" block. +// +// SHAPE +// Step ──▶ [ IF opened within 3d ] ──▶ Step … (the branch's steps flow below +// its IF block). An unconditional path is a plain line straight to the step. +// +// BUILD +// - Drag from a STEP's bottom dot → a plain "just go there" connection. +// - Drag from an IF block's SIDE dots → a new "if" branch from that step +// (onto a step, or empty for a new step). Drag the IF block's BOTTOM dot → +// change where that if leads. +// - Click a step → edit its email. Click an IF block → edit its condition. +// - Nothing connects automatically; a step with no outgoing path ends in STOP. + +import React from "react"; +import { + BellOffIcon, + ChevronDownIcon, + ChevronUpIcon, + ClockIcon, + FlagIcon, + GitBranchIcon, + Loader2Icon, + MailIcon, + PlusIcon, + SendIcon, + TagIcon, + Trash2Icon, + UnlinkIcon, + XIcon, + ZapIcon, +} from "lucide-react"; +import { + ReactFlow, + Background, + Controls, + Panel, + Handle, + MarkerType, + Position, + useNodesState, + useEdgesState, + type Node, + type Edge, + type Connection, + type NodeProps, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import dagre from "@dagrejs/dagre"; +import { useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; +import type { SequenceBranch, BranchCondition, BranchField } from "@/lib/api/models/app/campaigns/sequences/Branching"; +import { BRANCH_FIELD_LABELS } from "@/lib/api/models/app/campaigns/sequences/Branching"; +import useSequences from "@/lib/api/hooks/app/campaigns/sequences/useSequences"; +import useCreateSequence from "@/lib/api/hooks/app/campaigns/sequences/useCreateSequence"; +import useDeleteSequence from "@/lib/api/hooks/app/campaigns/sequences/useDeleteSequence"; +import updateSequence from "@/lib/api/client/app/campaigns/sequences/updateSequence"; +import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign"; +import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign"; +import { useConfirm } from "@/hooks/context/confirm"; +import useClickOutside from "@/hooks/useClickOutside"; +import { NumberInput, Label, TextInput } from "@/components/ui/field"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import SequenceView from "./SequenceView"; +import CategoryPicker from "@/components/app/contacts/CategoryPicker"; +import type { SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action"; + +const STOP_ID = "__stop__"; +const IF_PREFIX = "if-"; +const NODE_W = 248; +const NODE_H = 92; +const MAX_STEPS = 50; +const SEQ_KEY = (id: string) => ["campaigns", id, "sequences"] as const; + +const ifNodeId = (branchId: string) => `${IF_PREFIX}${branchId}`; +const isIfId = (id: string) => id.startsWith(IF_PREFIX); + +function newBranchId(): string { + try { + return crypto.randomUUID(); + } catch { + return `b_${Math.floor(performance.now())}_${Math.random().toString(36).slice(2, 8)}`; + } +} + +const isCond = (b: SequenceBranch) => (b.conditions?.length ?? 0) > 0; +const stepName = (s: Sequence | undefined) => (s?.name?.trim() ? s.name : "Untitled step"); + +// Conditions first-match; unconditional ("just go there") paths are the fallback. +function ordered(branches: SequenceBranch[]): SequenceBranch[] { + return [...branches.filter(isCond), ...branches.filter((b) => !isCond(b))]; +} + +function conditionText(b: SequenceBranch): string { + return (b.conditions ?? []) + .map((c) => { + if (c.field === "random") return `${c.value ?? 50}% random`; + const f = BRANCH_FIELD_LABELS[c.field] ?? c.field; + return `${f} within ${c.value ?? 3}d`; + }) + .join(" + "); +} + +function layoutGraph(nodes: Node[], edges: Edge[]): Node[] { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: "TB", nodesep: 220, ranksep: 120, marginx: 32, marginy: 32, edgesep: 120 }); + nodes.forEach((n) => { + let w = NODE_W; + let h = NODE_H; + if (n.id === STOP_ID) { + w = 96; + h = 40; + } else if (isIfId(n.id)) { + w = 210; + h = 40; + } + g.setNode(n.id, { width: w, height: h }); + }); + edges.forEach((e) => { + const text = typeof e.label === "string" ? e.label : ""; + // Keep the if / else-if spine (in / chain / else edges) straight and + // aligned; let the "then" steps fan out to the side. + const spine = e.id.startsWith("in-") || e.id.startsWith("chain-") || e.id.startsWith("else-"); + const label = text ? { width: Math.min(160, text.length * 6 + 24), height: 30, labelpos: "c" } : {}; + g.setEdge(e.source, e.target, { ...label, weight: spine ? 6 : 1 }); + }); + dagre.layout(g); + return nodes.map((n) => { + const p = g.node(n.id); + return p ? { ...n, position: { x: p.x - p.width / 2, y: p.y - p.height / 2 } } : n; + }); +} + +// dagre can pile disconnected pieces on top of each other at the origin, which +// hides orphaned steps (an upstream step was deleted) so they can't be clicked, +// deleted, or connected. Split the graph into connected components and stack +// them in vertical bands — the main flow (with the entry) first, orphans below. +function stackComponents(nodes: Node[], edges: Edge[]): Node[] { + const adj = new Map(); + const link = (a: string, b: string) => { + const list = adj.get(a) ?? []; + list.push(b); + adj.set(a, list); + }; + for (const e of edges) { + link(e.source, e.target); + link(e.target, e.source); + } + const comp = new Map(); + let count = 0; + for (const n of nodes) { + if (comp.has(n.id)) continue; + const queue = [n.id]; + comp.set(n.id, count); + while (queue.length) { + const id = queue.shift()!; + for (const m of adj.get(id) ?? []) { + if (!comp.has(m)) { + comp.set(m, count); + queue.push(m); + } + } + } + count++; + } + if (count <= 1) return nodes; // a single connected flow needs no banding + + const box = new Map(); + for (const n of nodes) { + const k = comp.get(n.id)!; + const h = n.id === STOP_ID ? 40 : NODE_H; + const b = box.get(k) ?? { minX: Infinity, minY: Infinity, maxY: -Infinity }; + b.minX = Math.min(b.minX, n.position.x); + b.minY = Math.min(b.minY, n.position.y); + b.maxY = Math.max(b.maxY, n.position.y + h); + box.set(k, b); + } + const baseX = Math.min(...[...box.values()].map((b) => b.minX)); + const GAP = 140; + let cursorY = 0; + const offset = new Map(); + for (const k of [...box.keys()].sort((a, b) => a - b)) { + const b = box.get(k)!; + offset.set(k, { dx: baseX - b.minX, dy: cursorY - b.minY }); + cursorY += b.maxY - b.minY + GAP; + } + return nodes.map((n) => { + const o = offset.get(comp.get(n.id)!)!; + return { ...n, position: { x: n.position.x + o.dx, y: n.position.y + o.dy } }; + }); +} + +// ── Custom nodes ──────────────────────────────────────────────────────────── +type StepNodeData = { + label: string; + subtitle: string; + isStart: boolean; + endsHere: boolean; + orphan: boolean; + onDelete: () => void; +}; + +function StepNode({ data, selected }: NodeProps) { + const d = data as StepNodeData; + return ( +
+ +
+ + + + + {d.label || "Untitled step"} + + {d.isStart && ( + + Start + + )} + +
+
+
Email
+
{d.subtitle || "No subject yet"}
+
+ {d.orphan ? ( +
+ + Not connected — drag a link in +
+ ) : d.endsHere ? ( +
+ + Ends here +
+ ) : null} + {/* Right dot = start an "if" branch; bottom dot = plain "go there". */} + + +
+ ); +} + +type IfNodeData = { label: string; onDelete: () => void }; + +function IfNode({ data, selected }: NodeProps) { + const d = data as IfNodeData; + return ( +
+ + {/* Right dot = the THEN path: where this if leads (drag to change). */} + +
+ + if + {d.label} + +
+ {/* Bottom dot = the ELSE path: drag to add the next condition (else-if). */} + +
+ ); +} + +function StopNode() { + return ( +
+ + + Stop +
+ ); +} + +// Per-type chrome for action nodes (icon + label + accent). +const ACTION_META: Record = { + add_tag: { label: "Add tag", Icon: TagIcon, tint: "text-emerald-600" }, + remove_tag: { label: "Remove tag", Icon: TagIcon, tint: "text-amber-600" }, + unsubscribe: { label: "Unsubscribe", Icon: BellOffIcon, tint: "text-rose-600" }, + notify: { label: "Notify", Icon: SendIcon, tint: "text-sky-600" }, +}; + +// actionSummary is the one-line subtitle shown on an action node. +function actionSummary(a?: SequenceAction | null): string { + if (!a) return "Not configured"; + switch (a.type) { + case "add_tag": + return a.category_id ? "Add a tag" : "Pick a tag…"; + case "remove_tag": + return a.category_id ? "Remove a tag" : "Pick a tag…"; + case "unsubscribe": + return "Unsubscribe the contact"; + case "notify": + return "Send a notification"; + default: + return "Action"; + } +} + +type ActionNodeData = { + actionType: string; + label: string; + subtitle: string; + endsHere: boolean; + orphan: boolean; + onDelete: () => void; +}; + +function ActionNode({ data, selected }: NodeProps) { + const d = data as ActionNodeData; + const meta = ACTION_META[d.actionType] ?? { label: "Action", Icon: ZapIcon, tint: "text-slate-500" }; + const Icon = meta.Icon; + return ( +
+ +
+ + + + {d.label} + +
+
+
{meta.label}
+
{d.subtitle}
+
+ {d.orphan ? ( +
+ + Not connected — drag a link in +
+ ) : d.endsHere ? ( +
+ + Ends here +
+ ) : null} + {/* Same handles as a step: right = "if" branch, bottom = "go there". */} + + +
+ ); +} + +const nodeTypes = { step: StepNode, ifcond: IfNode, stop: StopNode, action: ActionNode }; + +type IfMeta = { sourceId: string; branchId: string }; + +export default function CampaignFlow({ campaignId }: { campaignId: string }) { + const { data: sequences } = useSequences(campaignId); + const createSequence = useCreateSequence(campaignId); + const deleteSequence = useDeleteSequence(campaignId); + const { data: campaign } = useCampaign(campaignId); + const updateCampaign = useUpdateCampaign(campaignId); + const confirm = useConfirm(); + const qc = useQueryClient(); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedEdge, setSelectedEdge] = React.useState<{ sourceId: string; branchId: string } | null>(null); + const [editStepId, setEditStepId] = React.useState(null); + const [adding, setAdding] = React.useState(false); + // While dragging a node, kill the position transition so the drag is 1:1. + const [dragging, setDragging] = React.useState(false); + const structureSig = React.useRef(""); + const ifMetaRef = React.useRef>({}); + + const seqById = React.useMemo(() => { + const m = new Map(); + for (const s of sequences) m.set(s.id, s); + return m; + }, [sequences]); + + const invalidate = React.useCallback(() => qc.invalidateQueries({ queryKey: SEQ_KEY(campaignId) }), [qc, campaignId]); + + const openCondition = React.useCallback((sourceId: string, branchId: string) => { + setSelectedEdge({ sourceId, branchId }); + setEditStepId(null); + }, []); + const openEditStep = React.useCallback((id: string) => { + setEditStepId(id); + setSelectedEdge(null); + }, []); + + const reachableFrom = React.useCallback( + (rootId: string) => { + const set = new Set([rootId]); + const queue = [rootId]; + while (queue.length) { + const id = queue.shift()!; + for (const b of seqById.get(id)?.conditions?.branches ?? []) { + const t = b.target_sequence_id; + if (t && !set.has(t)) { + set.add(t); + queue.push(t); + } + } + } + return set; + }, + [seqById], + ); + + const saveBranches = React.useCallback( + async (sourceId: string, branches: SequenceBranch[]) => { + const b = ordered(branches); + qc.setQueryData(SEQ_KEY(campaignId), (old) => + old?.map((s) => (s.id === sourceId ? { ...s, conditions: { branches: b } } : s)), + ); + try { + await updateSequence(campaignId, sourceId, { conditions: { branches: b } }); + } catch { + toast.error("Couldn't save the connection"); + } finally { + invalidate(); + } + }, + [campaignId, qc, invalidate], + ); + + const saveWait = React.useCallback( + async (targetId: string, days: number) => { + const d = Math.max(0, Math.round(days)); + qc.setQueryData(SEQ_KEY(campaignId), (old) => + old?.map((s) => (s.id === targetId ? { ...s, wait_after: d } : s)), + ); + try { + await updateSequence(campaignId, targetId, { wait_after: d }); + } catch { + toast.error("Couldn't save the wait"); + } finally { + invalidate(); + } + }, + [campaignId, qc, invalidate], + ); + + const addUnconditional = React.useCallback( + (sourceId: string, target: string | null) => { + const src = seqById.get(sourceId); + if (!src) return; + saveBranches(sourceId, [ + ...(src.conditions?.branches ?? []), + { branch_id: newBranchId(), target_sequence_id: target, conditions: [] }, + ]); + }, + [seqById, saveBranches], + ); + const addIfTo = React.useCallback( + (sourceId: string, target: string | null) => { + const src = seqById.get(sourceId); + if (!src) return; + const branch: SequenceBranch = { + branch_id: newBranchId(), + target_sequence_id: target, + conditions: [{ field: "opened", operator: "within_days", value: 3 }], + }; + // Drop a default ("else") line that already points to the same step + // the if routes to — otherwise adding the if looks like it + // auto-created a duplicate else to the same step. The else stays + // something you add and connect yourself. + const existing = (src.conditions?.branches ?? []).filter( + (b) => !(!isCond(b) && b.target_sequence_id === target), + ); + saveBranches(sourceId, [...existing, branch]); + openCondition(sourceId, branch.branch_id); + }, + [seqById, saveBranches, openCondition], + ); + const retargetBranch = React.useCallback( + (sourceId: string, branchId: string, target: string | null) => { + const src = seqById.get(sourceId); + if (!src) return; + saveBranches( + sourceId, + (src.conditions?.branches ?? []).map((b) => + b.branch_id === branchId ? { ...b, target_sequence_id: target } : b, + ), + ); + }, + [seqById, saveBranches], + ); + const deleteBranch = React.useCallback( + (sourceId: string, branchId: string) => { + const src = seqById.get(sourceId); + if (!src) return; + saveBranches( + sourceId, + (src.conditions?.branches ?? []).filter((b) => b.branch_id !== branchId), + ); + setSelectedEdge((cur) => (cur?.branchId === branchId ? null : cur)); + }, + [seqById, saveBranches], + ); + const moveBranch = React.useCallback( + (sourceId: string, branchId: string, dir: -1 | 1) => { + const src = seqById.get(sourceId); + if (!src) return; + const all = src.conditions?.branches ?? []; + const conds = all.filter(isCond); + const i = conds.findIndex((b) => b.branch_id === branchId); + const j = i + dir; + if (i < 0 || j < 0 || j >= conds.length) return; + const next = [...conds]; + [next[i], next[j]] = [next[j], next[i]]; + saveBranches(sourceId, [...next, ...all.filter((b) => !isCond(b))]); + }, + [seqById, saveBranches], + ); + + // Step drops standalone; you connect it by dragging. + const addStep = React.useCallback(async () => { + if (adding || sequences.length >= MAX_STEPS) return; + setAdding(true); + try { + await toast.promise(createSequence.mutateAsync(), { + loading: "Adding step…", + success: "Step added — drag a dot to connect it.", + error: (err: AppError) => buildError(err), + }); + } finally { + setAdding(false); + } + }, [adding, sequences.length, createSequence]); + + // Add an action node. It drops standalone like a step; you connect it by + // dragging, and configure it by clicking it open. + const addAction = React.useCallback( + async (type: SequenceActionType) => { + if (adding || sequences.length >= MAX_STEPS) return; + setAdding(true); + try { + const created = (await createSequence.mutateAsync()) as Sequence; + await updateSequence(campaignId, created.id, { + kind: "action", + action: { type }, + }); + invalidate(); + toast.success("Action added — drag a dot to connect it."); + } catch (err) { + toast.error(buildError(err as AppError)); + } finally { + setAdding(false); + } + }, + [adding, sequences.length, createSequence, campaignId, invalidate], + ); + + // Drag from a step's dot to empty -> new step, plain ("just go there") link. + const dragOutStep = React.useCallback( + async (sourceId: string) => { + if (adding || sequences.length >= MAX_STEPS) return; + const src = seqById.get(sourceId); + setAdding(true); + try { + const created = (await createSequence.mutateAsync()) as Sequence; + await saveBranches(sourceId, [ + ...(src?.conditions?.branches ?? []), + { branch_id: newBranchId(), target_sequence_id: created.id, conditions: [] }, + ]); + } catch { + toast.error("Couldn't add the step"); + } finally { + setAdding(false); + } + }, + [adding, sequences.length, seqById, createSequence, saveBranches], + ); + // Drag from an IF block's side to empty -> new step + new if-branch. + const addIfToNew = React.useCallback( + async (sourceId: string) => { + if (adding || sequences.length >= MAX_STEPS) return; + const src = seqById.get(sourceId); + setAdding(true); + try { + const created = (await createSequence.mutateAsync()) as Sequence; + const branch: SequenceBranch = { + branch_id: newBranchId(), + target_sequence_id: created.id, + conditions: [{ field: "opened", operator: "within_days", value: 3 }], + }; + await saveBranches(sourceId, [...(src?.conditions?.branches ?? []), branch]); + openCondition(sourceId, branch.branch_id); + } catch { + toast.error("Couldn't add the step"); + } finally { + setAdding(false); + } + }, + [adding, sequences.length, seqById, createSequence, saveBranches, openCondition], + ); + // Drag an IF block's bottom dot to empty -> new step the if leads to. + const retargetToNew = React.useCallback( + async (sourceId: string, branchId: string) => { + if (adding || sequences.length >= MAX_STEPS) return; + setAdding(true); + try { + const created = (await createSequence.mutateAsync()) as Sequence; + retargetBranch(sourceId, branchId, created.id); + } catch { + toast.error("Couldn't add the step"); + } finally { + setAdding(false); + } + }, + [adding, sequences.length, createSequence, retargetBranch], + ); + + const deleteStep = React.useCallback( + (id: string) => { + const label = stepName(seqById.get(id)); + const referencing = sequences.filter( + (s) => s.id !== id && (s.conditions?.branches ?? []).some((b) => b.target_sequence_id === id), + ); + const extra = referencing.length + ? ` ${referencing.length} connection${referencing.length === 1 ? "" : "s"} into it will be removed too.` + : ""; + confirm.show(`Delete “${label}”? This can't be undone.${extra}`, async () => { + try { + await Promise.all( + referencing.map((s) => + updateSequence(campaignId, s.id, { + conditions: { + branches: (s.conditions?.branches ?? []).filter((b) => b.target_sequence_id !== id), + }, + }), + ), + ); + await deleteSequence.mutateAsync(id); + invalidate(); + setEditStepId((cur) => (cur === id ? null : cur)); + setSelectedEdge((cur) => (cur?.sourceId === id ? null : cur)); + toast.success("Step deleted"); + } catch { + toast.error("Couldn't delete the step"); + throw new Error("delete-failed"); + } + }); + }, + [sequences, seqById, confirm, campaignId, deleteSequence, invalidate], + ); + + // Node-data callbacks via refs so the layout effect deps don't churn. + const deleteStepRef = React.useRef(deleteStep); + const deleteBranchRef = React.useRef(deleteBranch); + React.useEffect(() => { + deleteStepRef.current = deleteStep; + deleteBranchRef.current = deleteBranch; + }, [deleteStep, deleteBranch]); + + React.useEffect(() => { + const waitTag = (targetId: string | null) => { + if (!targetId) return ""; + const w = seqById.get(targetId)?.wait_after ?? 0; + return w > 0 ? `wait ${w}d` : ""; + }; + + // Steps reachable from the entry (first step). Anything else became an + // orphan — e.g. an upstream step was deleted — and is flagged so it can + // be spotted and re-linked (it stays fully connectable). + const reachable = new Set(); + if (sequences[0]) { + const queue = [sequences[0].id]; + reachable.add(sequences[0].id); + while (queue.length) { + const id = queue.shift()!; + for (const b of seqById.get(id)?.conditions?.branches ?? []) { + const t = b.target_sequence_id; + if (t && !reachable.has(t)) { + reachable.add(t); + queue.push(t); + } + } + } + } + + let emailNum = 0; + const allNodes: Node[] = sequences.map((s, i) => { + const branches = s.conditions?.branches ?? []; + const isAction = s.kind !== "email"; + if (isAction) { + const at = s.action?.type ?? "add_tag"; + const fallback = ACTION_META[at]?.label ?? "Action"; + return { + id: s.id, + type: "action", + position: { x: 0, y: 0 }, + data: { + actionType: at, + label: s.name?.trim() || fallback, + subtitle: actionSummary(s.action), + endsHere: branches.length === 0, + orphan: !reachable.has(s.id), + onDelete: () => deleteStepRef.current(s.id), + } satisfies ActionNodeData, + }; + } + emailNum += 1; + return { + id: s.id, + type: "step", + position: { x: 0, y: 0 }, + data: { + label: s.name?.trim() || `Email ${emailNum}`, + subtitle: s.subject, + isStart: i === 0, + endsHere: branches.length === 0, + orphan: !reachable.has(s.id), + onDelete: () => deleteStepRef.current(s.id), + } satisfies StepNodeData, + }; + }); + + const ifMeta: Record = {}; + const flowEdges: Edge[] = []; + const edgeStyle = (cond: boolean) => + cond ? { stroke: "#0ea5e9", strokeWidth: 2 } : { stroke: "#94a3b8" }; + + sequences.forEach((s) => { + const branches = ordered(s.conditions?.branches ?? []); + const conds = branches.filter(isCond); + const uncond = branches.find((b) => !isCond(b)); + + conds.forEach((b, i) => { + const nid = ifNodeId(b.branch_id); + ifMeta[nid] = { sourceId: s.id, branchId: b.branch_id }; + allNodes.push({ + id: nid, + type: "ifcond", + position: { x: 0, y: 0 }, + data: { + label: conditionText(b), + onDelete: () => deleteBranchRef.current(s.id, b.branch_id), + } satisfies IfNodeData, + }); + // Incoming: from the step (first condition) or the PREVIOUS + // condition's ELSE — so conditions chain as if / else-if. + if (i === 0) { + flowEdges.push({ + id: `in-${b.branch_id}`, + source: s.id, + sourceHandle: "s", + target: nid, + style: edgeStyle(true), + data: { sourceId: s.id, branchId: b.branch_id }, + }); + } else { + flowEdges.push({ + id: `chain-${b.branch_id}`, + source: ifNodeId(conds[i - 1].branch_id), + sourceHandle: "else", + target: nid, + label: "else", + style: edgeStyle(false), + labelStyle: { fill: "#94a3b8", fontSize: 10 }, + labelBgStyle: { fill: "#fff", stroke: "#e2e8f0" }, + labelBgPadding: [4, 2], + labelBgBorderRadius: 5, + data: { sourceId: s.id, branchId: b.branch_id }, + }); + } + // THEN path -> the condition's target. + const wt = waitTag(b.target_sequence_id); + flowEdges.push({ + id: `then-${b.branch_id}`, + source: nid, + sourceHandle: "out", + target: b.target_sequence_id ?? STOP_ID, + label: wt || undefined, + reconnectable: true, + style: edgeStyle(true), + labelStyle: { fill: "#0369a1", fontSize: 10 }, + labelBgStyle: { fill: "#fff", stroke: "#bae6fd" }, + labelBgPadding: [5, 3], + labelBgBorderRadius: 5, + data: { sourceId: s.id, branchId: b.branch_id }, + }); + }); + + if (uncond) { + const wt = waitTag(uncond.target_sequence_id); + const target = uncond.target_sequence_id ?? STOP_ID; + if (conds.length > 0) { + // The final ELSE hangs off the LAST condition's else. + flowEdges.push({ + id: `else-${uncond.branch_id}`, + source: ifNodeId(conds[conds.length - 1].branch_id), + sourceHandle: "else", + target, + label: wt ? `else · ${wt}` : "else", + reconnectable: true, + style: edgeStyle(false), + labelStyle: { fill: "#475569", fontSize: 10 }, + labelBgStyle: { fill: "#fff", stroke: "#e2e8f0" }, + labelBgPadding: [5, 3], + labelBgBorderRadius: 5, + data: { sourceId: s.id, branchId: uncond.branch_id }, + }); + } else { + // No conditions on this step: a plain "just go there" line. + flowEdges.push({ + id: `u-${uncond.branch_id}`, + source: s.id, + sourceHandle: "s", + target, + label: wt || undefined, + reconnectable: true, + style: edgeStyle(false), + labelStyle: { fill: "#475569", fontSize: 10 }, + labelBgStyle: { fill: "#fff", stroke: "#e2e8f0" }, + labelBgPadding: [5, 3], + labelBgBorderRadius: 5, + data: { sourceId: s.id, branchId: uncond.branch_id }, + }); + } + } + }); + ifMetaRef.current = ifMeta; + + const anyStop = sequences.some((s) => (s.conditions?.branches ?? []).some((b) => b.target_sequence_id === null)); + if (anyStop) allNodes.push({ id: STOP_ID, type: "stop", position: { x: 0, y: 0 }, data: {} }); + + // Flowing curved (bezier) connectors with arrowheads — no boxy right + // angles. The arrow takes the edge's own stroke colour. + const smoothEdges: Edge[] = flowEdges.map((e) => ({ + ...e, + type: "default", + markerEnd: { + type: MarkerType.ArrowClosed, + width: 16, + height: 16, + color: (e.style as { stroke?: string } | undefined)?.stroke ?? "#94a3b8", + }, + })); + + const laid = stackComponents(layoutGraph(allNodes, smoothEdges), smoothEdges); + const sig = + allNodes.map((n) => n.id).sort().join(",") + + "|" + + smoothEdges.map((e) => `${e.source}>${e.target}`).sort().join(","); + const changed = sig !== structureSig.current; + structureSig.current = sig; + setNodes((cur) => { + if (changed) return laid; + const pos = new Map(cur.map((n) => [n.id, n.position])); + return laid.map((n) => (pos.has(n.id) ? { ...n, position: pos.get(n.id)! } : n)); + }); + setEdges(smoothEdges); + }, [sequences, seqById, setNodes, setEdges]); + + // Subtree highlight: select a step/if → light up what's reachable, dim rest. + React.useEffect(() => { + let root: string | null = null; + if (editStepId) root = editStepId; + else if (selectedEdge) { + const br = seqById + .get(selectedEdge.sourceId) + ?.conditions?.branches?.find((b) => b.branch_id === selectedEdge.branchId); + root = br?.target_sequence_id ?? selectedEdge.sourceId; + } + const hl = root ? reachableFrom(root) : null; + const stepIn = (id: string) => { + if (!hl) return true; + if (id === STOP_ID) return true; + if (isIfId(id)) { + const m = ifMetaRef.current[id]; + return m ? hl.has(m.sourceId) : true; + } + return hl.has(id); + }; + setNodes((ns) => + ns.map((n) => ({ ...n, style: { ...n.style, opacity: hl && !stepIn(n.id) ? 0.3 : 1 } })), + ); + setEdges((es) => + es.map((e) => { + const sid = (e.data as { sourceId?: string } | undefined)?.sourceId; + const on = !hl || (sid ? hl.has(sid) : true); + return { ...e, style: { ...e.style, opacity: on ? 1 : 0.15 } }; + }), + ); + }, [editStepId, selectedEdge, seqById, reachableFrom, setNodes, setEdges]); + + const onConnect = React.useCallback( + (c: Connection) => { + if (!c.source || !c.target || c.source === c.target || isIfId(c.target)) return; + const target = c.target === STOP_ID ? null : c.target; + if (isIfId(c.source)) { + const m = ifMetaRef.current[c.source]; + if (!m) return; + // "out" = retarget the then-target; "else" (bottom gray dot) = + // an unconditional "always / just go there" fallback by default. + if (c.sourceHandle === "out") retargetBranch(m.sourceId, m.branchId, target); + else addUnconditional(m.sourceId, target); + } else if (c.sourceHandle === "if") { + addIfTo(c.source, target); + } else { + addUnconditional(c.source, target); + } + }, + [retargetBranch, addIfTo, addUnconditional], + ); + + const selected = React.useMemo(() => { + if (!selectedEdge) return null; + const src = seqById.get(selectedEdge.sourceId); + const br = src?.conditions?.branches?.find((b) => b.branch_id === selectedEdge.branchId); + return src && br ? { source: src, branch: br } : null; + }, [selectedEdge, seqById]); + + // Touch / coarse-pointer devices: don't let a drag MOVE nodes (so a swipe + // pans to navigate instead of "placing a card"), and let page scroll through. + const isCoarse = React.useMemo( + () => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(pointer: coarse)").matches, + [], + ); + + const editStep = editStepId ? seqById.get(editStepId) : undefined; + const editIndex = editStep ? sequences.findIndex((s) => s.id === editStep.id) : -1; + const atMax = sequences.length >= MAX_STEPS; + + return ( +
+ setDragging(true)} + onNodeDragStop={() => setDragging(false)} + onConnect={onConnect} + nodesDraggable={!isCoarse} + zoomOnScroll={false} + panOnScroll={false} + preventScrolling={false} + minZoom={0.2} + maxZoom={1.75} + onConnectEnd={(_, state) => { + const from = state.fromNode; + if (!from || state.toNode) return; + const handle = state.fromHandle?.id; + if (isIfId(from.id)) { + const m = ifMetaRef.current[from.id]; + if (!m) return; + // "out" = new then-step; "else" (bottom gray dot) = a new + // step reached unconditionally ("always / just go there"). + if (handle === "out") retargetToNew(m.sourceId, m.branchId); + else dragOutStep(m.sourceId); + } else if (handle === "if") { + addIfToNew(from.id); + } else { + dragOutStep(from.id); + } + }} + onReconnect={(oldEdge, conn) => { + const d = oldEdge.data as { sourceId?: string; branchId?: string } | undefined; + if (!d?.sourceId || !d?.branchId || !conn.target || isIfId(conn.target)) return; + retargetBranch(d.sourceId, d.branchId, conn.target === STOP_ID ? null : conn.target); + }} + onEdgesDelete={(deleted) => + deleted.forEach((e) => { + // Cancel a line: removing an edge removes its branch (the + // then/else/“go there” path it represents). + const d = e.data as { sourceId?: string; branchId?: string } | undefined; + if (d?.sourceId && d?.branchId) deleteBranch(d.sourceId, d.branchId); + }) + } + nodeTypes={nodeTypes} + onEdgeClick={(_, edge) => { + const d = edge.data as { sourceId?: string; branchId?: string } | undefined; + if (d?.sourceId && d?.branchId) openCondition(d.sourceId, d.branchId); + }} + onNodeClick={(_, node) => { + if (node.type === "ifcond") { + const m = ifMetaRef.current[node.id]; + if (m) openCondition(m.sourceId, m.branchId); + } else if (node.id !== STOP_ID) { + openEditStep(node.id); + } + }} + fitView + proOptions={{ hideAttribution: true }} + > + + + + +
+ + + { + qc.setQueryData(["campaigns", campaignId], (old: unknown) => + old ? { ...(old as object), stop_on_reply: next } : old, + ); + updateCampaign + .mutateAsync({ stop_on_reply: next }) + .catch((err) => toast.error(buildError(err as AppError))); + }} + /> +
+
+ + +
+ + step: bottom dot = go there, right (amber) dot = add an “if” · IF block: right dot = then, bottom (gray) dot = else / just go there · click a line then press Delete to remove it · no match = stop + +
+
+
+ + {selected && ( + b.branch_id === selected.branch.branch_id)} + condCount={(selected.source.conditions?.branches ?? []).filter(isCond).length} + onMove={(dir) => moveBranch(selected.source.id, selected.branch.branch_id, dir)} + waitDays={seqById.get(selected.branch.target_sequence_id ?? "")?.wait_after ?? 0} + onClose={() => setSelectedEdge(null)} + onSetWait={(days) => { + if (selected.branch.target_sequence_id) saveWait(selected.branch.target_sequence_id, days); + }} + onSave={(updated) => { + saveBranches( + selected.source.id, + (selected.source.conditions?.branches ?? []).map((b) => + b.branch_id === updated.branch_id ? updated : b, + ), + ); + setSelectedEdge(null); + }} + onDelete={() => { + deleteBranch(selected.source.id, selected.branch.branch_id); + setSelectedEdge(null); + }} + /> + )} + + {editStep && ( +
+
+ Edit “{stepName(editStep)}” +
+ + +
+
+
+ + {editStep.kind !== "email" ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} + +// ── Stop-on-reply toggle ──────────────────────────────────────────────────── +function StopOnReplyToggle({ on, onToggle }: { on: boolean; onToggle: (next: boolean) => void }) { + return ( +
+ Stop on reply + +
+ ); +} + +function WaitRow({ value, onCommit }: { value: number; onCommit: (v: number) => void }) { + const [draft, setDraft] = React.useState(value); + React.useEffect(() => setDraft(value), [value]); + return ( +
+ + wait + onCommit(Math.max(0, Math.round(v)))} + min={0} + max={60} + className="w-16" + align="center" + /> + days before it +
+ ); +} + +// ── Connection editor (optional condition + wait behind a connection) ─────── +function ConnectionEditor({ + source, + branch, + steps, + order, + condCount, + onMove, + waitDays, + onSetWait, + onClose, + onSave, + onDelete, +}: { + source: Sequence; + branch: SequenceBranch; + steps: Sequence[]; + order: number; + condCount: number; + onMove: (dir: -1 | 1) => void; + waitDays: number; + onSetWait: (days: number) => void; + onClose: () => void; + onSave: (b: SequenceBranch) => void; + onDelete: () => void; +}) { + const c0 = branch.conditions?.[0]; + const [field, setField] = React.useState(c0 ? c0.field : "always"); + const [value, setValue] = React.useState(c0?.value ?? (c0?.field === "random" ? 50 : 3)); + + const sel = + "h-7 w-full rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-800 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"; + const isAlways = field === "always"; + const isRandom = field === "random"; + const isNegative = field === "not_opened" || field === "not_clicked" || field === "not_replied"; + const target = steps.find((s) => s.id === branch.target_sequence_id); + const targetLabel = branch.target_sequence_id === null ? "Stop the sequence" : target ? `“${stepName(target)}”` : "—"; + + const buildConditions = (): BranchCondition[] => { + if (isAlways) return []; + if (isRandom) return [{ field: "random", operator: "chance", value }]; + return [{ field: field as BranchField, operator: "within_days", value }]; + }; + const save = (target_sequence_id: string | null) => + onSave({ branch_id: branch.branch_id, target_sequence_id, conditions: buildConditions() }); + + return ( +
+
+ + From “{stepName(source)}” + + +
+ + {order >= 0 && condCount > 1 && ( +
+ When several match, this is checked {order + 1} of {condCount} + + + + +
+ )} + +
+
+ then go to + {targetLabel} + {branch.target_sequence_id !== null && ( + + )} +
+ +
+

Take this path

+ +
+ + {isRandom && ( +
+ setValue(Math.max(1, Math.min(99, Math.round(v) || 1)))} min={1} max={99} className="w-16" align="center" /> + % of contacts (chosen at random) +
+ )} + {!isAlways && !isRandom && ( +
+ within + setValue(Math.max(1, Math.min(60, Math.round(v) || 1)))} min={1} max={60} className="w-16" align="center" /> + days +
+ )} + {isNegative && ( +

+ We keep checking until {value} day{value === 1 ? "" : "s"} pass, then take this path if it still hasn’t happened. +

+ )} + + {branch.target_sequence_id !== null && } +
+ +
+ + +
+
+ ); +} + +// ── Add-action menu (Panel) ───────────────────────────────────────────────── +// Note: there is no "end" action — a path ends simply by leaving a node's +// bottom dot unconnected (shows "Ends here") or routing a branch to Stop. That +// keeps the cleaner Stop/"Ends here" visual instead of a configurable end node. +const ADD_ACTION_OPTIONS: { type: SequenceActionType; label: string }[] = [ + { type: "add_tag", label: "Add tag" }, + { type: "remove_tag", label: "Remove tag" }, + { type: "unsubscribe", label: "Unsubscribe" }, + { type: "notify", label: "Notify (webhook)" }, +]; + +function AddNodeMenu({ + onAddEmail, + onAddAction, + disabled, + busy, +}: { + onAddEmail: () => void; + onAddAction: (t: SequenceActionType) => void; + disabled: boolean; + busy: boolean; +}) { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + useClickOutside(ref, () => setOpen(false)); + return ( +
+ {/* Primary click = add an email step (the default, common case). */} + + {/* Chevron = the full list of node types (email + actions). */} + + {open && ( +
+ +
+ {ADD_ACTION_OPTIONS.map((o) => { + const meta = ACTION_META[o.type]; + const Icon = meta.Icon; + return ( + + ); + })} +
+ )} +
+ ); +} + +// defaultActionFor returns a fresh action config for a newly-picked type. +function defaultActionFor(type: SequenceActionType): SequenceAction { + return { type }; +} + +// ── Node-type switcher (top of the editor drawer) ─────────────────────────── +// One place to switch ANY node between Send email and every action type — +// mirrors the unified "Add" menu. Switching persists immediately and the drawer +// body re-renders to the matching editor (SequenceView for email, ActionEditor +// for actions). +function NodeTypeSwitcher({ + campaignId, + sequence, + onChanged, +}: { + campaignId: string; + sequence: Sequence; + onChanged: () => void; +}) { + const [busy, setBusy] = React.useState(false); + const current: "email" | SequenceActionType = + sequence.kind === "email" ? "email" : sequence.action?.type ?? "add_tag"; + + const items: { value: "email" | SequenceActionType; label: string; Icon: typeof MailIcon; tint: string }[] = [ + { value: "email", label: "Send email", Icon: MailIcon, tint: "text-sky-600" }, + ...ADD_ACTION_OPTIONS.map((o) => ({ + value: o.type, + label: o.label, + Icon: ACTION_META[o.type].Icon, + tint: ACTION_META[o.type].tint, + })), + ]; + + const pick = async (value: "email" | SequenceActionType) => { + if (busy || value === current) return; + setBusy(true); + try { + if (value === "email") { + await updateSequence(campaignId, sequence.id, { kind: "email" }); + } else { + await updateSequence(campaignId, sequence.id, { + kind: "action", + action: defaultActionFor(value), + }); + } + onChanged(); + } catch (err) { + toast.error(buildError(err as AppError)); + } finally { + setBusy(false); + } + }; + + return ( +
+ +
+ {items.map((it) => { + const active = it.value === current; + const Icon = it.Icon; + return ( + + ); + })} +
+
+ ); +} + +// ── Action editor (drawer body for non-email nodes) ───────────────────────── +function ActionEditor({ + campaignId, + sequence, + onSaved, +}: { + campaignId: string; + sequence: Sequence; + onSaved: () => void; +}) { + const [action, setAction] = React.useState(sequence.action ?? { type: "add_tag" }); + const [name, setName] = React.useState(sequence.name ?? ""); + const [saving, setSaving] = React.useState(false); + React.useEffect(() => { + setAction(sequence.action ?? { type: "add_tag" }); + setName(sequence.name ?? ""); + }, [sequence.id, sequence.action, sequence.kind, sequence.name]); + + const save = async () => { + setSaving(true); + try { + await updateSequence(campaignId, sequence.id, { + name, + kind: "action", + action, + }); + onSaved(); + toast.success("Action saved"); + } catch (err) { + toast.error(buildError(err as AppError)); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ + +

Internal label only — shown on the node.

+
+ + {(action.type === "add_tag" || action.type === "remove_tag") && ( +
+ + + setAction((a) => ({ ...a, category_id: ids.length ? ids[ids.length - 1] : null })) + } + placeholder="Pick a tag…" + /> +

Tags are your contact categories.

+
+ )} + + {action.type === "unsubscribe" && ( +

+ Suppresses this contact across your workspace — they won't receive further campaign emails, and a{" "} + campaign.unsubscribed event fires to your integrations. +

+ )} + + {action.type === "notify" && ( +

+ Sends a campaign.action event to your connected webhooks and + integrations (Slack, CRM…) with this contact's details. Configure where it goes in Integrations. +

+ )} + +
+ +
+
+ ); +} diff --git a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx new file mode 100644 index 00000000..c87fd9b9 --- /dev/null +++ b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx @@ -0,0 +1,447 @@ +// Rich email-body editor for campaign Steps, built on TipTap (no deprecated +// execCommand). Controlled by an HTML string; emits HTML on change. Ships a +// house-theme toolbar (headings, bold/italic/underline/strike, lists, link), a +// one-click {{variable}} inserter, and a spintax `{a|b}` helper. Personalization +// tokens are just text, so they survive serialization untouched. + +import React from "react"; +import { useEditor, EditorContent, type Editor } from "@tiptap/react"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import Bold from "@tiptap/extension-bold"; +import Italic from "@tiptap/extension-italic"; +import Underline from "@tiptap/extension-underline"; +import Strike from "@tiptap/extension-strike"; +import Heading from "@tiptap/extension-heading"; +import Link from "@tiptap/extension-link"; +import { BulletList, OrderedList, ListItem } from "@tiptap/extension-list"; +import { + BoldIcon, + ItalicIcon, + UnderlineIcon, + StrikethroughIcon, + Heading2Icon, + ListIcon, + ListOrderedIcon, + Link2Icon, + BracesIcon, + ShuffleIcon, + CheckIcon, + XIcon, + ChevronDownIcon, +} from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import useClickOutside from "@/hooks/useClickOutside"; +import { WEBSITE_URL } from "@/lib/information"; + +export default function RichTextEditor({ + html, + onChange, + variables, + placeholder, +}: { + html: string; + onChange: (html: string) => void; + variables: string[]; + placeholder?: string; +}) { + const editor = useEditor({ + extensions: [ + Document, + Paragraph, + Text, + Bold, + Italic, + Underline, + Strike, + Heading.configure({ levels: [2, 3] }), + BulletList, + OrderedList, + ListItem, + Link.configure({ openOnClick: false, autolink: true }), + ], + content: html || "", + editorProps: { + attributes: { + class: "tiptap-body min-h-[260px] px-3 py-2.5 text-[13px] leading-relaxed text-slate-800 focus:outline-none", + }, + }, + onUpdate: ({ editor }) => onChange(editor.getHTML()), + }); + + // Keep the editor in sync when the value changes from outside (template + // applied, step switched, reset) without clobbering the user's caret on + // their own edits. + React.useEffect(() => { + if (!editor) return; + const current = editor.getHTML(); + if (html !== current) { + editor.commands.setContent(html || "", { emitUpdate: false }); + } + }, [html, editor]); + + if (!editor) return null; + + return ( +
+ +
+ + {placeholder && editor.isEmpty && ( +

+ {placeholder} +

+ )} +
+
+ ); +} + +function Toolbar({ editor, variables }: { editor: Editor; variables: string[] }) { + const [linkOpen, setLinkOpen] = React.useState(false); + const [linkUrl, setLinkUrl] = React.useState(""); + + const applyLink = () => { + const url = linkUrl.trim(); + if (url) { + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + } else { + editor.chain().focus().unsetLink().run(); + } + setLinkOpen(false); + setLinkUrl(""); + }; + + return ( +
+ editor.chain().focus().toggleBold().run()} title="Bold"> + + + editor.chain().focus().toggleItalic().run()} title="Italic"> + + + editor.chain().focus().toggleUnderline().run()} title="Underline"> + + + editor.chain().focus().toggleStrike().run()} title="Strikethrough"> + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + title="Heading" + > + + + editor.chain().focus().toggleBulletList().run()} title="Bullet list"> + + + editor.chain().focus().toggleOrderedList().run()} title="Numbered list"> + + + { + setLinkUrl(editor.getAttributes("link").href ?? ""); + setLinkOpen((o) => !o); + }} + title="Link" + > + + + + editor.chain().focus().insertContent(v).run()} variables={variables} /> + editor.chain().focus().insertContent("{option one|option two}").run()} + title="Insert spintax — randomly picks one option per send" + > + + + + + {linkOpen && ( + + setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyLink(); + } else if (e.key === "Escape") { + setLinkOpen(false); + } + }} + placeholder="https://…" + className="h-7 w-56 rounded border border-slate-200 px-2 text-[12px] text-slate-800 outline-none focus:border-sky-400" + /> + + + + )} + +
+ ); +} + +function Btn({ + active, + onClick, + title, + children, +}: { + active?: boolean; + onClick: () => void; + title: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Divider() { + return ; +} + +// Friendly labels + one-line descriptions for the standard contact tokens, so +// the menu explains what each value does rather than dumping raw {{.Tokens}}. +const TOKEN_META: Record = { + "{{.FirstName}}": { label: "First name", desc: "The contact's first name" }, + "{{.LastName}}": { label: "Last name", desc: "The contact's last name" }, + "{{.Email}}": { label: "Email", desc: "The contact's email address" }, + "{{.Company}}": { label: "Company", desc: "Where the contact works" }, + "{{.Phone}}": { label: "Phone", desc: "The contact's phone number" }, +}; + +// cleanFieldName strips braces/leading dots so a pasted "{{.role}}" or ".role" +// still resolves to the bare key. +function cleanFieldName(raw: string): string { + return raw.replace(/[{}]/g, "").replace(/^\.+/, "").trim(); +} + +// Shared variable inserter — a personalization menu that explains each field and +// can insert a custom field by name. Flips horizontally so it never overflows +// the editor edge. +export function VariableMenu({ onPick, variables }: { onPick: (token: string) => void; variables: string[] }) { + const [open, setOpen] = React.useState(false); + // Fixed-viewport coordinates computed from the trigger, so the panel escapes + // the editor's overflow-hidden clipping and is clamped fully on-screen. + const [pos, setPos] = React.useState<{ left: number; width: number; top?: number; bottom?: number } | null>(null); + const [custom, setCustom] = React.useState(""); + const ref = React.useRef(null); + const triggerRef = React.useRef(null); + useClickOutside(ref, () => setOpen(false)); + + const toggle = () => { + if (!open && triggerRef.current) { + const r = triggerRef.current.getBoundingClientRect(); + const margin = 12; + const vw = window.innerWidth; + const vh = window.innerHeight; + const width = Math.min(vw < 640 ? vw - margin * 2 : 352, vw - margin * 2); + // Clamp horizontally so the full width is always on-screen. + const left = Math.max(margin, Math.min(r.left, vw - width - margin)); + // Open upward when the trigger sits low in the viewport. + const up = r.bottom > vh * 0.55; + setPos(up ? { left, width, bottom: vh - r.top + 6 } : { left, width, top: r.bottom + 6 }); + } + setOpen((o) => !o); + }; + + const customName = cleanFieldName(custom); + const insertCustom = () => { + if (!customName) return; + onPick(`{{.${customName}}}`); + setCustom(""); + setOpen(false); + }; + + return ( +
+ + + {open && ( + +
+

Personalization

+

+ Replaced per contact on send · click to insert · hover for what each does +

+
+ + {/* Contact fields — compact 2-column grid (description on hover). */} +
+
+ Contact fields +
+
+ {variables.map((v) => { + const meta = TOKEN_META[v]; + return ( + + ); + })} +
+
+ +
+
+ Custom field +
+
+ setCustom(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + insertCustom(); + } + }} + placeholder="field name (e.g. role)" + className="h-7 min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100" + /> + +
+

+ Inserts {`{{.${customName || "name"}}}`}{" "} + — exact field name; blank if the contact lacks it. +

+
+ + {/* Conditionals — compact 2-column snippet grid. */} +
+
+ Conditionals +
+
+ {[ + { label: "If set", code: "{{if .Company}}{{end}}" }, + { label: "If / else", code: "{{if .Company}}{{else}}{{end}}" }, + { label: "If equals", code: '{{if eq .Company "Acme"}}{{end}}' }, + ].map((s) => ( + + ))} +
+

+ Type text between the tags. Every {"{{if}}"} needs an{" "} + {"{{end}}"}; missing fields count as empty. +

+
+ + e.preventDefault()} + className="flex items-center justify-between gap-2 border-t border-slate-100 px-3 py-2 text-[11.5px] font-medium text-sky-600 transition-colors hover:bg-sky-50/60" + > + Full guide & examples + + +
+ )} +
+
+ ); +} diff --git a/web/src/components/app/campaigns/sequences/SequenceBox.tsx b/web/src/components/app/campaigns/sequences/SequenceBox.tsx deleted file mode 100644 index 2ea3a63c..00000000 --- a/web/src/components/app/campaigns/sequences/SequenceBox.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import MiniNumberInput from "@/components/app/popup/MiniNumberInput"; - -export default function SequenceBox({ - children, - next, - active, - def_wait, - wait, - setWait, - onClick, -}: { - children: React.ReactNode, - next: boolean, - active: boolean, - def_wait: number, - wait: number, - setWait: (v: number) => void, - onClick: () => void, -}) { - return ( - <> -
- {children} -
- {next && (
- Wait - setWait(e.target.valueAsNumber)} - /> - Day(s) -
-
)} - - ) -} diff --git a/web/src/components/app/campaigns/sequences/SequenceView.tsx b/web/src/components/app/campaigns/sequences/SequenceView.tsx index ba59c212..01e0e845 100644 --- a/web/src/components/app/campaigns/sequences/SequenceView.tsx +++ b/web/src/components/app/campaigns/sequences/SequenceView.tsx @@ -1,138 +1,476 @@ -import React, { useMemo } from "react"; -import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; -import SubTitle from "../../text/SubTitle"; -import MiniInput from "../../popup/MiniInput"; -import EmailEditor from "../../EmailEditor"; -import ContentScore from "../ContentScore"; -import { Loading } from "@/components/loader"; -import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence"; +// The campaign Step composer (right pane). A full email editor: rich TipTap +// body, subject with variable insert, a template picker + save-as-template +// (reusing the org template library), an Edit/Preview toggle that renders +// {{variables}} + spintax with sample data, and the advisory content score. + +import React from "react"; +import { + AlertCircleIcon, + BookmarkPlusIcon, + EyeIcon, + GitBranchIcon, + Loader2Icon, + PencilLineIcon, + SparklesIcon, +} from "lucide-react"; import toast from "react-hot-toast"; +import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; +import RichTextEditor, { VariableMenu } from "./RichTextEditor"; +import WriteWithAI from "./WriteWithAI"; +import ContentScore from "../ContentScore"; +import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; +import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence"; +import useTemplates from "@/lib/api/hooks/app/templates/useTemplates"; +import useCreateTemplate from "@/lib/api/hooks/app/templates/useCreateTemplate"; +import { useConfirm } from "@/hooks/context/confirm"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; +const VARIABLES = ["{{.FirstName}}", "{{.LastName}}", "{{.Email}}", "{{.Company}}", "{{.Phone}}"]; +const SAMPLE: Record = { + FirstName: "Alex", + LastName: "Rivera", + Email: "alex@acme.com", + Company: "Acme", + Phone: "+1 555-0100", + // Sample custom fields so conditional examples have something to evaluate. + role: "Engineer", + city: "Berlin", +}; + +// Body fields the composer owns. body_sync/body_code are legacy editor-only +// flags (they don't affect sending), so the new composer keeps HTML + plain in +// lockstep and leaves them alone. +type Draft = Pick; + +function toDraft(s: Sequence): Draft { + return { name: s.name, subject: s.subject, body_plain: s.body_plain, body_html: s.body_html }; +} + +// Derive plain text from the editor HTML so both alternatives ship populated. +function htmlToPlain(html: string): string { + const withBreaks = html + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/\s*(p|div|h[1-6]|li|tr)\s*>/gi, "\n"); + if (typeof document === "undefined") return withBreaks.replace(/<[^>]+>/g, ""); + const tmp = document.createElement("div"); + tmp.innerHTML = withBreaks; + return (tmp.textContent || "").replace(/\n{3,}/g, "\n\n").trim(); +} + +// Preview evaluator — a faithful JS model of the documented send-time subset: +// {{if}}/{{else}}/{{else if}}/{{end}}, {{if eq .x "y"}}, {{if and|or ...}}, +// variables/custom fields, and spintax (first option). Mirrors the Go renderer: +// missing or empty values are falsy in conditions, missing keys render empty. +type PreviewCtx = Record; + +const truthy = (v: string | undefined): boolean => v !== undefined && v !== ""; + +// Split an and/or argument list into top-level groups: '(.A) (eq .B "x")'. +function splitGroups(s: string): string[] { + const out: string[] = []; + let depth = 0; + let cur = ""; + for (const ch of s) { + if (ch === "(") { + if (depth === 0 && cur.trim()) { + out.push(cur.trim()); + cur = ""; + } + depth++; + cur += ch; + } else if (ch === ")") { + depth--; + cur += ch; + if (depth === 0) { + out.push(cur.trim()); + cur = ""; + } + } else if (ch === " " && depth === 0) { + if (cur.trim()) out.push(cur.trim()); + cur = ""; + } else { + cur += ch; + } + } + if (cur.trim()) out.push(cur.trim()); + return out.filter(Boolean); +} + +// Evaluate a single {{if ...}} condition (the part after "if "). +function evalCond(expr: string, ctx: PreviewCtx): boolean { + expr = expr.trim(); + let m = expr.match(/^eq\s+\.([A-Za-z0-9_]+)\s+"([^"]*)"$/); + if (m) return (ctx[m[1]] ?? "") === m[2]; + const logical = expr.match(/^(and|or)\s+(.*)$/s); + if (logical) { + const vals = splitGroups(logical[2]).map((p) => evalCond(p.replace(/^\(|\)$/g, ""), ctx)); + return logical[1] === "and" ? vals.every(Boolean) : vals.some(Boolean); + } + m = expr.match(/^\.([A-Za-z0-9_]+)$/); + if (m) return truthy(ctx[m[1]]); + return false; // unknown construct → false (degrade, don't crash preview) +} + +// Resolve {{if}}…{{end}} blocks recursively (handles nesting + else/else-if). +function renderConditionals(s: string, ctx: PreviewCtx): string { + const open = s.match(/\{\{\s*if\s+([^}]+?)\s*\}\}/); + if (!open || open.index === undefined) return s; + const start = open.index; + const tokenRe = /\{\{\s*(if\s+[^}]+?|else\s+if\s+[^}]+?|else|end)\s*\}\}/g; + tokenRe.lastIndex = start; + let depth = 0; + let endIdx = -1; + let endLen = 0; + const branches: { cond: string | null; from: number; bodyStart: number }[] = []; + let m: RegExpExecArray | null; + while ((m = tokenRe.exec(s))) { + const kind = m[1]; + if (kind.startsWith("if")) { + depth++; + if (depth === 1) branches.push({ cond: kind.slice(2).trim(), from: m.index, bodyStart: tokenRe.lastIndex }); + } else if (depth === 1 && kind.startsWith("else if")) { + branches[branches.length - 1].from = m.index; + branches.push({ cond: kind.slice(7).trim(), from: m.index, bodyStart: tokenRe.lastIndex }); + } else if (depth === 1 && kind === "else") { + branches[branches.length - 1].from = m.index; + branches.push({ cond: null, from: m.index, bodyStart: tokenRe.lastIndex }); + } else if (kind === "end") { + depth--; + if (depth === 0) { + endIdx = m.index; + endLen = m[0].length; + break; + } + } + } + if (endIdx < 0) return s; // unbalanced → leave as-is (matches send fallback) + let chosen = ""; + for (let i = 0; i < branches.length; i++) { + const b = branches[i]; + const bodyEnd = i + 1 < branches.length ? branches[i + 1].from : endIdx; + if (b.cond === null || evalCond(b.cond, ctx)) { + chosen = renderConditionals(s.slice(b.bodyStart, bodyEnd), ctx); + break; + } + } + return renderConditionals(s.slice(0, start), ctx) + chosen + renderConditionals(s.slice(endIdx + endLen), ctx); +} + +function renderPreview(s: string, ctx: PreviewCtx = SAMPLE): string { + let out = renderConditionals(s, ctx); + out = out.replace(/\{\{\s*\.([A-Za-z0-9_]+)\s*\}\}/g, (_, k: string) => ctx[k] ?? ""); + out = out.replace(/\{([^{}|]+(?:\|[^{}]+)+)\}/g, (_, g: string) => g.split("|")[0]); + return out; +} + +// templateIssue returns a friendly message when a template is obviously +// malformed (an {{if}} without a matching {{end}}, or vice versa). A heuristic +// for instant editor feedback — the renderer still degrades safely at send time. +function templateIssue(s: string): string | null { + const ifs = (s.match(/\{\{\s*if\b/g) || []).length; + const ends = (s.match(/\{\{\s*end\s*\}\}/g) || []).length; + if (ifs > ends) return "An {{if}} is missing its {{end}}."; + if (ends > ifs) return "There's an {{end}} with no matching {{if}}."; + return null; +} + export default function SequenceView({ - campaign_id, - def_sequence, + campaignId, sequence, - - setName, - setSubject, - setBodyPlain, - setBodyHTML, - setBodySync, - setBodyCode, - onUpdate, + index, }: { - campaign_id: string, - def_sequence: Sequence, - sequence: Sequence, - - setName: (v: string) => void, - setSubject: (v: string) => void, - setBodyPlain: (v: string) => void, - setBodyHTML: (v: string) => void, - setBodySync: (v: boolean) => void, - setBodyCode: (v: boolean) => void, - onUpdate: (v: Sequence) => void, + campaignId: string; + sequence: Sequence; + index: number; }) { - const updateSequence = useUpdateSequence(campaign_id, sequence.id) + const updateSequence = useUpdateSequence(campaignId, sequence.id); + const createTemplate = useCreateTemplate(); + const confirm = useConfirm(); + const { data: templates } = useTemplates(""); - const [load, setLoad] = React.useState(false); + const [load, setLoad] = React.useState(false); + const [tab, setTab] = React.useState<"edit" | "preview">("edit"); + const [saveTplOpen, setSaveTplOpen] = React.useState(false); + const [tplName, setTplName] = React.useState(""); - const savable = useMemo( - () => JSON.stringify(def_sequence) !== JSON.stringify(sequence), - [def_sequence, sequence] - ) + const [draft, setDraft] = React.useState(() => toDraft(sequence)); + React.useEffect(() => { + setDraft(toDraft(sequence)); + setTab("edit"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sequence.id]); + + const baseline = toDraft(sequence); + const savable = React.useMemo( + () => JSON.stringify(baseline) !== JSON.stringify(draft), + [baseline, draft], + ); + const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })); + const setBody = (htmlText: string) => patch({ body_html: htmlText, body_plain: htmlToPlain(htmlText) }); + + // Instant feedback for an obviously-broken conditional (unbalanced if/end). + // Covers the plain-text body too — it's rendered at send time and the + // backend blocks campaign start on a malformed template in any of the three. + const tplIssue = + templateIssue(draft.subject) || templateIssue(draft.body_html) || templateIssue(draft.body_plain); async function submit() { - if (load) return; + if (load || !savable) return; setLoad(true); try { - const data = { - ...(sequence.name !== def_sequence.name && { name: sequence.name }), - ...(sequence.subject !== def_sequence.subject && { subject: sequence.subject }), - - ...(sequence.body_plain !== def_sequence.body_plain && { body_plain: sequence.body_plain }), - ...(sequence.body_html !== def_sequence.body_html && { body_html: sequence.body_html }), - ...(sequence.body_sync !== def_sequence.body_sync && { body_sync: sequence.body_sync }), - ...(sequence.body_code !== def_sequence.body_code && { body_code: sequence.body_code }), - } - const resp = await toast.promise( - updateSequence.mutateAsync(data), - { - loading: "Updating sequence...", - success: "Sequence successfully updated.", - error: (err: AppError) => buildError(err), - } - ) - onUpdate(resp) + const data: Partial = { + ...(draft.name !== baseline.name && { name: draft.name }), + ...(draft.subject !== baseline.subject && { subject: draft.subject }), + ...(draft.body_plain !== baseline.body_plain && { body_plain: draft.body_plain }), + ...(draft.body_html !== baseline.body_html && { body_html: draft.body_html }), + }; + await toast.promise(updateSequence.mutateAsync(data), { + loading: "Saving step…", + success: "Step saved.", + error: (err: AppError) => buildError(err), + }); } finally { - setLoad(false) + setLoad(false); } } - return ( -
-
- Display Name - setName(e.target.value)} - /> -
+ function applyTemplate(t: { name: string; subject: string; body_html: string; body_plain: string }) { + const apply = () => { + patch({ + subject: t.subject || draft.subject, + body_html: t.body_html || (t.body_plain ? `

${t.body_plain.replace(/\n/g, "

")}

` : ""), + body_plain: t.body_plain || htmlToPlain(t.body_html), + }); + toast.success(`Applied "${t.name}"`); + }; + const dirty = draft.subject.trim() || htmlToPlain(draft.body_html).trim(); + if (dirty) { + confirm.show(`Replace this step's content with the "${t.name}" template?`, apply); + } else { + apply(); + } + } -
- setSubject(e.target.value)} - type="text" - /> -
- buildError(e) }, + ); + setSaveTplOpen(false); + setTplName(""); + } + + return ( +
+
+
+
+ Step {index + 1} +
+

Compose the email this step sends.

+
+
+ {/* Template picker */} + + + } label="Templates" /> + + + {(templates ?? []).length === 0 ? ( +
+ No templates yet. Save one below. +
+ ) : ( + (templates ?? []).map((t) => ( + + )) + )} +
+
+ + {/* Save as template */} + + + + + + +
+ + +
+
+
+ + { + const html = `

${text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n{2,}/g, "

") + .replace(/\n/g, "
")}

`; + setBody(draft.body_html ? `${draft.body_html}${html}` : html); + }} /> +
+ +
- -
- - -
+ +
+
+ + patch({ name: v })} placeholder={`Step ${index + 1}`} /> +

Internal label only — recipients never see it.

+
+ +
+
+ + patch({ subject: draft.subject + v })} /> +
+ patch({ subject: v })} placeholder="Quick question, {{.FirstName}}" /> +
+ +
+
+ +
+ setTab("edit")} icon={}> + Edit + + setTab("preview")} icon={}> + Preview + +
+
+ {tab === "edit" ? ( + + ) : ( +
+
+ Subject: + {renderPreview(draft.subject) || "—"} +
+
Nothing to preview yet.

' }} + /> +

+ Preview uses sample data ({SAMPLE.FirstName} at {SAMPLE.Company}); if/else conditionals + are evaluated against it and spintax shows the first option. +

+
+ )} + {tplIssue && ( +

+ + {tplIssue} It'll fall back to plain text — fix it before sending. +

+ )} +
+ + {index > 0 && ( +
+ +

+ Follow-ups thread on the previous step's subject. Change this subject and the follow-up + starts a new thread instead of replying in the existing one. +

+
+ )} + +
- ) + ); +} + +function TabBtn({ + active, + onClick, + icon, + children, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + ); } diff --git a/web/src/components/app/campaigns/sequences/StepAttachments.tsx b/web/src/components/app/campaigns/sequences/StepAttachments.tsx new file mode 100644 index 00000000..753321fc --- /dev/null +++ b/web/src/components/app/campaigns/sequences/StepAttachments.tsx @@ -0,0 +1,168 @@ +// Per-step attachments editor (lives under the Step composer). Drag-and-drop or +// click to upload files for this step; lists each file with its name + size and +// a delete control, and shows the total size used across the step's files. +// +// Attachments are scoped to the step via sequence_id on upload; the list is the +// campaign-wide set filtered down to this step. Campaign-level attachments (no +// sequence_id) are not shown here. + +import React from "react"; +import { PaperclipIcon, UploadCloudIcon, Loader2Icon, Trash2Icon, FileIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { + useCampaignAttachments, + useUploadAttachment, + useDeleteAttachment, +} from "@/lib/api/hooks/app/campaigns/useCampaignAttachments"; +import type Attachment from "@/lib/api/models/app/campaigns/Attachment"; +import { useConfirm } from "@/hooks/context/confirm"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import formatBytes from "@/lib/helper/formatBytes"; + +export default function StepAttachments({ + campaignId, + sequenceId, +}: { + campaignId: string; + sequenceId: string; +}) { + const { data: all, isLoading } = useCampaignAttachments(campaignId); + const upload = useUploadAttachment(campaignId); + const del = useDeleteAttachment(campaignId); + const confirm = useConfirm(); + + const inputRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + + const attachments = (all ?? []).filter((a) => a.sequence_id === sequenceId); + const totalSize = attachments.reduce((sum, a) => sum + (a.size || 0), 0); + + const uploadFiles = React.useCallback( + (files: FileList | File[]) => { + const list = Array.from(files); + if (list.length === 0) return; + for (const file of list) { + upload.mutate( + { file, opts: { sequenceId } }, + { + onSuccess: () => toast.success(`Attached "${file.name}"`), + onError: (e) => toast.error(buildError(e as unknown as AppError)), + }, + ); + } + }, + [upload, sequenceId], + ); + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + if (e.dataTransfer?.files?.length) uploadFiles(e.dataTransfer.files); + }; + + const remove = (a: Attachment) => { + confirm.show(`Remove "${a.filename}" from this step?`, async () => { + await del.mutateAsync(a.id); + toast.success("Attachment removed."); + }); + }; + + return ( +
+
+
+ +
+
+ Attachments +
+

+ {attachments.length === 0 + ? "Attach files to send with this step." + : `${attachments.length} file${attachments.length === 1 ? "" : "s"} · ${formatBytes(totalSize)} used`} +

+
+
+
+ +
+ { + if (e.target.files?.length) uploadFiles(e.target.files); + e.target.value = ""; + }} + /> + + + {isLoading ? ( +
Loading attachments…
+ ) : attachments.length === 0 ? ( +

No attachments yet.

+ ) : ( +
+ {attachments.map((a) => ( +
+ +
+ + {a.filename} + +
+ {formatBytes(a.size)} + {a.mime_type ? ` · ${a.mime_type}` : ""} +
+
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/src/components/app/campaigns/sequences/StepVariants.tsx b/web/src/components/app/campaigns/sequences/StepVariants.tsx new file mode 100644 index 00000000..bbbd69bb --- /dev/null +++ b/web/src/components/app/campaigns/sequences/StepVariants.tsx @@ -0,0 +1,227 @@ +// Per-step A/B variants editor (lives under the Step composer). Lists the +// variants scoped to this step; each variant has its own name, weight, active +// toggle, subject, and rich body. The step's own content is the implicit +// "original"; sends split across the original + active variants by weight. + +import React from "react"; +import { PlusIcon, Loader2Icon, Trash2Icon, FlaskConicalIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import type ABVariant from "@/lib/api/models/app/campaigns/ABVariant"; +import { Label, TextInput, NumberInput } from "@/components/ui/field"; +import { Toggle } from "../preferences/components/CampaignPreferenceBoolBox"; +import RichTextEditor, { VariableMenu } from "./RichTextEditor"; +import { + useCampaignABVariants, + useCreateABVariant, + useUpdateABVariant, + useDeleteABVariant, +} from "@/lib/api/hooks/app/campaigns/useCampaignABVariants"; +import { useConfirm } from "@/hooks/context/confirm"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +const VARIABLES = ["{{.FirstName}}", "{{.LastName}}", "{{.Email}}", "{{.Company}}", "{{.Phone}}"]; +const LETTERS = ["B", "C", "D", "E", "F"]; + +function htmlToPlain(html: string): string { + const withBreaks = html + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/\s*(p|div|h[1-6]|li|tr)\s*>/gi, "\n"); + if (typeof document === "undefined") return withBreaks.replace(/<[^>]+>/g, ""); + const tmp = document.createElement("div"); + tmp.innerHTML = withBreaks; + return (tmp.textContent || "").replace(/\n{3,}/g, "\n\n").trim(); +} + +export default function StepVariants({ + campaignId, + sequenceId, + baseSubject, + baseBodyHtml, +}: { + campaignId: string; + sequenceId: string; + baseSubject: string; + baseBodyHtml: string; +}) { + const { data: all, isLoading } = useCampaignABVariants(campaignId); + const create = useCreateABVariant(campaignId); + const variants = (all ?? []).filter((v) => v.sequence_id === sequenceId); + + const addVariant = () => { + const name = `Variant ${LETTERS[variants.length] ?? variants.length + 1}`; + create.mutate( + { + name, + sequence_id: sequenceId, + weight: 100, + is_active: true, + subject: baseSubject, + body_html: baseBodyHtml, + body_plain: htmlToPlain(baseBodyHtml), + }, + { onError: (e) => toast.error(buildError(e as unknown as AppError)) }, + ); + }; + + return ( +
+
+
+ +
+
+ A/B variants +
+

+ {variants.length === 0 + ? "Test alternate copy for this step — volume splits by weight." + : `${variants.length} variant${variants.length === 1 ? "" : "s"} + the original, split by weight.`} +

+
+
+ +
+ {isLoading ? ( +
Loading variants…
+ ) : variants.length === 0 ? ( +
+ No variants yet. The step's main content is sent to everyone until you add one. +
+ ) : ( +
+ {variants.map((v) => ( + + ))} +
+ )} +
+ ); +} + +function VariantCard({ campaignId, variant }: { campaignId: string; variant: ABVariant }) { + const update = useUpdateABVariant(campaignId); + const del = useDeleteABVariant(campaignId); + const confirm = useConfirm(); + + const [name, setName] = React.useState(variant.name); + const [weight, setWeight] = React.useState(variant.weight); + const [subject, setSubject] = React.useState(variant.subject); + const [bodyHtml, setBodyHtml] = React.useState(variant.body_html); + const [active, setActive] = React.useState(variant.is_active); + + // Re-seed from the canonical record after a save (updated_at changes) or when + // a different variant renders into this card slot. + React.useEffect(() => { + setName(variant.name); + setWeight(variant.weight); + setSubject(variant.subject); + setBodyHtml(variant.body_html); + setActive(variant.is_active); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [variant.id, variant.updated_at]); + + const dirty = + name !== variant.name || + weight !== variant.weight || + subject !== variant.subject || + bodyHtml !== variant.body_html || + active !== variant.is_active; + + const save = () => { + update.mutate( + { + variantId: variant.id, + input: { + name, + weight, + subject, + body_html: bodyHtml, + body_plain: htmlToPlain(bodyHtml), + is_active: active, + }, + }, + { + onSuccess: () => toast.success("Variant saved."), + onError: (e) => toast.error(buildError(e as unknown as AppError)), + }, + ); + }; + + const remove = () => { + confirm.show(`Delete "${variant.name}"? Its content will be removed from this step.`, async () => { + await del.mutateAsync(variant.id); + toast.success("Variant removed."); + }); + }; + + return ( +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ + setSubject(subject + v)} /> +
+ +
+
+ + +
+
+ ); +} diff --git a/web/src/components/app/campaigns/sequences/WriteWithAI.tsx b/web/src/components/app/campaigns/sequences/WriteWithAI.tsx new file mode 100644 index 00000000..4fe04a23 --- /dev/null +++ b/web/src/components/app/campaigns/sequences/WriteWithAI.tsx @@ -0,0 +1,137 @@ +// "Write with AI" — a compact popover that drafts email copy from a short +// prompt + tone and hands the result back to the composer. Surfaces remaining +// generation credits and turns a 402 (out of credits) into a friendly toast. +// +// The component is deliberately editor-agnostic: it calls `onInsert(text)` with +// the generated draft, so the host (SequenceView) decides whether to drop it +// into the subject, the body editor, etc. + +import React from "react"; +import { SparklesIcon, Loader2Icon, WandSparklesIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { Label } from "@/components/ui/field"; +import useGenerateWrite from "@/lib/api/hooks/app/generation/useGenerateWrite"; +import { WRITE_TONES } from "@/lib/api/models/app/generation/Write"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +export default function WriteWithAI({ onInsert }: { onInsert: (text: string) => void }) { + const generate = useGenerateWrite(); + const [open, setOpen] = React.useState(false); + const [prompt, setPrompt] = React.useState(""); + const [tone, setTone] = React.useState(""); + // Remembered from the last successful generation so we can show a running + // credit balance without a separate fetch. + const [credits, setCredits] = React.useState(null); + + const run = () => { + const text = prompt.trim(); + if (!text || generate.isPending) return; + generate.mutate( + { prompt: text, tone: tone || undefined }, + { + onSuccess: (res) => { + setCredits(res.credits_remaining); + onInsert(res.text); + toast.success( + `Draft inserted · ${res.credits_remaining} credit${res.credits_remaining === 1 ? "" : "s"} left`, + ); + setPrompt(""); + setOpen(false); + }, + onError: (e) => { + const err = e as unknown as AppError; + if (err?.status === 402) { + toast.error( + "You're out of AI writing credits. Upgrade your plan or wait for your credits to refresh.", + ); + return; + } + toast.error(buildError(err)); + }, + }, + ); + }; + + return ( + + + + + +
+
+ +