For mail server administrators ยท Section A4

Rewrite Microsoft's bounce messages before your users ever see them

When mail to a Microsoft-hosted address bounces, the failure notice that lands back in a mailbox on your server usually isn't worded by Microsoft at all โ€” it's your own mail server's default bounce template, quoting a fragment of Microsoft's rejection text. This covers detecting that pattern and replacing the wording with your own plain-language explanation โ€” for every mailbox you're responsible for.

This is a different direction from the rest of Section A. Those pages cover mail arriving from a Microsoft-hosted sender. This one covers what happens after you (or one of your users) sends mail to a Microsoft-hosted address and it bounces: a delivery-failure notice โ€” formally a Delivery Status Notification, or DSN โ€” lands back as an ordinary email in whichever mailbox on your server sent the original message. Because it's just another inbound message, you can detect it and change what your users see when they open it. Worth getting straight before any of that, though: who actually wrote it. Usually, it isn't Microsoft.

Who generates this notice: almost always, it's not Microsoft. Microsoft's mail servers typically reject a blocked message outright, synchronously, mid-conversation, at RCPT TO or DATA with a 4xx/5xx response โ€” they never accept it in the first place. (This is the same behaviour Section A2's live notice-test depends on: it can only tell you whether a notice would get through by attempting real delivery while the sender's connection is still open, which only works because the accept/reject decision happens synchronously, in that same conversation.) When that's what happens, your own mail server โ€” Postfix, Exim, Sendmail, whatever you run โ€” is the one that builds the DSN and delivers it to your user, not Microsoft. Microsoft's contribution is just the terse SMTP response text and status code from that one rejected conversation; your MTA quotes it inside a Diagnostic-Code field, and usually inside its own boilerplate human-readable wording too. What your user sees is mostly your own server's default bounce template, with a fragment of Microsoft's text embedded in it โ€” not something Microsoft composed and sent. Everything below targets that case. Microsoft's own infrastructure can generate and send a DSN itself instead โ€” if it accepted the message and only failed to deliver it afterwards, a full mailbox say โ€” but that's rare enough next to the synchronous-rejection case that this guide doesn't build separate matching for it; those few bounces will just pass through unrewritten.
Scope check: this only works for bounces that land on a mailbox your server hosts โ€” a DSN for a message your server sent. If a customer emails a friend's @outlook.com address from a personal Gmail account you don't run, that bounce goes to their Gmail inbox, not yours, and there's nothing here to intercept it. That's an inherent limit of the approach, not a configuration gap โ€” there's no way to intercept a message that never touches infrastructure you control.

How to recognise a Microsoft-triggered bounce

A DSN is a structured message (RFC 3464), not free text โ€” that's what makes reliable detection possible. It's a multipart/report; report-type=delivery-status message built from, in order: a human-readable text/plain part (this is the wording a person actually sees when they open it), a machine-readable message/delivery-status part (structured fields like Action, Status, Remote-MTA and Diagnostic-Code), and usually a message/rfc822-headers or full message/rfc822 part carrying the original message. Since the DSN itself is usually your own server's, not Microsoft's, don't bother looking for Microsoft in its headers โ€” Microsoft's fingerprint is inside that structured part instead. Signals worth combining rather than trusting individually:

  • A Remote-MTA field, or a quoted "host ... said:" line inside the human-readable text, ending in .protection.outlook.com, .outlook.com or .hotmail.com โ€” the host your own server tried (and failed) to hand the message to.
  • A Diagnostic-Code/Status using enhanced codes and phrasing Microsoft commonly emits โ€” e.g. 5.7.606, 5.1.10, 4.4.7, or text fragments like "Access denied, banned sender" or "Recipient address rejected". This is Microsoft's own text, quoted verbatim by your server from the rejection it received.
  • A Subject beginning "Undeliverable:", "Delivery has failed to these recipients or groups", or "Message not delivered" โ€” though your own server may use its own default subject instead (Postfix's is "Undelivered Mail Returned to Sender"), so treat this as the weakest signal of the set.
Treat each of these as a hint, not proof. Microsoft changes exact wording and status codes over time, and Exchange Online vs. consumer Outlook.com/ Hotmail phrase things differently from each other. Require at least two independent signals to match (e.g. a Remote-MTA/quoted-host match and a matching Diagnostic-Code) before rewriting anything, and log near-misses somewhere you'll actually look at them, so you can tune the pattern instead of silently missing โ€” or silently mangling โ€” the wrong messages.

Where to intercept it: the human-readable part, not the whole message

Because RFC 3464 puts the human-readable explanation in its own text/plain part, that's exactly what every mail client already renders first when someone opens a DSN โ€” it's why a fragment of Microsoft's wording, quoted by your own server, is what your users see today. The reliable fix is narrow: replace that part's content with your own explanation and a link to dumpmicrosoft.com/users.html, and leave the machine-readable message/delivery-status part and the original message underneath untouched, in case you or a support agent need to dig into the real diagnostic detail later. Don't try to rebuild the whole MIME structure from scratch or rely on a custom header the way the header-only page does for inbound mail โ€” most clients don't surface custom headers, but every client already shows this specific part, so target it directly instead.

A milter that rewrites the human-readable part in place

Postfix speaks the Milter protocol natively, and a milter is the right tool here because it sits in front of the queue โ€” it sees and can modify every message before any mailbox on the server receives it, regardless of which local user the DSN is addressed to. Register it in main.cf alongside any milters you already run (order matters only if you have several โ€” keep content-modifying milters after any that just accept/reject):

smtpd_milters = inet:127.0.0.1:9900
non_smtpd_milters = inet:127.0.0.1:9900
milter_default_action = accept

And the milter itself, using Python's pymilter (pip install pymilter) โ€” a sketch showing the shape of the match-then-rewrite logic; add real logging, the near-miss tracking mentioned above, and testing against your own mail flow before trusting it on live mailboxes:

#!/usr/bin/env python3
# microsoft-bounce-rewriter.py - Postfix milter on 127.0.0.1:9900
# Rewrites the human-readable part of a Microsoft-triggered DSN - whether
# Postfix's own bounce daemon, reacting to Microsoft rejecting the original
# message outright mid-SMTP-conversation - the common case this targets.
# (Microsoft generating and sending the DSN itself is rare enough by
# comparison that this doesn't build separate matching for it - those few
# bounces just pass through unrewritten.)
# Requires: pip install pymilter
import re, email, Milter
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

MICROSOFT_HOST = re.compile(r"\b[\w.-]*\.(protection\.outlook|outlook|hotmail)\.com\b", re.I)
MICROSOFT_DIAG = re.compile(r"\b(5\.7\.606|5\.1\.10|4\.4\.7)\b|access denied, banned sender|recipient address rejected", re.I)
DSN_SUBJECT = re.compile(r"^(undeliverable:|message not delivered|delivery has failed|undelivered mail returned to sender)", re.I)

REPLACEMENT_TEXT = (
    "This is a delivery failure notice for a message you sent to a "
    "Microsoft-hosted address (hotmail.com, outlook.com, live.com or "
    "msn.com). Microsoft's mail servers block legitimate senders "
    "unpredictably, and their original wording below is often unhelpful. "
    "Details: https://dumpmicrosoft.com\n"
    "A more reliable option for the recipient: https://dumpmicrosoft.com/users.html\n"
    "\n"
    "-- Original notice follows --\n")

class BounceRewriter(Milter.Base):
    def __init__(self):
        self.fp = None

    def envfrom(self, mailfrom, *str):
        # DSNs use a null return-path per RFC 3464 - a strong first signal.
        self.is_dsn_envelope = (mailfrom == "<>")
        self.fp = None
        return Milter.CONTINUE

    def header(self, name, value):
        if self.fp is None:
            self.fp = []
        self.fp.append((name, value))
        return Milter.CONTINUE

    def eom(self):
        headers = dict((k.lower(), v) for k, v in (self.fp or []))
        subject = headers.get("subject", "")

        body = self.getbody()   # see pymilter docs for streaming the body in a real filter
        msg = email.message_from_bytes(body)

        # Microsoft's fingerprint isn't in the headers above - this DSN is
        # Postfix's own, so its headers are Postfix's, not Microsoft's.
        # Microsoft's Remote-MTA and Diagnostic-Code live in the
        # delivery-status part instead.
        delivery_status = ""
        for part in msg.walk():
            if part.get_content_type() == "message/delivery-status":
                delivery_status = part.get_payload(decode=True).decode(errors="replace")
                break

        signals = [
            self.is_dsn_envelope,
            headers.get("content-type", "").lower().startswith("multipart/report"),
            bool(MICROSOFT_HOST.search(delivery_status)),
            bool(MICROSOFT_DIAG.search(delivery_status) or DSN_SUBJECT.search(subject)),
        ]
        if sum(bool(s) for s in signals) < 3:
            return Milter.ACCEPT   # not confident enough - leave the message alone

        replaced = False
        for part in msg.walk():
            if part.get_content_type() == "text/plain" and not replaced:
                original = part.get_payload(decode=True).decode(errors="replace")
                part.set_payload(REPLACEMENT_TEXT + original)
                replaced = True   # only the FIRST text/plain part - that's the human-readable one
        if replaced:
            self.replacebody(msg.as_bytes())
        return Milter.ACCEPT

Milter.factory = BounceRewriter
Milter.runmilter("microsoft-bounce-rewriter", "inet:9900@127.0.0.1")

Run it under systemd so it's always listening before Postfix needs it, and set milter_default_action = accept (as above) so a milter outage fails open rather than blocking all mail server-wide โ€” this filter should only ever add clarity, never become a new reason mail doesn't get delivered. replacebody() replaces the whole body Postfix queues for delivery, which is why matching conservatively (the signal count above) matters so much more here than on the accept/reject pages elsewhere in this guide.

A system filter piping matching mail through a rewrite script

Exim doesn't implement the Milter protocol, so the equivalent mechanism is a system filter โ€” a filter file that runs against every message Exim receives, before any per-user filter, which is what makes it apply to all mailboxes on the server. Enable it in exim.conf:

system_filter = /etc/exim4/microsoft-bounce.filter
system_filter_user = mail

The filter file uses Exim's own filtering language to cheaply test for any bounce before handing it to an external script โ€” it can't usefully pre-filter on From or a Microsoft-specific Subject the way you might expect, because the common case is Exim generating this DSN itself (Microsoft rejected the original message outright, mid-conversation), so those headers are Exim's own, not Microsoft's. That deeper, Microsoft-specific match happens in the script instead, against the delivery-status part:

# /etc/exim4/microsoft-bounce.filter
if error_message
then
  pipe "/usr/local/bin/rewrite-microsoft-bounce.py"
  seen finish
endif

And the script, which does the real MIME-level matching and either re-injects a rewritten copy or leaves the original to be delivered normally โ€” a sketch, add the same near-miss logging and testing called out above before relying on it:

#!/usr/bin/env python3
# rewrite-microsoft-bounce.py - reads the message from stdin (Exim's `pipe`).
# Targets DSNs Exim generated itself, reacting to Microsoft rejecting the
# original message outright mid-SMTP-conversation - the common case, where
# Microsoft's fingerprint lives in the delivery-status part, not the
# headers. (Microsoft generating and sending the DSN itself is rare enough
# by comparison that this doesn't match for it separately - those few
# bounces just get re-injected unchanged below, same as anything else that
# doesn't match.)
# Re-injects a rewritten copy via sendmail -t if it matches; otherwise
# re-injects the message completely unchanged. Either way, the system
# filter's `seen finish` stops Exim delivering the original a second time.
import sys, re, subprocess, email

MICROSOFT_HOST = re.compile(r"\b[\w.-]*\.(protection\.outlook|outlook|hotmail)\.com\b", re.I)
REPLACEMENT_TEXT = (
    "This is a delivery failure notice for a message you sent to a "
    "Microsoft-hosted address. Microsoft's mail servers block legitimate "
    "senders unpredictably, and their original wording below is often "
    "unhelpful. Details: https://dumpmicrosoft.com\n"
    "A more reliable option for the recipient: https://dumpmicrosoft.com/users.html\n"
    "\n-- Original notice follows --\n")

raw = sys.stdin.buffer.read()
msg = email.message_from_bytes(raw)
content_type = msg.get_content_type()

# Microsoft's fingerprint isn't in the headers above - this DSN is
# Exim's own, so its headers are Exim's, not Microsoft's. Look in the
# delivery-status part instead.
delivery_status = ""
for part in msg.walk():
    if part.get_content_type() == "message/delivery-status":
        delivery_status = part.get_payload(decode=True).decode(errors="replace")
        break

is_microsoft = bool(MICROSOFT_HOST.search(delivery_status))

if content_type == "multipart/report" and is_microsoft:
    for part in msg.walk():
        if part.get_content_type() == "text/plain":
            original = part.get_payload(decode=True).decode(errors="replace")
            part.set_payload(REPLACEMENT_TEXT + original)
            break   # only the first text/plain part - the human-readable one

subprocess.run(["/usr/sbin/sendmail", "-t", "-oi"], input=msg.as_bytes())

Test the filter syntax with exim -bf /etc/exim4/microsoft-bounce.filter before reloading. The seen finish plus unconditional re-injection in the script (matched or not) is what avoids either dropping mail the pattern missed or delivering two copies of what it caught โ€” get that pairing wrong in either direction and you'll lose messages or duplicate them, so test with real captured DSNs before this runs against live mailboxes. Because error_message is true for every bounce Exim delivers, not just Microsoft-triggered ones, the script will see (and correctly ignore) plenty of unrelated DSNs โ€” that's expected, not a sign the filter needs tightening further.

A MIMEDefang milter with direct MIME::Entity access

As on the other Sendmail tabs in this guide, a milter is the natural fit โ€” and MIMEDefang in particular gives you the message as a Perl MIME::Entity object, which is exactly what's needed to reach into a specific part and replace its content. In your filter.pl:

use MIME::Entity;

sub filter_end {
    my ($entity) = @_;

    return unless $entity->head->mime_type eq 'multipart/report';

    my $subject = $entity->head->get('Subject') || '';

    # Microsoft's fingerprint isn't in the headers - if Microsoft rejected
    # the original message outright, mid-SMTP-conversation (the common
    # case this targets), THIS bounce was generated by your own Sendmail/
    # MIMEDefang, not by Microsoft, so its headers are yours. Look inside
    # the message/delivery-status part instead, where the Remote-MTA/
    # Diagnostic-Code fields name Microsoft's server and quote its
    # rejection text.
    my $delivery_status = '';
    for my $part ($entity->parts) {
        if ($part->mime_type eq 'message/delivery-status') {
            $delivery_status = $part->bodyhandle ? join('', @{ $part->bodyhandle->as_lines }) : '';
            last;
        }
    }

    my $is_microsoft = $delivery_status =~ /\.(protection\.outlook|outlook|hotmail)\.com/i;
    my $looks_like_dsn = $subject =~ /^(undeliverable:|message not delivered|delivery has failed|undelivered mail returned to sender)/i;

    return unless $is_microsoft && $looks_like_dsn;

    for my $part ($entity->parts) {
        next unless $part->mime_type eq 'text/plain';
        my $original = join('', @{ $part->bodyhandle->as_lines });
        my $io = $part->bodyhandle->open('w');
        $io->print(
            "This is a delivery failure notice for a message you sent to a " .
            "Microsoft-hosted address. Microsoft's mail servers block " .
            "legitimate senders unpredictably, and their original wording " .
            "below is often unhelpful. Details: https://dumpmicrosoft.com\n" .
            "A more reliable option: https://dumpmicrosoft.com/users.html\n\n" .
            "-- Original notice follows --\n" .
            $original);
        $io->close;
        action_change_body($entity);
        last;   # only the first text/plain part - the human-readable one
    }
}

Adjust to match your existing MIMEDefang filter structure and version โ€” as with the milter examples on the deliver-and-notify page, this is illustrative of the hook and matching logic, not drop-in production code. Test against real captured DSNs, not assumptions about their format, before it runs against live mailboxes.

Same Exim system filter, added via WHM

cPanel/WHM servers run Exim under the hood, so the system filter from the Exim tab applies the same way. Upload the filter file and the rewrite script via File Manager or SSH, then in WHM โ†’ Service Configuration โ†’ Exim Configuration Manager โ†’ Advanced Editor, add:

system_filter = /etc/exim4/microsoft-bounce.filter
system_filter_user = mail

Make rewrite-microsoft-bounce.py executable and confirm the path referenced by the filter's pipe command matches where you uploaded it, then Restart Exim. Because a system filter runs ahead of every account's own mail filters, this applies across all mailboxes on the server without touching each account individually.

This complements, rather than replaces, Section A2: that page gates your own outbound delivery on a live notice-test at send time, but only for mail that passes through the specific path you've configured it on. This page catches the bounce afterwards, for whatever actually made it back to a mailbox you run โ€” including mail sent through other paths on the same server.

Pointing an affected visitor here? Send them straight to the switching guide.

Open the user guide โ†’