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.

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
}

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
}

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. The trackLink function returns raw URLs, and view handlers return the pixel without database writes.
  • IndividualTracking: When set to false, 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 via cmd/public.go → SelfExportSubscriberData.

Summary

  • Template Generation: internal/manager/manager.go rewrites links via trackLink and injects view pixels via TrackView during campaign sending.
  • HTTP Capture: cmd/public.go handlers RegisterCampaignView and RegisterCampaignLinkClick receive tracking requests and interface with the core layer.
  • Data Persistence: internal/core/campaigns.go commits events to campaign_views and link_clicks tables using prepared statements from models/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.

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.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →