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:
- what the code means and whether Postfix's retries can succeed (temporary) or are pointless (permanent);
- how much mail it is costing you: deferred attempts, bounces, queued recipients and the domains affected;
- ordered steps, written for Postfix (for Gmail 4.7.28: stop, then a dedicated
gmailtransport withgmail_destination_concurrency_limit = 1, then raise it one at a time); - the provider's own pages for that code.
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.
| Playbook | Codes · when | Official references |
|---|---|---|
| Gmail / Google Workspace | ||
gmail-4.7.28 | 421 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-auth | 4.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-ptr | 421 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-tls | 421 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-reputation | 421 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-required | 550 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-s3150 | 550 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-ip | 550 5.7.606-649 Microsoft 365: banned sending IP | Delist portal guide, fix 5.7.511 Anti-Spam IP Delist Portal |
microsoft-5.7.511 | 550 5.7.511 Microsoft 365: banned sender (needs a manual review) | Delist portal guide, fix 5.7.511 |
microsoft-rate-limit | 451 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-policy | 550 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.515 | 550 5.7.515 Outlook.com: high-volume sender authentication required | Postmaster: troubleshooting, codes Postmaster: policies and guidelines |
microsoft-exo-relay | 550 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-deferred | 421 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-blocklist | 553 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-auth | 550 5.7.9 / 554 5.7.9 (authentication) Yahoo: mail failed authentication | SMTP error codes: authentication Sender requirements |
yahoo-policy | 554 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]
| Check | What fails it |
|---|---|
mx | no MX (warn), null MX |
spf | missing, more than one record, more than 10 DNS lookups (counted through includes), +all; ?all or no all warn |
spf-ip | the sending IP does not match (ip4/ip6/a/mx/include/redirect are evaluated; ptr/exists are not) |
dkim | no key at selector._domainkey, revoked key, RSA under 1024 bits; 1024 bits or t=y warn |
dmarc | missing or duplicated record, invalid p=; no rua= warns |
ptr | no 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.
| Tool | What it does | Arguments |
|---|---|---|
postfix_doctor | One-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 checksips sending IPsskip_dns do not run DNS checks |
postfix_queue_summary | Summarise 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_list | List 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. deferreddomain recipient domain; a leading dot matches subdomains toosender substring of the envelope senderreason_contains case-insensitive substring of the delay reasoncategory reason category id, e.g. rate_limit, auth, ptr, timeoutmin_age only messages older than this, e.g. 2h or 3dlimit max messages returned (default 50) |
postfix_queue_shape | qshape-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 domaintop max domains (default 20) |
postfix_log_stats | Aggregate 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 alldomain only this recipient domain (leading dot = include subdomains)top max entries per list (default 15) |
postfix_log_trend | Per-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 alldomain 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_trace | Follow 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 addresssince time window (default 7d)limit max messages (default 20, newest kept) |
mail_dns_check | Check 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_config | Delivery-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_playbook | Recovery 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 updomain recipient domain of reply, when it names no hostid one playbook, e.g. gmail-4.7.28provider gmail, microsoft or yahoo (server scan)since log window for the scan (default 24h) |
postfix_reason_categories | List 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_flush | Retry now: queue IDs (postqueue -i), one site (postqueue -s, needs fast_flush_domains), or everything (postqueue -f). | queue_ids, site, all, confirm |
postfix_queue_requeue | postsuper -r by queue ID. | queue_ids, confirm |
postfix_queue_hold / _release | postsuper -h / -H by queue ID. | queue_ids, confirm |
postfix_queue_delete | postsuper -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:
| Category | Hint postsleuth gives |
|---|---|
loop | Postfix thinks it is the destination: check mydestination, myhostname and virtual domains, or the MX points back at this host. |
relay_denied | The next hop refuses to relay for you: check relayhost, SASL credentials, or the recipient domain is not hosted there. |
sasl_auth | Login to the relay host failed: check smtp_sasl_password_maps (and run postmap after editing it). |
blocklist | The 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. |
ptr | The receiver requires reverse DNS: set a PTR for your sending IP that resolves back to the same IP (FCrDNS), ideally matching myhostname. |
auth | The receiver could not authenticate your mail: check SPF covers this IP, DKIM signs with a published selector, and DMARC aligns (postsleuth dns DOMAIN). |
greylisting | Greylisting: normal, Postfix retries automatically. Only a problem if it persists for hours. |
rate_limit | The receiver is throttling you: lower smtp_destination_concurrency_limit / add smtp_destination_rate_delay for that domain, and check your sending reputation. |
recipient_unknown | The mailbox does not exist: fix or remove the address (check for typos). |
mailbox_full | The recipient's mailbox is full: nothing to fix on your side; the sender should use another channel. |
size | The message is larger than the receiver accepts. |
tls | TLS negotiation failed: check smtp_tls_security_level / smtp_tls_policy_maps for that destination and the system CA bundle (smtp_tls_CAfile). |
dns | DNS lookup for the destination failed: check the domain exists and has MX/A records, and that this server's resolver works (dig MX domain). |
timeout | Connections to port 25 time out: many providers block outbound port 25, so test with nc -vz MX 25 and use a relayhost if blocked. |
connect | Could not connect to the destination: the remote server is down or unreachable, or outbound port 25 is blocked. |
policy | The receiver's policy rejected the message (spam or content filter). Read the full reply text; check authentication and the message content. |
other | Read 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
- Queue:
postqueue -j(Postfix 3.1+). Or a saved snapshot:--queue-file queue.json(-for stdin), handy for analysing a customer's server offline. - Log:
--log FILE(repeatable,.gzfine,-for stdin), or--log-cmd "journalctl -u postfix@- -o short-iso". Without either: Postfix'smaillog_file, then/var/log/mail.log,/var/log/maillog,/var/log/mail/mail.log,/var/log/postfix.log, then journald (mail facility). Traditional syslog, RFC 3339 and journaldshort-isotimestamps are understood; multi-instance names likepostfix-out/smtptoo. - Config:
postconf -nandpostconf -x. Parameters whose names look like secrets are redacted. - DNS: the system resolver, or
--resolver IP.
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:
- the CLI accepts
flush,requeue,hold,releaseanddelete; - the MCP server adds the five tools above, annotated as non-read-only (delete as destructive), and each refuses to run without
confirm: true; - operations take explicit queue IDs (validated;
ALLand anything starting with-is rejected). Commands are executed directly, never through a shell.
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 -sonly works for domains infast_flush_domains. Flush by queue ID instead.
Source and issues: github.com/Blackman99/postsleuth · GOAL.md · ROADMAP.md · MIT