Skip to content

tutorials

Self-hosted email tracking: open and click tracking that respects privacy

August 26, 2026

#email-tracking#cloudflare#workers#privacy#tutorial
Self-hosted email tracking: open and click tracking that respects privacy
Visual: Soffit Underground on okslop

You want to know whether your campaign landed: who opened it, who clicked, which link won. Every email SaaS bakes this in, but it also routes your subscribers' opens and clicks through their servers, where the data gets logged, enriched, and tied to a profile you do not control. You can do the same tracking on your own domain, keep every event in your own database, and let no third party see a thing.

Email analytics is one of the few places where the "just use the vendor" answer quietly costs you something real: every open pixel and every wrapped link in a typical campaign points at the vendor's tracking domain, not yours. That means your subscriber list, their open behavior, and their click behavior all pass through someone else's infrastructure. For a transactional receipt that might be fine. For a newsletter you are trying to keep private, or a product where subscriber behavior is sensitive, it is a leak you signed up for without noticing.

The good news is that open and click tracking are not complicated. They are two HTTP endpoints and a bit of HTML rewriting. Let's build both from scratch on Cloudflare Workers and D1, be honest about exactly how reliable each signal is, and keep every byte of data on your own account. Then, at the end, the shortcut if you would rather not own the rewriting and storage glue.

What is self-hosted email tracking?

Self-hosted email tracking means the open pixel and the click redirect are served from your own domain, and the resulting events are written to a database you control, so no analytics vendor ever sees your subscribers. It is the same two mechanisms every email tool uses, just pointed at infrastructure you own instead of theirs.

There are exactly two signals you can collect from an email, because email is not a web page and runs no JavaScript:

  • Opens, detected by a remote image (the "tracking pixel") loading when the client renders the message.
  • Clicks, detected by rewriting each link to pass through a redirect on your domain before reaching the real destination.

That is the whole toolkit. There is no reliable "time spent reading", no scroll depth, no mouse tracking. Anyone selling you those for email is inferring them from these two events or making them up. Knowing the ceiling keeps you honest about what the data means.

How do email open tracking pixels work?

An open tracking pixel is a 1x1 transparent image whose URL is unique per recipient and per message; when the mail client loads images, the request to that URL is the open event. You log it and return the image bytes. Because email clients execute no JavaScript, a loaded remote image is the only open signal that exists.

Here is the endpoint. On a Cloudflare Worker, the pixel route decodes a token, writes an event, and returns a tiny transparent GIF:

// worker: GET /o/:token  -> records an open, returns a 1x1 GIF
import { Hono } from "hono";

const app = new Hono<{ Bindings: Env }>();

// A 43-byte transparent 1x1 GIF, base64-decoded at module load.
const PIXEL = Uint8Array.from(
  atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
  (c) => c.charCodeAt(0),
);

app.get("/o/:token", async (c) => {
  const { messageId, recipientId } = await verifyToken(c.req.param("token"), c.env);

  // Fire-and-forget the write so the pixel returns immediately.
  c.executionCtx.waitUntil(
    c.env.DB.prepare(
      `INSERT INTO email_events (message_id, recipient_id, type, ua, ip, created_at)
       VALUES (?, ?, 'open', ?, ?, ?)`,
    )
      .bind(
        messageId,
        recipientId,
        c.req.header("user-agent") ?? "",
        c.req.header("cf-connecting-ip") ?? "",
        Date.now(),
      )
      .run(),
  );

  return new Response(PIXEL, {
    headers: {
      "content-type": "image/gif",
      // Defeat client/proxy caching so repeat opens still register.
      "cache-control": "no-store, no-cache, must-revalidate, private",
      "pragma": "no-cache",
      "expires": "0",
    },
  });
});

export default app;

The token is the important part. It encodes which recipient and which message this pixel belongs to, and it must be tamper-proof so a recipient cannot forge events or, worse, hand your endpoint a URL of their choosing. We will sign it in a moment.

The HTML side is one tag, placed just before the closing </body>:

<img src="https://mail.yourdomain.com/o/eyJ...signed-token..."
     width="1" height="1" alt="" style="display:none" />

Is open tracking reliable? (No, and here is why)

Open tracking is not reliable and you should treat it as a coarse trend rather than a count. Two forces wreck it in opposite directions, and both are out of your control.

First, inflation from privacy proxies. Apple Mail Privacy Protection, on by default for a large share of consumer mail, pre-fetches every remote image through an Apple proxy the moment the message arrives, whether or not a human ever opens it. So you record an "open" for messages nobody read, with Apple's IP and a generic user agent instead of the real one. Gmail has long proxied images too, which is why open-tracking geolocation has been useless for years.

Second, suppression from blocked images. Many clients and corporate gateways block remote images until the reader explicitly clicks "show images". Those readers opened your mail; you just never hear about it. Plain-text readers never load the pixel at all.

The practical consequence: open rate is fine for watching a trend line move ("this subject line did better than that one, roughly"), and useless as a literal headcount. Do not gate logic on it. If you need to know a human engaged, use clicks. If the pixel's unreliability has you reconsidering whether you want it at all, the ways to get email analytics without creepy tracking pixels and tracking opens and clicks without a third party cover the lighter-touch options.

How do I track email clicks without a third party?

Rewrite every link in the email so it points at a redirect endpoint on your own domain, carrying the recipient, the message, and the encoded destination; on click, log the event and return a 302 to the real URL. The reader's browser only ever talks to your domain and the destination, never an analytics vendor.

This is the reliable signal, because a click is a deliberate human action that no proxy fakes. The redirect route:

// worker: GET /c/:token  -> records a click, redirects to the real URL
app.get("/c/:token", async (c) => {
  const { messageId, recipientId, url } = await verifyToken(
    c.req.param("token"),
    c.env,
  );

  // Reject anything that is not a normal web link, so the redirect
  // cannot be abused to bounce traffic to arbitrary schemes.
  let target: URL;
  try {
    target = new URL(url);
    if (target.protocol !== "https:" && target.protocol !== "http:") {
      return c.text("bad target", 400);
    }
  } catch {
    return c.text("bad target", 400);
  }

  c.executionCtx.waitUntil(
    c.env.DB.prepare(
      `INSERT INTO email_events (message_id, recipient_id, type, url, ua, ip, created_at)
       VALUES (?, ?, 'click', ?, ?, ?, ?)`,
    )
      .bind(
        messageId,
        recipientId,
        target.toString(),
        c.req.header("user-agent") ?? "",
        c.req.header("cf-connecting-ip") ?? "",
        Date.now(),
      )
      .run(),
  );

  return c.redirect(target.toString(), 302);
});

Notice the validation. A naive redirect that blindly trusts the decoded URL is an open-redirect vulnerability and, in the worst case, a way for someone to feed your endpoint a javascript: or data: URL. We sign the token (so recipients cannot inject their own URLs in the first place) and still re-validate the scheme on the way out. Defense in depth.

How do I sign the tracking token so it cannot be forged?

Pack the recipient id, message id, and destination URL into a payload, then attach an HMAC signature using a secret only your Worker knows; on every request, recompute the HMAC and reject any token whose signature does not match. This stops recipients from spoofing events or turning your redirect into an arbitrary proxy.

// Sign and verify tracking tokens with HMAC-SHA256 (Web Crypto, edge-safe).
async function key(env: Env) {
  return crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(env.TRACKING_SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"],
  );
}

function b64url(bytes: ArrayBuffer | Uint8Array) {
  const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
  return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export async function signToken(payload: object, env: Env) {
  const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));
  const sig = await crypto.subtle.sign("HMAC", await key(env), new TextEncoder().encode(body));
  return `${body}.${b64url(sig)}`;
}

export async function verifyToken(token: string, env: Env) {
  const [body, sig] = token.split(".");
  if (!body || !sig) throw new Error("malformed token");
  const expected = await crypto.subtle.sign("HMAC", await key(env), new TextEncoder().encode(body));
  if (b64url(expected) !== sig) throw new Error("bad signature");
  const json = atob(body.replace(/-/g, "+").replace(/_/g, "/"));
  return JSON.parse(json) as { messageId: string; recipientId: string; url?: string };
}

Set the secret with wrangler secret put TRACKING_SECRET and never ship it in code. If you skip signing, here is the failure you invite: a curious recipient opens the token, sees {"messageId":"...","url":"https://example.com"} in plain base64, and now hands /c/ any URL they like, logging junk clicks and using your domain as a redirect launderer that phishing filters will eventually flag. Sign the token.

Before sending, parse the HTML body, replace each href with a tokenized /c/ URL pointing at your tracking domain, and append the pixel img just before </body>. This runs once per recipient because each token is recipient-specific.

// Rewrite every link and inject the open pixel for one recipient.
export async function instrument(
  html: string,
  ctx: { messageId: string; recipientId: string; base: string },
  env: Env,
): Promise<string> {
  // Rewrite hrefs. A real implementation should use an HTML parser
  // (e.g. HTMLRewriter on Workers); the regex is shown for clarity.
  const rewritten = await replaceAsync(
    html,
    /href="(https?:\/\/[^"]+)"/g,
    async (_m, url: string) => {
      const token = await signToken(
        { messageId: ctx.messageId, recipientId: ctx.recipientId, url },
        env,
      );
      return `href="${ctx.base}/c/${token}"`;
    },
  );

  const openToken = await signToken(
    { messageId: ctx.messageId, recipientId: ctx.recipientId },
    env,
  );
  const pixel =
    `<img src="${ctx.base}/o/${openToken}" width="1" height="1" alt="" style="display:none" />`;

  return rewritten.includes("</body>")
    ? rewritten.replace("</body>", `${pixel}</body>`)
    : rewritten + pixel;
}

On Cloudflare specifically, prefer HTMLRewriter over a regex for the link pass; the regex above is for readability, but real HTML has attributes in any order, links inside conditional comments, and hrefs you must not touch (like mailto: and unsubscribe links you may want to exempt). HTMLRewriter streams and handles the parsing correctly.

A few things that will bite you if you skip them:

  • Do not rewrite the List-Unsubscribe link or mailto: links. Wrapping the unsubscribe URL in a redirect can break one-click unsubscribe and annoys receivers. See how to implement one-click unsubscribe correctly for why that header must stay clean.
  • Use a dedicated subdomain like mail.yourdomain.com for tracking, not your bare apex, so a tracking-domain reputation problem cannot drag down your main site.
  • Keep tokens short-ish. A 2KB URL in every link bloats the message and can trip clients. JSON payloads are fine for a handful of fields; do not stuff the whole email into the token.

How do I store and query the events?

Store events in a single append-only table keyed by message and recipient, then aggregate with plain SQL. Owning the storage is the entire point: the data is queryable, exportable, and never leaves your account.

CREATE TABLE email_events (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  message_id   TEXT NOT NULL,
  recipient_id TEXT NOT NULL,
  type         TEXT NOT NULL,        -- 'open' | 'click'
  url          TEXT,                 -- destination for clicks
  ua           TEXT,
  ip           TEXT,
  created_at   INTEGER NOT NULL
);

CREATE INDEX email_events_msg_idx ON email_events (message_id, type);

Campaign open and click rates are then a single grouped query:

SELECT
  type,
  COUNT(DISTINCT recipient_id) AS unique_count,
  COUNT(*)                     AS total_count
FROM email_events
WHERE message_id = ?
GROUP BY type;

Because it is your database, you can also do the privacy-respecting thing that vendors make hard: set a retention window and actually delete. A scheduled job that runs DELETE FROM email_events WHERE created_at < ? after, say, 90 days keeps your aggregates fresh without hoarding a permanent behavioral record of every subscriber. If you want it on a timer, wire it with a Cloudflare Cron Trigger.

Does self-hosted tracking actually respect privacy?

It respects privacy far more than vendor tracking, but only if you treat it as tracking and handle it responsibly: first-party storage, short retention, honored opt-outs, and an honest privacy policy. Self-hosting removes the third party; it does not remove the ethics.

What you genuinely gain over a vendor:

  • The event data lives in your D1 and is never sold, enriched against other datasets, or used to build a cross-site profile of your subscriber.
  • No tracking domain owned by a marketing company appears in your mail, which is also better for deliverability, since some filters score third-party tracking domains.
  • You can offer a real "we collect open/click events, here is how to opt out, here is our retention window" statement and mean it.

What self-hosting does not change: you are still recording behavior. If a subscriber has opted out of tracking, honor it by sending them a version with no pixel and unrewritten links. The cleanest privacy posture is to make open tracking optional per list, lean on clicks only when you need a real engagement signal, and keep retention short. For the broader argument, see running a newsletter that does not surveil readers, and for why dropping the pixel often helps more than it costs, the reasons to run a privacy-first newsletter.

Or skip the rewriting glue: tracking that ships with Tinbox

Everything above is maybe a hundred lines plus the edge cases, and the edge cases are where the time goes: HTMLRewriter instead of regex, exempting unsubscribe links, signing tokens, a dedicated tracking subdomain, retention jobs, and aggregations. It is very doable, and if you want to own each piece, the code above is a complete starting point.

If you would rather it already work, Tinbox does this as part of the stack it deploys to your own Cloudflare account. The sender rewrites links and injects the pixel against your own tracking subdomain, events land in your own D1 next to the messages they belong to, and the open/click aggregates show up in the campaign view, all first-party, with no analytics vendor anywhere in the path. Because it is on your account, the events table is yours to query and yours to delete. It is the same architecture this article describes, wired up and tested, including the parts (token signing, unsubscribe-link exemption, edge HTML rewriting) that are easy to get subtly wrong.

The honest caveat: tracking is still tracking. Tinbox keeps it first-party and is built so you can turn it off, but it does not make recording opens and clicks disappear as a privacy consideration. Own it, disclose it, and keep retention tight.

Deploy this yourself: npx tinbox init yourdomain.com, and the tracking endpoints, storage, and aggregation come up with the rest of the stack. See the docs for the campaign analytics view and how to scope tracking per list.

Frequently asked questions

How do open tracking pixels work?

An open tracking pixel is a 1x1 transparent image embedded in the email HTML whose src points at a URL unique to that recipient and message. When the mail client loads images, it requests that URL from your server, and the request itself is the 'open' event. You log the timestamp and user agent, then return the image bytes. No JavaScript runs in email, so a loaded image is the only open signal available.

Is open tracking reliable?

No. Apple Mail Privacy Protection pre-fetches every pixel through a proxy, so it reports an open whether or not a human looked. Many clients block remote images until the reader clicks 'show images', so real opens go unrecorded. Treat open rate as a coarse trend, never an exact count, and base real decisions on clicks, which require deliberate action.

How do I track email clicks without a third-party service?

Rewrite every link in the email before sending so it points at a redirect endpoint on your own domain, carrying the recipient id, message id, and the encoded destination. When the recipient clicks, your endpoint logs the event to your own database and returns an HTTP 302 to the real URL. The subscriber's browser only ever talks to your domain and the destination, never an analytics vendor.

Does self-hosted email tracking respect subscriber privacy?

It can, far more than vendor tracking, because the event data stays in your own database and is never sold, enriched, or cross-referenced against other sites. But tracking is still tracking: you are recording when someone opened or clicked. Privacy-respecting practice means first-party storage, short retention, honoring opt-outs, and disclosing it in your privacy policy, not pretending no data is collected.

Why use Cloudflare Workers for email tracking instead of a normal server?

The tracking endpoints must be fast, globally close to the reader, and cheap per request, because a campaign can generate thousands of pixel and redirect hits in seconds. A Cloudflare Worker serves the pixel and the redirect from the edge, writes the event to D1, and costs fractions of a cent at volume, with no server to keep warm. It also keeps tracking on the same Cloudflare account that owns the rest of your email stack.