Skip to content

Start an automation when a contact clicks a specific link - #507

Open
pierre-b wants to merge 15 commits into
devfrom
feat/link-click-automation
Open

pierre-b wants to merge 15 commits into
devfrom
feat/link-click-automation

Conversation

@pierre-b

@pierre-b pierre-b commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes #498.

What it does

  • New automation trigger "Clicked a link" (email.link_clicked). It starts the first time a contact clicks each link of an email. Optional filters: the link contains some text (case-insensitive), one broadcast, one template. A click on an email that the automation sent itself never starts it again, so every_time cannot loop on its own emails.
  • See who clicked each link. Each row of a broadcast's per-link stats gets a "See who clicked" button. It opens the message log filtered by clicked_url (only allowed together with broadcast_id), which messages.list also accepts.
  • Segments stay current. A filter on a clicked link now includes the contact as soon as they click that link, even when it isn't the first link they clicked in the email.
  • Erased contacts. Opens and clicks on an email whose contact was deleted (DELETED_EMAIL) are no longer recorded. The link still redirects.

How

  • track_message_history_changes() writes one email.link_clicked timeline entry for each clicked_links key that becomes verified. SetClicked sets verified: true only for clicks through a signed tracking token, so a forged URL starts nothing.
  • Migration v44 (workspace databases only) replaces that function body. No table, column, index or environment variable changes. v42 now freezes its own copy, as v7, v8 and v36 do.
  • The trigger generator adds the WHEN clause: the link filter, the broadcast and template filters, and the loop guard.
  • The monthly event meter excludes email.link_clicked, which details a click that is already counted.
  • The clicked_url filter uses the jsonb ? operator behind an object guard. PostgreSQL has no row estimate for -> key IS NOT NULL and assumes almost every row matches, so for a little-clicked link it scanned the whole table through the created_at index (measured on 1M rows: 99 ms down to 11 ms).
  • Console: the trigger form, the trigger node and card, the contact timeline entry, the log filter (offered only with a broadcast filter, and cleared with it), the per-link button, and translations in every locale.

Plan: plans/feat-link-click-automation-plan.md. Docs: Notifuse/docs#29.

Tests

  • Go: unit tests in every touched layer; full ./internal/... ./pkg/... green with -race.
  • Integration: timeline_link_clicked_test.go and automation_link_click_trigger_test.go drive the real /r/ route with signed and unsigned tokens against PostgreSQL. They cover a link clicked second, the 50-link cap, malformed clicked_links, the real erasure path, a link clicked before 44, the v44 upgrade, the meter, the round trip from per-link stats to the log, and the loop guard.
  • Console: unit tests green, build OK.

Known, not handled

  • No email.link_clicked webhook.
  • Clicks made before 44.0 are not replayed into the timeline. A link clicked before the upgrade writes its entry on its next signed click (documented).
  • Links in emails sent before 42.2 are unsigned and start no per-link automation.
  • Links clicked on the web version page (Add a "View in browser" link to broadcast and automation emails #502) are direct links: they record no click, so they start no "Clicked a link" automation.
  • Behind a proxy that forwards another Host, links to the instance itself (unsubscribe, notification center, "View in browser") are not recognised as such: they are recorded per link, as they already were in per-link stats, and so start an automation with no link filter. The click route doesn't read X-Forwarded-Host, unlike Add a "View in browser" link to broadcast and automation emails #502's /v/ route.
  • The monthly meter now reads kind from the table for each row of the month: the partial index no longer answers alone (it runs once a day per workspace).
  • For a broadcast that is a large share of the table, messages.list with clicked_url is planned like the list's other filters, which can walk the created_at index. This predates the change.
  • TriggerFormConfig in NodeConfigPanel.tsx doesn't list the three new fields (a type mirror only, no runtime effect).
  • The v42 freeze test compares against the shared body minus the link step, not a fixed hash.
  • The full URL is shown in the log's filter button, without truncation.
  • broadcast_id and template_id trigger filters have no length cap of their own.
  • The trigger node and card show the link filter, not the broadcast or template filter.
  • AutomationCard.tsx has no test file.
  • npm run lint (--max-warnings 0) already reports 12 warnings on dev in files this PR doesn't touch; CI's npx eslint . passes.
  • features/automations.mdx lists email.sent and email.delivered as triggers although ValidEventKinds excludes them (also on dev).

…eline trigger

The message history trigger writes one email.link_clicked entry each time a
link of clicked_links is first clicked through a token signed by the
workspace. Migration v44 installs the new body on existing workspaces; v42
keeps a frozen copy of the body it shipped. Opens and clicks are no longer
recorded on messages whose contact was erased.
…sage filter

An email.link_clicked trigger can filter on the clicked URL (substring),
broadcast and template, read from the timeline entry, and never restarts on
a click on an email the automation sent. messages.list takes clicked_url,
scoped to a broadcast, to list who clicked a link. The monthly event meter
leaves email.link_clicked out.
…ine, and who clicked each link

The trigger form picks email.link_clicked with optional link, broadcast and
template filters. The contact timeline shows each link clicked. The broadcast
per-link stats open the message log on the emails where that link was clicked,
through a clicked_url filter that only exists within a broadcast filter.
…ct what link click comments, docs and tests claim

messages.list with clicked_url now uses the jsonb ? operator: PostgreSQL has no
estimate for clicked_links -> key IS NOT NULL and walked the created_at index
over the whole table for a little-clicked link. The billable index guard EXPLAINs
the meter query as it now is, a link clicked before 44 is documented and tested,
and the erasure and no-URL subtests go through the real paths.
…tion

# Conflicts:
#	CHANGELOG.md
#	config/config.go
#	console/src/i18n/locales/ca.po
#	console/src/i18n/locales/de.po
#	console/src/i18n/locales/en.po
#	console/src/i18n/locales/es.po
#	console/src/i18n/locales/fr.po
#	console/src/i18n/locales/it.po
#	console/src/i18n/locales/ja.po
#	console/src/i18n/locales/pt-BR.po
#	web_analytics_sdk/dist/notifuse-analytics.cjs.js
#	web_analytics_sdk/dist/notifuse-analytics.esm.js
#	web_analytics_sdk/dist/notifuse-analytics.min.js
#	web_analytics_sdk/package.json
@codecov

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.96907% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.84%. Comparing base (ffa835f) to head (34fbc15).

Files with missing lines Patch % Lines
internal/service/demo_service.go 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #507      +/-   ##
==========================================
+ Coverage   88.82%   88.84%   +0.02%     
==========================================
  Files         368      369       +1     
  Lines       71862    71943      +81     
==========================================
+ Hits        63828    63915      +87     
+ Misses       5395     5390       -5     
+ Partials     2639     2638       -1     
Flag Coverage Δ
unit 88.84% <98.96%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…tion

# Conflicts:
#	CHANGELOG.md
#	config/config.go
#	web_analytics_sdk/dist/notifuse-analytics.cjs.js
#	web_analytics_sdk/dist/notifuse-analytics.esm.js
#	web_analytics_sdk/dist/notifuse-analytics.min.js
#	web_analytics_sdk/package.json
…tion

# Conflicts:
#	console/src/i18n/locales/ca.po
#	console/src/i18n/locales/de.po
#	console/src/i18n/locales/en.po
#	console/src/i18n/locales/es.po
#	console/src/i18n/locales/fr.po
#	console/src/i18n/locales/it.po
#	console/src/i18n/locales/ja.po
#	console/src/i18n/locales/pt-BR.po
…tion

# Conflicts:
#	console/src/i18n/locales/ca.po
#	console/src/i18n/locales/de.po
#	console/src/i18n/locales/en.po
#	console/src/i18n/locales/es.po
#	console/src/i18n/locales/fr.po
#	console/src/i18n/locales/it.po
#	console/src/i18n/locales/ja.po
#	console/src/i18n/locales/pt-BR.po
@pierre-b
pierre-b marked this pull request as ready for review November 10, 2026 23:06
…e proxy

The click routes now judge a link back to the instance against the
request's Host and every X-Forwarded-Host entry, so behind a proxy that
forwards its upstream name as Host the unsubscribe, notification center
and web version links are no longer recorded per link, and no longer
start link-click automations.
…nk exclusion

Everything merged since 43.0 ships as 44.0. The changelog line now says
the instance's own links are left out only when the proxy passes
X-Forwarded-Host, and that keys recorded earlier stay.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant