Matthew Meszaros
bfdbd77b04
feat: fix the eight defects the second review pass found, including three where the previous fix did not land: the bind-mounted state dir was root-owned so the node running as uid 1000 still could not write its update target, elevating the reserved-worker eviction did nothing because the rotation loop bailed on target-equals-current before the urgency was consulted, and the warmup-pool assertion was vacuous which hid that warmupPoolFor checked the subscription repo before the billing provider and answered free on a self-host install
2026-09-09 05:32:30 -07:00
Matthew Meszaros
8b83062eff
feat: fix ten defects the review found, the worst being that every seeder still wrote the worker columns migration 000141 moved to fleet_nodes so make dev could not seed, that the generated systemd unit used a command substitution systemd never expands so a joined machine restart-looped while the script reported success, that the state directory was not bind-mounted so the node wrote its update target inside the container where the host timer never saw it, and that placement stopped assigning warmup pool membership so paid mailboxes silently warmed in the free pool
2026-09-09 05:21:17 -07:00
Matthew Meszaros
704fecdcc0
feat: settle on WARMBLY_NODE_REGION as the one region variable for every role, because the join script wrote it while the worker still read WORKER_REGION, so a joined worker reported no region and the placement geography term stayed dead for exactly the machines that had been given one
2026-09-09 05:04:55 -07:00
Matthew Meszaros
de60bc3784
feat: let an isolated-egress reservation move a mailbox without clearing the score gain, because the reserved worker scores lower than the incumbent by construction (the incumbent holds the stickiness bonus) so the rotation loop refused the move on every tick and the organization never converged onto the worker it pays for
2026-09-09 05:02:53 -07:00
Matthew Meszaros
88992c396c
feat: hand the node its env base64 encoded at join instead of as a raw JSON string, because the POSIX-sh join script has no JSON parser and its sed extraction appended a stray ","heartbeat_seconds line to node.env, and refuse a heartbeat that re-registers an existing node id under the other role, which would have left a worker's mailboxes assigned to a machine that had stopped doing worker work
2026-09-09 05:01:13 -07:00
Matthew Meszaros
1a7fd85f4b
feat: drop the throwaway migration harness that a blanket git add swept into the placement commit, since it only existed to apply migrations against a scratch database while verifying the branch
2026-09-09 04:56:07 -07:00
Matthew Meszaros
58b7d81d33
Merge remote-tracking branch 'origin/main' into refactor/automatic-worker-mailbox-placement
2026-09-09 04:54:04 -07:00
Matthew Meszaros
435dbb522f
feat: replace the worker tier/type/risk-pool/egress categories with a scored placement model and make the fleet pull-based, so a machine joins with one command, workers and consumers share one node registry with usage and liveness, nodes self-update to the version the control plane resolves, and the Hetzner provisioning, worker profiles and SSH orchestrator are removed
2026-09-09 04:54:01 -07:00
Matthew Meszaros
db2a9ba439
feat: stamp the rotating campaign send's real sending mailbox onto tasks.email_account_id before dispatch so budgets, round-robin position, deliverability rates and the contact feed name the mailbox that actually sent, and stop the contact email list and campaign-state steps reporting automated prefetches as recipient opens (issue #392 )
2026-09-09 03:55:28 -07:00
Matthew Meszaros
e783c3e3a0
feat: merge main into the email editor branch
2026-09-08 21:27:28 -07:00
Matthew Meszaros
7ad49c649a
Merge branch 'main' into fix/thread-on-in-reply-to
2026-09-08 21:22:31 -07:00
Matthew Meszaros
1e1bb3622e
feat: address the CodeRabbit review on the email editor PR by generating email image object keys from a uuid and the sniffed extension instead of the uploader's filename so a name carrying '..' cannot produce a key the public route refuses to serve and a private filename never travels inside a recipient's URL, deleting the stored object before its row and refusing the request when storage will not take it so a failed delete leaves nothing loadable that the quota has stopped counting, accepting only https image addresses, resynchronising the HTML source view when a template replaces the body underneath it, and rendering the campaign preview through the inbox's sandboxed frame so markup written in the source view can neither run nor restyle the dashboard for a teammate
2026-09-08 21:17:36 -07:00
Matthew Meszaros
4f40ab7ec8
feat: merge main into the in-reply-to threading branch
2026-09-08 21:06:29 -07:00
Matthew Meszaros
95a4986d9e
feat: merge main into the verification reopen branch
2026-09-08 21:05:37 -07:00
Matthew Meszaros
e47937432f
feat: address the CodeRabbit review on the verification reopen PR by restricting the connection-triggered recheck to built-in verdicts so a verdict a paid verifier already produced is not re-spent when another connection is made, refusing to restore a connection the member disconnected while the credit round trip was in flight, and registering each live fixture's cleanup as it is written so a failed assertion cannot strand an integration row that then breaks the shared org fixture's teardown
2026-09-08 21:05:37 -07:00
Matthew Meszaros
9ce1e060dd
feat: fix the campaign step body editor for issue #380 by registering TipTap's UndoRedo and HardBreak so Ctrl+Z and Shift+Enter work at all, normalising pasted HTML from Gmail/Outlook/Word so their blank-line scaffolding stops rendering a second gap on top of our paragraph margins, adding a workspace email image library (migration 000139, public email-images/ objects sharing the attachment storage quota, keyset-paginated /email-images endpoints) with toolbar upload, URL and library insertion, drag-drop, clipboard paste and a size/alignment/alt bubble, and adding an HTML source view that names the tags the schema would drop before switching back
2026-09-08 21:00:09 -07:00
Matthew Meszaros
0e7525866a
feat: merge main into the select-all branch and rebuild the contact selection on top of its new keyset pagination, dropping this branch's own cursor repair now that Search carries the sort key and boundary value in a SortCursor, re-extracting the shared WHERE builder from that version, and pointing SearchIDs at the contactSorts spec and the conditional campaign-count lateral so a resolved selection orders and joins exactly the way the list does
2026-09-08 20:26:34 -07:00
Matthew Meszaros
5a1a726fdc
Merge branch 'main' into fix/reopen-verification-on-connect
2026-09-08 20:10:31 -07:00
Matthew Meszaros
29b479321a
feat: merge main and renumber the campaign entry-delay migration to 000138, since main released 000137_contacts_org_created_index while this branch was waiting to merge
2026-09-08 09:59:43 -07:00
Matthew Meszaros
c8042e2942
Merge remote-tracking branch 'origin/main' into feat/campaign-entry-delay
2026-09-08 09:58:38 -07:00
joao-crm
83ba4b38b4
fix: re-check the addresses the built-in probe left unknown as soon as a workspace connects a paid verifier, and withdraw the connection's degraded state once that verifier answers again, since a verdict reached before the connection existed otherwise waited out the 30-day shelf life while the credits sat unused and the card kept reporting whatever the provider said on the very first pass
2026-09-08 15:48:12 +00:00
Matthew Meszaros
a62652de3a
feat: merge main and renumber the contacts index migration to 000137, since main released its own 000136 for lead sync segments while this branch was in review and two branches that each take the next number are green alone but leave golang-migrate refusing to build its source driver once both land
2026-09-08 08:34:52 -07:00
Matthew Meszaros
37ed967919
Merge remote-tracking branch 'origin/main' into fix/issue-382-user-experience
2026-09-08 08:32:31 -07:00
Matthew Meszaros
073bb3c6bc
feat: merge main and renumber the campaign entry-delay migration to 000137, since main released 000136_lead_sync_segments while this branch was open and a duplicate version makes golang-migrate refuse to build its source driver, which is what failed the Migrations job and the check-migrations prerequisite inside make lint on Go CI
2026-09-08 07:33:21 -07:00
Matthew Meszaros
4e52e9476b
Merge remote-tracking branch 'origin/main' into feat/campaign-entry-delay
2026-09-08 07:31:31 -07:00
Matthew Meszaros
f8c0899be4
feat: address the CodeRabbit review by promoting a rounded wait into the next unit so a contact drawer never reads 60 minutes or 24 hours, describing the entry delay's anchor as the lead's own enrolment moment with the campaign's creation date only as the legacy fallback in both the campaigns and segments guides, replacing the launch dialog's 'sending begins immediately' line when a delay is set, following an outside value change with the picker's preset or custom mode, and chaining the trigger card's saves so two commits inside one round trip cannot land out of order
2026-09-08 07:30:24 -07:00
Matthew Meszaros
0f83329936
feat: answer the CodeRabbit re-review by guaranteeing the promise the failed-pin warning makes: an import-level note now goes to the front of the error list and is never dropped by the per-row cap, so a file full of bad addresses cannot bury or evict the one note explaining why the rows that did import are not in the segment they were imported into, with a single note however many segments were targeted and a live test that forces the write to fail behind a capped error list
2026-09-08 06:54:36 -07:00
Matthew Meszaros
78dd032a06
feat: address the CodeRabbit review on the contacts pagination PR by refusing a cursor boundary the SQL cast would choke on so a hand-made token is a 400 instead of a database error surfacing as a 500, writing timestamp boundaries with an explicit to_char pattern the Go validator mirrors so a token never depends on the server DateStyle, basing the full-screen error guard on rows loaded rather than rows left after the client-side subscription filter, keeping the failure visible when that filter hides every loaded row, and pointing Try again at the request that actually failed by reading isFetchNextPageError instead of guessing from hasNextPage
2026-09-08 06:53:57 -07:00
Matthew Meszaros
687aa6ec5e
feat: page through the contact list under a text query, the min and max campaign filters and a segment scope in the pagination regression test, since each appends bound parameters either side of the cursor's or compiles a SQL fragment of its own and a shifted placeholder would otherwise only show up in production
2026-09-08 06:00:16 -07:00
Matthew Meszaros
f46728733e
feat: address the CodeRabbit review on the select-all PR by tying the header checkbox to the rows on screen and making it put unticked rows back before it clears a select-all, bounding the exclusion list before it sizes a map, skipping half-filled custom-field pills when a filter is saved as a segment so the segment matches the search, and naming the fifty-thousand selection cap on the campaigns and segments guides
2026-09-08 05:39:16 -07:00
Matthew Meszaros
a6862477b3
feat: add a 'select all matching' selection to every contact list so a bulk action can cover the whole filtered set instead of only the loaded pages: a ContactSelection body (all + filters + exclude) on bulk update, bulk delete, verification, batch research, segment members and CRM push resolved server-side through a new SearchIDs that shares the search's WHERE builder, a Gmail-style banner and shared selection module in the contacts table, campaign Leads and segment members views, an uncapped select-all in the From contacts picker, and fixes for the keyset cursor comparing a row against itself (contact pagination repeated and skipped rows past page one) and the dashboard sending custom-field filters under the wrong wire name
2026-09-08 05:19:59 -07:00
Matthew Meszaros
99c4ec9e21
feat: answer the CodeRabbit review on #386 by making ContactImportResult.SegmentsPinned a *bool so omitempty stops collapsing a failed membership write into the same absent value as an import that asked for none, warning in the result step when a pin did not land instead of leaving it to a note under a green Import complete, holding the pinned-contacts truncation notice until the overrides listing has actually arrived so it cannot read the newest 0 of 5,000 while it loads, and pointing that notice at the contact drawer, since a pinned-out contact can never be reached from the member list it used to name
2026-09-08 05:19:03 -07:00
Matthew Meszaros
8b1ceccb05
feat: carry the unexcluded routing pass's own re-check time back when the daily new-lead cap is spent, so a campaign whose only remaining leads are still inside the entry delay defers to their due time instead of reporting itself completed, with a live test that fails without it
2026-09-08 05:13:13 -07:00
Matthew Meszaros
846d691768
feat: write the campaign's 'no step is due yet' activity line once a day instead of on every deferred wake-up, and name the entry delay in it when the campaign holds its first emails, so launching a delayed campaign explains itself instead of filling the feed with two hundred identical lines
2026-09-08 05:09:37 -07:00
Matthew Meszaros
ed50ad9f2f
feat: add a campaign entry delay so a contact's first email can wait a set time after they enter the campaign, with campaigns.entry_delay_minutes and a campaign_leads.added_at anchor (migration 000136), the delay applied in the router's per-lead due check and floored into the placer through ContactSequencePair.NotBefore, a distinct entry_delay constraint in the contact next-action preview, and the control surfaced on the Schedule tab, a new Trigger card at the top of the Steps canvas, the campaign wizard's Schedule step, the launch dialog, the update_campaign AI tool and the iOS schedule page, plus guides, API reference and live scheduler and repository tests
2026-09-08 05:06:04 -07:00
Matthew Meszaros
55894a0e9d
feat: fix the campaign Leads tab and contact export stopping partway through a large list by rebuilding the contacts keyset cursor: the boundary subquery named the outer row's alias so Postgres read it as a correlated self-reference and the whole comparison collapsed to c.id >= <cursor uuid>, serving each page the newest rows of a randomly shrinking id range until it ran out at a third of the leads, so the opaque token now carries the ordering it was taken under plus the boundary row's own sort value and id, rejects a token replayed under another sort, and pairs with a direction-following id tiebreak, an ORDER BY that names its NULL placement, a new (organization_id, created_at DESC, id DESC) index, EXISTS campaign and category filters and a lateral campaign_count the query only joins when a filter or sort asks for it, taking a page of a 50k-contact organization from 120ms to 3ms, and the dashboard now keeps the rows it already loaded when a later page fails and says how far through the list Load more is
2026-09-08 05:01:15 -07:00
Matthew Meszaros
7bac16aab0
feat: cover the segment import path with live tests: an import pins its inserted and skipped-as-duplicate rows into the target segment and the segment's own contact query returns them, an unknown segment is refused before any row is written while a recurring source skips one that is gone, and a sheet-sync source round-trips its segment targets through the new column and the ?segment_id= filter
2026-09-08 04:59:31 -07:00
Matthew Meszaros
6284eb27ec
feat: give a saved Google Sheets sync source segment targets (migration 000136 adds lead_sync_sources.segment_ids) that pin every synced row into those segments on each run, validated against the organization when the source is written and dropped from the run when a segment is deleted later, and filter GET /lead-sync/sources by ?segment_id= through a jsonb containment test so a segment can list the sources feeding it
2026-09-08 04:59:18 -07:00
Matthew Meszaros
2818cfa938
feat: encode a segment's empty condition list as [] rather than null in segmentRepository Create and Update, so creating the condition-less segment a static imported list wants no longer trips the jsonb array CHECK and answers 500
2026-09-08 04:59:10 -07:00
Matthew Meszaros
f5b97f3875
feat: resolve a contact import's segment targets through one shared path that a recurring source can ask to be lenient about, expose it as ValidateSegmentTargets so a saved sheet-sync source is refused when it is written rather than on its next run, and report segments_pinned on the import result so a caller never claims a membership write that did not land
2026-09-08 04:59:10 -07:00
Ralf Klein and Claude Opus 5
3cef4e8268
fix: thread an IMAP message on In-Reply-To only, never on the sender's Reply-To
...
imapStore fell back to Reply-To as the thread parent whenever a message
carried no In-Reply-To. Reply-To is an address header -- send replies to
this mailbox -- not a message identifier, so the thread key became a
person's address and every message that sender ever sent collapsed into
one strand.
Measured on a production instance with 38 IMAP mailboxes: 484 of 1431
stored messages sat in 62 threads. One reporter's 76 unrelated DMARC
aggregate reports arrived as a single 76-message conversation, and one
mailbox's sequence tests, compose tests and live outreach merged into
another. The remaining 947 messages, which did carry In-Reply-To, were
threaded correctly.
Sequence safety was never affected: stop_on_reply matches In-Reply-To
against the task rather than the thread. The damage is to the unibox,
where replies are triaged.
A message that answers nothing now has no parent and roots its thread on
its own Message-ID, which is what the existing else branch already does.
The test fails if the fallback is reintroduced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-09-08 12:20:52 +02:00
Matthew Meszaros
9729f608c0
feat: address the CodeRabbit review on the admin backend PR by carrying the keyset boundary inside the sync cursor token instead of re-reading the cursor row's mutable updated_at, clearing only a throttle that is still active so an expired one is not reported as cleared, counting a medium-only UTM attribution in the acquisition with_channel total the way the channel list already does, releasing the exact dedicated assignment the handler read by id so a binding created meanwhile is never released, and returning the underlying failure from the warmup engagement poller, heartbeat sync, danger zone and org transfer housekeeping loops so scheduled_job_runs records an error instead of ok
2026-09-07 21:56:23 -07:00
Matthew Meszaros
c26300ae5e
feat: backend half of the admin panel upgrade: delete the unrouted provisioning, releases, plan, discount and enterprise-inquiry admin handlers with their service and repository methods, retire the six admin permission bits nothing gated as reserved placeholders so live bit positions and existing masks are unchanged and IsSuperAdmin checks the live set, add forty admin endpoints for mailbox sync governor state with clear-throttle and restart-backfill, in-flight send reservations, cross-workspace dead letters with replay, task failures, webhook delivery health with reclaim, fleet capacity, the control loops decision log, dedicated bindings with release and the routed convert-to-dedicated, operator-driven workspace export and import, per-organization API keys and webhooks, warmup invalid-token abuse and action history, and signups by acquisition channel, and add a scheduled_job_runs table (migration 000135) with a jobrun package that every backend and consumer loop now records through and a run-now request the owning process picks up within fifteen seconds
2026-09-07 21:40:38 -07:00
Matthew Meszaros
156a90c39a
feat: default every PostHog capture host (site build, dashboard runtime, backend analytics client) to PostHog Cloud US instead of EU, and update the configuration docs table, since the customers are primarily US-based
2026-09-07 20:28:31 -07:00
joao-crm
e116912f4e
fix: log the reply-intent automation error in HandleNewEmail instead of discarding it with a bare underscore, since a silent failure there is indistinguishable from a reply that linked correctly and leaves nothing anywhere to explain why replied_at stayed NULL while stop_on_reply kept mailing someone who had answered
2026-09-08 00:31:45 +00:00
joao-crm
b99997b51f
fix: match an inbound Message-ID with and without its RFC 5322 angle brackets in GetTaskByMessageID, since the outbound stamp always stores the bracketed form while an In-Reply-To header arrives either way depending on the replying client, so every reply whose brackets had been stripped missed its task, replied_at was never stamped, stop_on_reply stayed inert and a contact who had already answered kept receiving the rest of the sequence, and an empty header matched one of the 151 tasks carrying an empty message_id and linked the reply to an arbitrary campaign
2026-09-08 00:31:45 +00:00
Matthew Meszaros
25484f70ab
Merge remote-tracking branch 'origin/main' into feat/issue-357-loopback-mail-relays
2026-09-07 09:05:11 -07:00
Matthew Meszaros
8b757985f3
feat: address the review on the folder identity change by retiring a deleted folder's backfill floor with it, since a name is reusable and an inherited floor silently skips the next folder's history, by claiming a rename only when a UIDVALIDITY has exactly one missing folder and one new one, because two missing folders and one arrival cannot say which was renamed and guessing moves the wrong folder's mail, by deduplicating names before the folder cap rather than after so a name listed twice cannot spend a real folder's slot, by deleting on a legacy UIDVALIDITY-only event only when that number still names exactly one folder, and by moving the folder row and its mail in one transaction so a refused rename cannot leave the messages in a folder nothing renamed
2026-09-07 08:49:23 -07:00
Matthew Meszaros
d48d57464b
feat: hand the SMTP auth negotiation the normalized host, because net/smtp records the name NewClient was given as the server name and PlainAuth refuses to authenticate when its own host does not match it, so a bracketed IPv6 literal failed on an address it was correct about, and stop the bulk CSV calling an unencrypted row invalid while the deployment config is still loading, where a self-hosted instance would have accepted it
2026-09-07 08:44:32 -07:00
Matthew Meszaros
63070fb833
feat: address the review on the loopback mailbox mode by building every mail address with net.JoinHostPort, so an IPv6 literal keeps the brackets a host:port string needs and "::1" on 1143 stops dialling a host called "::1:1143" with no port, by moving the self-hosted half of the rule into the dialers as well as the connect form, because an organization archive exported from a self-hosted instance carries its mailboxes and an import must not hand a hosted worker one that dials its own loopback in the clear, by refusing an unencrypted CSV row on a hosted instance where the API would only reject it a moment later, and by saying in the docs that the port never selects the mode and that the whole 127.0.0.0/8 range counts
2026-09-07 08:27:54 -07:00