Skip to content

guides

First-party email analytics without sending data to a vendor

April 23, 2026

#email-analytics#privacy#first-party-tracking#cloudflare#data-ownership
First-party email analytics without sending data to a vendor
Visual: Moss Cathedral on okslop

You want to know whether your newsletter is landing: who opened it, what they clicked, which campaign performed. Every off-the-shelf answer routes that data through a tracker on someone else's domain, which means a third party now knows your subscribers' reading habits before you do.

Open and click analytics are the one part of email almost everyone outsources without thinking. You drop in a sending SaaS, it rewrites your links and injects a pixel pointing at its domain, and you get a dashboard. The price is that your subscriber engagement data, arguably the most sensitive thing you hold about your list, now lives with a vendor, gets mingled into their analytics subprocessors, and shows up as another line in your privacy policy you have to disclose. For a privacy-conscious sender, that is exactly backwards: the people whose behavior you are measuring are your relationship, and the measurement should stay on your side of the wire.

The good news is that email analytics is not hard technology. It is a tracking pixel, a redirect, and a table. This guide builds the whole thing from scratch on infrastructure you own, including the parts vendors don't advertise (open tracking is mostly broken, and here's why), and then shows where owning the stack removes the last bit of glue work.

What does first-party email analytics actually mean?

First-party email analytics means the tracking endpoints and the events database both live on infrastructure you control, so no third party sees your subscribers' opens and clicks. Concretely: the open pixel's src is a URL on your sending domain, the links in your email point to a redirect on your domain, and both write to a database in your account. The recipient's mail client talks only to you. There is no analytics SaaS, no tracking-vendor subprocessor, no shared pixel domain that correlates your subscribers with someone else's.

The contrast is the default arrangement. A typical sending provider rewrites https://yourblog.com/post into https://click.sendingvendor.com/abc123, and injects <img src="https://open.sendingvendor.com/xyz.gif">. Every open and click flows through the vendor first. That is convenient, and the dashboard is nice, but it means the vendor (and their own subprocessors) hold a behavioral profile of your audience. First-party analytics produces the same numbers from the same mechanisms; the only thing that changes is who the request goes to.

Three components do all the work:

  1. The open pixel: a 1x1 transparent image endpoint that logs a load and returns the image bytes.
  2. The click redirect: an endpoint that logs a click and 302-redirects to the real URL.
  3. The events store and rollup: a table of raw events you aggregate into open rate, click rate, and per-link breakdowns.

Build those three on your own domain and store and you have full analytics with nobody else in the loop. Let's build each by hand.

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

Embed a 1x1 transparent image whose src points to an endpoint on your own domain, carrying a unique per-send token; when the mail client loads it, your endpoint records the open and returns the pixel. The token is what ties the load back to a specific recipient and campaign, so generate one per (campaign, recipient) pair and never reuse it.

Here is the pixel endpoint as a Cloudflare Worker writing to a D1 database. It is complete: it decodes the token, records the event, and always returns valid GIF bytes so the client never shows a broken image.

// A 1x1 transparent GIF, 43 bytes, base64-encoded.
const PIXEL = Uint8Array.from(
  atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
  (c) => c.charCodeAt(0),
);

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    // /o/<token>.gif  ->  token identifies (campaign, recipient)
    const token = url.pathname.replace(/^\/o\//, "").replace(/\.gif$/, "");

    if (token) {
      // Fire-and-forget the write so the pixel returns instantly.
      env.DB.prepare(
        `INSERT INTO email_events (token, type, ua, ts)
         VALUES (?, 'open', ?, ?)`,
      )
        .bind(token, req.headers.get("user-agent") ?? "", Date.now())
        .run()
        .catch(() => {}); // a failed log must never break image delivery
    }

    return new Response(PIXEL, {
      headers: {
        "content-type": "image/gif",
        // Defeat caching so re-opens are re-counted, not served from cache.
        "cache-control": "no-store, no-cache, must-revalidate, private",
        "expires": "0",
      },
    });
  },
};

In the email HTML, the pixel is one tag, placed near the end of the body:

<img src="https://mail.yourdomain.com/o/cmp_42.rcpt_8a3f.gif"
     width="1" height="1" alt="" style="display:none">

That is genuinely all the open tracking is. But before you wire a dashboard to it, you need to know that this number is the least trustworthy metric in email, and the reason has nothing to do with your code.

Why your open rate is mostly fiction

Open tracking depends on the mail client loading a remote image, and that assumption fails badly in both directions. Many clients (and many privacy-minded users) block remote images by default, so a real human reads your email and registers zero opens. Worse, since 2021 Apple Mail Privacy Protection pre-fetches remote images through Apple's proxy the moment mail is delivered, for every Apple Mail user, whether or not a human ever opens the message. Apple Mail is a huge share of consumer email, so a large slice of your "opens" are bots loading the pixel on arrival.

The combined effect: your open rate is simultaneously undercounted (image blockers) and overcounted (Apple pre-fetch), and you cannot cleanly separate the two. A few practical consequences:

  • Don't gate logic on opens. "Re-send to everyone who didn't open" will re-spam people who read it with images off and skip Apple users who never actually opened.
  • Watch trends, not absolutes. A 40% open rate is not a fact; a drop from your own baseline is still a useful signal that something changed.
  • Strip the proxy noise if you care. Apple's pre-fetch comes from identifiable user agents and IP ranges, so you can flag and exclude obvious machine opens, but you will never get it perfectly clean.

This is not a reason to skip open tracking. It is a reason to trust clicks far more, which is the next piece.

How do I track clicks on my own domain?

Rewrite every link in the email to point at a redirect endpoint on your domain that logs the click and then issues a 302 to the real destination. A click is a deliberate human action that the mail client cannot fake or pre-fetch the way it pre-fetches images, so click rate is the metric you actually report on.

The redirect endpoint takes a token (identifying the campaign and recipient) and the encoded destination URL, records the event, and forwards the browser:

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname.startsWith("/c/")) {
      const token = url.pathname.replace(/^\/c\//, "");
      const dest = url.searchParams.get("u"); // the real, encoded URL

      if (!dest) return new Response("missing destination", { status: 400 });

      // Validate the destination before redirecting to avoid an open redirect.
      let target: URL;
      try {
        target = new URL(dest);
      } catch {
        return new Response("bad destination", { status: 400 });
      }
      if (target.protocol !== "https:" && target.protocol !== "http:") {
        return new Response("bad scheme", { status: 400 });
      }

      env.DB.prepare(
        `INSERT INTO email_events (token, type, url, ua, ts)
         VALUES (?, 'click', ?, ?, ?)`,
      )
        .bind(token, target.toString(), req.headers.get("user-agent") ?? "", Date.now())
        .run()
        .catch(() => {});

      return Response.redirect(target.toString(), 302);
    }
    return new Response("not found", { status: 404 });
  },
};

The link-rewriting step runs at send time, transforming each <a href> in the template into a wrapped URL:

function wrapLinks(html: string, token: string, base: string): string {
  return html.replace(
    /href="(https?:\/\/[^"]+)"/g,
    (_m, dest) =>
      `href="${base}/c/${encodeURIComponent(token)}?u=${encodeURIComponent(dest)}"`,
  );
}

// "https://list.example.com/click" rewrites
// href="https://yourblog.com/post" into
// href="https://list.example.com/click/c/cmp_42.rcpt_8a3f?u=https%3A%2F%2Fyourblog.com%2Fpost"

Two failure modes worth flagging, because they bite people who roll this themselves:

  • Open redirects. If you forward to any URL without validation, your domain becomes a phishing hop and lands on blocklists. The scheme check and new URL() parse above are the minimum; in production also consider signing the destination so it can't be tampered with.
  • Naive regex rewriting. The simple href="..." replace works for clean templates but misses links in unusual quoting or already-encoded HTML. For real campaigns, rewrite from a parsed DOM, not a regex, or you'll silently mangle some links.

With clicks landing in the same email_events table as opens, you have the raw data. Now you aggregate it.

How do I turn raw events into open and click rates?

Aggregate the raw events table by campaign and event type, then divide by the number of recipients you sent to. The events table holds one row per open and per click; the rates are just grouped counts, with one important detail: count distinct recipients, not raw events, or one obsessive re-opener will skew your numbers.

A minimal schema and the rollup queries:

CREATE TABLE email_events (
  id      INTEGER PRIMARY KEY,
  token   TEXT    NOT NULL,   -- "cmp_42.rcpt_8a3f"
  type    TEXT    NOT NULL,   -- 'open' | 'click'
  url     TEXT,               -- destination, for clicks
  ua      TEXT,
  ts      INTEGER NOT NULL
);

CREATE INDEX email_events_token_idx ON email_events (token);
CREATE INDEX email_events_type_ts_idx ON email_events (type, ts);

The token encodes campaign and recipient, so split it in SQL (or store campaign and recipient as separate columns, which is cleaner). Counting distinct recipients per campaign:

-- Unique openers and clickers per campaign.
-- token = "<campaign>.<recipient>", so substr before the dot is the campaign.
SELECT
  substr(token, 1, instr(token, '.') - 1)                         AS campaign,
  COUNT(DISTINCT CASE WHEN type = 'open'  THEN token END)         AS unique_opens,
  COUNT(DISTINCT CASE WHEN type = 'click' THEN token END)         AS unique_clicks
FROM email_events
GROUP BY campaign;

To get rates, join against how many you sent (which lives in your campaigns table, not the events table, because a recipient who never opens generates no event):

SELECT
  c.id                                  AS campaign,
  c.sent_count,
  e.unique_opens,
  e.unique_clicks,
  ROUND(100.0 * e.unique_opens  / c.sent_count, 1) AS open_rate_pct,
  ROUND(100.0 * e.unique_clicks / c.sent_count, 1) AS click_rate_pct
FROM campaigns c
LEFT JOIN (
  SELECT
    substr(token, 1, instr(token, '.') - 1) AS campaign,
    COUNT(DISTINCT CASE WHEN type = 'open'  THEN token END) AS unique_opens,
    COUNT(DISTINCT CASE WHEN type = 'click' THEN token END) AS unique_clicks
  FROM email_events
  GROUP BY campaign
) e ON e.campaign = c.id;

A per-link breakdown (which call-to-action actually got clicks) falls out of the same table:

SELECT url, COUNT(DISTINCT token) AS unique_clickers
FROM email_events
WHERE type = 'click'
  AND token LIKE 'cmp_42.%'
GROUP BY url
ORDER BY unique_clickers DESC;

That is a full analytics dashboard's worth of numbers, computed entirely from your own data, with no vendor having seen a single open or click. The remaining work is the unglamorous operational layer: token generation at send time, cache-busting the pixel, validating redirect destinations, excluding Apple's pre-fetch noise, retaining events under your retention policy, and respecting unsubscribe. None of it is hard, but all of it is yours to maintain, and that maintenance is the real reason people reach for a SaaS in the first place.

Or skip the glue: own the analytics without building the plumbing

If you want first-party analytics but not the chore of hand-maintaining the pixel endpoint, the redirect, the token scheme, and the rollups, the alternative is a stack that ships them as part of your own deployment. That is the gap Tinbox fills: a tenant-deployed email system that runs on your Cloudflare account, so the open pixel and click redirect serve from your sending domain and the events land in your own D1 database, with no analytics vendor anywhere in the path.

You deploy it with one command:

npx tinbox init yourdomain.com
✔ Provisioned D1 database (tinbox-yourdomain-db)
✔ Provisioned R2 bucket (tinbox-yourdomain-mail)
✔ Wrote wrangler.jsonc (assets + queue consumer + cron + email handler)

Next: add the printed DNS records (MX, SPF, DKIM, DMARC), then `npx tinbox deploy`.

The send pipeline does the token generation, pixel injection, and link wrapping for you; the events table and the open/click rollups are the same shape as the ones above, queryable from the dashboard or directly with SQL because it is your database. The difference from a SaaS dashboard is not the numbers, it is the ownership: the engagement data about your subscribers never leaves your account, so it adds no subprocessor to your privacy policy and stays in whatever region you pinned your D1 to.

Two honest caveats, because overstating analytics is its own kind of dishonesty. First, Tinbox does not fix open-rate unreliability, because nobody can: Apple Mail Privacy Protection still pre-fetches the pixel, image blockers still suppress real opens, and click rate is still the metric you trust. Owning the data lets you filter the obvious bot opens, not invent accurate ones. Second, a deploy is scoped to one brand and one domain today; you run multiple senders by doing one deploy per domain on the same Cloudflare account. For a single newsletter on a single domain, that is exactly the setup, and the analytics are wholly yours.

If privacy is the driving reason you're doing this, the same first-party principle extends to the whole sending program: see running a newsletter that doesn't surveil readers for the consent and analytics posture, and self-hosted open and click tracking that respects privacy for the deeper tracking build. For the round-up versions, the ways to track opens and clicks without a third party and getting analytics without creepy tracking pixels cover the same ground as a checklist. The reason any of this matters in the first place comes back to ownership, which who actually owns your email data unpacks: if a vendor holds your subscribers' behavior, it isn't really your relationship. And if you want these events to drive downstream automation rather than just a chart, emitting webhook events from your email stack shows how delivered, opened, and clicked events become a stream your app can consume.

Deploy this yourself: npx tinbox init yourdomain.com. The pixel endpoint, click redirect, and the events rollups above are exactly what the generated stack runs; the docs cover the token scheme and the Apple-pre-fetch filtering next.

Frequently asked questions

What is first-party email analytics?

First-party email analytics is open and click measurement where the tracking endpoints (the open pixel and the click redirect) run on a domain you own and the resulting events are stored in a database you control, with no third-party analytics vendor in the request path. It contrasts with the typical setup where a SaaS sender or tracking provider hosts the pixel and redirect on their domain, sees every open and click, and gives you a dashboard built from data they hold. First-party means the same metrics, but the engagement data about your subscribers never leaves your infrastructure.

Is email open tracking accurate?

No, open tracking is the least reliable email metric and you should treat it as a directional signal, not a hard number. Opens are measured with a tiny tracking image, and that breaks in two directions: many clients block remote images by default so real opens go uncounted, and Apple Mail Privacy Protection pre-fetches the pixel for every message whether or not a human opened it, which inflates opens. Click tracking is far more trustworthy because a click is a deliberate human action that the recipient's client cannot fake. Build your reporting around click rate and use open rate only for rough trends.

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

Embed a 1x1 transparent image in the email whose src points to an endpoint on your own domain, with a unique per-recipient token in the URL. When a mail client loads that image, your endpoint records an open event (token, timestamp, user agent) in your own database and returns the transparent pixel bytes. Because the endpoint is on your domain and the event lands in your store, no external tracker is involved. The same idea applies to clicks: rewrite each link to point at a redirect endpoint on your domain that logs the click and then 302s to the real destination.

Does first-party email tracking still need consent?

Yes. Who hosts the tracker does not change your obligations to the recipient. Under GDPR and the ePrivacy rules, tracking opens and clicks processes personal data and often requires consent or a clear lawful basis, and you must honor unsubscribe regardless. First-party hosting improves your data-ownership and residency posture (the data stays in your region, with no extra subprocessor), but it does not exempt you from disclosing tracking in your privacy policy or from suppressing recipients who opt out.

Why does Apple Mail Privacy Protection break open rates?

Apple Mail Privacy Protection (MPP), on by default for Apple Mail since 2021, routes image loads through Apple's proxy and pre-fetches remote content (including your tracking pixel) when mail arrives, before any human reads it. The result is that a large share of your audience registers an open the moment the message is delivered, whether or not they actually looked at it. This inflates open rate and makes open-based segmentation unreliable. It does not affect click tracking, because Apple does not pre-click your links, which is the main reason to anchor reporting on clicks.