Skip to content

Incoming mail is not arriving

When to use: senders report bounces or long delays to addresses on this server, a test message from an outside account never reaches the inbox, or the Delivery log shows no inbound traffic when there should be some. Preconditions: shell access to the host; an outside machine (not this server) that can make connections to port 25; admin access to the console.

Work from the outside in and stop at the first step that fails: each one is only meaningful once the one before it passes. Commands use the container names Compose gives the stack (mailserver-postfix-1, mailserver-dovecot-1, …). Start with mailctl doctor (Doctor): it answers steps 3 and 4 in one pass.

It is not the licence. No licence stage ever stops incoming mail: SMTP, LMTP and IMAP are never checked against it, and the daemons do not read it. A lapsed licence refuses growth from 30 days, makes the admin panel read-only from 60 and, on a billing lapse, refuses outgoing mail from 90 days — never receiving or reading (Licensing). Do not spend time on Licence while inbound mail is down.

  1. DNS: does the world send to this server? From any machine:

    Terminal window
    dig +short MX example.com
    dig +short A mail.example.com

    The MX must name the mail hostname, and that hostname’s A record must be this server’s public IP. Domains → the domain → DNS & routing → Verify now checks the same from the server’s side and shows what is actually published next to what should be. A domain whose routing is set to Another server is deliberately left out of what Postfix accepts; its mail goes to its own MX, not here.

  2. Is port 25 reachable from outside? From a machine that is not this server (many home and office connections block outbound 25, so use another server):

    Terminal window
    nc -v mail.example.com 25

    A working server answers 220 <mail hostname> ESMTP. A timeout means something between the internet and Postfix drops the connection: the provider’s or host’s firewall, or a cloud security group. Check that Docker publishes the port on the host:

    Terminal window
    docker port mailserver-postfix-1 25

    On a default install it prints 0.0.0.0:25; the address and port come from PUBLISH_BIND and SMTP_PORT in .env. PUBLISH_BIND=127.0.0.1 publishes on loopback only, which is for a development stack and receives nothing from outside.

  3. Is Postfix running?

    Terminal window
    docker ps --filter name=mailserver-postfix-1
    docker logs --tail 100 mailserver-postfix-1

    The status must be Up … (healthy). A container that restarts in a loop usually means a configuration it refused: GET /api/v1/server/config/history shows the validator output, and POST /api/v1/server/config/rollback returns to the previous version (Doctor → Common findings). The container healthcheck is postfix status, which passes with a dead database, so healthy does not clear step 4.

  4. Can Postfix read the database? Postfix and Dovecot read the domains, mailboxes and aliases straight from Postgres views, with their own credentials (DB_DAEMON_USER, default mail_daemon). When that path breaks, all mail stops while every container still reports healthy. mailctl doctor reports it as postfix → postgres and dovecot → postgres; to ask by hand, query the maps Postfix uses:

    Terminal window
    docker exec mailserver-postfix-1 postmap -q example.com pgsql:/etc/postfix/sql/domains.cf
    docker exec mailserver-postfix-1 postmap -q user@example.com pgsql:/etc/postfix/sql/mailboxes.cf
    docker exec mailserver-postfix-1 postmap -q user@example.com pgsql:/etc/postfix/sql/aliases.cf

    The domain map prints the domain and the mailbox map prints the Maildir path (example.com/user/). A warning: connect to pgsql server on stderr is the broken path: check postgres is up, then restart api (or, if you changed DB_DAEMON_PASSWORD in .env, recreate api postfix dovecot), which sets the mail_daemon password from DB_DAEMON_PASSWORD on every boot (Doctor). No output and no warning means the database answered and the row is not there. The views leave out:

    • a domain that is inactive, or whose tenant is inactive, or that is routed to Another server;
    • a mailbox that is inactive or suspended (Suspended (login and delivery refused; mail is kept) on the mailbox’s page) — mail to it is refused as User unknown in virtual mailbox table;
    • an alias or forwarder that is inactive.
  5. What is Postfix saying to the senders? The Postfix log is in the shared maillog volume and covers the current day; earlier days are rotated beside it, gzipped, and kept for seven days.

    Terminal window
    docker exec mailserver-postfix-1 grep 'NOQUEUE' /var/log/mail/postfix.log | tail -20

    The same rejections are in the Delivery log with Outcome set to rejected at the door and direction inbound (Delivery log & bounce classification). What the common lines mean:

    • Relay access denied — Postfix does not consider the recipient’s domain one of its own: back to step 4 for the domain map.
    • User unknown in virtual mailbox table — the domain is ours, the address is not a mailbox, alias or catch-all here (or the mailbox is suspended).
    • milter-reject — Rspamd refused the message as spam (score at or above the reject score, default 15, under Settings → Spam thresholds, and per domain or per mailbox under Spam on their pages, Per-domain and per-mailbox spam settings). Messages scoring at or above the greylist score (default 4) are deferred with Try again later and accepted when the sender retries after Rspamd’s five-minute greylist timeout, so a delay of a few minutes on a first message is greylisting, not an outage.
    • Connection rate limit exceeded — one sending IP opened more connections than smtpdClientConnectionRateLimit allows (default 60 per hour, Settings → Sending limits; see Outbound rate limits). A large sender delivering from one address can hit it.

    If Rspamd is down, Postfix accepts mail without scanning it (milter_default_action = accept) rather than refusing it, so a stopped rspamd makes mail less filtered, not undelivered.

  6. Is Postfix handing the mail to Dovecot? Accepted mail goes to Dovecot over LMTP (dovecot:24):

    Terminal window
    docker exec mailserver-postfix-1 grep 'relay=dovecot' /var/log/mail/postfix.log | tail -5
    docker exec mailserver-postfix-1 postqueue -p | tail -5

    status=sent (250 2.0.0 … Saved) is a delivered message. status=deferred with relay=dovecot means Postfix accepted the mail and Dovecot did not take it — the message waits in the queue (Mail queue in the console, Mail queue (viewer, hold / release / delete)) and is retried, so nothing is lost yet. Check Dovecot next:

    Terminal window
    docker ps --filter name=mailserver-dovecot-1
    docker exec mailserver-dovecot-1 doveadm user user@example.com
    docker exec mailserver-dovecot-1 grep 'lmtp' /var/log/mail/dovecot.log | tail -20

    doveadm user prints the mailbox’s home and mail location; doesn't exist is the same missing row as step 4, and a lookup failure is Dovecot’s own database path (dovecot → postgres in doctor).

  7. Is the mailbox full?

    Terminal window
    docker exec mailserver-dovecot-1 doveadm quota get -u user@example.com

    A mailbox at its limit defers new mail (452 4.2.2, Dovecot’s quota_full_tempfail = yes): it waits in this server’s queue (Mail queue, or postqueue -p in the postfix container), and is bounced only after five days. Raise the quota on the mailbox’s page, or have the owner delete mail; Postfix delivers it at its next retry, within about an hour (its back-off tops out at 4000 s). The mailbox’s page shows its storage use, and doveadm quota get -A lists every mailbox.

  8. Is the disk full? A full disk stops Postfix accepting mail and Dovecot storing it. df -h on the host, then The disk is full.

From an outside account, send a message to an address on the domain. It appears in the mailbox, and the Delivery log shows it as inbound, class delivered. mailctl doctor reports no ✖. Anything still deferred in the queue drains on Postfix’s next retry; release anything you put on hold.

Diagnosis changes nothing. If you rolled the configuration back in step 3, the version you left is still in GET /api/v1/server/config/history and can be applied again once the reason it was refused is fixed.