Open & click tracking

CamelMailer can tell you whether a message was opened and which links a recipient followed. Two techniques do the work, both applied by the delivery worker as it sends.

How it works

Two things happen to the HTML part of an outgoing message at send time, and the numbers roll up into message detail, per-server statistics and campaign analytics.

  • Open tracking injects an invisible 1×1 GIF (the pixel) before </body>. When the recipient's mail client loads that image, it hits a CamelMailer endpoint and an open is recorded.
  • Click tracking rewrites every http(s) link in the HTML body to point at CamelMailer. The endpoint records the click, then 302-redirects the recipient to the original URL, preserved exactly.

Tracking touches the HTML part only. A text-only message keeps its body verbatim and produces no open or click events.

Open trackingClick tracking
Mechanism1×1 transparent GIF before </body>href values rewritten to a redirect URL
EndpointGET /track/o/{token}.gifGET /track/c/{token}
Responsethe pixel, with Cache-Control: no-store302 Found to the original URL
Recorded inthe loads tablethe link_clicks table
Fires whenthe client loads remote imagesthe recipient clicks the link
Data capturedIP address, User-Agent, timestampIP address, User-Agent, clicked URL, timestamp

Turn it on

Tracking is configured per mail server. A server carries two booleans, track_opens and track_clicks, the defaults for mail it sends over the HTTP API. Read them on the server record and set them through the management API, or use the same two toggles on the server settings page in the dashboard.

terminal
# inspect current settings
curl -s "$API/api/v2/admin/organizations/acme/servers/production" \
  -H "X-Admin-API-Key: $ADMIN_KEY"
#   … "track_opens": true, "track_clicks": true …

# change them
curl -s -X PATCH "$API/api/v2/admin/organizations/acme/servers/production" \
  -H "X-Admin-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"track_opens": false, "track_clicks": false}'

There is one level of granularity here: the mail server. Individual messages and streams inherit the server's setting. No per-message or per-stream toggle exists.

Honest note on the current build. The two flags are stored, editable, and shown everywhere a server appears. The worker's tracking pass, though, rewrites the HTML of every outgoing message that has an HTML part, and it does not yet read these per-server flags. Treat the toggles as the intended control surface and check the behaviour against your own installation before relying on them to suppress tracking. To be certain a message carries no tracking today, send it as text only, or run the server in privacy mode.

The tracking domain

Both public endpoints are served by the CamelMailer web process, the same one that answers the API. Recipients reach them through a dedicated hostname, the tracking domain, set by dns.track_domain:

camelmailer.yml
dns:
  track_domain: track.example.com

Publish it as a CNAME to the web server (see the DNS table in Deliverability & DNS). The worker builds every tracking URL as <web_protocol>://<track_domain>, so the domain has to be publicly resolvable and reachable over the web protocol for the pixel to load and the redirect to fire.

The public endpoints

Both endpoints are unauthenticated: the incoming request carries only an opaque token. They sit behind the load balancer or reverse proxy that terminates TLS, so the client IP comes from the first entry of the X-Forwarded-For header and the client from User-Agent.

EndpointOn a matchOn anything else
GET /track/c/{token}records a click, returns 302 Found with Location set to the stored original URL404 Not Found
GET /track/o/{token}.gifrecords an open, returns the 1×1 GIFreturns the same GIF

The open endpoint always answers with the pixel and keeps token validity opaque, so a scraped or expired pixel URL reveals nothing. The click endpoint answers 404 for an unknown token or a token that is not a click token. A third route, GET/POST /track/u/{token}, handles one-click unsubscribe for broadcast mail and is documented with campaigns.

Tokens are pre-registered by the worker at delivery time, in the same pass that rewrites the body and before DKIM signing, so the signature covers the final message. For each outgoing HTML message the worker stores a row per link, generates a 24-character click token, swaps in the redirect URL, then generates one open token and injects the pixel. Resolution at request time is a single lookup on the token, which yields the tenant, message and (for clicks) the target URL. The recorded event lands in that tenant's loads or link_clicks table under row-level security, so isolation holds even though the token lookup spans tenants.

What a rewrite looks like

Take this HTML message body:

before
<html>
  <body>
    <p>Please confirm your address:
       <a href="https://acme.example/confirm?u=42">Confirm</a></p>
    <p>Questions? <a href="mailto:help@acme.example">Email us</a></p>
  </body>
</html>

With track_domain = track.example.com, the worker delivers this instead:

after
<html>
  <body>
    <p>Please confirm your address:
       <a href="https://track.example.com/track/c/Xk3f9Qm2Lp7Rt1Vb8Nd4Zc0">Confirm</a></p>
    <p>Questions? <a href="mailto:help@acme.example">Email us</a></p>
    <img src="https://track.example.com/track/o/9pQ2Ls6Wk1Fj7Ht3Vb8Nd4Zc0.gif"
         alt="" width="1" height="1" style="display:none"/>
  </body>
</html>
  • Only the http(s) link was rewritten. The mailto: link, and any other non-web scheme, stays untouched.
  • The original https://acme.example/confirm?u=42 is kept in the click token. When the recipient follows the rewritten link, the endpoint records the click and 302-redirects the browser straight to that URL, so the recipient arrives at the intended page.
  • The pixel is 1×1 and display:none, invisible in a rendered message.

Where opens and clicks show up

Every event is readable through the Server API (X-Server-API-Key) and in the dashboard. Two per-message endpoints list the raw events, newest first, each with IP address, User-Agent and timestamp (clicks also carry the URL):

terminal
curl -s "$API/api/v2/server/messages/8/opens"  -H "X-Server-API-Key: $SERVER_KEY"
curl -s "$API/api/v2/server/messages/8/clicks" -H "X-Server-API-Key: $SERVER_KEY"
  • The message detail page renders these as a lifecycle timeline, Sent → Delivered → Opened → Clicked, using the first open and first click.
  • GET /api/v2/server/stats returns opens, clicks, unique_opens and unique_clicks over the window, and feeds the Statistics view.
  • A campaign rolls its opens and clicks up over exactly the messages it produced, joining the event tables back to the attributed messages.

Opens and clicks travel with a message shared through a public share link, so support can triage engagement without an account. To get the events pushed to you rather than polled, point a webhook at your endpoint.

Privacy and disabling tracking

Opens and clicks record the recipient's IP address and User-Agent. For an EU-focused deployment, weigh that against your legal basis and disclosures before enabling it. Several controls help:

  • Disable per server. Set track_opens and track_clicks to false on the mail server (see the honest note above about the current worker behaviour).
  • Send text only. A message with a text body and no HTML part is left verbatim and produces no events.
  • Privacy mode. A server in privacy_mode drops message content: the raw MIME endpoint answers 404 NotAvailable and the dashboard hides message bodies.
  • The open pixel is served with Cache-Control: no-store and the endpoint keeps token validity opaque, so tracking URLs cannot be probed for whether a given message exists.

Tracking in local development

Tracking events depend on two things a laptop stack usually lacks:

  • The running worker. The rewrite and token registration happen only when the worker actually processes and sends a queued message. A worker that is down, or a message that never leaves the queue, means no tracking is applied.
  • A reachable tracking domain. An open is recorded when the client loads https://<track_domain>/track/o/…, a click when the browser hits https://<track_domain>/track/c/…. On a local stack the track_domain is usually not publicly resolvable, and test messages rarely reach a real inbox, so those requests never arrive.
The practical consequence: on a local stack you will see the rewritten HTML in the delivered message, but the opens and clicks endpoints stay empty until real recipients load the pixel and follow the links against a reachable tracking domain.