postsleuth

postsleuth docs

postsleuth answers one question: why isn't this Postfix server delivering mail? It reads the queue, the mail log and DNS, never changes anything unless you opt in, and speaks MCP so an AI agent can do the digging for you.

Install

go install github.com/Blackman99/postsleuth/cmd/postsleuth@latest

Or download a static binary (linux, darwin, windows; amd64 and arm64) from GitHub Releases, unpack it, and put postsleuth on your PATH. It has no runtime dependencies. On the mail host it calls the Postfix tools that are already there: postqueue, postconf and, only when you allow it, postsuper.

Quick start

postsleuth doctor                 # everything, with next steps
postsleuth queue                  # what is stuck and why
postsleuth shape                  # which destination, since when
postsleuth log --since 24h        # what happened
postsleuth trend --domain gmail.com # deferral rate over time
postsleuth playbook               # Gmail / Microsoft / Yahoo recovery steps
postsleuth trace bob@example.org  # one message, start to finish
postsleuth dns example.com --ip 203.0.113.7

Run it as a user that can read the mail log. On Debian and Ubuntu that is the adm group (sudo usermod -aG adm $USER). postqueue -j is allowed for every user by default (authorized_mailq_users).

For a worked example with real output, read Diagnosing stuck mail with postsleuth (中文版).

doctor

postsleuth doctor combines the queue summary, a qshape of the deferred queue, log outcomes and destination deferral trends for the window (--since, default 24h) and the DNS checks for your sending domain. It ends with a list of findings (including worsening high-volume destinations) and exits 1 if any is a problem.

postsleuth doctor [--since 24h] [--domain example.com] [--ip 203.0.113.7] [--selector mail] [--no-dns] [--no-log] [--json]

The sending domain defaults to myorigin (or mydomain). The sending IP defaults to smtp_bind_address, else the public A/AAAA records of myhostname when there is no relayhost. DKIM selectors are read from opendkim's "DKIM-Signature field added (s=…, d=…)" log lines, else common names are probed.

queue & shape

postsleuth queue [--queue deferred] [--domain .outlook.com] [--sender billing@] [--reason "4.7.28"] [--category rate_limit] [--min-age 2h]
postsleuth queue --list --category timeout --limit 20
postsleuth shape [deferred|active|hold|incoming|all] [--by sender] [--top 20]

The summary groups recipients by normalised delay reason (addresses, IPs and tracking IDs are stripped so identical failures group together), classifies each group, and lists the domains it affects. --domain with a leading dot also matches subdomains.

shape is the qshape view: rows are domains, columns are message age in minutes (5, 10, 20, … 1280, 1280+), each message counted once per domain. A row that fills the left columns is a fresh problem; one that fills the right is old. --by sender is how you find the compromised account behind a spam run.

log & trace

postsleuth log [--since 1h|24h|7d|all] [--domain gmail.com] [--top 15]
postsleuth trace 4F2A1C3B9
postsleuth trace bob@example.org --since 7d

log reports totals (received, sent, deferred attempts, bounced, expired, smtpd rejects), per-domain outcomes, domains whose latest attempt was a deferral ("still failing"), grouped deferral reasons, bounce causes and reject reasons, each with a category and hint. For the same data over time, use trend.

trace prints every line for each matching queue ID (smtpd, pickup, cleanup, qmgr, smtp/local, bounce) and its final status. Matching by address also pulls in the non-delivery notice Postfix generated for a bounce.

trend

postsleuth trend [--since 1h|24h|7d|all] [--domain gmail.com] [--bucket 15m|1h|4h|12h|1d] [--top 15]

trend buckets sent and deferred attempts by recipient domain over the window (bucket width defaults from --since: 15m for ≤2h, 1h for ≤24h, 4h for ≤3d, …). For each destination it shows the deferral rate per bucket and a direction — improving, worsening, flat, or insufficient_data — by comparing the early half of the window to the late half. Use it to answer "is Gmail getting better?" after you lower concurrency or wait out a rate limit. doctor surfaces worsening high-volume destinations as findings.

playbook

postsleuth playbook [--since 24h]            # playbooks for what is failing here, from the queue and the log
postsleuth playbook gmail|microsoft|yahoo    # only one provider
postsleuth playbook gmail-4.7.28             # one playbook, every step and link
postsleuth playbook --reply "421-4.7.28 Gmail has detected an unusual rate …" [--domain gmail.com]
postsleuth playbook --list

A category says what kind of failure it is. When the receiver is Gmail, Microsoft (Outlook.com, Hotmail, Microsoft 365) or Yahoo/AOL, the reply also names a provider code, and each provider has its own way back: Google's 4.7.28 recovery steps and Postmaster Tools, Microsoft's SNDS, JMRP and delist forms, Yahoo's Sender Hub and Complaint Feedback Loop. playbook recognises the provider from the MX that answered (*.google.com, *.protection.outlook.com, *.yahoodns.net…) or from the reply's own wording, picks the playbook for the code, and prints:

doctor shows the same playbooks in a compact PLAYBOOK section (the verdict, the evidence and the first step), adds an info finding per playbook with the full steps and links in --json, and queue / log point at the playbook under each matching reason. Playbooks never change anything and postsleuth never contacts the providers: signing in to SNDS or filing a delist request is yours to do.

PLAYBOOK 2 provider playbooks match what is failing here
  ! Gmail 4.7.28 · Gmail is rate limiting you: unusual rate of mail
         temporary (Postfix retries) · 82 deferred attempts · 150 queued · gmail.com, googlemail.com
      1  Send nothing to Gmail for at least 10 minutes. Don't flush the queue (postqueue -f, postsleuth
         flush): a burst of retries counts against you.
         + 5 more steps, 4 official links: postsleuth playbook gmail-4.7.28
  ✗ Microsoft S3150 · Outlook.com block list: your IP's network is blocked
         permanent (retrying won't help) · 38 deferred attempts · 14 bounced · 34 queued

The built-in playbooks (postsleuth playbook --list). Codes are taken from the providers' published error pages; a playbook only cites pages that exist.

PlaybookCodes · whenOfficial references
Gmail / Google Workspace
gmail-4.7.28421 4.7.28
Gmail is rate limiting you: unusual rate of mail
Email sender guidelines
Gmail SMTP errors and codes
Postmaster Tools (sign in)
Set up Postmaster Tools
gmail-auth4.7.26 / 5.7.26, 5.7.27, 5.7.30, 5.7.32, 5.7.40
Gmail: mail failed SPF, DKIM or DMARC checks
Email sender guidelines
Gmail SMTP errors and codes
Email sender guidelines FAQ
gmail-ptr421 4.7.0, 451 4.7.23, 550 5.7.25, 550 5.7.1 (IPv6)
Gmail: reverse DNS (PTR) missing or not forward-confirmed
Email sender guidelines
Gmail SMTP errors and codes
gmail-tls421 4.7.29 / 550 5.7.29, 421 4.7.0 (TLS required)
Gmail: mail must be sent over TLS
Email sender guidelines
Gmail SMTP errors and codes
gmail-reputation421 4.7.0, 550 5.7.1, 550 5.7.28
Gmail: blocked for low reputation or unsolicited mail
How Gmail blocks messages
Postmaster Tools (sign in)
Email sender guidelines
Troubleshooting for senders
Sender contact form (mitigation)
gmail-relay-required550 5.7.1
Gmail: this IP may not send mail directly
Gmail SMTP errors and codes
Email sender guidelines
Microsoft (Outlook.com / Microsoft 365)
microsoft-s3150550 5.7.1 (S3150 / S3140)
Outlook.com block list: your IP's network is blocked
Postmaster: troubleshooting, codes
SNDS: IP status and data (sign in)
JMRP: junk reports (in SNDS)
Outlook.com sender support form
microsoft-banned-ip550 5.7.606-649
Microsoft 365: banned sending IP
Delist portal guide, fix 5.7.511
Anti-Spam IP Delist Portal
microsoft-5.7.511550 5.7.511
Microsoft 365: banned sender (needs a manual review)
Delist portal guide, fix 5.7.511
microsoft-rate-limit451 4.7.650, 421 RP-001 / RP-002 / RP-003
Outlook.com is rate limiting you because of IP reputation
Postmaster: troubleshooting, codes
SNDS: IP status and data (sign in)
JMRP: junk reports (in SNDS)
Outlook.com sender support form
microsoft-policy550 SC-001..004, DY-001/002, OU-001/002
Outlook.com rejected the mail for policy reasons
Postmaster: troubleshooting, codes
SNDS: IP status and data (sign in)
JMRP: junk reports (in SNDS)
Outlook.com sender support form
microsoft-5.7.515550 5.7.515
Outlook.com: high-volume sender authentication required
Postmaster: troubleshooting, codes
Postmaster: policies and guidelines
microsoft-exo-relay550 5.7.705 / 5.7.708 / 5.7.750
Exchange Online refused mail relayed through a Microsoft 365 tenant
Fix NDR 5.7.705, 5.7.708, 5.7.750
Yahoo / AOL
yahoo-tss09[TSS09]
Yahoo: IP permanently deferred
SMTP error codes: TS* deferrals
Complaint Feedback Loop (CFL)
Sender requirements
Sender support request
yahoo-deferred421 4.7.0 [TSS04] and other [TS*]
Yahoo is deferring your mail (complaints, volume or reputation)
SMTP error codes: TS* deferrals
Sender requirements
Complaint Feedback Loop (CFL)
Sender support request
yahoo-blocklist553 5.7.1 [BL21] / [BL23]
Yahoo refuses your IP because of a blocklist
SMTP error codes: 5xx errors
Spamhaus (IP lookup)
Sender support request
yahoo-auth550 5.7.9 / 554 5.7.9 (authentication)
Yahoo: mail failed authentication
SMTP error codes: authentication
Sender requirements
yahoo-policy554 5.7.9, [PH*]
Yahoo rejected the message for policy reasons
SMTP error codes: content, policy
SMTP error codes: 5xx errors
Sender requirements
Sender support request

Got a Gmail, Microsoft or Yahoo reply that finds no playbook? postsleuth playbook --reply "…" tells you; open an issue with the anonymised text.

dns

postsleuth dns [DOMAIN] [--ip 203.0.113.7] [--selector mail] [--helo mail.example.com] [--resolver 1.1.1.1]
CheckWhat fails it
mxno MX (warn), null MX
spfmissing, more than one record, more than 10 DNS lookups (counted through includes), +all; ?all or no all warn
spf-ipthe sending IP does not match (ip4/ip6/a/mx/include/redirect are evaluated; ptr/exists are not)
dkimno key at selector._domainkey, revoked key, RSA under 1024 bits; 1024 bits or t=y warn
dmarcmissing or duplicated record, invalid p=; no rua= warns
ptrno PTR, PTR that does not resolve back to the IP (FCrDNS); PTR name different from myhostname warns

MCP server

postsleuth mcp speaks the Model Context Protocol over stdio. Every tool is read-only and annotated as such.

On the mail host (Claude Code, Cursor, Claude Desktop…)

claude mcp add postsleuth -- postsleuth mcp
{"mcpServers": {"postsleuth": {"command": "postsleuth", "args": ["mcp"]}}}

From your laptop over SSH

{"mcpServers": {"postsleuth": {"command": "ssh", "args": ["-T", "mx1.example.com", "postsleuth", "mcp"]}}}

Use an SSH key and a user in the adm group. Nothing listens on the network.

Postfix in Docker (docker-mailserver, mailcow, …)

{"mcpServers": {"postsleuth": {"command": "postsleuth",
  "args": ["mcp", "--wrap", "docker exec -i mailserver"]}}}

--wrap prefixes every Postfix command, and also reads the container's log (maillog_file, /var/log/mail.log, /var/log/mail/mail.log…) through it. For mailcow, the Postfix container is usually mailcowdockerized-postfix-mailcow-1, and the log is easiest via --log-cmd "docker logs mailcowdockerized-postfix-mailcow-1".

Prompts that work well: "Why is mail to Gmail stuck?", "Outlook says S3150, what do I do?", "Which sender filled the deferred queue today?", "Did bob@example.org's message arrive?", "Is our SPF/DKIM/DMARC set up right for this IP?"

Tool reference

Each tool returns structured JSON (with an output schema) plus the same JSON as text.

ToolWhat it doesArguments
postfix_doctorOne-call diagnosis of a Postfix server: queue summary, qshape of the deferred queue, log outcomes for the window, DNS authentication checks, and a list of findings with next steps.since log window (default 24h)
domain sending domain for DNS checks
ips sending IPs
skip_dns do not run DNS checks
postfix_queue_summarySummarise the Postfix queue (postqueue -j): counts per queue, top recipient and sender domains, delay reasons grouped and classified with hints, oldest message.queue only this queue: active, deferred, hold, incoming, maildrop or corrupt (default all)
top max entries per list (default 10)
postfix_queue_listList queued messages (queue ID, queue, arrival time, size, sender, recipients with delay reasons), filtered by queue, recipient domain, sender, reason text, reason category or minimum age.queue queue name filter, e.g. deferred
domain recipient domain; a leading dot matches subdomains too
sender substring of the envelope sender
reason_contains case-insensitive substring of the delay reason
category reason category id, e.g. rate_limit, auth, ptr, timeout
min_age only messages older than this, e.g. 2h or 3d
limit max messages returned (default 50)
postfix_queue_shapeqshape-style matrix of a queue: rows are recipient (or sender) domains, columns are message age buckets in minutes (5,10,20,...,1280+). Shows which destination is backing up and since when.queue queue to shape (default deferred; 'all' for every queue)
by recipient (default) or sender domain
top max domains (default 20)
postfix_log_statsAggregate the Postfix mail log over a time window: totals (received, sent, deferred attempts, bounced, expired, smtpd rejects), per-domain outcomes, domains still failing, grouped deferral reasons and bounce causes with category and hint, and DKIM selectors seen in signing logs.since time window, e.g. 1h, 24h (default), 7d, or all
domain only this recipient domain (leading dot = include subdomains)
top max entries per list (default 15)
postfix_log_trendPer-destination deferral rate over time buckets: sent/deferred counts and rate per bucket, plus a direction (improving / worsening / flat / insufficient_data) comparing the early vs late half of the window.since time window, e.g. 1h, 24h (default), 7d, or all
domain only this recipient domain (leading dot = include subdomains)
bucket bucket size, e.g. 15m, 1h, 4h, 12h, 1d (default: auto from window)
top max destinations (default 15)
postfix_log_traceFollow one message through the log by queue ID or email address: every line for each matching queue ID (smtpd, cleanup, qmgr, smtp/local, bounce notifications) and its final status.query a queue ID (e.g. 4F2A1C3B9) or an email address
since time window (default 7d)
limit max messages (default 20, newest kept)
mail_dns_checkCheck the DNS records receivers use to accept or reject your mail: MX, SPF (syntax, 10-lookup limit, whether the sending IP is authorised), DKIM selector keys (presence, key size), DMARC policy and reporting, PTR with forward confirmation (FCrDNS) against myhostname.domain sending domain (default: myorigin/mydomain from postconf)
ips sending IPs to check SPF authorisation and PTR/FCrDNS for (default: smtp_bind_address or myhostname's public A/AAAA)
selectors DKIM selectors (default: taken from opendkim log lines, else common names are probed)
postfix_configDelivery-relevant non-default Postfix settings from postconf -n (relayhost, TLS policy, SASL, concurrency/rate limits, queue lifetimes, domains, milters). Secret-looking values are redacted.none
postfix_provider_playbookRecovery playbooks for Gmail / Google Workspace, Microsoft (Outlook.com, Microsoft 365) and Yahoo / AOL: what the code means, whether Postfix's retries can succeed, ordered Postfix-specific steps and the provider's official pages. With no arguments it scans the queue and log and returns the playbooks for what is failing, with evidence counts and domains; reply looks up one pasted reply; id returns one playbook.reply one SMTP reply or delay reason to look up
domain recipient domain of reply, when it names no host
id one playbook, e.g. gmail-4.7.28
provider gmail, microsoft or yahoo (server scan)
since log window for the scan (default 24h)
postfix_reason_categoriesList the failure categories postsleuth assigns to delay/bounce reasons, with the hint for each.none
Only with --allow-mutations, each also needs confirm: true:
postfix_queue_flushRetry now: queue IDs (postqueue -i), one site (postqueue -s, needs fast_flush_domains), or everything (postqueue -f).queue_ids, site, all, confirm
postfix_queue_requeuepostsuper -r by queue ID.queue_ids, confirm
postfix_queue_hold / _releasepostsuper -h / -H by queue ID.queue_ids, confirm
postfix_queue_deletepostsuper -d by queue ID. Annotated destructive. ALL is refused.queue_ids, confirm

Failure categories

Every delay reason, bounce text and reject is classified by its SMTP enhanced status code and wording. First match wins, in this order:

CategoryHint postsleuth gives
loopPostfix thinks it is the destination: check mydestination, myhostname and virtual domains, or the MX points back at this host.
relay_deniedThe next hop refuses to relay for you: check relayhost, SASL credentials, or the recipient domain is not hosted there.
sasl_authLogin to the relay host failed: check smtp_sasl_password_maps (and run postmap after editing it).
blocklistThe receiver rejects your IP or domain because of a blocklist entry: look the IP up (Spamhaus, Microsoft SNDS) and request delisting after fixing the cause.
ptrThe receiver requires reverse DNS: set a PTR for your sending IP that resolves back to the same IP (FCrDNS), ideally matching myhostname.
authThe receiver could not authenticate your mail: check SPF covers this IP, DKIM signs with a published selector, and DMARC aligns (postsleuth dns DOMAIN).
greylistingGreylisting: normal, Postfix retries automatically. Only a problem if it persists for hours.
rate_limitThe receiver is throttling you: lower smtp_destination_concurrency_limit / add smtp_destination_rate_delay for that domain, and check your sending reputation.
recipient_unknownThe mailbox does not exist: fix or remove the address (check for typos).
mailbox_fullThe recipient's mailbox is full: nothing to fix on your side; the sender should use another channel.
sizeThe message is larger than the receiver accepts.
tlsTLS negotiation failed: check smtp_tls_security_level / smtp_tls_policy_maps for that destination and the system CA bundle (smtp_tls_CAfile).
dnsDNS lookup for the destination failed: check the domain exists and has MX/A records, and that this server's resolver works (dig MX domain).
timeoutConnections to port 25 time out: many providers block outbound port 25, so test with nc -vz MX 25 and use a relayhost if blocked.
connectCould not connect to the destination: the remote server is down or unreachable, or outbound port 25 is blocked.
policyThe receiver's policy rejected the message (spam or content filter). Read the full reply text; check authentication and the message content.
otherRead the full reply text from the remote server.

Seen a reply that lands in other? Open an issue with the (anonymised) text.

Where data comes from

Changing the queue

postsleuth is read-only unless the process was started with --allow-mutations. That flag is chosen by whoever configures the MCP server, not by the agent, so a model cannot grant itself write access. When it is set:

Before flushing a destination that is rate-limiting you (Gmail 4.7.28), don't: a burst of retries makes it worse. Fix the cause, wait, and let Postfix's backoff work.

JSON & exit codes

Every command takes --json. Exit codes: 0 fine · 1 problems found (doctor, or a failing check in dns) · 2 error or refused. NO_COLOR=1 or --no-color disables colour.

postsleuth doctor --json | jq '.findings[] | select(.severity=="problem") | .title'

Troubleshooting

"permission denied reading /var/log/mail.log"
Add the user to adm, run with sudo, or pass --log-cmd.
Every DNS record is "missing"
Your resolver is filtering or faking answers (some sandboxes and VPNs do). Try --resolver 1.1.1.1.
Log shows nothing for the last hour
Check the clock and time zone of the log; traditional syslog lines carry no year or zone and are read in local time.
"Flush service is not configured for destination"
postqueue -s only works for domains in fast_flush_domains. Flush by queue ID instead.

Source and issues: github.com/Blackman99/postsleuth · GOAL.md · ROADMAP.md · MIT