Matthew Meszaros
|
8aaf9e8c8c
|
feat: say in the contacts API reference and the OpenAPI 400 description that a before value on the contact timeline that is not an RFC 3339 timestamp is rejected with a 400 rather than accepted
|
2026-09-03 20:28:51 -07:00 |
|
Matthew Meszaros
|
2f32b1b8c3
|
feat: bring the ContactTimelineEvent OpenAPI schema up to the real event shape with the lifecycle, form_submitted and page_hit types and the machine, link, origin, category, form and page_hit fields backed by new ContactLinkClick, EngagementOrigin and WebsitePageHit schemas, and list form_submitted and page_hit with their payloads in the contacts API reference
|
2026-09-03 20:21:42 -07:00 |
|
Matthew Meszaros
|
0981635c3a
|
feat: reject a contact timeline cursor whose source rank names no merged table with a 400 in the handler through ContactTimelineSource.Valid, since rank zero is reserved for the legacy before bound and a rank past the last source would re-admit the events at the cursor's instant
|
2026-09-03 20:21:42 -07:00 |
|
Matthew Meszaros
|
83b0a5ede3
|
feat: add the cursor query parameter to the contact timeline operation in the OpenAPI spec, mark before as deprecated, describe the invalid cursor and limit 400s, and reference the shared Pagination schema from ContactTimelineResult
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
eeed44f2ed
|
feat: document the contact timeline's cursor parameter and pagination envelope in the contacts API reference, mark before as deprecated with why a bare timestamp can skip events at a page boundary, and say that a bad cursor or limit is a 400
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
8503ad4f37
|
feat: page the dashboard contact timeline with the server's opaque cursor from pagination.next_cursor instead of deriving a bare timestamp from the last event, in the listContactTimeline client and the useContactTimeline infinite query
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
5dbbaad5b5
|
feat: add the pagination envelope with its opaque next_cursor to the dashboard's ContactTimelineResult type and note that the top-level has_more only mirrors it
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
630dd4cab1
|
feat: add a live repository test that walks ten timeline events, eight of them at one instant across progress stamps, notes and activities, in pages of three and proves nothing is skipped or repeated, each cursor is the last event's key, the final page carries no cursor, and before still means strictly older
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
a4665579de
|
feat: accept an opaque cursor on GET /contacts/:id/timeline, keep before as a deprecated alias that maps onto the keyset at rank zero, and answer an invalid cursor, before or limit with a 400 instead of silently ignoring it
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
88d8a2ba8a
|
feat: page every contact timeline source on a row comparison against the (at, source, id) cursor, unnest campaign progress stamps to one row per event so the limit and the logged-open and logged-click suppression apply per event, fetch one row past the page from every source, return all matching suppression entries, and encode the next cursor from the last event's key
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
18a1862d82
|
feat: rank the contact timeline's sources in models.ContactTimelineSource, give every event a ContactTimelineKey (at, source, id) with the newest-first comparison the merged sort uses, and add the standard pagination envelope to ContactTimelineResult while keeping has_more as a mirror
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
407a85ecf9
|
feat: cover the merged-feed cursor codec with a round-trip test that keeps sub-second precision and rejects a bare timestamp, a wrong-version token, bad base64 and trailing garbage
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
0cb4f312a0
|
feat: add a versioned opaque cursor codec for merged feeds to internal/utils/paging that carries an (at, source rank, id) keyset position and decodes a malformed or wrong-version token to a 400
|
2026-09-03 20:19:16 -07:00 |
|
Matthew Meszaros
|
febe324243
|
Merge pull request #301 from warmbly/fix/click-without-open-tracking
Count a click as an open, label scanner clicks, and record where every open and click came from
v0.3.1
|
2026-09-03 07:19:21 -07:00 |
|
Matthew Meszaros
|
b9e81b6947
|
feat: fetch one row past the page from the per-event open and click sources of the contact timeline so a page filled by either source alone still reports that more events follow
|
2026-09-03 06:14:14 -07:00 |
|
Matthew Meszaros
|
dc04ea1b52
|
feat: match a logged open to the step's first open inside a one-minute window on both sides so an unrelated older row cannot hide the summary event, and say in the contacts API reference that only a person's click counts as an open in the contact engagement totals
|
2026-09-03 05:57:31 -07:00 |
|
Matthew Meszaros
|
611704b365
|
feat: count only a person's opens in the contact engagement totals and last-opened time as analytics already does, keep a step's legacy first open on the timeline when only later opens were logged per event, and say so in the contacts API reference
|
2026-09-03 05:43:31 -07:00 |
|
Matthew Meszaros
|
701dc404a2
|
Merge remote-tracking branch 'origin/main' into fix/click-without-open-tracking
|
2026-09-03 05:22:05 -07:00 |
|
Matthew Meszaros
|
e97477a718
|
feat: renumber the engagement origin and pending index migrations to 000125 and 000126 because main released 000124 for unsubscribe opt-out while this branch was open
|
2026-09-03 05:22:05 -07:00 |
|
Matthew Meszaros
|
0354e51a7a
|
Merge pull request #303 from warmbly/feature/self-hosted-update-banner
Self-hosted update indicator and one-click updates
|
2026-09-03 05:13:26 -07:00 |
|
Matthew Meszaros
|
d9527ad98a
|
feat: reject an overflowing commit distance in the update version parser instead of clamping it, with a regression case, so a malformed build string can never suppress an available update
|
2026-09-03 05:04:30 -07:00 |
|
Matthew Meszaros
|
3d5eb36ae5
|
feat: second review pass on self-hosted updates: git describe suffixes after a prerelease (rc.1-2-gabc1234) now parse as prerelease plus commit distance, Apply validates the updater for every target including explicit tags, a DNS failure only reads as the compose profile being off when UPDATER_URL names the updater service and stays unreachable for custom hosts, the admin dialog re-checks that the update can still start before launching it and drops the confirmation when it cannot, the bare-metal installer refuses symlinks anywhere inside a build directory, restores config.js with --remove-destination and bounds every health probe, and the bare-metal docs add the single sudoers rule before the upgrade command
|
2026-09-03 05:04:30 -07:00 |
|
Matthew Meszaros
|
bb35de0455
|
feat: address review on self-hosted updates: the updater no longer re-locks its mutex when a job finishes (every job used to deadlock at completion and freeze the status API), the backend caches the updater view so the member version pill, the health checks and the admin poll share one read and an absent updater is reported as not running rather than broken, the bare-metal upgrade builds unprivileged and hands off to a root-owned fixed-path installer that refuses symlinks so sudoers allows one command instead of install/cp/rm/chown/chmod/systemctl/ln, the installer fails when the backend does not come back, the seed image gets the version build args, the dashboard gates the update action on manage_settings and stops polling a backend that answers 404, and revived timestamps are typed as Date
|
2026-09-03 05:04:30 -07:00 |
|
Matthew Meszaros
|
b2ea1f1961
|
feat: add self-hosted update awareness and one-click updates: every binary is stamped with its version and commit, the backend polls GitHub Releases and a new host-side updater (cmd/updater, compose profile or systemd unit) reports the checkout's commit distance, the admin panel's top bar shows a version pill that turns into an update indicator and opens a dialog with confirmation, live step progress and log, restart tracking and result, the dashboard header shows the same pill to every member of a self-hosted instance with the full update flow for platform admins, Setup and health gains update_available and updater_unreachable checks, warmblyctl status prints the version, make upgrade and scripts/upgrade-bare-metal.sh cover the by-hand paths, and docs gain an Updates page plus configuration, health, deployment and API reference updates
|
2026-09-03 05:04:30 -07:00 |
|
Matthew Meszaros
|
62f538da5b
|
Merge pull request #302 from warmbly/feat/unsubscribe-opt-out
feat: unsubscribe opt-out in every campaign email, signed unsubscribe links, and a first-class suppression list
|
2026-09-03 05:02:43 -07:00 |
|
Matthew Meszaros
|
4c7229c7d6
|
feat: make a held-back click announcement retryable until its effects ran: the claim leases the click row for one attempt via announce_claimed_at and the flag clears only after the effects complete, the sweep retries expired leases as well as clicks a restart cut off, and the partial index for the sweep moves to its own concurrent migration 000125 so the live click table is never write-blocked
|
2026-09-03 04:34:30 -07:00 |
|
Matthew Meszaros
|
a3264f0d39
|
feat: address the review on the rebuilt engagement branch: a deferred human click's effects are now durable and once-only (announce_pending on the click row written before the event is marked processed, a claim in finishHumanClick and a minute-by-minute sweep that finishes what a restart cut off, a burst relabel clears the flag), the tracking service keys the source-address token with TRACKING_IP_HASH_KEY so it cannot be enumerated back into an IPv4 address, the migration no longer adds a blocking index to the existing click table, and timeline click and open rows carry task_id
|
2026-09-03 04:13:39 -07:00 |
|
Matthew Meszaros
|
46f61f6ad8
|
feat: leave a link whose URL has no host untouched by click tracking, since a ticket for it could never redirect
|
2026-09-03 04:00:33 -07:00 |
|
Matthew Meszaros
|
6830e3277d
|
feat: keep the tracking-domain exclusion when link tracking falls back to UTM-only tagging, leave links the URL parser rejects untouched instead of minting a dead click ticket, and say in the campaigns guide that the sequence versus one-time choice is fixed at creation
|
2026-09-03 03:53:39 -07:00 |
|
Matthew Meszaros
|
31dabea0a4
|
feat: rebuild the click-without-open fix on top of the per-link click attribution from #298: a person's click now also counts as an open and a burst that withdraws the click withdraws the open it implied unless a real open is on record, routing readers ignore machine opens as the docs promised, every open gets its own log row and every open and click records the mail client or proxy, browser, device, OS, country, region and city (migration 000124: origin columns on email_link_clicks plus an email_opens table), the tracking service publishes only the address's network in a nullable client_ip field which the consumer resolves with GeoLite and drops, the contact Activity tab shows each open and the origin of opens and clicks, the campaign overview gains a who-engaged-from-where breakdown exposed as engagement in campaign analytics, live open and click events carry occurred_at, client and location, the leads table explains why an open is not always counted, both logs are pruned daily after a year, email_opens joins the export registry, the consumer reads GEODB_PATH optionally, and the guides and API references are updated (fixes #294)
|
2026-09-03 03:49:44 -07:00 |
|
Matthew Meszaros
|
579b0ac04c
|
feat: merge main into the unsubscribe branch again, carrying the UTM campaign columns alongside unsubscribe_mode in every campaign scanner, keeping unsubscribe links out of the moved link tracker and its UTM tagging, and renumbering the opt-out migration to 000124 because main released 000123 for link clicks
|
2026-09-03 03:43:47 -07:00 |
|
Matthew Meszaros
|
5fcb0fe882
|
feat: fold existing suppression rows to lowercase in migration 000123 and compare stored values as equalities so the existing unique key is the single case-insensitive identity and no extra index build is needed, and keep the plain-text alternative when a chosen A/B variant carries HTML only
|
2026-09-03 03:22:37 -07:00 |
|
Matthew Meszaros
|
769d4e0da9
|
Merge pull request #298 from warmbly/feat/link-click-attribution
Per-link click attribution, automatic UTM tagging, and machine open/click detection
|
2026-09-03 03:21:53 -07:00 |
|
Matthew Meszaros
|
78c4021053
|
Merge remote-tracking branch 'origin/main' into feat/link-click-attribution
# Conflicts:
# docs/content/docs/api/reference/campaigns.mdx
# docs/content/docs/guides/campaigns.mdx
# internal/repository/pg_campaign.go
# internal/repository/pg_campaign_lifecycle.go
# web/src/components/app/campaigns/NewCampaignDialog.tsx
|
2026-09-03 03:14:19 -07:00 |
|
Matthew Meszaros
|
3614d65d96
|
feat: renumber the link clicks migration to 000123 because main released 000122 for the campaign kind column first
|
2026-09-03 03:13:30 -07:00 |
|
Matthew Meszaros
|
64f2d1637b
|
feat: address the review on the unsubscribe PR: register the suppression family in warmblyctl, require the confirm field on the browser unsubscribe POST and cap its body, render a chosen A/B variant through the template engine so its merge fields and unsubscribe link resolve, fold curly apostrophes before opt-out phrase matching, write pasted suppression lists in one transaction, clamp copy by runes instead of bytes, add the constraints NOT VALID plus a lower(email) index in migration 000123, scope the unsubscribe link type-ahead to email bodies, and document DELETE /suppressions/:id
|
2026-09-03 02:20:47 -07:00 |
|
Matthew Meszaros
|
42f4afb883
|
feat: compare a ticketless click-log row by destination in the burst check so a repeat click on one link during a mixed old-and-new tracking rollout never reads as a scanner burst, keeping the ticket comparison for rows that carry one
|
2026-09-03 02:20:23 -07:00 |
|
Matthew Meszaros
|
9485503e03
|
feat: merge main into the unsubscribe branch, keeping the new campaigns.kind column alongside unsubscribe_mode in every campaign scanner and renumbering the opt-out migration to 000123 because main took 000122 for campaign kind
|
2026-09-03 01:56:38 -07:00 |
|
Matthew Meszaros
|
5eb92c601c
|
feat: give every campaign email a working opt-out: a reply-to-opt-out line by default or an unsubscribe link (workspace setting under Settings > Sending with a per-campaign override and a {{.UnsubscribeLink}} variable), signed per-recipient unsubscribe links served on the API origin so the List-Unsubscribe header no longer points at a dead warmbly.com page, a confirm page on GET with RFC 8058 one-click on POST and a resubscribe button, reply opt-out detection through the whole-word compliance lexicon with quoted history stripped, a first-class suppression list (Contacts tab, GET/POST/DELETE /suppressions with address and domain entries, audited removal, contact drawer action), the contact Subscribed flag enforced in campaign routing, migration 000122 with a shared recipient_suppressed() predicate, and docs for all of it
|
2026-09-03 01:52:40 -07:00 |
|
Matthew Meszaros
|
797ece8f15
|
Merge pull request #300 from warmbly/amber-lark
feat: one-time email campaign preset with send estimate, one-time status wording, plain-text send fix and campaign tab mobile padding (#288)
|
2026-09-03 01:50:12 -07:00 |
|
Matthew Meszaros
|
1cb2b65cd7
|
Merge remote-tracking branch 'origin/main' into feat/link-click-attribution
|
2026-09-03 01:42:44 -07:00 |
|
Matthew Meszaros
|
6b1ddd8bb8
|
feat: address the CodeRabbit review on the one-time email preset: rate-limit POST /campaigns-estimate as a read, count a mailbox whose sent-today counter fails as having nothing left today instead of untouched, lock the campaign row FOR UPDATE before counting email steps so concurrent inserts cannot give a one-time campaign two messages, keep a plain-text test email free of the HTML signature, and make the wizard's estimate panel say an audience beyond the two-year horizon cannot be projected instead of reading the null as one sending day
|
2026-09-03 01:42:00 -07:00 |
|
Matthew Meszaros
|
24b7e26fbb
|
feat: add the one-time email campaign preset from #288: campaigns.kind column with sequence and one_time values and a kind field on POST /campaigns, GET /campaigns?kind= and an one_time count on /campaigns-overview, a POST /campaigns-estimate endpoint projecting deduplicated segment recipients against the mailbox pool's per-day capacity and finish date, a wizard type picker whose one-time flow runs Basics, Email, Audience, Sending and Send with send-now or scheduled start and the estimate panel and then creates, links segments and starts the campaign, a guard refusing a second email step on a one-time campaign, draft/scheduled/sending/sent wording with a One-time badge and Type filter in the campaigns list, kind on the AI create_campaign_draft tool, honouring text_only in the send and test-email paths so plain-text campaigns ship no HTML part or tracking, and the campaigns, segments, API reference and endpoint docs
|
2026-09-03 01:42:00 -07:00 |
|
Matthew Meszaros
|
7aa19a9227
|
feat: stop the campaign detail tabs triple-padding phones: the Leads tab renders its full-bleed contacts page without the extra 20px wrapper and the other tabs use a 12px gutter below sm, the campaign name truncates with min-w-0 so its status and One-time pills wrap under it instead of pushing off screen, stat strip cells lose vertical padding on phones, and the templates body, API key meta row, delivery hours grid and the AI variable dialog's 300px sample pane collapse or stack below sm/md
|
2026-09-03 01:42:00 -07:00 |
|
Matthew Meszaros
|
e8393eaeb8
|
feat: identify links by ticket in the click burst check so two tickets sharing a destination still count as a scanner walking the email, re-read a deferred human click's classification with retries and skip its effects entirely when the read keeps failing instead of assuming it was a person, and document the burst window plus one second wait
|
2026-09-03 01:36:37 -07:00 |
|
Matthew Meszaros
|
8096ca047c
|
feat: hold a human click's side effects (evidence, instant actions, webhook, live event) until the click burst window has passed and re-read its classification first, so the first click of a security scanner's burst can no longer fire a clicked automation or webhook before the second click reveals the burst; the stamp, log row and dedupe mark are still written immediately and walked back as before
|
2026-09-03 01:29:54 -07:00 |
|
Matthew Meszaros
|
d3a9afd775
|
Merge pull request #299 from warmbly/fix/profile-avatar-persistence
Fix profile and workspace avatar changes not persisting after refresh
|
2026-09-03 01:27:13 -07:00 |
|
Matthew Meszaros
|
b785d3b86b
|
feat: address review on per-link click attribution: read bare href values and ignore data-href when tracking anchors, compare the destination host instead of substring-matching the tracking domain, tag bare URLs in plain-text bodies when UTM tagging is on, count UTM limits in characters, expose the UTM overrides in the AI campaign tool, dedupe clicks by ticket so two links sharing a destination are two clicks, count machine_clicks only for steps with machine and no human clicks, never withdraw a click stamp that predates per-link logging, keep the coarse timeline click unless a logged click stands for it, resolve the link once per event, give auto-clicks their own tooltip and make the wizard's discard guard notice toggled settings
|
2026-09-03 01:20:48 -07:00 |
|
Matthew Meszaros
|
117affbb09
|
Merge remote-tracking branch 'origin/main' into fix/profile-avatar-persistence
|
2026-09-03 01:20:48 -07:00 |
|
Matthew Meszaros
|
3601147c7c
|
feat: address review on the avatar persistence fix: avatar object keys carry a random nonce next to the millisecond epoch so two uploads in the same millisecond cannot share an immutably cached URL, the org avatar hooks capture the target workspace id when the mutation starts and patch the org pointer, list and current-org caches only for that id so a mid-flight workspace switch cannot stamp the avatar onto the wrong org, and the API docs describe the stored file deletion on remove as best effort
|
2026-09-03 01:15:10 -07:00 |
|