Skip to content

guides

Email deliverability monitoring and alerts before mail goes dark

May 2, 2026

#deliverability#monitoring#sending-reputation#alerting#email-infrastructure
Email deliverability monitoring and alerts before mail goes dark
Visual: Lin Mei-Ling on okslop

Your password-reset emails have been landing in spam for four days. You find out when a customer complains they cannot log in, not from any system you own. By then your sender reputation has been sliding for a week.

Email deliverability monitoring means tracking the leading indicators of sender reputation (bounce rate, complaint rate, blocklist presence, and authentication pass rate) and alerting the moment any of them moves the wrong way, so you act before delivery visibly fails. The defining property of a deliverability problem is that it is invisible until it is expensive: mail does not bounce loudly, it just quietly starts going to spam, and the only downstream signal is a slow drop in signups, replies, or revenue that you notice days too late. Monitoring closes that gap. It turns a silent multi-day slide into a webhook that fires within hours.

This guide builds the whole thing the way you would by hand: which metrics actually lead the problem, where the raw data comes from, how to roll it into queryable counters, and how to wire alerts that catch a slide before users feel it. It is honest about the catch, which is that all of this depends on owning your delivery events. At the end I will show how a Cloudflare-native stack gives you that data and the dashboard for free, but the logic applies no matter what you send through.

Which deliverability metrics actually warn you early?

Four metrics lead a deliverability problem: bounce rate, spam-complaint rate, blocklist presence, and authentication pass rate. They move before opens, clicks, and revenue do, which is the entire reason to track them. By the time your open rate visibly drops, the reputation damage that caused it happened days earlier and is reflected in these four numbers already.

  • Hard bounce rate. The share of sends that permanently fail (invalid address, no mail server). Keep it under 2%, ideally under 1%. A spike means a stale or purchased list, and mailbox providers read high bounce rates as a spammer signal. This is your fastest indicator that a specific list or campaign is bad.
  • Spam-complaint rate. The share of recipients who hit "report spam," learned through feedback loops and the one-click List-Unsubscribe-Post flow. This is the most important metric. Gmail and Yahoo's bulk-sender rules want you under 0.3% and recommend aiming for 0.1%. Complaints damage reputation more than bounces, and the damage hits valid recipients on the same stream.
  • Blocklist presence. Whether your sending IP or domain appears on a DNS-based blocklist (Spamhaus, Barracuda, SORBS, and others). A listing can silently kill delivery to entire providers at once, and you will see no bounce for it, just disappearing mail.
  • Authentication pass rate. The percentage of your mail passing SPF, DKIM, and DMARC at the receiver, read from DMARC aggregate reports. A drop here (a misconfigured new sending source, a rotated DKIM key) means receivers are increasingly distrusting your mail, and it precedes a reputation hit.

The trap with all four is monitoring only absolute thresholds. A complaint rate sitting at 0.05% is fine; a complaint rate that climbed from 0.02% to 0.05% to 0.12% over three days is a fire, even though every individual number is "under 0.3%." Track the trend, not just the line. For the wider set of numbers worth watching and the alert thresholds to attach to each, see the deliverability metrics worth watching; if you want to spot-check a domain by hand before wiring alerts, the free tools for testing deliverability and DNS cover the quick checks.

How do I track bounce and complaint rates over time?

Roll your raw delivery events into daily per-domain counters, then compute rates from the counters. You do not query millions of raw events every time you want a rate; you aggregate once per day into a small table and read from that. This is the same pattern any analytics pipeline uses, and it keeps the alerting job cheap.

Start with a daily-metrics table keyed by sending domain (or stream) and date:

CREATE TABLE deliverability_daily (
  domain        TEXT NOT NULL,
  day           TEXT NOT NULL,          -- 'YYYY-MM-DD'
  sent          INTEGER NOT NULL DEFAULT 0,
  delivered     INTEGER NOT NULL DEFAULT 0,
  hard_bounced  INTEGER NOT NULL DEFAULT 0,
  soft_bounced  INTEGER NOT NULL DEFAULT 0,
  complained    INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (domain, day)
);

Your sender already emits delivery events (sent, delivered, bounced, complained). Aggregate yesterday's events into the table with a single grouped insert rather than looping per event:

INSERT INTO deliverability_daily
  (domain, day, sent, delivered, hard_bounced, soft_bounced, complained)
SELECT
  domain,
  ?,                                                       -- the day being rolled up
  COUNT(*),
  SUM(type = 'delivered'),
  SUM(type = 'bounced' AND code LIKE '5%'),
  SUM(type = 'bounced' AND code LIKE '4%'),
  SUM(type = 'complained')
FROM delivery_events
WHERE day = ?
GROUP BY domain
ON CONFLICT(domain, day) DO UPDATE SET
  sent         = excluded.sent,
  delivered    = excluded.delivered,
  hard_bounced = excluded.hard_bounced,
  soft_bounced = excluded.soft_bounced,
  complained   = excluded.complained;

Now rates are a cheap read. Computing today's rates and a trailing 7-day baseline in one query gives you both the current value and the context to judge it:

-- Today's rates vs the prior 7-day baseline, per domain.
WITH today AS (
  SELECT domain, sent, hard_bounced, complained
  FROM deliverability_daily WHERE day = ?
),
baseline AS (
  SELECT domain,
         SUM(hard_bounced) * 1.0 / NULLIF(SUM(sent), 0) AS bounce_rate,
         SUM(complained)   * 1.0 / NULLIF(SUM(sent), 0) AS complaint_rate
  FROM deliverability_daily
  WHERE day >= ? AND day < ?                       -- the 7 days before today
  GROUP BY domain
)
SELECT
  t.domain,
  t.hard_bounced * 1.0 / NULLIF(t.sent, 0) AS bounce_today,
  t.complained   * 1.0 / NULLIF(t.sent, 0) AS complaint_today,
  b.bounce_rate    AS bounce_baseline,
  b.complaint_rate AS complaint_baseline
FROM today t
LEFT JOIN baseline b ON b.domain = t.domain;

The NULLIF(sent, 0) guard matters: a domain with zero sends on a given day would otherwise divide by zero. Now you have, per domain, today's bounce and complaint rates next to their trailing baseline, which is exactly the input an alert needs.

How do I check if my domain is on a blocklist?

Query DNS-based blocklists (DNSBLs) directly by doing a reversed-IP DNS lookup against each blocklist's zone. A DNSBL answers "is this IP listed" through the DNS system: you reverse the octets of your sending IP, append the blocklist's domain, and resolve it. An answer in the 127.0.0.0/8 range means you are listed; NXDOMAIN means you are clean.

For IP 203.0.113.5 against Spamhaus ZEN, you query 5.113.0.203.zen.spamhaus.org. A scheduled checker over your sending IPs looks like this:

// Check one IP against a set of DNS blocklists. Returns the lists it appears on.
async function checkBlocklists(ip, zones) {
  const reversed = ip.split(".").reverse().join(".");
  const hits = [];

  for (const zone of zones) {
    const name = `${reversed}.${zone}`;
    // DNS-over-HTTPS so it works from an edge runtime with no raw DNS socket.
    const res = await fetch(
      `https://cloudflare-dns.com/dns-query?name=${name}&type=A`,
      { headers: { accept: "application/dns-json" } }
    );
    const data = await res.json();
    // An A record in 127.x.x.x means "listed". NXDOMAIN (no Answer) means clean.
    if (data.Answer?.some((a) => a.data?.startsWith("127."))) {
      hits.push({ zone, codes: data.Answer.map((a) => a.data) });
    }
  }
  return hits;
}

// Example
const zones = ["zen.spamhaus.org", "b.barracudacentral.org"];
const listed = await checkBlocklists("203.0.113.5", zones);
if (listed.length) {
  // fire an alert: you are listed
}

Two practical notes. First, public DNSBLs rate-limit automated queries and some refuse queries from large shared resolvers, so run this a few times a day per IP, not per send, and cache the result. Second, a listing is the start of work, not the end: the process for getting your domain off a blocklist differs per operator and is its own task. Monitoring just makes sure you learn about the listing in hours instead of from a customer.

How do I monitor whether SPF, DKIM, and DMARC are passing?

Read your DMARC aggregate reports, which receivers send you daily, and compute the share of mail that passed authentication. DMARC reports are XML files mailed to the rua address in your DMARC record; each one summarizes, per sending source IP, how many messages a given receiver saw and whether they passed SPF and DKIM alignment. The pass rate across those reports is your authentication health.

A DMARC aggregate report contains records like this:

<record>
  <row>
    <source_ip>203.0.113.5</source_ip>
    <count>128</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>pass</dkim>
      <spf>pass</spf>
    </policy_evaluated>
  </row>
</record>

Sum count where both dkim and spf evaluate to pass, divide by total count, and you have a pass rate per source IP. A sudden appearance of a new source_ip you do not recognize, or a pass rate dropping below ~95% for a source you own, is the alert condition: it usually means a new sending service was added without auth, or a DKIM key was rotated and not republished. The full parsing walkthrough is in reading DMARC aggregate reports without a paid tool; for monitoring, you only need the aggregate pass rate and a watch for unfamiliar sources.

If you want a cheaper signal than full report parsing, you can also periodically re-resolve your own _dmarc, SPF, and DKIM TXT records and alert if they change unexpectedly or disappear, which catches the common "someone edited DNS and broke auth" failure directly.

How do I wire the alert so I find out in hours, not days?

Run a scheduled job that reads the rolled-up metrics, evaluates each against a threshold and against its baseline, and posts to a webhook on any breach. The alert logic is small; the value is that it runs on a timer without you watching a dashboard. On Cloudflare this is a Cron Trigger; anywhere else it is a cron job or scheduled function.

// Runs on a schedule (e.g. hourly). Reads metrics, fires on breach.
const THRESHOLDS = {
  bounce:    { absolute: 0.02, jumpFactor: 2.5 },   // 2%, or 2.5x baseline
  complaint: { absolute: 0.003, jumpFactor: 2.0 },  // 0.3%, or 2x baseline
};

async function runDeliverabilityAlerts(db, env) {
  const rows = await db.prepare(TODAY_VS_BASELINE_SQL).all(); // the query above
  const alerts = [];

  for (const r of rows.results) {
    check(alerts, r.domain, "bounce", r.bounce_today, r.bounce_baseline);
    check(alerts, r.domain, "complaint", r.complaint_today, r.complaint_baseline);
  }

  if (alerts.length) {
    await fetch(env.ALERT_WEBHOOK_URL, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ text: formatAlerts(alerts) }),
    });
  }
}

function check(alerts, domain, metric, value, baseline) {
  const t = THRESHOLDS[metric];
  const overAbsolute = value >= t.absolute;
  const overJump = baseline > 0 && value >= baseline * t.jumpFactor;
  if (overAbsolute || overJump) {
    alerts.push({
      domain, metric,
      value: (value * 100).toFixed(2) + "%",
      reason: overAbsolute ? "over threshold" : "spiked vs baseline",
    });
  }
}

The two-condition check is the part that earns its keep. overAbsolute catches the obvious "you crossed the line" case. overJump catches the dangerous case the line misses: a complaint rate climbing fast while still numerically small. A rate moving from 0.02% to 0.06% trips the jumpFactor long before it nears the 0.3% absolute limit, which buys you days of lead time. Send the alert somewhere a human sees it within the hour (Slack, PagerDuty, an on-call email) and the silent multi-day slide becomes a same-day fix.

One discipline: suppress duplicate alerts. If a domain is over threshold today and still over tomorrow, you want one alert and a state change, not the same page every hour. Store the last-alerted state per domain-and-metric and only fire on transitions.

Why is this so hard to do with a SaaS sender?

Because monitoring is only as good as your access to the raw delivery events, and a SaaS sender shows you the subset they choose to expose, retained for the window they choose. You can read a provider dashboard and consume their event webhooks (usually bounces, complaints, opens), and you can run blocklist and DMARC checks independently because those use public DNS and your own DNS records. But you cannot query the event stream as data you own, correlate it across streams freely, retain it indefinitely, or compute a metric the vendor did not anticipate.

This is the structural reason deliverability "monitoring" sold as a feature is usually a read-only dashboard: the data lives on the vendor's side, and you rent a view of it. When you want rate-of-change alerting across your own thresholds, joined against your own campaign and suppression data, you are limited to what their API returns. The alternative is to own the events in the first place, so that monitoring is just queries over a table you control.

Or own the data: deliverability monitoring on your own stack

Everything above is real work, and most of it is not the queries, it is getting the delivery events into a database you can query at all. Parse webhook events, roll daily counters, poll blocklists, ingest DMARC XML, evaluate thresholds against baselines, deduplicate alerts. The logic is straightforward once the data is local; the friction is always the data.

Tinbox is a self-contained email stack you deploy onto your own Cloudflare account: D1 for storage, R2 for attachments, Queues for sending. Because every send routes through the HTTPS path and Queues, delivery events (sent, delivered, bounced, complained) land as structured rows in your own D1, the same shape as the delivery_events and deliverability_daily tables above. The deliverability dashboard reads those rows directly, bounce and complaint rates, authentication pass rate, and blocklist status sit in your own database, and a scheduled job evaluates them and alerts on breach. You are not renting a view of a vendor's metrics; you are querying your own, and you can write any threshold or correlation you want because it is your data and your SQL.

Two honest caveats. Monitoring tells you reputation is dropping; it does not repair it. When an alert fires you still do the work: clean the list, pause the bad campaign, fix the auth record, request delisting. And public blocklists and DMARC feedback are external signals with their own latency and rate limits, so monitoring shrinks the detection gap to hours but never to zero. The discipline still applies: keep streams separated, watch your rates, and send to engaged recipients. See handling email bounces and suppression the right way to keep bounce rate down at the source, why you separate transactional and marketing sending so one bad campaign cannot poison your receipts, and the email deliverability guide for the fundamentals these metrics are measuring.

Deploy this yourself: npx tinbox init example.com. The deploy wires the delivery-event pipeline and the deliverability dashboard for you, so bounce, complaint, and authentication rates are recorded from your first send and you can alert on them from data you own. See the docs for the deliverability dashboard and feedback-loop setup.

Frequently asked questions

What email deliverability metrics should I monitor?

Monitor four leading indicators per sending domain or stream: hard bounce rate (keep under 2%, ideally under 1%), spam-complaint rate (keep under 0.1%, hard-fail at 0.3% per Gmail/Yahoo bulk-sender rules), blocklist presence (is your domain or IP listed on Spamhaus, Barracuda, or similar), and authentication pass rate (the percentage of mail passing SPF, DKIM, and DMARC at the receiver). These move before opens and clicks do, so they give you the earliest warning that delivery is degrading.

How do I get alerted when my email reputation drops?

Roll your delivery events into daily per-domain counters, compute bounce and complaint rates, and run a scheduled job (a cron trigger) that compares today's rates against your thresholds and against a trailing baseline. When a metric crosses a threshold or jumps sharply versus the baseline, post to a webhook (Slack, PagerDuty, email) with the domain, metric, and value. Alert on rate-of-change as well as absolute level, because a complaint rate climbing from 0.02% to 0.15% is an emergency even though both numbers are 'low'.

How do I know if my sending domain is on a blocklist?

Query the major DNS-based blocklists (DNSBLs) directly. Reverse your sending IP, append the blocklist's zone (for example zen.spamhaus.org), and do a DNS A lookup; a returned 127.0.0.x address means you are listed. Run this on a schedule for every sending IP and domain, and alert on any hit. Public DNSBLs are free to query but rate-limit heavy automated use, so cache results and check a few times a day rather than per-send.

Can I monitor deliverability if I send through a SaaS like SendGrid or Mailchimp?

Partially. You can monitor what the provider chooses to expose through their dashboard, API, or event webhooks (usually bounces, complaints, and opens), and you can run your own blocklist and DMARC checks independently. What you cannot do is query the raw event stream as data you own, correlate it freely, or retain it past the provider's window. When the sender is your own infrastructure, every delivery event is a row in your database and monitoring is just SQL over data you already hold.

What complaint rate gets my email blocked?

Gmail and Yahoo's bulk-sender requirements tell senders to stay under a 0.3% spam-complaint rate and warn that 0.1% is the target you should actually aim for. Sustained complaint rates above 0.3% lead to throttling, spam-foldering, or outright blocking, and it affects valid recipients on the same stream, not just the complainers. This is why complaint rate is the single most important metric to alert on, with a tight threshold and rate-of-change detection.

How often should deliverability monitoring run?

Run rate calculations and threshold checks at least daily; hourly is better for high-volume senders where a bad campaign can spike complaints fast. Blocklist lookups a few times a day are enough. DMARC aggregate reports arrive roughly daily from each receiver, so process them as they land. The goal is for the gap between a problem starting and you being alerted to be hours, not the days it takes for downstream signups or revenue to visibly dip.