Merge pull request #27 from warmbly/feature/campaign-experience-2
feat: campaign experience overhaul
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE campaigns DROP COLUMN IF EXISTS schedule_windows;
|
||||
@@ -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": <minute-of-day>, "end": <minute-of-day>} 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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS campaign_attachments;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sequences DROP COLUMN IF EXISTS conditions;
|
||||
@@ -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 '{}';
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 <conditions> -> 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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -71,3 +71,35 @@ func TestEmailBlob_EncodeDecode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailBlob_Attachments(t *testing.T) {
|
||||
in := &EmailBlob{
|
||||
PlainText: []byte("hi"),
|
||||
HTMLBody: []byte("<b>hi</b>"),
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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<<uint(bit)) != 0 {
|
||||
wd := (bit + 1) % 7 // Monday-indexed bit → time.Weekday
|
||||
sw[wd] = []models.TimeInterval{{Start: start, End: end}}
|
||||
}
|
||||
}
|
||||
return sw
|
||||
}
|
||||
|
||||
// nextScheduleSlot returns the earliest time >= 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {{.<anything-but-brace>}} 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 {{.<key>}} 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 656 B After Width: | Height: | Size: 806 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 150 KiB |
@@ -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);
|
||||
|
||||
@@ -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)!;
|
||||
|
||||