Commit Graph

52 Commits

Author SHA1 Message Date
Matthew Meszaros c790812207 Merge pull request #11 from warmbly/feature/api-keys-dashboard
feat: ship api keys dashboard with smart limits and analytics
2026-05-25 12:26:54 +02:00
Matthew Meszaros e38d571610 Merge pull request #12 from warmbly/feature/campaign-creation-overhaul
feat: fix campaign creation 500 and ship full-config wizard
2026-05-25 12:26:44 +02:00
Matthew Meszaros 512fea76ff feat: add contact 360 detail, sent-emails, and timeline endpoints 2026-05-25 08:30:19 +00: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 9123c4bfcf feat: full api keys product (smart limits, analytics, dashboard) 2026-05-24 16:07:18 +00:00
Matthew Meszaros 07b0c8b5ff feat: backend contacts categories, smart export, csv/xlsx import 2026-05-24 16:04:48 +00:00
Matthew Meszaros 9aedd8cbb0 Merge remote-tracking branch 'origin/main' into feature/implement-templates
# Conflicts:
#	internal/api/routes.go
2026-05-24 15:25:02 +00:00
Matthew Meszaros 9bce2ad29e feat: full reply templates (search/reorder/duplicate/render + UI) 2026-05-24 12:01:37 +00:00
Matthew Meszaros 8002d56b86 feat: full api key support across data routes 2026-05-24 11:59:05 +00:00
Matthew Meszaros 939f2160d9 Merge pull request #6 from warmbly/feature/redesign-email-onboarding
ci: fix CI permissions, add web pipeline, prune go linters
2026-05-24 13:26:25 +02:00
Matthew Meszaros c822e95e7f Merge remote-tracking branch 'origin/main' into feature/danger-zone-delayed-deletions
# Conflicts:
#	cmd/backend/main.go
#	internal/api/handler/handler.go
#	internal/api/routes.go
#	internal/models/audit.go
#	internal/models/organization.go
#	internal/models/user.go
#	internal/repository/pg_organization.go
#	internal/repository/pg_user.go
2026-05-24 04:12:10 +00:00
Matthew Meszaros 337d823703 merge: bring main into branch, reconcile email service constructor 2026-05-24 04:09:16 +00:00
Matthew Meszaros e42feac0e4 feat: danger zone with delayed deletion for orgs and accounts 2026-05-24 04:06:07 +00:00
Matthew Meszaros 45f48ba93b feat: redesign email account onboarding flow 2026-05-24 04:01:16 +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
Matthew Meszaros 4eb8f7babe feat: settings overhaul, avatars, RBAC, plan alignment, perf
Backend:
- Fix contact-create 500 (nil custom_fields, doubled slice, bad RETURNING SQL)
- Avatar upload: migration 000033, S3 public-read, PNG/JPG only,
  client-resized to 512px + server dimension cap (1024px)
- Pull avatar_url through user + organization repo queries

Frontend:
- Settings restructured into nested routes with a rail layout
  (/app/settings/{profile,notifications,security,members,roles,
  workspace,billing,danger}); flat Section/Row primitives replace
  the per-card rectangles; Save buttons only render when dirty
- Standalone /app/billing and /app/team removed; legacy URLs
  redirect to the settings sections; UserNav trimmed accordingly
- CRM rebuilt: Pipelines CRUD + stage editor, Deals kanban with
  HTML5 drag/drop, Tasks bucketed by due-date with inline toggle.
  Frontend models realigned with backend (Deal.name, CRMTask.status
  enum, paginated list shapes)
- Avatars: AvatarUploader component, client-side canvas resize,
  wired into Profile + Workspace settings; UserNav + OrgSwitcher
  render the uploaded image with initials fallback
- RBAC: lib/permissions.ts mirrors organization_permission.go;
  inline role picker in Members; Roles & access section shows the
  permission matrix and per-role member counts
- Audit log page at /app/audit, gated to owner+admin via canManage
- Plans aligned with warmbly-web pricing: Starter/Grow/Business/
  Enterprise via lib/plans.ts; PlanPill, billing page, sidebar
  badges and LockedSurface all read from the same catalogue
- Header PlanPill shows current plan with status-aware coloring;
  sidebar locked rows show the required-plan badge instead of a
  generic lock icon

Perf:
- QueryClient defaults (staleTime: 30s, refetchOnWindowFocus: false,
  retry: 1) — kills 3-5 round-trip storm on every navigation
- useSubscription, usePlans → staleTime: Infinity (only invalidate
  on plan-change mutations); useUser/Timezones/Orgs get long stales
  with refetchOnMount: false
- vite.config: optimizeDeps for heavy libs + server.warmup for the
  most-mounted entry pages
2026-05-23 16:04:24 +00:00
Matthew Meszaros 5845fc3069 feat: inbox tag+multi-account filter + org gate + invite/join flow
Inbox filter:
- Backend: MailSearchParams gained EmailAccountIDs []uuid.UUID; the
  search SQL filters with `email_id = ANY($)`. /unibox handler now
  accepts both `email_id=<uuid>` (legacy) and `email_ids=<csv>`.
- Frontend: UniboxSearchParams gained accountIds[] and a UI-only
  tagId. searchIncoming sends email_ids=csv. UniboxFilterSheet:
  Accounts section is now (a) a row of tag chips backed by user.tags
  with per-tag account counts and (b) a multi-select list of every
  connected mailbox with an inline checkbox + avatar; accounts that
  belong to the active tag get a "via tag" affordance. Picking a tag
  resolves to the underlying account IDs at Apply time. "Select all"
  / "Clear" inline in the SectionBar header.

Org gate + onboarding:
- New /select-org page. Three sections: pending invitations (one-
  click Join), existing memberships (pick one to enter), and a
  Create New Workspace form (slate-900 primary). Routed at
  /select-org.
- OrgGate hook lives inside RealtimeManager. On load, if the user
  has zero orgs and no current org, it navigates to /select-org
  replace. Renders null so it doesn't displace AppLayout.

Invite + join:
- Team page rebuilt with real data: useMembers + usePendingInvitations,
  plus InviteDialog (email + role popover, slate-900 send button).
  Inline remove on member rows (skip "owner"), inline cancel on
  pending invitations.
- Pending invitations show up on /select-org too — a freshly
  invited user can accept without ever entering the dashboard first.

Response unwrapping:
- Org/member/invitation list clients now tolerate the backend's
  {data: T[] | null} envelope (it's the consistent shape across the
  Go handlers). Map nested membership rows into the flat
  Organization shape the rest of the app expects.

Seeder: re-run verified — dev@warmbly.com still gets "Dev's
Organization" so they don't bounce through /select-org.
2026-05-23 10:19:56 +00:00
Matthew Meszaros c8c4440b50 fix: confirm dialog z + theme, persist folders/tags across reload
User: "when I click on delete the confirm appears behind the form and
it looks really bad, doesn't fit in the theme; and also after I reload
the page, nothing appears after creation".

Two distinct bugs:

1) Confirm dialog stacking + styling
   FoldersModal/TagsModal render at z-[110]. ConfirmProvider rendered
   the confirm overlay at z-101 with bg-black/30 + scale animation +
   poppins styling — visually it landed BEHIND the folders modal and
   clicks went through to the backdrop instead.
   Rewrote ConfirmProvider in the brae chrome:
   - z-[200] so it stacks above page-level overlays AND nested
     dialogs.
   - Hairline-bordered card, 48px header (red alert tile + "Confirm"
     eyebrow), prose body, slate-900 footer (Cancel / red Confirm).
   - Escape closes; backdrop closes (both gated on !loading).
   - Spinner inside Confirm during the awaited action.

2) Created folders/tags disappeared after page reload
   POST /folders + /tags persisted to Postgres fine. The frontend
   optimistic-updated the cached user via setQueryData. But
   /auth/me did not return folders/tags/categories — the User payload
   omitted them entirely. On reload the cache refetched /auth/me,
   got missing fields, defaulted to [], and the items vanished from
   the UI.

   Backend fix:
   - models.User now carries Folders/Tags/Categories ([]Group),
     always serialized as arrays.
   - GroupRepository + GroupService gained a List(ctx, userID)
     method; ordered by position then created_at.
   - /auth/me handler now calls List on FolderService, TagService,
     CategoryService and attaches them to the user before responding.

Verified end-to-end:
  GET /auth/me → 200 with full folders/tags arrays populated.
  Create a folder, reload the page → folder still in the list.
2026-05-23 09:38:20 +00:00
Matthew Meszaros abf73d168b fix: dropdowns + folder/tag create (server-side + client-side)
Two real bugs surfaced from "All folders / Newest dropdowns don't open"
and "hex color must be a valid string":

1) Dropdowns silently no-op (broken across the whole dashboard)
   PopoverMenuTrigger asChild uses React.cloneElement to inject
   onClick / ref / aria-expanded onto the trigger child. SelectButton
   was a plain function component that destructured a fixed prop set
   and rendered its own <button> — so the injected props were
   dropped on the floor. Click did nothing.

   Fix: SelectButton is now React.forwardRef + spreads {...rest} onto
   the inner button. The injected click handler reaches the real
   element, the dropdown opens, the menu renders, and selection
   actually applies state.

   Every PopoverMenu trigger using SelectButton was affected — that's
   campaigns (folders + sort), emails (tag filter), contacts (sort +
   filters page rows). All now work.

2) Adding a folder/tag failed with "hex color must be a valid string"
   The /folders + /tags POST landed on groupRepository.Create with
   an empty color and the validator rejected. Even before the color
   check, the INSERT used tx.QueryRow + Scan against an INSERT with
   no RETURNING clause, which always errored with
   "sql: no rows in result set" once it got past validation.

   API improvements (kept the design but made it forgiving):
   - Color defaults: if the request omits color, the server picks one
     from an 8-swatch palette based on the new item's position. Two
     consecutive creates won't end up identical. Non-empty but
     invalid still 400s — that's a client bug worth surfacing.
   - Title min length 3 → 1. "Q1", "VIP", short names are common
     and shouldn't fail. Trimmed before validation so " " doesn't
     pass.
   - INSERT now uses tx.Exec instead of QueryRow.Scan — the broken
     code would never reach success even when validation passed.

   Verified end-to-end:
     POST /folders {"title":"Q1"} → 200, color=#94a3b8 (default).
     POST /folders {"title":"Q2","color":"#38bdf8"} → 200.
     POST /tags    {"title":"VIP","color":"#10b981"} → 200.

   Frontend:
   - createFolder / createTag clients accept an optional color param.
   - LabelListModal now picks a default palette color when entering
     add-row mode (rotating with item count) and offers a swatch
     popover to override before submitting. Selected color is sent to
     the backend.
2026-05-23 09:18:45 +00:00
Matthew Meszaros 72a6496086 fix(api): /campaigns 500 — SQL referenced cet.tag / cec.folder, columns are tag_id / folder_id
CAMPAIGN_SELECT_FULL had:
  array_agg(cet.tag_id)    FILTER (WHERE cet.tag    IS NOT NULL)
  array_agg(cec.folder_id) FILTER (WHERE cec.folder IS NOT NULL)

The columns referenced in the FILTER clauses don't exist:

  warmbly_dev=# \d campaign_email_tags
   Column    | Type
  -----------+------
   tag_id    | uuid
   campaign_id | uuid

  warmbly_dev=# \d campaign_folders
   Column    | Type
  -----------+------
   campaign_id | uuid
   folder_id   | uuid

Result: every GET /campaigns returned 500 with
  *pgconn.PgError: ERROR: column cet.tag does not exist (SQLSTATE 42703)
which is why the frontend page was perpetually blank — the request was
failing before any data could land. Fixed both FILTER predicates to
use the actual *_id columns.

Verified after rebuild:
  - dev@warmbly.com (no campaigns): 200 with empty data array.
  - beth@beta.test (owns seeded campaign): 200 with the Beta Cold
    Outreach Q1 record.
2026-05-23 05:50:31 +00:00
Matthew Meszaros 695e2b5a33 fix: contacts crash, campaigns panic, websocket — 4 distinct bugs found while triaging the page-blank symptom
1) Contacts crash "c is null":
   contactRepository.Search declared `var contacts []models.Contact` so
   an empty result set returned a nil slice, which Go marshals as JSON
   null. The frontend's flatMap((p) => p.data) over null yields [null],
   and the page then accesses c.subscribed → throws. Initialize as
   make([]models.Contact, 0, limit+1) so the wire format is always [].
   Also defensive on the client: useSearchContacts + useCampaigns now
   coerce p.data ?? [] and drop nulls before returning.

2) Campaigns panic on any non-empty result:
   campaignRepository.Search allocated `make([]models.Campaign, 0, limit+1)`
   (length 0) then did `campaigns[i] = campaign`. That's an
   index-out-of-range on the first iteration. Switched to `append`.
   Anyone with at least one campaign would see a 500 / blank screen.

3) Websocket "Token expired":
   SocketTTL was 60s. The frontend reconnect backoff caps at 30s, so
   after a rejected handshake the next attempt could fire 30-60s
   later. Combined with rare back-pressure on /getaway the token was
   already past exp by the time the realtime saw it. Bumped to 10 min
   — short enough to keep the token low-impact, long enough to outlast
   the backoff schedule.

4) Websocket "Connection limit exceeded":
   Realtime.Connections only untracked on channel terminate, never on
   socket disconnect. Sockets that connected and disconnected without
   joining a channel leaked. Each reconnect loop bumped the counter
   until the per-user limit (10) was hit, after which every legitimate
   connect was rejected even after fixing #3.
   Fix: GenServer Process.monitor's the socket pid on track, and
   `:DOWN` handler calls do_untrack with the right (user_id, ip).

5) Phoenix protocol mismatch:
   Frontend appended vsn=2.0.0 to the WS URL, but sendRaw + joinChannel
   send the V1 object format. Realtime's Phoenix.Socket.V2.JSONSerializer
   crashed with a badmatch on the first phx_join, killing the socket
   right after connect. Switched to vsn=1.0.0 to match what the client
   actually emits.
2026-05-23 05:32:28 +00:00
Matthew Meszaros f9c02bba6e fix(db): plug 4 tx leaks + bump pool from 4 → 25 — root cause of 10-min logout
Root cause for the 10-min auto-logout (confirmed via pg_stat_activity):
the postgres pool MaxConns was 4, and four repository functions opened
a tx without committing or rolling back. After four calls each leaked
a connection in "idle in transaction" state. Once all four were gone
the pool was permanently exhausted — every new request that needed a
connection blocked until the client gave up. The 10-min trigger is
because that's when the first /auth/refresh fires; refresh tries to
acquire a connection, hangs, eventually the browser aborts the request,
the frontend treats the failure as session expiry, kicks the user.

The four leaking sites:
  - emailRepository.Search        (drove the leak — Accounts page)
  - campaignRepository.Search
  - sequenceRepository.Create
  - contactRepository.BulkUpdate

Each now has `defer tx.Rollback(ctx)` immediately after Begin, matching
the pattern used in the non-leaky sites in the same files. Rollback is
a no-op after Commit, so this is safe for both read-only tx (Search)
and read-write tx (Create / BulkUpdate).

Additional hardening so a future leak can't silently brick the backend:
  - MaxConns 4 → 25. 4 was reckless even without leaks; one bursty
    admin page would saturate. 25 is still well under postgres'
    default max_connections=100.
  - MinConns 0 → 2. Keep a couple of warm connections at idle so the
    first request after a quiet period doesn't pay the connect cost.
  - idle_in_transaction_session_timeout=300000 (5 min) as a session
    RuntimeParam. If a code path forgets the defer, postgres aborts
    the leaked tx after 5 min and reclaims the connection.
  - statement_timeout=60000 (60 s) as a session RuntimeParam.
    Statement runaway can't pin a connection forever.

Verified after backend restart:
  SELECT count(*) FROM pg_stat_activity
    WHERE datname='warmbly_dev' AND state='idle in transaction';
  → 0
2026-05-23 04:41:40 +00:00
Matthew Meszaros 7766b690b4 feat(workers): free-form tags for categorizing the fleet
Migration 000032 + repo + endpoints for arbitrary string tags on
workers. The fixed attributes (worker_type, free_tier, risk_pool)
cover the dimensions assignment logic uses. Tags cover everything
else admins want to group by: region (eu-west, fra), provider
(hetzner, ovh), role (warmup-only, burst-capacity), customer cohort —
whatever.

Schema:
  - worker_tags(worker_id, tag) composite PK
  - tag VARCHAR(64), lowercase + dashed via CHECK constraint
  - ON DELETE CASCADE so deleting a worker drops its tags

Endpoints:
  - GET  /admin/workers/tags             list distinct tags (autocomplete)
  - PUT  /admin/workers/:id/tags         replace tag set; normalizes input

Repo:
  - GetWorkerTags / SetWorkerTags / ListAllWorkerTags
  - HydrateWorkerTags batch-loads tags onto a slice of workers in one
    round-trip so the dashboard list doesn't do N+1 queries

PUT is transactional (delete + bulk insert) so the list view never
catches a worker mid-tag-swap. Auto-derived "smart" labels
(tier:free, pool:risky, state:error) are NOT stored — those are
computed client-side from the worker row so they stay in sync with
the source attributes automatically. Next commit wires the UI.
2026-05-20 14:15:52 +00:00
Matthew Meszaros d0ff189fcd feat(admin-ui): risk pool toggle + Pool column on workers list
Worker list grows a Pool column (clean=green, risky=amber,
quarantine=red badge). Dedicated workers render "n/a" — risk pools are
a shared-worker concept since dedicated workers don't share IPs across
customers.

Worker detail page (shared workers only) gets a "Risk pool" section
with three big buttons. Clicking a non-current pool confirms, then
calls PUT /admin/workers/:id/risk-pool. Action audited with the new
pool value.

Saving doesn't migrate accounts directly — the hourly rebalancer
notices the mismatch and moves mailboxes to a matching-pool worker
on its next tick. Documented in the section's helper text.

Endpoint accepts {risk_pool: "clean"|"risky"|"quarantine"} and is
gated by AdminPermManageWorkers. The worker detail row scan now
includes risk_pool so the column actually has data.
2026-05-19 05:41:41 +00:00
Matthew Meszaros ba1c10fe19 feat(workers): risk-pool schema + per-mailbox risk band
Threat-level segregation, schema layer. Two new concepts:

  workers.risk_pool ∈ {clean, risky, quarantine}
    buckets shared workers by acceptable risk. Dedicated workers don't
    use it (single tenant = no cross-contamination risk).

  email_accounts.risk_band ∈ {clean, risky, quarantine}
    per-mailbox classification, derived from warmup_health_state by the
    rebalancer (next commit). Never set by user input.

The mapping is one-way and intentionally simple:

  healthy           → clean
  watch, throttled  → risky
  quarantined,      → quarantine
  blocked

Rebalancer code lands in the next commit. This commit just adds:

  - migration 000031 with enums + columns + filtered indexes
  - WorkerRiskPool / EmailRiskBand types + RiskBandFromHealth helper
  - WorkerRepository methods: SetWorkerRiskPool, SetEmailAccountRiskBand,
    GetSharedWorkersByTierAndPool, ListRiskCandidates
  - RiskCandidate result type joining email_accounts + warmup health
    (picks WORST state across pools via CASE ranking) + worker columns
    so the rebalancer can decide migrations in one scan
2026-05-19 05:36:25 +00:00
Matthew Meszaros 261cc439ad feat(admin): manage worker fleet from dashboard with encrypted credentials and GitHub release auto-update
Workers are no longer curl|sh-only. Admins add and manage them from the
dashboard over SSH, with all runtime config (Kafka, Schema Registry,
Redis, AWS keys) stored encrypted via the existing KMS-envelope cipher
service.

Worker lifecycle:
  1. Admin POSTs host/port/user. Backend generates an ed25519 keypair,
     encrypts the private key under uuid.Nil (platform identity), and
     stores the row in 'pending' state.
  2. Admin pastes the returned public key into the VPS's authorized_keys.
  3. Test connection — runs `true` over SSH, pins the host SHA256
     fingerprint on first success (TOFU).
  4. Install — backend scp's install-worker.sh + a per-worker env file
     and runs it. State moves pending → provisioning → installed.
  5. From then on: restart, update image, apply config, uninstall,
     rotate keys, tail logs, live status, OS package update, reboot —
     all dashboard buttons backed by SSH operations.

Credentials are reusable entities:
  - aws_credentials: named keypair, secret encrypted at rest
  - worker_profiles: bundles Kafka + Schema Registry + Redis + image +
    release channel, references one AWS credentials row
  - workers.profile_id links a worker to a profile; many workers can
    share one profile

Saving a profile doesn't restart anything. The dashboard compares
profile.updated_at to each worker's config_applied_at and shows a
"stale config" badge; Apply rewrites /etc/warmbly/worker.env over SSH
and restarts the unit.

Auto-update on GitHub release:
  - profile.release_channel ∈ {pinned, stable, dev}
  - profile.auto_update toggles automatic rollout
  - Trigger model is push, not poll: one check on backend boot, then
    the /webhooks/github/releases endpoint (HMAC-validated with
    RELEASES_WEBHOOK_SECRET) on every release event. Manual "Check now"
    button as fallback.
  - When a new tag resolves, the orchestrator SSHes into each assigned
    worker, runs install-worker.sh --update --image <new>, which now
    rewrites the systemd unit (not just `docker pull`) so the image
    actually changes. workers.image_version captures the running tag
    for the UI's "v1.2.3 → v1.2.4" diff.

Self-hostable: every release knob is env-driven —
RELEASES_GITHUB_REPO, RELEASES_WORKER_IMAGE_REPO,
RELEASES_WEBHOOK_SECRET, RELEASES_GITHUB_TOKEN, RELEASES_ENABLED. Set
RELEASES_ENABLED=false to disable the feature entirely.

OS-level updates and reboot are also exposed: detect apt / dnf / yum /
pacman / apk, run the right upgrade noninteractively, return the full
output and a reboot-required flag. Reboots are never automatic.

Migrations:
  000028_worker_ssh        — ssh fields, install_state enum, last_seen,
                              host fingerprint
  000029_worker_credentials — aws_credentials + worker_profiles +
                              workers.profile_id + workers.config_applied_at
  000030_worker_releases   — release_channel enum, auto_update,
                              resolved_image_tag, workers.image_version

Endpoints added:
  POST   /admin/workers                        (create + keypair)
  GET    /admin/workers/managed
  GET    /admin/workers/:id/managed
  POST   /admin/workers/:id/{test,install,restart,upgrade,uninstall,rotate-keys,apply,system-update,reboot}
  PUT    /admin/workers/:id/profile
  GET    /admin/workers/:id/{live-status,logs}
  DELETE /admin/workers/:id
  GET    /admin/aws-credentials                CRUD
  GET    /admin/worker-profiles                CRUD + /workers + /apply + /release
  GET    /admin/releases/state
  POST   /admin/releases/check
  POST   /webhooks/github/releases             public, HMAC-validated

Admin UI:
  /app/admin/workers           list with status + version columns
  /app/admin/workers/new       add form with profile dropdown
  /app/admin/workers/:id       detail with all actions + logs + system update
  /app/admin/credentials       tabs: AWS credentials + worker profiles,
                                Releases panel, channel selector +
                                auto-update toggle in profile form
2026-05-18 13:09:11 +00:00
Matthew Meszaros 1ad8e7e995 feat: implement dead worker detection with heartbeat TTL and auto-reassignment 2026-04-12 11:14:24 +00:00
Matthew Meszaros 3e556825d8 fix: add org scoping to contact notes and activities to prevent IDOR 2026-04-12 11:09:40 +00:00
Matthew Meszaros 562752d20e feat: skip bounced and suppressed contacts in campaign scheduling 2026-04-12 11:06:04 +00:00
Matthew Meszaros fbd62ea20c fix: invert nil checks in dynamo email message Del so non-nil keys are added 2026-04-10 06:54:58 +00:00
Matthew Meszaros 80358168ae test: add unit tests for warmup health evaluation, campaign state machine, and templates 2026-04-09 15:59:27 +00:00
Matthew Meszaros 23b5c925e4 feat: add scheduled warmup health sweep, pool health summary endpoint, and admin overview 2026-04-09 15:57:04 +00:00
Matthew Meszaros 9c4b6d21fd fix: add contact email deduplication with unique index and upsert on conflict 2026-04-09 15:12:41 +00:00
Matthew Meszaros 41aa183be2 fix: add campaign state machine to prevent invalid status transitions 2026-04-09 15:11:58 +00:00
Matthew Meszaros 36f4a94814 fix: prevent race condition in dedicated worker assignment with atomic insert 2026-04-09 14:54:22 +00:00
Matthew Meszaros 306690db0a feat: add explicit position column to sequences for deterministic ordering 2026-04-09 14:52:17 +00:00
Matthew Meszaros 421a3fb3dc fix: add campaign ownership validation to prevent IDOR in bulk campaign operations 2026-04-09 14:48:29 +00:00
Matthew Meszaros c2948d464b fix: add hard limit to pipeline list query to prevent unbounded results 2026-04-09 14:47:14 +00:00
Matthew Meszaros d903c26255 fix: add hard limit of 500 to template list query to prevent unbounded results 2026-04-09 14:46:20 +00:00
Matthew Meszaros 4b8ff94201 fix: use correct column names contact_id and campaign_id in campaign_leads queries 2026-04-09 14:43:10 +00:00
Matthew Meszaros 0799020dac feat: add metrics, warmup content variety, tz-aware scheduling, org budget, admin stubs, and bug fixes 2026-04-09 14:33:45 +00:00
Matthew Meszaros 19d20e72b4 feat: add warmup health throttled state, complaint/bounce metrics, A/B analysis, and rate limiting 2026-04-09 12:51:30 +00:00
Matthew Meszaros d8d88c7f69 feat: add warmup health tracking, migrate repos to postgres, and overhaul web UI 2026-04-03 06:08:52 +00:00
Matthew Meszaros 21ffb6a748 feat: add advanced outreach controls with A/B testing, deliverability dashboard, and DLQ 2026-02-20 08:59:17 +00:00
Matthew Meszaros c564b3ac95 feat: implement unibox replies, warmup conversations, and daily email limits 2026-02-20 04:54:46 +00:00
Matthew Meszaros 6c6d26d8f0 Update auth and onboarding flow 2026-02-14 05:38:27 +01:00
Máté Mészáros (Laptop) ed35ab2dbc Realtime Updates 2026-01-30 15:32:58 +01:00
Máté Mészáros (Laptop) 41624a6f79 Analytics & Tracking 2026-01-29 05:59:04 +01:00
Máté Mészáros (Laptop) 6adb4cdd5a Organization, Subscription, Inqueries, limits. 2026-01-27 05:55:48 +01:00
Máté Mészáros (Laptop) 5ac159d2f8 Realtime, api keys & more 2026-01-26 16:04:42 +01:00