Testing End-to-End and how this all works.

This is part three in the series, and part one is here, along with part two being here.

Purpose:
Configure Postfix and Dovecot to use OpenLDAP for alias resolution and mailbox delivery, allowing one real mailbox (testuser@example.com) to receive mail for many virtual addresses (e.g., admin, webmaster, postmaster).


Step 0: Postfix LDAP lookup:

postmap -q admin@example.com ldap:/etc/postfix/ldap-aliases.cf

Returns

# => testuser@example.com

Step 1: Authentication:

doveadm auth test testuser `password you set in ldap`

Returns

# => passdb: testuser auth succeeded

Step 2: SMTP delivery via mailutils:

Send an email fromthe command line.

echo "Test body" | mail -s "Mailutils Test" admin@example.com

Check the logs that the emails sent.

tail /var/log/mail.log

Example:

2025-05-26T08:41:24.272207-04:00 testvm dovecot: lmtp(15174): Connect from local
2025-05-26T08:41:24.272207-04:00 testvm dovecot: lmtp(15174): Connect from local
2025-05-26T08:41:24.580419-04:00 testvm dovecot: lmtp(testuser@example.com)<15174><qr0sEHRhNGhGOwAAzPIkgg>: msgid=<20250526124124.27763495@testvm.example.com>: saved mail to INBOX
2025-05-26T08:41:24.580419-04:00 testvm dovecot: lmtp(testuser@example.com)<15174><qr0sEHRhNGhGOwAAzPIkgg>: msgid=<20250526124124.27763495@testvm.example.com>: saved mail to INBOX
2025-05-26T08:41:24.581983-04:00 testvm postfix/lmtp[15173]: 27763495: to=<testuser@example.com>, orig_to=<admin@example.com>, relay=testvm.example.com[private/dovecot-lmtp], delay=0.53, delays=0.18/0.02/0.03/0.31, dsn=2.0.0, status=sent (250 2.0.0 <testuser@example.com> qr0sEHRhNGhGOwAAzPIkgg Saved)
2025-05-26T08:41:24.581983-04:00 testvm postfix/lmtp[15173]: 27763495: to=<testuser@example.com>, orig_to=<admin@example.com>, relay=testvm.example.com[private/dovecot-lmtp], delay=0.53, delays=0.18/0.02/0.03/0.31, dsn=2.0.0, status=sent (250 2.0.0 <testuser@example.com> qr0sEHRhNGhGOwAAzPIkgg Saved)
2025-05-26T08:41:24.582857-04:00 testvm postfix/qmgr[15063]: 27763495: removed
2025-05-26T08:41:24.582857-04:00 testvm postfix/qmgr[15063]: 27763495: removed
2025-05-26T08:41:24.583336-04:00 testvm dovecot: lmtp(15174): Disconnect from local: Logged out (state=READY)
2025-05-26T08:41:24.583336-04:00 testvm dovecot: lmtp(15174): Disconnect from local: Logged out (state=READY)

Check for the actual email in the user directory.

ls /home/testuser/Maildir/new

`
Example:

'1748263284.M277947P15174.testvm,S=610,W=627'

Check inside email for msgid and match to logs.

cat /home/testuser/Maildir/new/1748263284.M277947P15174.testvm\,S\=610\,W\=627

Example:

Return-Path: <root@testvm.example.com>
Delivered-To: testuser@example.com
Received: from testvm.example.com
        by testvm.example.com with LMTP
        id qr0sEHRhNGhGOwAAzPIkgg
        (envelope-from <root@testvm.example.com>)
        for <testuser@example.com>; Mon, 26 May 2025 08:41:24 -0400
Received: by testvm.example.com (Postfix, from userid 0)
        id 27763495; Mon, 26 May 2025 08:41:24 -0400 (EDT)
Subject: Mailutils Test
To: <admin@example.com>
User-Agent: mail (GNU Mailutils 3.15)
Date: Mon, 26 May 2025 08:41:24 -0400
Message-Id: <20250526124124.27763495@testvm.example.com>
From: root <root@testvm.example.com>

Test body

Step 3: SMTP delivery via telnet:

telnet localhost 25
EHLO localhost
MAIL FROM:<root@testvm.example.com>
RCPT TO:<admin@example.com>
DATA
Subject: Telnet Test
Hello.
.
QUIT

Example:

telnet localhost 25
Trying ::1...
Connected to localhost.
Escape character is '^]'.
220 testvm.example.com ESMTP Postfix (Debian/GNU)
EHLO localhost
250-testvm.example.com
250-PIPELINING
250-SIZE 10240000
250-VRFY
250-ETRN
250-STARTTLS
250-AUTH PLAIN
250-ENHANCEDSTATUSCODES
250-8BITMIME
250-DSN
250-SMTPUTF8
250 CHUNKING
MAIL FROM:<root@testvm.example.com>
250 2.1.0 Ok
RCPT TO:<admin@example.com>
250 2.1.5 Ok
DATA
354 End data with <CR><LF>.<CR><LF>
Subject: Telnet Test
Hello.
.
250 2.0.0 Ok: queued as 95FA14A1
QUIT
221 2.0.0 Bye
Connection closed by foreign host.

Step 4: IMAP retrieval via telnet:

telnet localhost 143
A1 LOGIN testuser `password from ldap`
A2 SELECT INBOX
A3 FETCH 1 (ENVELOPE BODY[TEXT])
A4 LOGOUT

Example:

telnet localhost 143
Trying ::1...
Connected to localhost.
Escape character is '^]'.
* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS AUTH=PLAIN] Dovecot (Debian) ready.
A1 LOGIN testuser `password from ldap`
A1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND URL-PARTIAL CATENATE UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH ESORT SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY PREVIEW STATUS=SIZE SAVEDATE LITERAL+ NOTIFY SPECIAL-USE] Logged in
A2 SELECT INBOX
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft \*)] Flags permitted.
* 2 EXISTS
* 2 RECENT
* OK [UNSEEN 1] First unseen.
* OK [UIDVALIDITY 1748263284] UIDs valid
* OK [UIDNEXT 3] Predicted next UID
A2 OK [READ-WRITE] Select completed (0.003 + 0.000 + 0.002 secs).
A3 FETCH 1 (ENVELOPE BODY[TEXT])
* 1 FETCH (FLAGS (\Seen \Recent) ENVELOPE ("Mon, 26 May 2025 08:41:24 -0400" "Mailutils Test" (("root" NIL "root" "testvm.example.com")) (("root" NIL "root" "testvm.example.com")) (("root" NIL "root" "testvm.example.com")) ((NIL NIL "admin" "example.com")) NIL NIL NIL "<20250526124124.27763495@testvm.example.com>") BODY[TEXT] {11}
Test body
)
A3 OK Fetch completed (0.003 + 0.000 + 0.002 secs).
A4 LOGOUT
* BYE Logging out
A4 OK Logout completed (0.001 + 0.000 secs).
Connection closed by foreign host.

Or use mutt -f /home/testuser/Maildir or Thunderbird.


Alternatives & Notes on how this all works.

  • Local delivery (mbox): if you omit virtual_transport, mail goes to /var/mail/<user>.
  • Dovecot LDA: instead of LMTP, set virtual_transport = dovecot and configure the lda listener in 10-master.conf.
  • Autocreate plugin: replaced by namespace auto = create|subscribe syntax.
  • Authentication: choose between auth_bind = yes or hashed password compare.

How Postfix Uses LDAP

When you run:

postmap -q admin@example.com ldap:/etc/postfix/ldap-aliases.cf

Postfix performs an LDAP search with these parameters:

  • Host: 127.0.0.1
  • Port: 389
  • Version: 3
  • Base DN: ou=aliases,dc=example,dc=com
  • Scope: sub (subtree)
  • Filter:
  (|(cn=admin)(mail=admin@example.com))

– where %uadmin (the local‐part) and %sadmin@example.com (the full address)

  • Requested attribute: mail

So under the covers it effectively runs:

ldap_search(
  host="127.0.0.1", port=389, version=3,
  baseDN="ou=aliases,dc=example,dc=com",
  scope=LDAP_SCOPE_SUBTREE,
  filter="(|(cn=admin)(mail=admin@example.com))",
  attrs=["mail"]
)

and returns the mail value (testuser@example.com) as the lookup result.

How Dovecot Uses LDAP

When Dovecot is configured with the LDAP driver and auth_bind = yes, it actually does two LDAP operations for each login:

doveconf -n | grep -i ldap

Output:

  args = /etc/dovecot/dovecot-ldap.conf.ext
  driver = ldap
  args = /etc/dovecot/dovecot-ldap.conf.ext
  driver = ldap

1. Find the user’s DN (passdb lookup)

# LDAP search parameters
Host:    127.0.0.1
Port:    389
Version: 3
Base DN: ou=people,dc=example,dc=com
Scope:   subtree
Filter:  (&(objectClass=posixAccount)(|(uid=testuser)(mailAddress=testuser@example.com)))
Attrs:   (none—just fetching the DN)

# Pseudo‐call:
ldap_search(
  host="127.0.0.1", port=389, version=3,
  baseDN="ou=people,dc=example,dc=com",
  scope=LDAP_SCOPE_SUBTREE,
  filter="(&(objectClass=posixAccount)(|(uid=testuser)(mailAddress=testuser@example.com)))",
  attrs=[]
)
# Returns DN: uid=testuser,ou=people,dc=example,dc=com

Dovecot then attempts:

# Bind as the user
ldap_bind(
  dn="uid=testuser,ou=people,dc=example,dc=com",
  password="`ldap password`"
)

If the bind succeeds, authentication is granted.


2. Retrieve the user’s mailbox attributes (userdb lookup)

# LDAP search parameters (same as above)
Host:    127.0.0.1
Port:    389
Version: 3
Base DN: ou=people,dc=example,dc=com
Scope:   subtree
Filter:  (&(objectClass=posixAccount)(|(uid=testuser)(mailAddress=testuser@example.com)))
Attrs:   homeDirectory, uidNumber, gidNumber

# Pseudo‐call:
ldap_search(
  host="127.0.0.1", port=389, version=3,
  baseDN="ou=people,dc=example,dc=com",
  scope=LDAP_SCOPE_SUBTREE,
  filter="(&(objectClass=posixAccount)(|(uid=testuser)(mailAddress=testuser@example.com)))",
  attrs=["homeDirectory","uidNumber","gidNumber"]
)
# Returns e.g.
# homeDirectory=/home/testuser
# uidNumber=10000
# gidNumber=10000

Dovecot uses those to set the user’s home, UID, and GID for delivery and session ownership.


If you’d instead use password‐compare (no auth_bind), the passdb lookup changes to:

# passdb lookup
Filter:  (&(objectClass=posixAccount)(|(uid=%u)(mailAddress=%u)))
Attrs:   userPassword

and Dovecot pulls userPassword, compares the SSHA hash, and then (if successful) does the same userdb search as above.


So in your bind‐as‐user setup the exact Dovecot LDAP query for both auth and userdb is:

(&(objectClass=posixAccount)(|(uid=testuser)(mailAddress=testuser@example.com)))

against the base ou=people,dc=example,dc=com, retrieving first no attributes (to get DN), then homeDirectory, uidNumber, gidNumber.

That is it for this series for now and I have attached a zip file that contains this article in text format.