Commit Graph
1720 Commits
Author SHA1 Message Date
Matthew Meszaros ae17c8dead feat: give models.AddContact an optional Subscribed pointer and honour it in the pg_contact upsert (passed twice with explicit boolean casts because one placeholder cannot serve both the INSERT value and the DO UPDATE set without tripping Postgres parameter inference), where nil now means leave the flag alone, and stop the same upsert erasing a populated first_name, last_name, company or phone when the incoming row's cell is blank so re-importing a partial export enriches contacts instead of wiping them 2026-08-27 03:43:54 -07:00
Matthew Meszaros 66bd9e54e2 feat: route every contact custom-field write through one normalizeCustomFields helper in pg_contact so Add, Update and the bulk field editor all trim and whitespace-collapse a key before storing it and answer a bad one with the name and the rule instead of the opaque errx.ErrJSONKey, which is now unused and deleted, and bind the custom-field search filter key as a query parameter rather than interpolating it into the JSONB ->> operand now that a key can legitimately contain spaces 2026-08-27 03:43:24 -07:00
Matthew Meszaros 49234877a6 feat: widen the contact custom-field key rule in internal/utils/json.go from ^[a-zA-Z0-9_]+$ to identifier segments joined by spaces or dashes, the exact set tasks.rewriteSpacedFieldRefs already resolves, so a spreadsheet column named "Company Mobile" is storable as well as addressable as {{.Company Mobile}}, and add NormalizeJSONKey plus an exported JSONKeyRules string so every caller trims and whitespace-collapses a key identically and reports the same rule back to the user 2026-08-27 03:42:47 -07:00
Matthew Meszaros ec0a6f3b2f Merge pull request #208 from warmbly/fix/195-warmup-health-evaluation-silent-failure
fix: warmup health evaluation has never run — pgx's ErrNoRows is not sql.ErrNoRows, and UpdateParticipantHealth was never preparable (#195)
2026-08-27 01:24:55 -07:00
Matthew Meszaros 0bbfa4b2ac feat: make warmup health evaluation actually run, which it never has: GetParticipantHealth compared pgx's not-found error with == sql.ErrNoRows but pgx.ErrNoRows is a proxy that wraps it rather than being it, so "this account is not in this pool" surfaced as a hard error and the premium-first probe failed before ever reaching a free-pool account's own row; UpdateParticipantHealth used a bare $1 as both a health_state assignment and an equality test, so Postgres deduced character varying from one and text from the other and refused the statement with 42P08, meaning no health state was ever persisted for anyone in any pool; both are now fixed along with the other 18 == sql.ErrNoRows comparisons in the repository layer and the same unpreparable-parameter defect in the contacts bulk custom-field writes, the worker install-state update and the referral earnings upsert, all found by a new live test that asks Postgres to PREPARE every parameterised statement in the package; evaluation failures now log their real cause instead of collapsing into errx.InternalError, the hourly sweep reports how many participants it could not evaluate instead of skipping them in silence, ApplyInvalidTokenAttempt no longer reports failure once the attempt is persisted so the caller cannot double-count it, and migration 000096 adds a per-participant signal floor so the first working evaluation cannot block a mailbox on history gathered while nothing was watching 2026-08-27 01:04:50 -07:00
Matthew Meszaros dd1119e302 Merge pull request #204 from joaoppa/fix/195-invalid-token-double-count
fix: stop double-counting invalid warmup token attempts, and log the health evaluation failure that was being discarded (#195)
2026-08-27 00:46:59 -07:00
Matthew Meszaros c6448fecbe Merge branch 'main' into fix/195-invalid-token-double-count 2026-08-27 00:41:23 -07:00
Matthew Meszaros 08a91b39d0 Merge pull request #206 from warmbly/fix/realtime-join-throttle-and-channel-refcount
fix: throttle channel joins on the realtime service, and make both clients handle a refused join without storming it
2026-08-27 00:16:39 -07:00
Matthew Meszaros 42ddb78e60 feat: make the realtime service survive a client that spams channel joins, and stop both clients from being that client: ws_join is now spent per phx_join in a shared RealtimeWeb.ChannelGuard that every channel runs BEFORE its Auth lookup (it was only ever spent on the socket handshake, so an established socket could issue unlimited joins and each one cost a Postgres query), handshakes move to their own ws_connect bucket so a reconnect storm cannot eat the budget a client needs to rejoin with, the retry_after_ms hint now points at the next window instead of shrinking as overage grew, every channel's join rejection carries the numeric code the API reference already promised (plus 4005 for a malformed topic), BulkChannel only pushes an event to the user who owns the operation instead of to anyone who guesses the id, and the web and admin clients treat a rate_limited join as transient (wait out the hint, keep the socket) while backing off per-channel rejoins on a decaying schedule rather than reconnecting on the 120ms floor forever; adds an ExUnit suite for realtime and a vitest suite for admin, both wired into CI 2026-08-26 21:14:34 -07:00
Matthew Meszaros 49e1696011 Merge pull request #205 from joaoppa/fix/socket-channel-rejoin-loop
fix: stop the realtime socket rejoining a channel on every render (~400 joins/second from one open campaign page)
2026-08-26 20:51:42 -07:00
joao-crm f82e3c3402 feat: stop the realtime socket tearing down and rejoining a channel on every render, which made one open campaign page issue about 400 channel joins per second and saturate the realtime service: the provider's context value is now memoised instead of an inline object literal, and useChannel/useChannelEvent depend on the stable joinChannel/leaveChannel/subscribeToChannel callbacks and on params BY VALUE rather than on the whole context, so channelStates re-rendering the provider no longer re-runs the effect that owns the subscription 2026-08-27 03:05:15 +00:00
joao-crm 9130498ade feat: stop double-counting every invalid warmup token attempt by treating a failed health evaluation as non-fatal inside ApplyInvalidTokenAttempt, since the attempt and its spam score are already persisted when it runs and the caller's degraded path recorded both a second time, and log the evaluation failure that was being discarded so the band that never fired can finally be diagnosed 2026-08-26 23:05:52 +00:00
Matthew Meszaros 28fbde314d Merge pull request #203 from warmbly/fix/issue-200-investigation
fix: one undeliverable address no longer wedges a whole campaign, and the pre-send verifier stops inventing the verdict that caused it
2026-08-26 06:10:04 -07:00
Matthew Meszaros 0a8c3314d0 feat: stop one undeliverable address from wedging a whole campaign, and stop the pre-send verifier from inventing the verdict that caused it: campaign routing in FindNextRoutedPair now excludes contacts whose address verification refused them (invalid, or risky with the campaign's send-to-risky toggle off) because the pre-send gates in campaign_task.go skip those without recording progress, so the finder re-served the same lead every tick and the campaign never reached the healthy leads behind it; the SMTP prober no longer reads every 5xx reply to RCPT TO as a dead mailbox, since Postfix defers HELO/sender/policy rejections to RCPT time and the old default greeting of localhost made them arrive as 504 5.5.2 Helo command rejected against the recipient, so a 5xx now becomes invalid only when the reply names the recipient and degrades to unknown otherwise; the HELO name falls back to the APP_URL host and the probe is skipped outright without a public FQDN; migration 000095 resets the verdicts the corrected classifier would no longer reach so poisoned contacts are re-verified; and skipped leads now report as undeliverable in the Leads view and in the campaign completion log instead of sitting at Queued forever 2026-08-26 06:03:20 -07:00
Matthew Meszaros a9ff56d2c5 Merge pull request #202 from warmbly/chore/remove-stray-repo-files
chore: remove stray, unreferenced files from the repository
2026-08-26 05:02:22 -07:00
Matthew Meszaros 8953b2a132 chore: delete repository junk that was never referenced by anything: the paseo worktree-tool config, the root .astro type output an astro run from the repo root left behind, the empty schema.sql, the stray root package.json plus pnpm-lock.yaml from an accidental pnpm add motion (every frontend tree owns its own manifest and lockfile, and CI only ever reads those), the zero-byte cmd/consumer/envsample, the empty web CampaignSearchProvider.tsx, six Go files holding nothing but a package clause, and models.WMailAdd which had no callers; root .gitignore now covers each of them so they cannot drift back in 2026-08-26 04:55:45 -07:00
Matthew Meszaros 51fec3dd44 Merge pull request #201 from warmbly/placid-ridge
fix: verify warmup mail whose verify header Graph stripped in transit
2026-08-26 04:40:43 -07:00
Matthew Meszaros b77664cd09 feat: verify warmup mail that arrives without its verify header, because Microsoft Graph strips custom headers in transit and re-stamps the Message-ID, so every warmup email sent from an Outlook or Microsoft 365 mailbox reached its recipient unmarked, consumed no token, earned no engagement and was filed in the recipient's unibox as ordinary mail; the Graph client now creates the message as a draft and sends that so it can read the internetMessageId Exchange assigned, the consumer records that delivered id on the task and its warmup token alongside the sent subject, and the recipient resolves an unmarked warmup email by delivered Message-ID or by the pending sender/recipient/subject pair 2026-08-26 04:31:19 -07:00
Matthew Meszaros 6b00ab23cb Merge pull request #199 from warmbly/fix/graph-spam-rescue-guard
fix: Graph rescues a message from Junk that was never in Junk, undoing the Warmbly foldering and re-ingesting the message
2026-08-25 20:40:07 -07:00
Matthew Meszaros f1856749e4 feat: only rescue a Graph message from Junk when it is actually in Junk, because engagementPlan folders into the untracked Warmbly folder first and the unconditional move undid that foldering and dropped the message back into the tracked Inbox under a new id, where live sync re-ingested it as new mail and burned its already-consumed warmup token as an invalid-token attempt 2026-08-25 20:14:22 -07:00
Matthew Meszaros 4c9d0b1c87 Merge pull request #198 from joaoppa/fix/186-warmup-selfmove-and-namespace
fix: stop banning a warmup recipient for the foldering we ordered, and create the Warmbly folder inside the server's IMAP namespace (#186)
2026-08-25 20:07:15 -07:00
Matthew Meszaros c65d083545 Merge branch 'main' into fix/186-warmup-selfmove-and-namespace 2026-08-25 20:01:11 -07:00
Matthew Meszaros 35ca9967cf Merge pull request #197 from joaoppa/fix/181-warmup-generation-schema
fix: make warmup AI generation work at all, and report the provider's real reason when a batch fails (#181)
2026-08-25 19:57:48 -07:00
joao-crm 8752b02217 feat: create and move into the Warmbly folder inside the server's personal IMAP namespace instead of the root, so Dovecot hosts that keep user folders under INBOX. stop failing every warmup foldering action with "nonexistent namespace" and silently losing that engagement signal 2026-08-25 20:47:15 +00:00
joao-crm 135e0c8c94 feat: stop banning a warmup recipient for the foldering the platform itself ordered, by marking each move_to_warmbly before it is published and excusing the single removal it produces, since Microsoft Graph reports a move out of a tracked folder exactly like a deletion and the tampering threshold of one blocked every Outlook mailbox on its first warmup email 2026-08-25 20:46:37 +00:00
joao-crm ac40eca920 feat: read a fully failed batch's error file instead of reporting "empty output file id", so a generation job that had every request refused names the provider's actual reason in the admin panel rather than an internal guard message 2026-08-25 20:46:27 +00:00
joao-crm 93a2fa0962 feat: reflect the structured-output schema inline and anonymously so OpenAI stops rejecting every warmup generation request with "$ref cannot have keywords {'$id'}", which made AI warmup content impossible to generate on any install 2026-08-25 20:46:27 +00:00
Matthew Meszaros c9f831c59a Merge pull request #192 from warmbly/feature/issue-185-implementation
Add Delete and Duplicate campaign actions to the dashboard
2026-08-25 09:08:36 -07:00
Matthew Meszaros 6ebc925a93 feat: fix the campaigns guide so the docs site builds again: the new Duplicate and delete section wrote the derived copy name as "<name> (copy)", which MDX parses as an unclosed JSX tag and fails the Cloudflare Pages build; it is now inline code 2026-08-25 09:02:05 -07:00
Matthew Meszaros d396f53fc4 feat: add Delete and Duplicate campaign actions to the dashboard (issue #185): every campaign row and the detail header get a ⋯ menu (Edit, Duplicate, Start/Pause, Delete) plus a Delete card at the bottom of Settings, all permission-gated with a confirm that spells out what goes; DELETE /campaigns/:id is now organization-scoped instead of user_id-scoped so teammates can delete, runs in one transaction that also deletes the campaign's pending tasks and cancels a wakeup tick claimed at that moment (campaign_tasks only nulls its link, so those rows kept firing), removes attachment objects and publishes CAMPAIGN_DELETED so a teammate's open detail page is sent back to the list; new POST /campaigns/:id/duplicate copies the campaign row as a draft with steps and their branch graph rewired onto new step ids, tags, folders, senders with rotation reset, A/B variants, advanced settings and attachments (quota-checked, blobs undone if the copy fails) and none of the leads, progress, logs, counters, ramp level, guardrail trip or past dates, naming it (copy)/(copy N) inside the 50 byte cap without splitting runes; a claimed campaign tick whose campaign vanished now ends the chain instead of staying active forever; covered by TestLiveCampaignLifecycle* against real SQL, RemapBranchTargets and duplicateName unit tests and a react-query vitest for the list cache, with API reference, endpoint map and campaigns guide updated 2026-08-25 08:54:51 -07:00
Matthew Meszaros 5da840ba72 Merge pull request #191 from warmbly/fix/issue-187
fix: adding a contact to a campaign actually creates the lead and shows it on the Leads tab
2026-08-25 08:04:24 -07:00
Matthew Meszaros 560776619c feat: fix adding a contact to a campaign so the lead is actually created and shows up on the campaign Leads tab: contact writes matched campaign membership on campaigns.user_id, so a member adding a contact to a campaign a teammate created selected no rows and the API answered 200 having done nothing, and GetDetail rendered the contact 360 through the same filter so existing memberships read as empty; Update, Add and the GetDetail badge subselect now scope on organization_id, the membership diff runs as one statement per direction with UUID-validated ids, and the response always re-reads membership so an unrelated field edit no longer returns campaigns:null; on the dashboard the Leads tab is a ["contacts","list"] search scoped to one campaign, and useUpdateContactsBulk patched rows under a ["campaigns","list"] key that holds no contacts and invalidated nothing, so the cached lead-less result survived its 5 minute staleTime and only a search (a new query key) appeared to find the contact, so every contact mutation hook now invalidates ["contacts"] and ["campaigns"] from onSuccess, covered by TestLiveContact* against real SQL and a react-query test that fails on the old hooks 2026-08-25 07:55:15 -07:00
Matthew Meszaros 9f5b4499b1 Merge pull request #190 from warmbly/fix/issue-189-image-review-fixes
fix: campaign stuck at "Queued / Not started" with nothing sending (#189)
2026-08-25 07:45:08 -07:00
Matthew Meszaros efa914025c feat: stop an active campaign sitting at "Queued / Not started" with nothing sending: a campaign is one self-perpetuating task, so a tick that found nothing due parked its successor at the literal next-due moment (three days out for a "wait 3 days" step) and that parked task was also the next time anything re-read the campaign, so leads imported meanwhile stayed invisible until it fired and the reconciler never noticed because it only re-seeds chains with no pending task; deferral parks are now capped at config.CampaignMaxDeferMinutes via scheduler.DeferSlot at all three enqueue sites (a tick that actually sent still parks at its paced interval, so send spacing is untouched), the reconciler re-checks any active campaign parked beyond CampaignStaleParkHours and pulls its wakeup forward when the real next slot is CampaignReparkMarginMinutes sooner, attaching leads to a running campaign wakes it immediately through one CampaignWaker seam in the contact service that covers add/update/bulk-edit/import/Sheets-sync, even distribution now paces across the whole sender pool via poolRemainingOn instead of the one mailbox the tick picked (a three-mailbox campaign was sending at one mailbox's rate), the flat +/-20 minute jitter that was wider than the interval it perturbed is scaled to half the distance to the slot so it stops landing slots in the past where notBefore collapsed them onto the min-gap, and on the dashboard a full-day window renders "12am-midnight" instead of "12am-12pm", the campaign lead strip uses the server's campaign-wide lead_counts instead of counting the 50 loaded rows, and channel state moves out of a ref into React state so a live campaign's panel stops reading "Disconnected" forever 2026-08-25 07:39:18 -07:00
Matthew Meszaros 2e5ea3b3af Merge pull request #183 from warmbly/fix/release-notes-formatting
fix: release notes render as one crammed paragraph with a duplicate What's Changed section
2026-08-24 20:33:54 -07:00
Matthew Meszaros 4589f32634 Merge remote-tracking branch 'origin/main' into fix/release-notes-formatting 2026-08-24 20:29:06 -07:00
Matthew Meszaros 07fe5e7f34 feat: fix the release notes so What's Changed renders as grouped sections instead of one crammed paragraph: the changelog step encoded newlines as %0A for the set-output command GitHub disabled in 2022, so all 50 commits collapsed into a single run-on line with literal %0A separators, and generate_release_notes:true alongside a hand-written body made GitHub append a second duplicate What's Changed section (44KB body on v0.2.1); a new .github/scripts/release-notes.sh now walks --first-parent so each PR is one entry, takes the PR title from the merge commit body, groups by conventional-commit type into Features/Fixes/Performance/Security/Documentation/Maintenance/Other, trims this repo's deliberately long subjects at a clause boundary, links the PR (or the commit for direct pushes), picks the previous tag via git tag --sort=-v:refname --merged so stable releases skip prereleases and still list what an rc covered, states truncation instead of silently cutting at 50, and the workflow passes the assembled body via body_path so no escaping layer remains 2026-08-24 20:23:41 -07:00
Matthew Meszaros 13d52cf76b Merge pull request #182 from warmbly/fix/imap-live-lane-denial-stops-pass
Stop the IMAP live pass once a lane is denied, so unfreezing a mailbox cannot deactivate it
2026-08-24 20:20:01 -07:00
Matthew Meszaros 430a645858 feat: stop the IMAP incremental pass fetching further batches once a sync lane is denied, so a mailbox unfrozen by the LIST-STATUS release fix does not walk its whole invisible backlog into the flood detector and deactivate itself: imapIncremental now returns not-complete on the first batch that could not be fully stored (holding the folder's mod-sequence for the next tick instead of setting stats.aborted, which would also skip the backfill and every other folder), ReleaseMailbox takes Client.mu like every other selected-state command so it cannot interleave with a warmup MOVE/STORE, every SELECT is funnelled through Client.selectMailbox so UNSELECT is skipped when nothing is selected and a strict server never answers BAD, and SmtpImapData.ImapClient plus WMail.gov become the narrow ImapConn and syncBudget interfaces so a full IMAP pass can be driven against a fake and its fetch round trips counted in TestImapSyncStopsFetchingOnceTheLiveLaneIsDenied, TestImapSyncKeepsWhatFitBeforeTheDenial and TestImapSyncWalksEveryBatchWithinBudget 2026-08-24 20:16:03 -07:00
Matthew Meszaros 4fa20644c9 Merge pull request #180 from joaoppa/fix/165-imap-release-mailbox
fix: release the selected IMAP mailbox before the LIST-STATUS poll so live sync keeps detecting new mail on Dovecot
2026-08-24 20:06:53 -07:00
joao-crm afdeb9622e feat: release the selected IMAP mailbox before the LIST-STATUS poll so Dovecot servers stop reporting a frozen HIGHESTMODSEQ and live sync keeps detecting new mail after the first fetch 2026-08-24 23:43:51 +00:00
Matthew Meszaros 2f5fc52ec6 Merge pull request #177 from warmbly/fix/167-sender-org-scoping
fix: resolve campaign senders by organization, not by the campaign owner (#167)
v0.2.1
2026-08-24 09:24:54 -07:00
Matthew Meszaros 222c9d2554 feat: scope campaign sender resolution to the campaign's organization instead of its owner so a multi-org user can no longer send organization A's campaign from an organization B mailbox: GetByTags/GetAllActiveInScope/GetByCampaignSenders now take a repository.AccountScope keyed on organization_id where a scope with no organization resolves to no mailboxes rather than widening to the owner (tags carry no organization of their own, so one user's tag legitimately spans workspaces and the predicate is the only thing holding the boundary), the campaign scheduler and the preflight tracking-domain check build that scope from campaign.OrganizationID, unibox compose scores only the current workspace's mailboxes, the 'all' lanes of AccountHasActiveCampaign/CountActiveCampaignsForAccount join ea.organization_id = c.organization_id instead of ea.user_id = c.user_id, dead and broken PauseAllByUserID (which wrote the reason string into status and had no callers) is removed, the campaigns/unibox guides now state that senders resolve inside the campaign's workspace, and TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient is updated because an orgless campaign now finds no senders before routing is consulted while the send gate it covers still refuses; live-tested in TestLiveSenderResolutionStaysInsideTheCampaignOrg, TestLiveSenderSchedulerNeverPicksAnotherOrgMailbox, TestLiveSenderSchedulerPicksTheCampaignOrgMailbox, TestLiveSenderScopeWithoutAnOrganizationReachesNothing and TestLiveActiveCampaignLookupIsOrgScoped 2026-08-24 09:20:41 -07:00
Matthew Meszaros 05c8334a11 Merge pull request #179 from warmbly/fix/issue-169-campaign-send-loop
fix: a campaign email can be sent twice when the progress write after dispatch is lost
2026-08-24 09:19:02 -07:00
Matthew Meszaros 15e139e15d feat: stop a campaign email going out twice when the progress write after dispatch is lost: a step is now RESERVED before its SEND_EMAIL reaches the bus (migration 000093 adds campaign_contact_progress.dispatched_at + dispatch_task_id, and ReserveSend takes the claim and the day's counters in one transaction) and routing treats a step as attempted on sent_at OR dispatched_at, so a crash or a failed stamp in the dispatch window can no longer read as "never sent" and email the same person again; the ON CONFLICT claim is exactly-once so two ticks racing the same pair cannot both send (the loser ends skipped_duplicate), the stamp is retried and escalated to the campaign feed instead of warned and swallowed, HandleEmailSent repairs a lost stamp from the worker's own confirmation, ReleaseSend gives a reservation back only when the command provably never left (a publish failure is ambiguous via ErrSendDispatchUnknown and keeps it), and StartStuckSendReclaimer walks back a reservation nobody answered after 30 minutes so a worker that died mid-send cannot park a lead in flight forever; live-tested in TestLiveLostProgressWriteDoesNotResend, TestLiveDispatchedSendIsNeverOfferedTwice, TestLiveConcurrentTicksSendOnce, TestLiveStuckDispatchIsReclaimed, TestLiveReclaimBelievesADeliveredSend and TestLiveInFlightSendIsNotOfferedAgain 2026-08-24 09:15:06 -07:00
Matthew Meszaros ce678bd8e6 Merge pull request #178 from warmbly/fix/issue-173-end-to-end
fix: make a custom tracking domain verifiable instead of permanently "Pending DNS" (#173)
2026-08-24 09:09:30 -07:00
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 e3e1137877 Merge pull request #176 from warmbly/fix/orgtransfer-import-blockers
Workspace import fails on any archive with a webhook endpoint, a notification, or a campaign-scoped suppression
2026-08-24 09:01:58 -07:00
Matthew Meszaros 019b45fca7 feat: unblock workspace import for any archive carrying a webhook endpoint, a notification, or a campaign-scoped suppression: ResetOnImport columns are now left out of the insert so the destination's own DEFAULT applies instead of writing NULL, which aborted the whole transaction on the four NOT NULL ones (webhook_endpoints.consecutive_failures, notifications.email_state and email_attempts, ai_mcp_servers.last_error) and made a webhook endpoint arrive carrying the source's failure streak and auto-disable state, and three tables that sat above something they reference are moved below it (webhook_endpoints under oauth_applications, suppressed_recipients under campaigns, reply_intents under tasks) because referencePlan only clears a reference the run does not write at all, so a forward reference to a table written later landed as a foreign-key violation; both classes are now guarded against the live schema in TestLiveResetColumnsHaveSomethingToFallBackOn and TestLiveTablesAreInDependencyOrder, and a cross-instance export/import round trip carrying all three previously fatal tables completes with failure counters back at their defaults and the suppression rows still attributed to their campaign 2026-08-24 08:57:05 -07:00
Matthew Meszaros 7d13b6ff46 Merge pull request #175 from warmbly/fix/issue-168-campaign-send-loop
Fail closed when a campaign has no organization, and remove the state entirely
2026-08-24 08:51:08 -07:00