SMTP is a simple, text-based, line-oriented protocol, which is why it's easy to test by hand. Knowing the raw conversation makes every Postfix log line and every smtpd_*_restrictions setting make sense.
A manual SMTP conversation¶
$ telnet localhost 25
Trying 127.0.0.1...
Connected to localhost.
220 mail.domain.tld ESMTP Postfix
EHLO client.domain.tld
250-mail.domain.tld
250-PIPELINING
250-SIZE 10240000
250-STARTTLS
250-ENHANCEDSTATUSCODES
250-8BITMIME
250-SMTPUTF8
250 CHUNKING
MAIL FROM:<[email protected]>
250 2.1.0 Ok
RCPT TO:<[email protected]>
250 2.1.5 Ok
DATA
354 End data with <CR><LF>.<CR><LF>
From: Sender <[email protected]>
To: Recipient <[email protected]>
Subject: Test message
This is the body of the message.
.
250 2.0.0 Ok: queued as A1B2C3D4
QUIT
221 2.0.0 Bye
Command by command:
220: the server's greeting banner, sent unprompted as soon as the connection opens.EHLO(or the olderHELO): the client introduces itself and asks which extensions the server supports. The250-continuation lines advertise them:STARTTLS,AUTH,PIPELINING,SIZE,8BITMIME,SMTPUTF8,CHUNKINGand so on. That's the "E" in ESMTP. PlainHELOgets no extension list, so noSTARTTLSorAUTH.MAIL FROM:sets the envelope sender, independent of anything in the headers.MAIL FROM:<>(the null sender) is used for bounces and auto-replies so they can't trigger more bounces.RCPT TO:sets an envelope recipient. Repeat it for multiple recipients. Each gets its own accept or reject.DATA: everything until a line containing only.is the message itself: headers, a blank line, then the body. This is whereheader_checks,body_checksand milters operate.QUITcloses politely.
Notice that AUTH isn't advertised in the example. The session isn't encrypted yet, and Postfix hides AUTH until after STARTTLS when smtpd_tls_auth_only = yes, which is the correct setting.
Other commands you'll meet: RSET (abort the current transaction, keep the connection), NOOP, VRFY/EXPN (verify an address or expand a list, which leaks information, so disable them with disable_vrfy_command = yes), and BDAT (CHUNKING's binary alternative to DATA).
Reply codes¶
Replies are three-digit codes, and the first digit gives the category:
| First digit | Meaning |
|---|---|
| 2xx | Success |
| 3xx | Intermediate: more input needed (e.g. 354 after DATA, 334 during AUTH) |
| 4xx | Temporary failure: the client should keep the message queued and retry later |
| 5xx | Permanent failure: don't retry; the sender gets a bounce |
The extended codes in logs (4.7.1, 5.1.1) are Enhanced Status Codes (RFC 3463), in the form class.subject.detail:
| Code | Typical meaning |
|---|---|
2.0.0 |
OK |
4.2.0 / 4.7.1 |
Temporary policy rejection: greylisting, rate limits |
4.4.1 / 4.4.2 |
No answer from host / connection dropped |
5.1.1 |
No such user |
5.2.2 |
Mailbox full |
5.7.1 |
Delivery not authorized: relay denied, policy, spam |
5.7.26 / 5.7.27 |
Failed sender authentication: SPF/DKIM/DMARC (wording varies by receiver) |
The 4xx/5xx distinction is the whole reason greylisting works. A 450 4.7.1 tells a legitimate, RFC-compliant MTA "try again in a few minutes", while most spam software never retries.
STARTTLS vs implicit TLS¶
There are two ways to encrypt an SMTP session:
- STARTTLS: the connection starts in plaintext on the normal port (25 or 587). The client sees
STARTTLSin theEHLOreply, sendsSTARTTLS, both sides do a TLS handshake, and then the client must sendEHLOagain. Everything learned before TLS is discarded. In Postfix,smtpd_tls_security_level = may(server) andsmtp_tls_security_level = may(client) enable this opportunistically. - Implicit TLS: TLS starts immediately when the connection opens, before any SMTP. That's port 465 for submission (Postfix:
smtpd_tls_wrappermode = yeson the server side,smtp_tls_wrappermode = yeson the client side).
530 5.7.0 Must issue a STARTTLS command first means the server requires TLS (smtpd_tls_security_level = encrypt) and the client tried to continue without it.
Port 25 between MTAs is opportunistic STARTTLS almost everywhere. To make it resistant to downgrade attacks, domains publish MTA-STS or DANE policies (see Email Authentication and Anti-Spam).
SMTP AUTH¶
After TLS, a submission client authenticates with SASL, most commonly PLAIN (username and password base64-encoded in one line) or LOGIN:
AUTH PLAIN AHVzZXJAZG9tYWluLnRsZABwYXNzd29yZA==
235 2.7.0 Authentication successful
Build the PLAIN token for a manual test (base64 is encoding, not encryption, which is why AUTH must only ever happen inside TLS):
printf '\0%s\0%s' '[email protected]' 'password' | base64
Providers that disable password logins use XOAUTH2 or OAUTHBEARER instead (Gmail, Microsoft 365).
Talking SMTP by hand¶
telnet and nc only work for plaintext sessions. For anything with TLS, use openssl s_client or swaks:
# Plain SMTP
nc -C mx.domain.tld 25 # -C sends CRLF line endings
# STARTTLS on 25 or 587: s_client handles the upgrade, then you type EHLO etc.
openssl s_client -connect smtp.domain.tld:587 -starttls smtp -crlf -quiet
# Implicit TLS on 465
openssl s_client -connect smtp.domain.tld:465 -crlf -quiet
# Postfix's own TLS diagnostic client: shows certificate, protocol, cipher, DANE/MTA-STS status
posttls-finger -c -l secure mx.domain.tld
# swaks does all of it, including AUTH, with a readable transcript
swaks --to [email protected] --server smtp.domain.tld --port 587 --tls --auth LOGIN --auth-user [email protected]
Inside s_client, a line beginning with a capital R (RCPT TO) or Q is interpreted as "renegotiate" or "quit" by OpenSSL. Type rcpt to: in lowercase, or use the -quiet / -ign_eof options as above.
Bounces and Delivery Status Notifications¶
When a message can't be delivered (after retries are exhausted, or immediately on a 5xx), the MTA that gives up sends a bounce to the envelope sender, formatted as a Delivery Status Notification (DSN, RFC 3464) with the original message or its headers attached. That's why the envelope sender matters so much: it's where failure reports go. It's also why spammers forge it, producing backscatter (bounces sent to innocent forged addresses), and why mailing list software uses per-recipient bounce addresses (VERP).
Rejecting during the SMTP session (a 5xx at RCPT or after DATA) is always better than accepting then bouncing. The sending MTA generates the bounce to its user, and you never send mail to a possibly-forged address.
Why this matters for reading logs¶
One message produces several log lines as it moves through Postfix's pipeline (smtpd accepts → cleanup processes headers → qmgr queues → smtp/local/virtual/lmtp delivers), all tagged with the same queue ID. Once you can mentally replay the SMTP conversation behind a log entry, troubleshooting stops being guesswork:
grep A1B2C3D4 /var/log/maillog # or: journalctl -t postfix/smtp -t postfix/qmgr | grep A1B2C3D4