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 |
|