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. Five steps: register it in main.cf, install pymilter, write the milter, give it a system account, then run it under systemd.

1
Register the milter in main.cf

Alongside any milters you already run โ€” order matters only if you have several, so keep content-modifying milters after any that just accept/reject. The last line, internal_mail_filter_classes = bounce, is the one people miss โ€” without it the milter never sees a single bounce, because by default Postfix doesn't run its own locally-generated mail (bounces, in particular) through non_smtpd_milters at all; this setting opts that class of internal mail back into content filtering, and its default is empty:

smtpd_milters = inet:127.0.0.1:9900
non_smtpd_milters = inet:127.0.0.1:9900
milter_default_action = accept
internal_mail_filter_classes = bounce
  • Why Postfix warns about this: its own docs say "It's generally not safe to enable content inspection of Postfix-generated email messages." The danger is a filter that rejects or holds mail โ€” a bounce that gets rejected has nowhere else to go.
  • Why it's fine here: eom() in the milter below always returns Milter.ACCEPT, including on the caught-exception path โ€” it can rewrite a bounce but never block one.
  • List only bounce, not notify โ€” notify would also route postmaster copies through the milter, which gains nothing since those are addressed to you, not the affected user.
  • Verify it worked: postfix reload, trigger another bounce, then check journalctl -u microsoft-bounce-rewriter. It logs rewrote DSN or DSN not matched for everything it sees โ€” silence means Postfix still isn't handing it the message, not that nothing matched.
2
Install pymilter and its build dependencies

pymilter compiles a small C extension against Sendmail's libmilter headers, so the -dev packages need to be present before pip can build it:

apt-get install -y libmilter-dev python3-dev
pip install pymilter
3
The milter itself

This version collects the body correctly, never lets an exception escape to eom() as an unintended TEMPFAIL, and requires the Microsoft host match plus one corroborating signal rather than any-3-of-4 (which rewrote non-Microsoft bounces too, since a null sender, multipart/report and Postfix's default subject are true of every Postfix bounce). Test it 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
# Requires: pip install pymilter
import re, sys, html, traceback, email, email.policy
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import Milter

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)

# ---- Notice wording (edit here; both versions are built from these) ----
NOTICE_HEADING = "Your message was blocked by Microsoft - it was not delivered"
NOTICE_BODY = (
    "You sent this message 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.")
NOTICE_LINKS = [
    ("Why this happens", "https://dumpmicrosoft.com"),
    ("A more reliable option for the recipient", "https://dumpmicrosoft.com/users.html"),
]

# Plain-text version: a boxed banner for clients that only show text.
_RULE = "=" * 70
REPLACEMENT_TEXT = (
    f"{_RULE}\n"
    f"  *** {NOTICE_HEADING.upper()} ***\n"
    f"{_RULE}\n\n"
    f"{NOTICE_BODY}\n\n"
    + "".join(f"  > {label}:\n    {url}\n" for label, url in NOTICE_LINKS)
    + f"\n{_RULE}\n"
    "  Original delivery failure notice follows\n"
    f"{_RULE}\n\n")


def build_html(original_text):
    # Inline styles only - most mail clients strip <style> blocks. Colours
    # are set explicitly for both background and text so dark-mode clients
    # that invert colours keep enough contrast.
    links = "".join(
        f'<p style="margin:6px 0;"><a href="{html.escape(url)}" '
        f'style="color:#9a3412;font-weight:bold;">{html.escape(label)} &rarr;</a></p>'
        for label, url in NOTICE_LINKS)
    return (
        '<!DOCTYPE html><html><body style="margin:0;padding:16px;'
        'font-family:Segoe UI,Arial,sans-serif;background:#ffffff;color:#1f2937;">'
        '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
        'style="max-width:680px;border:3px solid #dc2626;border-radius:8px;'
        'background:#fef2f2;border-collapse:separate;">'
        '<tr><td style="background:#dc2626;color:#ffffff;padding:12px 16px;'
        'font-size:18px;font-weight:bold;">&#9888; '
        f'{html.escape(NOTICE_HEADING)}</td></tr>'
        '<tr><td style="padding:14px 16px;font-size:15px;line-height:1.5;color:#1f2937;">'
        f'<p style="margin:0 0 10px 0;">{html.escape(NOTICE_BODY)}</p>{links}'
        '</td></tr></table>'
        '<p style="margin:20px 0 6px 0;font-size:13px;color:#6b7280;">'
        'Original delivery failure notice:</p>'
        '<pre style="margin:0;padding:12px;background:#f3f4f6;color:#374151;'
        'font-size:12px;white-space:pre-wrap;border-radius:6px;">'
        f'{html.escape(original_text)}</pre>'
        '</body></html>')


def log(msg):
    print(msg, file=sys.stderr, flush=True)   # ends up in journalctl under systemd


class BounceRewriter(Milter.Base):
    def __init__(self):
        self._reset()

    def _reset(self):
        self.hdrs = []
        self.chunks = []
        self.is_dsn_envelope = False

    def envfrom(self, mailfrom, *args):
        self._reset()
        # DSNs use a null return-path (RFC 3464). pymilter passes it as '<>'.
        self.is_dsn_envelope = mailfrom in ("<>", "")
        return Milter.CONTINUE

    def header(self, name, value):
        self.hdrs.append((name, value))
        return Milter.CONTINUE

    def body(self, chunk):
        # FIX: the original never collected the body (and getbody() doesn't exist).
        self.chunks.append(chunk)
        return Milter.CONTINUE

    def eom(self):
        # FIX: never let an exception reach pymilter - it turns that into a
        # TEMPFAIL for the sender, which milter_default_action does NOT cover.
        try:
            self._rewrite()
        except Exception:
            log("microsoft-bounce-rewriter: error, message passed unchanged\n" + traceback.format_exc())
        return Milter.ACCEPT

    def _rewrite(self):
        headers = {k.lower(): v for k, v in self.hdrs}
        is_report = headers.get("content-type", "").lower().lstrip().startswith("multipart/report")

        # Cheap early exit: ordinary mail is never parsed or touched.
        if not (self.is_dsn_envelope and is_report):
            return

        # FIX: parse headers + body together, otherwise the MIME structure
        # (boundary lives in the Content-Type header) is invisible.
        head = b"".join(f"{k}: {v}\r\n".encode("utf-8", "surrogateescape") for k, v in self.hdrs)
        body = b"".join(self.chunks)
        msg = email.message_from_bytes(head + b"\r\n" + body, policy=email.policy.compat32)

        # FIX: message/delivery-status parses as a list of header blocks, so
        # get_payload(decode=True) returns None. Stringify the blocks instead.
        delivery_status = ""
        for part in msg.walk():
            if part.get_content_type() == "message/delivery-status":
                payload = part.get_payload()
                if isinstance(payload, list):
                    delivery_status = "\n".join(str(p) for p in payload)
                else:
                    delivery_status = str(payload)
                break

        subject = headers.get("subject", "")
        # FIX: null sender + multipart/report + Postfix's default subject are
        # true for EVERY Postfix bounce, so the original 3-of-4 score rewrote
        # non-Microsoft bounces too. Require the Microsoft host explicitly,
        # plus one corroborating signal.
        host_hit = bool(MICROSOFT_HOST.search(delivery_status))
        other_hit = bool(MICROSOFT_DIAG.search(delivery_status) or DSN_SUBJECT.search(subject))
        if not (host_hit and other_hit):
            log(f"microsoft-bounce-rewriter: DSN not matched (host={host_hit}, other={other_hit}), subject={subject!r}")
            return

        # The human-readable part is the FIRST child of multipart/report
        # (RFC 3464). Replace it with multipart/alternative holding a
        # bannered text/plain and a styled text/html version - RFC 3464
        # allows any content type for that first part, so the
        # delivery-status and original-message parts stay untouched.
        parts = msg.get_payload()
        if not isinstance(parts, list) or not parts or parts[0].get_content_type() != "text/plain":
            log(f"microsoft-bounce-rewriter: matched but first part isn't text/plain, left unchanged, subject={subject!r}")
            return
        first = parts[0]
        raw = first.get_payload(decode=True) or b""
        original = raw.decode(first.get_content_charset() or "utf-8", errors="replace")

        alt = MIMEMultipart("alternative")
        del alt["MIME-Version"]   # only belongs on the top-level message
        for sub in (MIMEText(REPLACEMENT_TEXT + original, "plain", "utf-8"),
                    MIMEText(build_html(original), "html", "utf-8")):
            del sub["MIME-Version"]
            alt.attach(sub)
        parts[0] = alt

        # FIX: replacebody() wants the BODY only - the original passed the
        # whole message, which would have pasted the headers into the body.
        out = msg.as_bytes(policy=email.policy.SMTP)
        new_body = out.split(b"\r\n\r\n", 1)[1]
        self.replacebody(new_body)
        log(f"microsoft-bounce-rewriter: rewrote DSN, subject={subject!r}")


if __name__ == "__main__":
    Milter.factory = BounceRewriter
    # FIX: body changes must be negotiated with the MTA up front, or
    # replacebody() raises.
    Milter.set_flags(Milter.CHGBODY)
    Milter.runmilter("microsoft-bounce-rewriter", "inet:9900@127.0.0.1", 240)
4
Give it a dedicated system account

Not root, not postfix โ€” it only needs to bind a local port and read message content, not touch the mail queue directly:

useradd --system --no-create-home --shell /usr/sbin/nologin milter
install -o milter -g milter -m 0750 microsoft-bounce-rewriter.py /usr/local/bin/microsoft-bounce-rewriter.py

--system skips a login shell and home directory โ€” there's nothing to clean up later. install copies the script and sets its ownership/mode in one command; 0750 keeps it readable and executable by milter alone, not world-readable, since it embeds your matching logic. Run both as root (or sudo) โ€” the milter account itself never needs permission to create its own script or user.

5
Run it under systemd, then enable and verify

Pinned to that account with User=/Group=, so it starts on boot, restarts if it crashes, and is already listening on 127.0.0.1:9900 before Postfix tries to reach it โ€” /etc/systemd/system/microsoft-bounce-rewriter.service:

[Unit]
Description=Microsoft bounce-rewriting milter for Postfix
After=network.target
Before=postfix.service

[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/microsoft-bounce-rewriter.py
Restart=on-failure
RestartSec=2
User=milter
Group=milter

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now microsoft-bounce-rewriter
ss -ltnp | grep 9900        # confirm it's listening before reloading Postfix
postfix reload
  • milter_default_action = accept (step 1) means 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.
  • Before=postfix.service only affects boot order. If you edit and restart the milter later, restart it on its own โ€” Postfix keeps retrying the connection per milter_connect_timeout and doesn't need a restart itself.
  • Watch journalctl -u microsoft-bounce-rewriter for the rewrote DSN/DSN not matched lines from step 1, and Postfix's own mail log for warning: milter lines if messages stop being rewritten.
  • replacebody() replaces the whole body Postfix queues for delivery โ€” which is why matching conservatively (the host-plus-corroborating-signal check in the script) 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 โ†’