How Listmonk Tracks Clicks and Views in Email Campaigns: A Deep Dive
Listmonk tracks email opens and link clicks using template-generated tracking URLs and 1×1 pixel images, recording events in the campaign_views and link_clicks database tables while respecting configurable privacy settings.
Listmonk is a high-performance, open-source newsletter and mailing list manager that provides granular campaign analytics through a sophisticated tracking mechanism. Understanding how listmonk tracks click and view links in campaigns reveals a multi-layered architecture spanning template preprocessing, HTTP handlers, and database persistence. The implementation relies on specific components within the internal/manager, cmd/public, and internal/core packages to capture engagement data accurately.
Template Processing: Generating Tracking URLs
Tracking begins when a campaign is being sent, as the manager processes the email body to inject measurable elements. This occurs in internal/manager/manager.go, where URLs are rewritten and view pixels are generated before the message leaves the server.
Link Rewriting with trackLink
The trackLink function intercepts every hyperlink in the email body and transforms it into a trackable endpoint. When building the message, the manager calls this function to register the raw URL in the database and return a tracking surrogate.
// trackLink registers a URL and returns its UUID to be used in message templates
// for tracking links.
func (m *Manager) trackLink(url, campUUID, subUUID string) string {
if m.cfg.DisableTracking { return url }
url = strings.ReplaceAll(url, "&", "&")
// … cache lookup …
uu, err := m.store.CreateLink(url) // registers the raw URL
// …
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
}
The generated URL follows the pattern defined by cfg.LinkTrackURL, defaulting to {{.BaseURL}}/t/{{.LinkUUID}}/{{.CampUUID}}/{{.SubUUID}}. This ensures that when a recipient clicks a link, the request first hits listmonk's tracking endpoint before redirecting to the original destination.
The View Tracking Pixel
For open tracking, listmonk injects a 1×1 transparent pixel via the TrackView template function defined in internal/manager/manager.go within makeGnericFuncMap. This template tag expands to a URL such as /t/v/{{.CampUUID}}/{{.SubUUID}}, which renders as an invisible image in the email client.
HTTP Handlers: Capturing Engagement Events
Once the email is delivered, recipient interactions trigger HTTP endpoints exposed by the public API in cmd/public.go. These handlers receive the UUIDs embedded in the tracking URLs and delegate persistence to the core layer.
Recording Campaign Views
The view pixel requests hit GET /track/view/:campUUID/:subUUID/:dummy?, handled by RegisterCampaignView. This function validates the request and records the open event while always returning a valid image to prevent broken image icons in email clients.
func (a *App) RegisterCampaignView(c echo.Context) error {
if a.cfg.Privacy.DisableTracking { … return pixel }
subUUID := c.Param("subUUID")
if !a.cfg.Privacy.IndividualTracking { subUUID = "" }
campUUID := c.Param("campUUID")
if campUUID != dummyUUID && subUUID != dummyUUID {
a.core.RegisterCampaignView(campUUID, subUUID) // ← core call
}
// always return a 1×1 PNG
}
Handling Link Clicks
Link clicks route to GET /track/link/:linkUUID/:campUUID/:subUUID, implemented by RegisterCampaignLinkClick. This handler looks up the original destination, records the click, and issues an HTTP 302 redirect.
url, err := a.core.RegisterCampaignLinkClick(linkUUID, campUUID, subUUID)
// on success, HTTP 302 redirects to the original URL
Core Registration and Database Persistence
The internal/core/campaigns.go file contains the business logic that actually commits tracking data to the database, isolating SQL execution from the HTTP transport layer.
Registering Campaign Views
The RegisterCampaignView function executes a prepared statement to insert a row into the campaign_views table, capturing the campaign ID, subscriber ID, and timestamp.
func (c *Core) RegisterCampaignView(campUUID, subUUID string) error {
if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil { … }
return nil
}
Registering Link Clicks
Similarly, RegisterCampaignLinkClick records the interaction in link_clicks and retrieves the original URL for the redirect.
func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) (string, error) {
var url string
if err := c.q.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { … }
return url, nil
}
Prepared Statements in models/queries.go
These core functions rely on prepared statements defined in models/queries.go:
RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"`
RegisterLinkClick *sqlx.Stmt `query:"register-link-click"`
Database Schema and Storage Architecture
The tracking data resides in two primary tables created via migration files such as internal/migrations/v2.0.0.go and v3.0.0.go:
campaign_views: Stores(campaign_id, subscriber_id, created_at)tuples to count unique and total opens.link_clicks: Stores(link_id, campaign_id, subscriber_id, created_at)to attribute clicks to specific links and recipients.
These tables support materialized views for fast aggregation, exposed through the Campaign model in models/campaigns.go via Views and Clicks fields.
Privacy Controls and Configuration
Listmonk respects user privacy through settings defined in internal/core/settings.go and accessed via cfg.Privacy:
DisableTracking: When enabled, all tracking is bypassed. ThetrackLinkfunction returns raw URLs, and view handlers return the pixel without database writes.IndividualTracking: When set tofalse, the subscriber UUID is emptied before processing, recording aggregate counts without linking events to specific individuals.AllowExport: Governs whether subscribers can request a data export of their personal view and click history viacmd/public.go → SelfExportSubscriberData.
Summary
- Template Generation:
internal/manager/manager.gorewrites links viatrackLinkand injects view pixels viaTrackViewduring campaign sending. - HTTP Capture:
cmd/public.gohandlersRegisterCampaignViewandRegisterCampaignLinkClickreceive tracking requests and interface with the core layer. - Data Persistence:
internal/core/campaigns.gocommits events tocampaign_viewsandlink_clickstables using prepared statements frommodels/queries.go. - Privacy Compliance: Three configuration options allow administrators to disable tracking entirely, anonymize subscriber data, or permit self-service data exports.
Frequently Asked Questions
How does Listmonk track email opens without storing personally identifiable information?
Listmonk inserts a 1×1 transparent pixel URL generated by the TrackView template function. When the email client loads this image, the RegisterCampaignView handler in cmd/public.go receives the request. If the IndividualTracking privacy setting is disabled, the handler empties the subscriber UUID before recording the event, storing only that an open occurred without linking it to a specific individual.
What happens when link tracking is disabled in listmonk?
When DisableTracking is set to true in the privacy configuration, the trackLink function in internal/manager/manager.go returns the original URL unchanged, bypassing the UUID generation and database registration. Consequently, no entry is created in the link_clicks table, and recipients navigate directly to the destination without hitting listmonk's redirect endpoint.
How does listmonk reconstruct the original URL when a tracked link is clicked?
When a tracked link is clicked, the RegisterCampaignLinkClick handler invokes Core.RegisterCampaignLinkClick in internal/core/campaigns.go. This function executes the RegisterLinkClick prepared statement, which inserts a row into link_clicks and uses a RETURNING clause (or equivalent) to fetch the original URL stored during the initial CreateLink call. The handler then returns an HTTP 302 redirect to that original URL.
Can subscribers export their own campaign view and click history?
Yes, if the AllowExport privacy setting is enabled, subscribers can request a data export through the SelfExportSubscriberData handler in cmd/public.go. This retrieves the subscriber's aggregated data from the campaign_views and link_clicks tables, providing a complete record of their interactions with campaigns sent through the listmonk instance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →