1769 Commits
Author SHA1 Message Date
Matthew Meszaros f3c5708892 Merge pull request #224 from warmbly/fix/issue-221
feat: make Sign in with Google and Apple actually work in the browser
v0.2.2
2026-08-28 01:52:03 -07:00
Matthew Meszaros 029bc27bfd feat: bind a browser sign-in to the browser that started it, so a handoff link cannot be forwarded: one-time state proves the callback answers a request this server made, not one THIS browser made, so anyone could run the flow against their own Google or OIDC account and send the resulting URL to someone else, whose browser would then hold the sender's session (RFC 9700 4.7.1); begin now mints a binding secret that never reaches the provider and never appears in a URL, the callback carries it into the handoff, and the exchange refuses a collection that cannot present it with sso_wrong_browser, while the comments this PR added are condensed to the constraint they exist to state 2026-08-28 01:44:39 -07:00
Matthew Meszaros 299634777b feat: make the contacts and campaign leads bulk-action bar visible the moment rows are selected, instead of parking it below the fold: the bar was absolutely positioned inside a wrapper that grows with the table, so on a campaign's Leads tab selecting leads appeared to do nothing until you scrolled past every row; it is fixed to the viewport now, which is what the floating selection bar was always meant to be 2026-08-28 01:33:08 -07:00
Matthew Meszaros 9ab42cfd65 feat: build the browser half of social sign-in, which was never wired: GOOGLE_CLIENT_ID was read at boot and made the login screen render a Google button, but the button opened a popup at /auth/google/login which no route served, and authService.GoogleAuth/AppleAuth had no caller anywhere in the codebase; internal/app/socialauth now runs Google and Apple through the flow generic OIDC already used (one-time state, PKCE, nonce, id_token verified against the provider JWKS, identity keyed on issuer and subject, JIT provisioning, the ban and 2FA gates), the redirect URI defaults to API_PUBLIC_URL/v1/auth/<provider>/callback and is logged at boot because registering the dashboard origin instead is the mistake that produces a valid OAuth client and a dead button, /auth/config advertises only providers the backend can actually complete, the SSO landing page no longer swallows a two_fa_required response, and OIDC_PROVIDER_NAME finally reaches the button it documents 2026-08-28 01:33:08 -07:00
Matthew Meszaros 61a988747d Merge pull request #223 from warmbly/fix/issue-219
feat: tell the worker to drop a mailbox the customer disabled or disconnected
2026-08-28 01:02:14 -07:00
Matthew Meszaros baa5f89045 Merge branch 'main' into fix/issue-219 2026-08-28 00:56:13 -07:00
Matthew Meszaros 36224c8caf feat: give the deleted mailbox's worker its capacity back inside the delete's own transaction, because a refund made beside it can be lost for good: once the row is gone nothing records which worker was charged for that mailbox, so a partially applied unassign left account_count or load_score charged with nothing able to repair it; emailRepository.Delete now deletes the row RETURNING worker_id and credits the worker in the same transaction, taking the placement weight from the caller so it matches what assignment charged, and the long comments this PR added are condensed to the constraint they exist to state 2026-08-28 00:49:11 -07:00
Matthew Meszaros 565bad1e2a Merge pull request #222 from warmbly/fix/issue-220-end-to-end
fix: only skip a Graph backfill folder the tenant actually does not have
2026-08-28 00:46:25 -07:00
Matthew Meszaros bd435a2687 feat: tell the worker to drop a mailbox the customer disabled or disconnected, the two paths #217 left out: emailService.Update now publishes REMOVE_EMAIL when a status leaves active (and re-ships the mailbox when it returns to active instead of waiting on the reconciler), Delete publishes it before the row goes and refuses to delete when it cannot, since after the row there is no assignment left to read; delete also stopped failing outright, because tasks and warmup_admin_actions referenced email_accounts with no delete action so disconnecting anything that had ever warmed up or sent a step raised a foreign key violation (migration 000098), read the mailbox through an org-scoped query while passing a user id so the warmup pool cleanup, realtime event and webhook never ran, and never refunded the worker's account count or load score 2026-08-28 00:40:39 -07:00
Matthew Meszaros e6210dff09 feat: stop a transient Graph failure on a folder's first backfill page from marking that folder's backfill permanently complete: msgraph.HandleError mapped 404, every 5xx and any unrecognised status onto the same ErrMailServerUnreachable, so graphBackfill's "the tenant does not have this folder" skip fired on a 503 as well and wrote the folder off in a cursor that is persisted through SYNC_STATE and handed back on every later load, silently costing a customer who connected a mailbox during a Graph incident their archive history; 404 now carries its own RESOURCE_NOT_FOUND code, the skip keys on that alone, a genuine unreachable server ends the pass with the folder's cursor held so the next one retries it, and the Gmail and IMAP imports get the same regression shape pinned by tests 2026-08-28 00:40:18 -07:00
Matthew Meszaros dc2c134f58 Merge pull request #218 from warmbly/fix/196-gmail-revoked-grant
fix: stop reporting a revoked Gmail grant as an unreachable mail server (#196)
2026-08-27 20:52:55 -07:00
Matthew Meszaros 6c7eb86676 Merge pull request #217 from joaoppa/fix/196-deactivated-mailbox-and-auth-error
fix: drop a deactivated mailbox from its worker, and stop calling a revoked OAuth grant an unreachable mail server (#196)
2026-08-27 20:52:12 -07:00
Matthew Meszaros 46095f44df feat: stop reporting a revoked Gmail grant as an unreachable mail server, the Gmail half of the same defect: goog.HandleError classified anything that was not a *googleapi.Error as a transport failure, but the token source runs inside the API call, so a grant the customer revoked in their Google account (or Google expired) fails the call itself and never becomes a 401, and the mailbox told its owner the server was offline while retrying a refresh that could not succeed; it now runs the same mailauth classification the Graph client does, so invalid_grant asks for a reconnect while a throttled or broken token endpoint stays retryable, and the googleapi type assertion became errors.As so a wrapped 401 or 403 is no longer read as an offline server 2026-08-27 20:44:14 -07:00
Matthew Meszaros 5241256274 feat: classify OAuth token refusals by what the provider actually said instead of calling every non-5xx refusal a dead grant: internal/pkg/mailauth reads the RFC 6749 error code, so a revoked or expired grant (invalid_grant and the interaction family) is an authentication error the customer must reconnect, while a 429 from the token endpoint, a 5xx, an unrecognised code and above all invalid_client stay retryable, because an expired app secret returns invalid_client for every Outlook mailbox on the install at once and deactivating all of them into a re-consent that cannot work either is a far worse outage than retrying until it is rotated; the Graph client logs the provider's own error code and description next to the verdict, and the tests drive real refusals through the real oauth2 transport on the fetch, list and send paths 2026-08-27 20:35:01 -07:00
Matthew Meszaros dd4b405f8e feat: make the mailbox removal this PR adds actually reach the worker: deactivateAccount read the assignment off the row EmailRepository.Update returns, whose RETURNING list carries the mailbox as the dashboard sees it and no worker_id, so account.WorkerID was always nil, the guard fired every time and PublishRemoveEmail still had no caller in the codebase; it now asks GetWorkerID directly, the three deactivation handlers hand it the ids they already parsed, LoadAccountOntoWorker refuses to ship a mailbox that is not active so the reconciler cannot put back the mailbox the consumer just removed, and tests drive all three handlers end to end against a repository stub that withholds worker_id exactly like the real one 2026-08-27 20:34:55 -07:00
joao-crm 801d1d5f83 feat: tell the worker to drop a mailbox the moment it is deactivated, and stop reporting a revoked OAuth grant as an unreachable mail server: every deactivation path now goes through deactivateAccount, which publishes the REMOVE_EMAIL command that already existed on both ends but had no caller, and the Graph client classifies a failure of the HTTP call itself so a refresh the provider refuses is an authentication error instead of a network one that promises a retry which can never succeed 2026-08-27 22:56:54 +00:00
Matthew Meszaros ff2c7406a1 Merge pull request #216 from warmbly/feature/index-bento-grid-redesign
Redesign the marketing index sections
2026-08-27 07:14:45 -07:00
Matt 46beea4884 feat: keep cd warmbly in the self-host terminal so make up runs inside the cloned repository instead of the parent directory where no Makefile exists 2026-08-27 16:12:09 +02:00
Matt 932a29cda4 feat: replace the how-we-differ comparison table with six drawn guardrail cards joined into one connected panel like pricing, showing the cap bar, send spacing dots, the 10-vs-80 percent quarantine axis, the warmup staircase, auth check pills and the real CampaignLimitDefault constant, and rebuild the changelog into a plinth release feed on a rail that dissolves off the bottom edge with its columns mirrored against the open-source split below 2026-08-27 16:05:46 +02:00
Matt 4426278f9b feat: redesign the personalization section as a rendered-per-recipient artifact, one template panel beside three recipient results showing the if branch, the else branch and the default fallback with color-coded merge, spintax and fallback spans plus a feature grid covering custom CSV fields with spaces, coercing helper functions, nested spintax, per-step A/B variants, real-engine preview and launch-blocking validation 2026-08-27 15:44:18 +02:00
Matt 13ddfc6607 feat: rebuild the index bento as layered product-UI artifact cards with viewport-gated continuous motion, join the pricing tiers into one connected panel with yearly savings and log-scaled volume meters in place of the dedicated-IPs bullet, redesign the open-source band from the dark cloud gradient into a light repo-browser-plus-terminal plinth, drop the templating section separator borders, and update pool copy so self-hosted instances connect to the shared warmup pool with a free tier 2026-08-27 15:31:00 +02:00
Matthew Meszaros 74839791b0 Merge pull request #215 from warmbly/fix/issue-207-reported-defects
feat: make a 1,000-row contact import with spaced custom-field names actually land, and report a bad mapping once instead of once per row
2026-08-27 04:08:09 -07:00
Matthew Meszaros e189320d28 Merge remote-tracking branch 'origin/main' into fix/issue-207-reported-defects 2026-08-27 03:56:42 -07:00
Matthew Meszaros 0a79997228 feat: document the subscribed field on POST /contacts along with the enrich-not-duplicate behaviour a repeated address gets, and state the import error cap in terms of entries returned rather than rows failed since the list also carries per-row notes, adding the invariant that imported, updated, skipped and failed always sum to total 2026-08-27 03:56:24 -07:00
Matthew Meszaros d1ac72679b feat: fill the contact and lead-sync holes in the endpoint scope map, which claims to be the source of truth for every route an API key can reach but was missing GET /contacts/:id, its emails and timeline reads, the lookup, the export, both import steps and the whole /lead-sync group, and note that import commit takes the stricter BULK_CONTACTS scope because one call writes up to 50,000 rows while a sync source's column mapping is validated when it is saved 2026-08-27 03:56:24 -07:00
Matthew Meszaros 6a70ee7431 feat: update the contacts and integrations API reference for the import contract: the custom column target and its custom_key, the character set a custom-field name may use, that a bad name or a mapping with no email column is a 400 raised before any row is written while per-row errors stay reserved for the data itself, the 1,000-row errors cap with errors_truncated, and that a Google Sheets source's column_mapping is validated when it is saved 2026-08-27 03:46:12 -07:00
Matthew Meszaros 8cae0a76ed feat: document the real import behaviour on the Contacts & CRM guide: which custom-field names are allowed and that the wizard fills one in from the column header and flags a bad one inline, the values the subscribed column accepts, that the categories column creates names you do not have yet up to 100 per import, that skipping an existing contact still enrols it in the import's campaign and categories, that a repeated address in one file becomes one contact, that a blank cell never erases a stored value, and that a mapping problem is reported once before anything is written 2026-08-27 03:46:12 -07:00
Matthew Meszaros fc42f55f70 feat: give the dashboard content panel a Suspense boundary and a scroll reset in AppShell, because router navigations run inside a transition so any page that suspends without a boundary of its own commits an empty panel that only a reload recovers, and because the shell scrolls an inner div that nothing put back to the top between routes, and pin both plus the campaign tab bar swapping Overview, Leads and Steps with a test that mounts the real shell around the real campaign routes 2026-08-27 03:46:05 -07:00
Matthew Meszaros 7d3ef6e1f8 feat: gate the Google Sheets sync wizard's Continue on the same mappingProblem check the file importer uses, so a saved sync source with an unusable custom-field name is caught on the mapping screen rather than by the API when the source is written 2026-08-27 03:45:59 -07:00
Matthew Meszaros c11660a685 feat: make the contact import wizard catch a bad column mapping before the upload instead of after: picking "Use as custom field" pre-fills the name from the column header so "Company Mobile" needs no typing at all, a name the API would refuse is flagged inline on the field, Continue is blocked with the specific reason rather than only on a missing email column, a "Keep N more as custom fields" action claims every unrecognised header in one click instead of a dropdown per column, and the result step says "1,000 of 1,500" when the API truncated the error list 2026-08-27 03:45:55 -07:00
Matthew Meszaros 3fafe276a9 feat: mirror the server's custom-field name rule in web importShared with normalizeCustomKey, isValidCustomKey, suggestCustomKey (which turns a spreadsheet header such as "Revenue ($)" into a name the API accepts) and mappingProblem, which returns the first reason a column mapping cannot be committed in the same order the server checks it, plus unit tests pinning the two rules together 2026-08-27 03:45:50 -07:00
Matthew Meszaros 995ca1697b feat: teach the shared TextInput primitive an invalid flag that paints our own red hairline and sets aria-invalid plus a title for the reason, so a rejected value is shown in the dashboard's theme rather than a browser default, and declare errors_truncated on the ImportResult client type 2026-08-27 03:45:46 -07:00
Matthew Meszaros 2af44edb50 feat: add TestLiveImport* in internal/app/contact covering the issue 207 report against real Postgres: a 1,000-row 13-column file whose custom fields are named "Company Mobile" and "Job Title" imports clean, joins its campaign and stays filterable, a bad mapping is one 400 and not a thousand row errors, the subscribed and categories columns take effect, one address listed twice becomes one contact, skipped rows still pick up the import campaign and categories without their names being erased, blank campaign and category ids are ignored while malformed ones are refused, an update never resubscribes an opted-out contact, and every row lands in exactly one of the imported/updated/skipped/failed buckets 2026-08-27 03:45:41 -07:00
Matthew Meszaros 1ba07028a4 feat: rework contactService.ImportCommit end to end so a 1,000-row 13-column upload lands: the column mapping is resolved once before any row is read (a name Warmbly cannot use, an unnamed custom column, an unknown target or a mapping with no email column is one actionable 400 instead of the same message per row, accepting both {target:"custom", custom_key} and the legacy "custom:<key>"), the subscribed column is actually applied instead of parsed and discarded and subscribed_default is confined to new contacts so an update never resubscribes someone who opted out, the categories column resolves titles to ids and creates the missing ones, a file that lists one address twice becomes one contact with the later row merged in, rows the dedup strategy skips still join the campaign and categories the import targets, campaign and category ids are canonicalised so a blank one cannot fail every row on an empty string cast to uuid[], the plan ceiling is checked once for the whole batch, and per-row notes are separated from failures so total always equals imported plus updated plus skipped plus failed; ValidateImportMapping exposes the same verdict so a saved Google Sheets sync source is rejected when it is written rather than on its next sync 2026-08-27 03:45:32 -07:00
Matthew Meszaros 242e721966 feat: extract the plan contact-ceiling check out of contactService.Add into checkContactLimit so a caller that writes in chunks can ask once for the whole batch instead of turning one plan problem into one error per row, and report the numbers (how many are being added, the plan's limit, how many the workspace already has) instead of the bare "contact limit reached for your plan" 2026-08-27 03:44:43 -07:00
Matthew Meszaros 79ceec3b32 feat: add the ContactImportTargetCustom column target so a client can say {target:"custom", custom_key:"Company Mobile"} instead of packing the name into a "custom:<key>" string, and add MaxContactImportReportedErrors with an ErrorsTruncated result flag so a 50k-row file of bad addresses reports the first thousand failures with an honest total rather than echoing the whole upload back as JSON 2026-08-27 03:44:27 -07:00
Matthew Meszaros eff67f64eb feat: add ContactRepository.ResolveCategoryNames, which maps the category titles an imported file names to the caller's category ids in one round trip and creates the missing ones at the end of their list with a palette colour, rejecting a title over 50 characters and refusing more than MaxImportCategoryNames distinct values so a free-text column mapped to Categories by mistake cannot mint a category per row 2026-08-27 03:44:17 -07:00
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 06e5127e58 Merge pull request #213 from warmbly/fix/issue-211
Keep a mailbox in exactly one warmup pool
2026-08-27 03:41:07 -07:00
Matthew Meszaros dbe31eeb96 Merge remote-tracking branch 'origin/main' into fix/issue-211 2026-08-27 03:33:24 -07:00
Matthew Meszaros ff341b1008 Merge pull request #214 from warmbly/fix/issue-209
Fix the six admin queries that reference schema which does not exist
2026-08-27 03:28:30 -07:00
Matthew Meszaros 8b76355bf5 Merge remote-tracking branch 'origin/main' into fix/issue-211 2026-08-27 03:27:55 -07:00
Matthew Meszaros 6dea4f13cf Merge remote-tracking branch 'origin/main' into fix/issue-209 2026-08-27 03:21:31 -07:00
Matthew Meszaros 0f715da88e feat: close the force-stop race and trim the comments the review flagged: the eligibility test moved out of the admin service and into the UPDATE's WHERE clause, so a campaign that completes between the status read and the write can no longer be dragged back to paused and recorded as force-stopped, StopCampaign reports whether it actually stopped anything and the service turns a refusal into the same 400 it used to raise from the pre-check, a new live test parks the campaign at completed and at draft and fails if either is overwritten, and the explanatory comment blocks added across pg_admin.go, the admin service, the admin models and the rate-limit dialog are cut back to the one-line form CLAUDE.md asks for 2026-08-27 03:21:25 -07:00
Matthew Meszaros 4b1ccf0bed feat: keep an indefinite warmup block indefinite when migration 000097 collapses a dual membership: a blocked row carries blocked_until NULL to mean 'until an appeal succeeds', so MAX(blocked_until) skipped it as a missing value and adopted a sibling row's finite expiry, handing a permanently blocked mailbox (tampering) a release date and making it re-evaluatable by UpdateParticipantHealth, which refuses to touch a block only while blocked_until IS NULL; the merge now treats any indefinitely blocked row in the partition as the longest block there is, with a live test that an indefinite block survives a pool move, and the Go comments added by this branch are trimmed to the constraint they state 2026-08-27 03:21:08 -07:00
Matthew Meszaros 4ceb0eee1e Merge pull request #212 from warmbly/fix/issue-210
Fix leaveChannel destroying other subscribers' handlers, and balance RealtimeManager's org effect
2026-08-27 03:12:30 -07:00
Matthew Meszaros bb1f96ba79 feat: shorten the new socket lifecycle comments in SocketProvider, RealtimeManager and their channel tests to the one-line constraint-stating form CLAUDE.md asks for, since the implementation narrative they carried is already in the PR description and the regression tests 2026-08-27 03:10:36 -07:00
Matthew Meszaros 1c4fcff558 feat: make the admin panel's broken queries run: seven statements referenced schema that does not exist and failed 100% of the time, so a force-stop wrote status = 'stopped' and stopped_at to a campaign_status enum and a campaigns table that have neither, the plan writes named a duration column that became duration_id long ago, the user rate-limit read and write named a daily_email_limit column that user_rate_limits has never had, and the user preview compared email_accounts.user_id (uuid) against a text parameter and then swallowed the error so every operator saw an empty mailbox list; the same uuid = text defect in GetUserEmails, which the prepare sweep cannot see because that WHERE clause is assembled at runtime, was a live 500 on GET /admin/users/:id/emails; a stop now parks the campaign at 'paused' like the owner-facing stop and records the reason the UI has always sent and the backend has always dropped into both the audit log and the owner's campaign feed, the plan writes resolve durations.title to duration_id and answer 400 rather than a constraint violation on an unknown period, the rate-limit editor now covers the seven real limit columns instead of one that never existed and patches insert-then-update in a transaction because every column is NOT NULL, AdminWorkerEmail.LastSyncedAt is a pointer so a never-synced mailbox stops being silently dropped from every admin list, and TestLiveEveryQueryPrepares now fails on undefined columns, tables, operators and enum values instead of only reporting them 2026-08-27 03:09:40 -07:00