Skip to content

Open rates after Apple Mail Privacy Protection

MPP pre-fetches every image, so a raw open rate measures nothing. How the privacy-adjusted rate is computed, which classification rules actually fire, and which are inert today.

DeliverabilityAdvanced12 min readUpdated

01What MPP did to the open rate

Apple Mail Privacy Protection routes message content through Apple's proxies and pre-fetches every remote image, for every message, whether or not the recipient ever looks at it. An open pixel is a remote image. So the pixel fires on delivery, from an Apple address, for a person who may never open the mail at all.

TL;DR

MPP did not make the open rate noisier — it changed what the number is, from weak evidence that somebody rendered your message into a measure of Apple’s cache.

This did not degrade the open rate — it changed what the number is. Before MPP, an open event was weak evidence that somebody rendered your message: undercounted by images-off clients, but the events you did get mostly corresponded to a human. After MPP, a large share of your open events correspond to a cache being warmed. The metric did not get noisier; it started measuring a different thing, and the share of your list that thing applies to is set by Apple's adoption among your recipients, not by anything you do.

MPP is the largest of these effects but not the only one. Corporate security appliances fetch every image and follow every link in a message before the recipient sees it — which produces opens and clicks, for mail nobody has read yet. Gmail proxies and caches images through its own infrastructure. The design response is the same for all of them: classify the hit at the edge, store everything, and default the headline charts to the one class that plausibly means a person.

02Classifying a hit instead of counting it

Every open and click is stored with an AudienceClass. Nothing is discarded — somebody investigating one specific delivery needs to see the scanner hit too — but only human is in COUNTS_AS_ENGAGEMENT, so only human reaches a headline rate.

TL;DR

Five classes, everything stored, and exactly one of them — human — counted toward a headline rate.

ClassCounts?What it means
humanYesNo bot signal matched. The weakest possible claim, stated honestly: this is what is left after every rule that could rule a person out has declined to.
mppNoApple Mail Privacy Protection. The message reached a privacy-protected mailbox; whether anyone read it is unknowable, in both directions.
proxy_prefetchNoAn image proxy warming its cache, close enough to delivery that no human was involved. It restates the delivery event and adds nothing.
scannerNoA security appliance walking the message — Proofpoint, Mimecast, SafeLinks, Defender and the rest. Usually the most damaging class to count, because it produces clicks too.
botNoCrawlers, monitoring, HTTP libraries, headless browsers, and anything arriving with no user agent at all.

COUNTS_AS_ENGAGEMENT holds one entry. Everything else is recorded with the reason string that produced it and left out of the headline.

Why proxy_prefetch is excluded rather than counted. It is tempting to treat a proxy fetch as an open, since something did request the image. But the fact it establishes — the message arrived — is the one the delivery event already gave you. Counting it would double-count delivery and file the duplicate under engagement, which is the specific way an engagement metric becomes a deliverability metric wearing a hat.

Why mpp is its own class and not just “bot”. Behind an MPP hit there is a real subscriber with a real mailbox, and the message did reach them. Everything about the delivery is fine; only the observation is destroyed. Filing that under bot would imply something bad about the address, and the adjusted rate in the next section depends on being able to count MPP recipients separately in order to remove them cleanly.

EVERY CLASS IS STORED
The classification narrows what is charted, not what is recorded. Each hit keeps its class and the reason string that produced it, so a specific delivery can always be inspected — which is also how you would notice a rule misfiring on your traffic.

03The privacy-adjusted open rate

There are three arithmetics available for MPP opens and two of them are wrong in opposite directions. Count them as opens and the rate inflates. Count them as non-opens and it deflates, because those recipients are in the denominator being treated as people who did not open. The third option is to remove them from both sides and say so.

TL;DR

MPP opens come out of the numerator and the denominator, and when that leaves nothing the function returns null with a sentence rather than a number.

PRIVACY-ADJUSTED OPEN RATE

Runs privacyAdjustedOpenRate from @mailysend/events, the function behind the number in your dashboard.

Privacy-adjusted open rate: 26.5%
16,000 privacy-protected opens excluded from both sides of the ratio.
IF YOU COUNT MPP AS OPENS
50.0%
Inflated: it counts Apple's cache as readers.
IF YOU COUNT THEM AS NON-OPENS
18.0%
Deflated: those recipients may well have read it.

The function is four lines of arithmetic and one refusal:

privacyAdjustedOpenRate({ delivered, humanOpens, mppOpens })

  denominator = delivered - mppOpens

  // nothing observable is left to compute a rate from
  if (denominator <= 0) → { rate: null, excluded: mppOpens, note: "…" }

  rate     = humanOpens / denominator
  excluded = mppOpens
  note     = "N privacy-protected opens excluded from both sides"

The null matters as much as the division. When every delivery in the window went to a privacy-protected mailbox, the denominator is zero or negative and there is no honest number to show — so the function returns no rate and a sentence explaining why, rather than a zero that reads as catastrophic performance or a silently-clamped percentage that reads as fine. A rate of null is a different claim from a rate of 0, and the type says so.

The excluded count travels with the rate for the same reason. A 22% open rate with 400 excluded and a 22% open rate with 40,000 excluded are not comparable figures, and the second one deserves much less of your confidence. If you export this number into a report, export the exclusion count next to it.

04Which rules fire in production today

Here is the part a vendor would leave out. The classifier contains several rules that are better than user-agent matching, and in production today they never fire — because the tracking endpoint does not pass them the inputs they need. The heuristics are good; the wiring is incomplete. Telling you which is which is the point of this page.

TL;DR

Classification in production today is user-agent matching plus a HEAD check. Every rule that reads timing or network context is dead code on live traffic.

apps/app/src/server/tracking.ts calls classifyHit with exactly four things: the user agent, the connecting IP, the HTTP method, and (on the open path) the country header. It never populates msSinceDelivery, recentLinkHits, cfAsn or cfVerifiedBot. Every rule that reads one of those fields therefore evaluates against an absent value and falls through.

const hit = classifyHit({
  userAgent: request.headers.get('user-agent') ?? '',
  ip:        request.headers.get('cf-connecting-ip') ?? '',
  method:    request.method,
  // …country, on the open path
})

// not passed: msSinceDelivery · recentLinkHits · cfAsn · cfVerifiedBot
Inert today
  • Three links in two seconds → scanner
  • Gmail image proxy within 2s of delivery → proxy_prefetch
  • Apple Private Relay network → mpp
  • Google network within 2s of delivery → proxy_prefetch
  • Cloudflare verified bot → bot
Live today
  • HEAD request → scanner
  • Security-vendor user agent → scanner
  • Apple MPP user agents → mpp
  • Bot user agents → bot
  • Empty user agent → bot
  • Everything else → human
Inert ruleInput it needsWhat happens instead
Three or more links hit within two seconds → scannerrecentLinkHitsA link checker walking the message is indistinguishable from a reader, so a scanner presenting a browser-like user agent is classified human.
Gmail image proxy within 2s of delivery → proxy_prefetchmsSinceDeliveryThe comparison runs against positive infinity, so every GoogleImageProxy hit takes the delayed branch and is classified human — “likely a real open”.
Apple Private Relay egress (ASNs 714, 6185, 2709) → mppcfAsnApple traffic is caught only when its user agent matches. Anything from those networks with an unfamiliar user agent is not.
Google egress (ASNs 15169, 396982) within 2s → proxy_prefetchcfAsn and msSinceDeliveryBoth routes into proxy_prefetch depend on timing data that is not there, which is why that class is almost certainly an under-count.
Cloudflare verified bot → botcfVerifiedBotA well-behaved crawler is caught only if its user agent happens to match the bot pattern list.

These rules exist and are tested. They never trigger on real traffic, because tracking.ts does not populate the field each one reads.

Live ruleWhat it matches
HEAD request → scannerNo mail client fetches an image or follows a link with HEAD. Method is passed, so this fires.
Security-vendor user agents → scannerBarracuda, Proofpoint, Mimecast, Symantec, Forcepoint, MessageLabs, TrendMicro, FireEye, Cisco, IronPort, Sophos, McAfee, Fortinet, Zscaler, urlscan, VirusTotal, SafeLinks, ATP, Defender.
Apple MPP user agents → mppThe Apple Mail, iPhone Mail and MacOutlook preview patterns, plus the exact Private Relay user-agent string. This is what makes the adjusted rate work at all.
Bot user agents → botbot, crawler, spider, slurp, curl/, wget, python-requests, okhttp, axios, Go-http-client, Java/, libwww, HeadlessChrome, PhantomJS, Playwright, Puppeteer, monitoring, uptime, pingdom, newrelic.
Empty user agent → botA request with no user agent at all is not a mail client, and this is the last check before human.
Everything else → humanThe default, reached by falling through every rule above. Read it as “nothing ruled this out”, not as “a person did this”.

What this means for your numbers, concretely. Classification today is user-agent matching plus a HEAD check. That catches the honest actors — Apple identifies itself, security vendors identify themselves, most crawlers identify themselves — and misses anything that presents a plausible browser string from an interesting network. So human is an over-count, by an amount nobody here can quantify for your traffic, and proxy_prefetch is almost certainly an under-count, because both routes into it depend on timing data that is not there.

05What to measure instead of opens

Opens were always the weakest signal in the stack. MPP made that impossible to ignore, which is a favour: the metrics that survive intact are the ones that were more informative anyway, because each of them requires a decision by a person.

TL;DR

Clicks, replies and downstream conversion survive MPP intact, because each of them takes a decision by a person that no proxy can manufacture.

MetricWhat it provesWhy it survives MPP
ClicksSomebody chose to go somewhere.MPP does not follow links — it pre-fetches images. The nearest thing to an intact engagement signal, with one caveat worth taking seriously: security scanners do follow links, which is why scanner classification matters more for clicks than for opens.
RepliesSomebody wrote back.The highest-quality signal available and the one no proxy can manufacture. It is also read by receivers as a strong positive on your reputation, which makes it worth optimising for its own sake as well as for what it tells you.
Downstream conversionSomebody did the thing.A signup, a purchase, a login, a renewal. It sits outside the mail system entirely, so nothing in the mail system can fake it — and it is the only one of the three denominated in the outcome you actually wanted.

The practical consequence is in your segments. A re-engagement or sunset rule built on opens will now retire people who read every message on an iPhone and keep people whose corporate scanner has been opening their mail for a year. Build those rules on clicks instead — and where you have it, on conversion. The segments query language has click_count and last_click_at as first-class columns, plus clicked_last_30d and never_clicked sugar, so the honest version of the rule is about as short as the dishonest one.

KEEP TRACKING OPENS ANYWAY
An open is still a useful per-message diagnostic — it is evidence that a message rendered somewhere, which is exactly what you want when investigating one delivery that a customer says never arrived. The classification is stored alongside it, so you can see whether that render was a person, Apple, or a scanner. Keep collecting it; just stop making decisions with the aggregate.

What just happened

You know why a raw open rate stopped measuring anything, how the privacy-adjusted rate is computed, and — the part most pages skip — exactly which classification rules run in production today and which are inert because the tracking endpoint does not pass their inputs. The consequence to keep hold of: user-agent matching is doing all the work right now, so treat the classes as a good filter rather than a precise census, and build your segments on clicks and replies, which MPP does not touch.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.