Commit Graph
1775 Commits
Author SHA1 Message Date
Matthew Meszaros b9cce2fbdc feat: ease a graduating mailbox into cold volume (#231)
* feat: ease a graduating mailbox into cold volume instead of handing it the full cap the day it joins a campaign: cold sending read warmup HEALTH but never whether the mailbox had actually warmed enough, so one at its 40/day warmup ceiling could send 50 cold the next morning, which is the post-warmup spike providers penalise; effectiveCap gains a min() term that starts the mailbox at 5, 10 or 20 a day by how long it warmed and adds 5 per clean day toward its own cap, freezing on a spam placement through the same union-of-freezes the warmup ramp uses, anchored by a new cold_ramp_started_at stamped idempotently on the first cold send and reset on org import because it raises a ceiling the destination never watched being earned; mailboxes that never warmed are not gated, since capping senders who never opted into warmup is a different decision from smoothing the transition out of it

* feat: anchor the graduation ramp on a CONFIRMED send, and make the hold the drawer reports the hold the scheduler applies: stamping cold_ramp_started_at at dispatch started the clock on a send the worker then failed, so a mailbox climbed on days it had not proven anything, and the stamp moves to the worker's EMAIL_SENT; separately the drawer computed its hold over every placement while ColdCeiling only counts placements after the first cold send, so a placement predating the ramp read as paused while the scheduler kept climbing, and both now go through one ColdHeldUntil

* fix: restore the Callout closing tag my conflict resolution dropped, which types:check and lint both pass and only pnpm build catches
2026-08-28 10:47:03 -07:00
Matthew Meszaros d47d31b7c4 feat: let a warmup recipient answer the mailbox that wrote to it (#230)
* feat: let a warmup recipient answer the mailbox that just wrote to it, so a thread reads as a conversation rather than two mailboxes monologuing on their own ramps: warmup_tasks.target_account_id was written as nil and never read by anything, so a reply only happened when the recipient's own ramp fired AND the draw happened to land on that partner; a verified receipt now sometimes re-points the recipient's pending warmup task at the sender 25 minutes to 5 hours later inside its own warmup hours, which is a re-pointing rather than new work because only one warmup task may be pending per mailbox, it can never delay a send the mailbox had planned sooner, and it stops before the thread cap so replies cannot answer replies forever; the clock parser also moves into models.ClockMinutes so a second copy of the HH:MM parsing that silently disabled every sending window cannot drift back in

* feat: stop the reply-back drawing the reply rate twice, and stop its jitter escaping a short warmup window: the scheduler drew the recipient's reply rate to decide whether to answer at all, then the task handler drew it again to decide reply-versus-new, so a 30 percent reply rate produced a 9 percent answer rate and a directed task could send a fresh message to the mailbox it was meant to be answering; a directed task now IS the reply, and the opening-time jitter is capped to the window width so a mailbox warming 09:00 to 09:20 is not scheduled past its own close
2026-08-28 10:40:25 -07:00
Matthew Meszaros ca5acb5aaf feat: score campaign copy as sent, not just as written (#229)
* feat: score campaign copy where it actually matters, on the message the recipient receives rather than only the template in the editor: warmlint.Score already ran at save time through /templates/score, but nothing checked the copy after merge fields, spintax, A/B selection and AI blocks resolved, which is exactly where a clean template becomes Hi comma or picks the one spammy spintax branch; the send path now scores the rendered message and writes one campaign-feed warning per step per day rather than one per recipient, RunPreflight gains the same check across every step, warmlint gains image-heavy, many-images and attachment heuristics, and the whole thing is advisory behind Settings > Sending > Content checks; the launch dialog also finally calls POST /campaigns/:id/preflight, whose report had no UI at all and so never reached the person about to send

* feat: fix three defects the review found in the content lint: a preflight run that could not read the campaign's steps left the worst score at 100 and reported the check as PASSED, which is the one outcome a check that did not run must never produce, so it now reports failed with the reason; the send-time path read organization settings while preflight read campaign-effective ones, so a campaign that turned the check off or moved its floor was still warned, and EffectiveSettings is now exposed and used by both; and the check-then-insert dedupe let concurrent recipients of the same low-scoring step each find nothing and each write, so it becomes one advisory-locked conditional insert proven by twelve racers producing exactly one feed entry
2026-08-28 10:29:39 -07:00
Matthew Meszaros bc9978dab3 feat: feed per-provider placement back into warmup partner routing (#228)
* feat: feed each sender's per-provider spam placement back into warmup partner selection, so a mailbox failing only at Microsoft stops being handed Microsoft partners instead of waiting for an aggregate band to trip: pickWeightedPartner weighted on inverse domain frequency and routing rules alone, while PoolSpamPlacementsByProvider's segmentation only ever reached the admin overview; the new per-sender query keys on who RUNS the recipient's mail via models.ClassifyProvider rather than email_accounts.provider, because that enum collapses every custom host into smtp_imap which is exactly the bucket the signal matters in, and the weight multiplies by 1/(1+4*rate) with a five-send floor so a failing provider is downweighted but never excluded, since a sender that stops mailing a provider can never discover it recovered

* feat: stop failed warmup sends flattering a provider's junk rate, and stop a re-admitted partner sidestepping the penalty: a warmup_tokens row is written before the send leaves, so counting every token put failed sends in the denominator and understated the rate exactly when the sender was doing worst; the send count now joins tasks on status = completed, and GetPoolParticipantProviders is read unfiltered because that map only resolves a candidate's provider and never decides eligibility, so a just-unblocked partner missing from it scored an unpenalized 1.0

* feat: move the campaign content-lint work off this branch, where it landed by mistake: the pre-send scoring, its preflight check and the warmlint image and attachment heuristics belong to the content-lint change and are unrelated to per-provider partner routing, so this branch now carries only the routing signal it is about
2026-08-28 10:20:35 -07:00
Matthew Meszaros 7a8406e35a feat: close the warmup ramp loop with an early-signal hold (#227)
* feat: close the warmup ramp loop so an early spam placement holds the mailbox instead of letting it climb into the wall: every band in evaluateMetrics needs a sample floor before it can trip (20 warmup sends in 7 days, 100 delivered in 30), which a mailbox in its first fortnight never reaches, so one landing in junk on day three kept adding an email a day until it had sent enough to be judged; a placement inside 48h now cuts the day about a quarter and holds the ramp where it stood, and the resume subtracts the frozen days rather than catching them up because climbing three steps in one morning is the spike the hold exists to prevent; the ramp arithmetic moves into internal/app/warmupramp so the mailbox drawer reports the target the scheduler will actually act on instead of its own private copy, and says which signal cut it and when it resumes

* feat: fix three defects the review found in the warmup ramp hold: the ramp is now elapsed days minus frozen days rather than a level held at the newest placement, because MAX(created_at) meant a second placement arriving mid-hold RAISED the held level and a mailbox landing in junk repeatedly ramped up; the early-signal cut and the health band are resolved in one shared warmupramp.Resolve so the scheduler's health gate and the dashboard's ungated copy can no longer disagree about the same mailbox; and the hold is reported for the whole 72h freeze instead of only the 48h cut window, so a mailbox between the two no longer shows a ramp that has stopped climbing with nothing to explain it
2026-08-28 10:06:19 -07:00
Matthew Meszaros b83a1a3ce5 feat: make send-time optimization actually schedule sends (#226)
* feat: make send-time optimization actually schedule sends, instead of being a documented setting with no caller: advanced.OptimizeSendTime had zero call sites anywhere in the codebase, so an org that enabled send_time_optimization through PATCH /outreach/settings changed nothing about when its campaign mail left, and the API reference said so in a callout; the campaign scheduler now resolves the recipient's timezone from the contact's timezone custom field, then the country-code suffix of its email domain, then the org fallback, and holds the slot until that clock reaches a preferred hour, raising hardFloor so the task handler reschedules rather than sending immediately, the snap is forward-only and never crosses the campaign end date, the default flips to off so no existing workspace silently re-times its sends, and Settings > Sending gives the block its first UI

* feat: stop the recipient-hour gate deferring a send forever, and fix the window parser that silently disabled every campaign schedule: recipientSlot now searches for a moment BOTH calendars accept and yields when they never meet, because raising a hard floor at an hour the sender cannot serve made every tick re-derive it, defer, wake in the sender's window and defer again; separately parseTimeOfDay accepted only the 15:04 layout while start_time, end_time, warmup_start_time and warmup_end_time are Postgres time columns pgx renders as 09:00:00.000000, so every read parsed to 0 and effectiveWindows read that as unconstrained, leaving both the campaign sending window and its day-of-week gate off for every campaign on the legacy fields and pinning warmup to its 08:00-20:00 fallbacks; Settings > Sending also gains the MANAGE_SETTINGS gate a direct visit needs
2026-08-28 09:46:39 -07:00
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