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 tracking | Click tracking | |
|---|---|---|
| Mechanism | 1×1 transparent GIF before </body> | href values rewritten to a redirect URL |
| Endpoint | GET /track/o/{token}.gif | GET /track/c/{token} |
| Response | the pixel, with Cache-Control: no-store | 302 Found to the original URL |
| Recorded in | the loads table | the link_clicks table |
| Fires when | the client loads remote images | the recipient clicks the link |
| Data captured | IP address, User-Agent, timestamp | IP 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.
# 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.
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:
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.
| Endpoint | On a match | On anything else |
|---|---|---|
GET /track/c/{token} | records a click, returns 302 Found with Location set to the stored original URL | 404 Not Found |
GET /track/o/{token}.gif | records an open, returns the 1×1 GIF | returns 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:
<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:
<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. Themailto:link, and any other non-web scheme, stays untouched. - The original
https://acme.example/confirm?u=42is 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):
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/statsreturnsopens,clicks,unique_opensandunique_clicksover 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_opensandtrack_clickstofalseon 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_modedrops message content: the raw MIME endpoint answers404 NotAvailableand the dashboard hides message bodies. - The open pixel is served with
Cache-Control: no-storeand 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 hitshttps://<track_domain>/track/c/…. On a local stack thetrack_domainis usually not publicly resolvable, and test messages rarely reach a real inbox, so those requests never arrive.
