โ† All posts

42% of our bounce log was not about our mail

Open your MTA's delivery log and count the lines that look like failure. On our own sending host, over 24 hours, that count was wrong by a factor of two and a half — and it was wrong in the direction that makes a healthy system look broken.

Here is the sample: 720 records, one host, one day. 304 of them are rejections. 208 are receptions, 208 are deliveries, and every single one of those deliveries returned 250. Bounces: zero. Transient failures: zero. So 42% of the file is a record type that has nothing to do with whether our mail arrived, and a naive grep for failure counts every one of them against us.

This is a post about telling those apart. The numbers are ours and only ours — one host, 24 hours — but the record structure is standard, and the confusion is not specific to us.

The four record types

KumoMTA writes zstd-compressed JSONL: one JSON object per line, one file per rotation interval. Our parser's docstring names four record types — Reception, Delivery, TransientFailure, Bounce — and there is a fifth, Rejection, which turned out to be the most common thing in the file.

The distinction that matters is direction. Delivery, Bounce and TransientFailure describe mail we sent and what the receiving server said about it. Reception describes mail arriving at our server. Rejection describes a connection we refused, at the SMTP command stage, before any message existed.

That last one is the trap. A rejection is not a failed send. It is usually not even a message. It is somebody knocking on port 25 and being turned away.

Why a rejection is not a bounce

The clearest tell is in the record itself: on a rejection, sender, recipient and queue are empty strings. There is no message, so there is nobody it failed to reach. Three real examples from our log, redacted, follow. Every field you see is as logged except where it says redacted.

Someone trying an authentication method we do not offer

{
  "type": "Rejection",
  "sender": "",
  "recipient": "",
  "queue": "",
  "response": {
    "code": 504,
    "enhanced_code": {
      "class": 5,
      "subject": 5,
      "detail": 4
    },
    "content": "AUTH LOGIN not supported",
    "command": "AUTH LOGIN"
  },
  "peer_address": "<redacted>",
  "event_time": "2026-08-05T13:13:16.207108659Z",
  "num_attempts": 0,
  "bounce_classification": "Uncategorized"
}

44 of our 304 rejections looked like this. A client opened a session, asked for AUTH LOGIN, and was told 504 — we do not support that command. Nothing was sent. Nothing failed to arrive.

Someone guessing a password

{
  "type": "Rejection",
  "sender": "",
  "recipient": "",
  "queue": "",
  "response": {
    "code": 535,
    "enhanced_code": {
      "class": 5,
      "subject": 7,
      "detail": 8
    },
    "content": "AUTH invalid",
    "command": "<base64 AUTH payload - redacted>"
  },
  "peer_address": "<redacted>",
  "event_time": "2026-08-05T14:46:53.224081140Z",
  "num_attempts": 0,
  "bounce_classification": "Uncategorized"
}

This was the single largest category: 209 of 304, all code 535. Each one carries a base64 AUTH payload in the command field, which is a username and a password attempt. We have redacted ours and we would suggest you redact yours — more on that at the end.

Someone checking whether we will relay their spam

{
  "type": "Rejection",
  "sender": "<redacted>",
  "recipient": "",
  "queue": "",
  "response": {
    "code": 550,
    "enhanced_code": {
      "class": 5,
      "subject": 7,
      "detail": 1
    },
    "content": "relaying not permitted for <ip>:<port>",
    "command": "rcpt TO:<redacted>"
  },
  "peer_address": "<redacted>",
  "event_time": "2026-08-05T16:04:43.811378398Z",
  "num_attempts": 0,
  "bounce_classification": "Uncategorized"
}

An open-relay probe: connect, name a sender we have no relationship with, and try to hand us a recipient somewhere else entirely. 550, refused. The remaining rejections in our sample were syntax errors — 36 of them, including clients that opened with raw HTTP headers, which is a port scanner and not a mail server at all.

What a real outcome looks like

For contrast, here is a record that is about our mail, from the same file, minutes apart from the ones above.

{
  "type": "Delivery",
  "sender": "<redacted>",
  "recipient": "<redacted>",
  "queue": "<redacted>",
  "response": {
    "code": 250,
    "enhanced_code": {
      "class": 2,
      "subject": 0,
      "detail": 0
    },
    "content": "OK  <queue id - redacted> - gsmtp",
    "command": ".\r\n"
  },
  "peer_address": "<redacted>",
  "event_time": "2026-08-05T13:15:04.139418922Z",
  "num_attempts": 0,
  "bounce_classification": "Uncategorized",
  "site": "<redacted>",
  "size": 1309,
  "egress_pool": "unspecified",
  "egress_source": "unspecified",
  "delivery_protocol": "ESMTP",
  "reception_protocol": "ESMTP",
  "tls_protocol_version": "TLSv1_3",
  "tls_cipher": "TLS13_AES_256_GCM_SHA384",
  "tls_peer_subject_name": "<redacted>"
}

A message left, a receiving server took it, and it answered 250. Note the fields that are populated here and empty above: sender, recipient, queue, site. Note delivery_protocol, which only exists when there was a delivery to attempt. That is the shape of an outcome. Everything above is the shape of a refused handshake.

What this costs you in practice

Run the obvious command — count the lines that are not deliveries — and our 24 hours reads as 512 problems against 208 successes. A 71% failure rate. Escalate that and you will spend a day investigating a sending reputation that is fine.

The opposite error is worse and quieter. If you build a dashboard on that denominator, real bounces get diluted into the noise. Ours were zero that day, so nothing was hidden. On a day when they are not zero, a handful of genuine hard bounces inside a few hundred attack records is a signal you will not see moving.

How to count properly

The fix is a set and one guard. Our parser defines which record types are outbound outcomes:

OUTCOME_TYPES = {"Delivery", "Bounce", "TransientFailure"}

And drops everything else before any counting happens — the first thing the parse loop does, so a rejection can never reach a denominator:

for rec in iter_log_records(path):
    records_seen += 1
    if rec.get("type") not in OUTCOME_TYPES:
        continue

Reception is excluded for the same reason as Rejection: it is inbound. It is legitimate inbound, which makes it easier to mistake for something that belongs in a sending metric, but mail arriving at your server tells you nothing about mail leaving it.

Once that guard is in place, the rate is computed over attempts that actually happened: deliveries, bounces and deferrals, and nothing else.

One more thing worth knowing

Those 535 records are a disclosure risk, and it is easy to miss. The command field on a failed AUTH is base64, and it decodes to an email address and a password attempt — someone else's credentials, or their guess at someone else's. 209 of our 304 rejections carried one.

If you paste raw log lines into a ticket, a vendor thread, or a blog post, that is what you are pasting. The rejection reason and the SMTP code are the useful part; the payload is not. The same applies to the IP inside a relaying not permitted message, which sits in the reason text and not just in peer_address, so redacting one field is not enough.

Our 21 distinct source addresses over 24 hours are not interesting on their own. What they are is a permanent, low-grade background hum in every log you will ever read, and the first thing to subtract before you conclude anything about your own sending.

If you want to check the part that actually is about your mail — whether your domain authenticates, and whether receivers can tell your mail from someone using your name — our free domain audit covers SPF, DKIM and DMARC and tells you which line to change. If you are working through a specific failure, we have plain explanations of what an SPF PermError means and what email deliverability actually measures.