โ Read this before you change anything
Everything on this page is an illustrative example, not a tested, certified, or supported configuration for your mail server, OS, version, or environment. This technique rewrites live mail content on its way to every mailbox on the server โ a matching mistake doesn't affect one test account, it affects everyone at once.
You are solely responsible for reviewing, testing, and validating any change before it touches a production system, for confirming it's compatible with your existing configuration and policies, and for any legal, contractual, or regulatory obligations that apply to your organisation's handling of email.
By copying, adapting, or deploying any snippet, script, or configuration from this page, you accept full responsibility for the consequences โ including but not limited to misidentified messages, corrupted or lost mail content, and downstream impact on your users. DumpMicrosoft and its authors accept none. If you are not able or willing to take that responsibility, do not apply any of the changes described in this guide.
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.
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.
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-MTAfield, or a quoted "host ... said:" line inside the human-readable text, ending in.protection.outlook.com,.outlook.comor.hotmail.comโ the host your own server tried (and failed) to hand the message to. - A
Diagnostic-Code/Statususing 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
Subjectbeginning "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.
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.
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 returnsMilter.ACCEPT, including on the caught-exception path โ it can rewrite a bounce but never block one. - List only
bounce, notnotifyโnotifywould 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 checkjournalctl -u microsoft-bounce-rewriter. It logsrewrote DSNorDSN not matchedfor everything it sees โ silence means Postfix still isn't handing it the message, not that nothing matched.
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
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)} →</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;">⚠ ' 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)
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.
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.serviceonly affects boot order. If you edit and restart the milter later, restart it on its own โ Postfix keeps retrying the connection permilter_connect_timeoutand doesn't need a restart itself.- Watch
journalctl -u microsoft-bounce-rewriterfor therewrote DSN/DSN not matchedlines from step 1, and Postfix's own mail log forwarning: milterlines 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 โ