Commit Graph
23 Commits
Author SHA1 Message Date
Matthew Meszaros cbf4190f42 feat: make a custom tracking domain verifiable instead of permanently "Pending DNS": the CNAME target is now this install's TRACKING_DOMAIN rather than a hardcoded t.warmbly.com that resolves nowhere, matching is exact on the label boundary (or on shared addresses, so a provider-flattened CNAME stops reading as no record at all) instead of strings.Contains, and every outcome carries the reason plus what DNS actually returned, including when the tracking host the customer is told to point at has no record of its own; a pasted URL is normalized to its host and a malformed one is rejected up front instead of saved and left pending forever; only a VERIFIED mailbox domain is used at send time with the shared host as the fallback and a campaign-feed entry saying why; pixels and click tickets are built from the configured host, and with none configured mail ships untracked rather than carrying links to another deployment's tracking service; adds GET /emails/:id/track and POST /emails/:id/track/verify plus an hourly re-resolution sweep so a record that propagates later starts being used and one that breaks stops routing links; and scopes the tracking-domain write by organization like the read, which also fixes GET /emails/:id passing a user id to an org-scoped query and 404ing for every caller 2026-08-24 09:02:29 -07:00
Matthew Meszaros 1018068942 feat: stop recipient suppression and the entitlement gate being skipped when a campaign has no organization: the send path now fails closed (an orgless campaign is paused with the reason in its activity feed instead of mailing an unsubscribed, bounced or complained address, since routing's own suppression filter joins on the campaign's organization_id and matches nothing when it is NULL), the same tenancy gate covers warmup and unibox sends and an orgless mailbox resolves to the free pool rather than defaulting into the paid one, the state is no longer creatable (sequenceRepository.Create inherits organization_id from its campaign instead of inserting NULL, campaign creation and mailbox onboarding refuse without a workspace via errx.ErrNoOrganization, guardInboxLimit / guardMailboxThrottle / the campaign creation throttle no longer treat a missing org as exempt, and a new session resolves the user's default workspace so the orgless session that produced these rows cannot exist), and migration 000092 backfills then sets organization_id NOT NULL on campaigns, contacts, email_accounts and sequences plus sessions.current_organization_id, provisioning a recovery workspace for any user with none so no row is deleted to satisfy the constraint (live-tested in TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient, TestLiveSuppressedRecipientIsSkipped, TestLiveCampaignRequiresAnOrganization and TestLiveHealthyCampaignStillSends) 2026-08-24 08:47:08 -07:00
Matthew Meszaros c3066f9cc9 feat: unbox campaign start dates and make follow-up pacing real: accept today as "start now" and let an explicit null clear start/end dates on PATCH /campaigns (models.NullableTime distinguishes absent from null, which used to silently no-op while the error message told users to send null), reschedule an active campaign's parked wakeup when any schedule field changes so clearing a future start date takes effect immediately instead of at the old slot, let a completed campaign be started again and turn the past-end-date start 500 into a clear 400, gate the campaign task on the step's hard-constraint floor (wait_after, start date, windows, day capacity, mailbox min-gap) via ErrCampaignDeferred so an early successor tick can no longer send a wait-3-days follow-up seconds after step one (live-tested in TestLiveFollowUpWaitIsHonored), disable past days in the schedule date picker, and fix the sandbox seed leaving worker 1a01 free-tier after make seed which unassigned the paid org's mailboxes and failed every send 2026-08-24 05:20:26 -07:00
Matthew Meszaros 3739a36b67 feat: enforce the persisted SPF/DKIM/DMARC state as a real cold-send and warmup gate behind a 72h grace clock and an operator toggle, after first fixing the DMARC organizational-domain fallback in dnsauth so a dedicated sending subdomain covered by its parent's record stops reading as unauthenticated, adding auth_state to the four mailbox loaders that never selected it (which would have made the gate dead code), stamping auth_failing_since on entry to failing so a resolver hiccup can never stop a campaign, notifying the org on that transition, and reporting an all-gated pool as ErrDomainAuthFailing instead of a message about sending windows (#160) 2026-08-22 09:37:26 -07:00
Matthew Meszaros fe9a21a79f feat: stop a freshly connected mailbox being silently excluded from every campaign send, by making an unset mailbox timezone representable as the empty string the campaign scheduler already checks for, since email_accounts.timezone defaulted to 'UTC' while campaigns.timezone defaults to 'Europe/London' and nothing in the OAuth or SMTP onboarding paths ever set either, so a brand new mailbox looked deliberately placed in UTC, was compared against the differing campaign zone and dropped by the hardcoded 8am-8pm business-hours gate whenever the current UTC hour fell outside it, emptying the candidate pool and failing the campaign start, adding a migration that changes the column default and converts existing 'UTC' rows because until now no API field, dashboard control or onboarding path could set that column at all so every such row is the old default rather than a choice, adding the missing Timezone field to UpdateEmail with IANA validation so the setting the sending-behaviour UI already tells people to change is finally reachable and an unloadable zone is rejected instead of being silently coerced to UTC by the scheduler, and replacing the misleading 'no active email accounts found for campaign's email tags' response for a pool that exists but is entirely gated out with a distinct message naming the real cause, via an ErrNoEligibleMailbox that wraps ErrNoEmailAccounts so the three callers that pause a campaign on it are unaffected (#126) 2026-08-16 07:46:37 +02:00
Matthew Meszaros 8f465fdb1c feat: give each mailbox a human sending persona (randomized daily and hourly caps, send spacing, work start/end, lunch break and working weekdays, rolled once per local day in the mailbox's own timezone and applied across the campaign, warmup and smart-send schedulers), add campaign auto-pause guardrails that stop a campaign when its bounce, complaint or reply rate leaves the configured band, make mailbox rotation actually rotate for tag-resolved and all-mailbox campaigns, stop every scheduler from ever returning a slot in the past, and correct the mailbox min-gap field that stored seconds while labelling them minutes 2026-08-13 16:51:29 +02:00
Matthew Meszaros e8de52ce30 refactor: put the task client behind a scheduler interface 2026-07-20 09:56:16 +02:00
Matthew Meszaros 3acb51df6c feat: add server-side status-bucket filtering to GET /campaigns (draft/active/paused/completed, paused matches every paused_* variant, invalid values 400) and a new GET /campaigns-overview endpoint returning status and per-folder counts, with the campaigns API reference and endpoint scope map updated 2026-07-07 09:40:27 +02:00
Matthew Meszaros 57e8705a82 feat: make public API list cursors opaque base64 tokens (internal/utils/paging) instead of raw record UUIDs, decoding on input with a 400 on a bad cursor; admin endpoints keep transparent UUID cursors 2026-06-13 07:18:23 +02:00
Matthew Meszaros a21dc57e07 fix: scope campaign list/detail/count queries by organization_id instead of user_id, so all workspace members see the org's campaigns rather than only the creator 2026-06-11 17:47:04 +02:00
Matthew Meszaros abdfd05c34 feat: org-scope realtime events (inbox, campaign, tracking, account health) and emit EMAIL_SENT/EMAIL_REPLIED/EMAIL_DELETED pulses so the whole team's dashboard updates live 2026-06-11 07:43:57 +02:00
Matthew Meszaros da82993846 feat: add campaign workflow backend support
Add sequence action-node storage and execution, template conditional rendering, lead progress state, profile updates, webhook fan-out throttling, and supporting repository fixes.
2026-06-06 07:49:52 +02:00
Matthew Meszaros 49f1b28bd1 feat: add campaign lead order api
Wire campaign lead ordering through handlers, service methods, and campaign routes.
2026-06-05 15:44:26 +02:00
Matthew Meszaros 293842c842 feat: expose campaign lead order service contract
Add campaign service method declarations for listing and replacing manual campaign lead order.
2026-06-05 15:42:40 +02:00
Matthew Meszaros 77de336d78 feat: unify campaign sender pools
Resolve campaign senders as the union of explicit accounts and tag-selected mailboxes, with all active mailboxes as the empty-selection fallback. Update readiness checks, active-campaign counts, and rotation cursor handling for the unified pool.
2026-06-05 14:35:52 +02:00
Matthew Meszaros e1e02c5c18 feat: add campaign send controls backend 2026-06-04 08:53:23 +02:00
Matthew Meszaros 72073c669e feat: bootstrap campaign wakeups
Create the initial per-campaign Cloud Tasks wakeup when a campaign starts, using the same idempotent task chain as subsequent sends. Pause or complete campaigns instead of leaving active campaigns with no scheduler when no work can be queued.
2026-05-31 04:51:03 +00:00
Matthew Meszaros 946cd087e5 feat: cap active campaign schedulers
Limit organizations to 50 active campaigns before starting another campaign, make campaign wakeups idempotent per campaign, keep warmup wakeups idempotent per mailbox, and no-op stale non-pending campaign/warmup dispatches.
2026-05-31 04:48:39 +00:00
Matt b633e326e5 feat(throttle): per-day creation throttles for campaigns/mailboxes/orgs
The HardCap* constants stop "you have 5000 campaigns on this org"; the
throttles in this commit stop "you created 1000 campaigns today on a
fresh unlimited account." Different shape, different abuse, different
mechanism — Redis-backed per-(scope, resource, UTC-day) counters that
reset by key design at midnight UTC, no scheduled job needed.

New service internal/app/dailythrottle:
  - CheckAndIncrement(scope, resource, ceiling) atomically bumps the
    counter and returns errx.TooManyRequests when the post-increment
    value exceeds the ceiling.
  - 25h TTL so the key always expires after the day rolls over even
    if the process restarts before midnight.
  - Fail-open when the cache is absent (jobs/tests) so creation paths
    that haven't been wired with a cache still work.

Caps (config.DailyThrottleNew*):
  - 20 new campaigns/org/day
  - 5  new mailboxes/org/day
  - 3  new workspaces/owner/day

Wired into three creation paths:
  - campaign.Create — scoped on the orgID when present
  - email.OAuthFinish + email.OnboardSMTPIMAP — scoped on the orgID;
    fires only at actual create, not OAuthStart, so retrying a failed
    OAuth flow doesn't burn the day's budget.
  - organization.Create — scoped on the owner uuid (the org doesn't
    exist yet)

Adds errx.TooManyRequests (HTTP 429) since no caller had one before.

emailService gains WireThrottle alongside the existing WireWebhooks
pattern so jobs / tests can build the service without a cache. Same
treatment in main.go.
2026-05-28 13:34:01 +02:00
Matthew Meszaros 03dc3bce95 feat: overhaul campaign creation with atomic transactional create + multi-step wizard
- fix 500 on POST /campaigns: insert now sets NOT NULL created_at/updated_at
- atomic create writes campaign + sequences + ab variants + tag/folder links + advanced overrides in one tx
- create accepts full optional config (schedule, sender pool, tracking, sequences, variants)
- handler forwards orgID so future start/stop and org-scoped routes resolve
- fix Get/GetByID joining campaign_email_tags/campaign_folders on wrong column
- publish CAMPAIGN_CREATED realtime event after successful create
- replace name+description modal with 4-step wizard (basics, schedule, sender pool, first email + follow-ups)
2026-05-24 16:13:22 +00:00
Matthew Meszaros d227038ca0 ci: drop unused/unconvert/gosimple + shadow/nilness, run gofmt
Disable the linters that fire on legacy code without flagging real
bugs: `unused` (orphan repos kept for future feature flags),
`unconvert` (defensive type conversions), `gosimple` (style
suggestions in code we don't want to touch).

govet: disable `shadow` (idiomatic `err :=` re-decls in transaction
patterns) and `nilness` (legitimate defensive nil checks that look
tautological to the analyzer).

Ran `gofmt -w internal/ cmd/` — every Go file now passes
gofmt -l with no output.

Kept: govet, staticcheck, ineffassign, typecheck, bodyclose, noctx,
sqlclosecheck, gofmt, goimports, misspell — the real-bug checks.
2026-05-23 16:54:12 +00:00
Máté Mészáros (Laptop) 41624a6f79 Analytics & Tracking 2026-01-29 05:59:04 +01:00
Matthew Meszaros 772c19820d New Repository: Add Backend Code 2026-01-17 14:11:14 +00:00