IMAP vs POP3 for Developers: Ports, Commands, and Test Scripts
IMAP and POP3 both fetch mail from a server. IMAP works on the server's copy: folders, read flags, and search live there, and every client sees the same state. POP3 downloads the inbox and can delete it afterwards. It has no folders, no flags, and no search.
Every sandbox account speaks both, so you can read one test message each way and see the difference.
IMAP, POP3, and SMTP Ports
| Protocol | Port | Encryption |
|---|---|---|
| IMAP | 993 | TLS from the first byte |
| IMAP | 143 | Plain text, upgraded with STARTTLS |
| POP3 | 995 | TLS from the first byte |
| POP3 | 110 | Plain text, upgraded with STLS |
| SMTP submission | 587 | Plain text, upgraded with STARTTLS |
| SMTP submission | 465 | TLS from the first byte |
Those are the standard assignments. RFC 8314 recommends the TLS-first ports for mail clients.
The sandbox listens on these:
| Protocol | Host | Port |
|---|---|---|
| IMAP | imap.smtp.dev | 993, or 143 with STARTTLS |
| POP3 | pop.smtp.dev | 995 |
| SMTP | send.smtp.dev | 587 with STARTTLS |
The username is the account's full address and the password is the account's password, on all three (connection settings).
IMAP vs POP3 at a Glance
| IMAP | POP3 | |
|---|---|---|
| Where mail lives | On the server. Clients cache it | Downloaded. Keeping the server copy is optional |
| Folders | Any number, plus INBOX | The inbox only |
| Read and flagged state | Stored on the server, shared by every client | None |
| Search | On the server, with SEARCH | None. Download, then filter |
| Message ids | A UID per message, stable within a mailbox | A unique id from UIDL. Message numbers change between sessions |
| New mail | Pushed while the client waits in IDLE | Seen at the next login. A session sees the inbox as it was |
| Part of a message | Any header or MIME part | Headers plus the first n lines, with TOP |
| Deleting | Flag \Deleted, then EXPUNGE | DELE, applied at QUIT |
Which One for a Test
- Usually neither. To assert on a message, the REST API returns it as JSON with the parts already decoded. There's no MIME to parse and no connection to keep open.
- IMAP when the code under test is an IMAP client, or to open the message in a desktop client.
- POP3 when your product pulls mail over POP3: a helpdesk importing a support inbox, or a "check mail from another account" feature.
By Hand with openssl
Typing the commands shows exactly what a client library does for you. Both sessions need a TLS connection, so use openssl instead of telnet.
IMAP Commands
openssl s_client -connect imap.smtp.dev:993 -crlf -quiet
Every command starts with a tag you choose, and the server repeats the tag on the line that ends its reply. Type one line at a time:
a1 LOGIN qa@test.example.com your-password
a2 LIST "" "*"
a3 SELECT INBOX
a4 UID SEARCH UNSEEN
a5 UID FETCH 1 (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])
a6 UID FETCH 1 BODY[]
a7 UID STORE 1 +FLAGS (\Deleted)
a8 EXPUNGE
a9 LOGOUT
| Command | Does |
|---|---|
LOGIN | Logs in. Put the password in double quotes if it has spaces or punctuation |
LIST "" "*" | Lists every mailbox |
SELECT INBOX | Opens a mailbox. The reply has the message count as EXISTS, and UIDVALIDITY |
UID SEARCH UNSEEN | Returns the UIDs of unread messages. ALL, FROM, TO, and SUBJECT are other keys |
UID FETCH 1 (… BODY.PEEK[…]) | Reads flags and four headers of UID 1 without marking it read |
UID FETCH 1 BODY[] | Returns the whole raw message and marks it read |
UID STORE … (\Deleted), EXPUNGE | Deletes it |
Replace 1 with a UID from the search. The server ends each reply with OK, NO when it refuses, or BAD when it can't parse the command.
Port 143 starts in plain text. To test the STARTTLS path instead:
openssl s_client -starttls imap -connect imap.smtp.dev:143 -crlf -quiet
POP3 Commands
openssl s_client -connect pop.smtp.dev:995 -crlf -quiet
-quiet matters here. Without it, openssl treats a line that starts with Q or R as one of its own commands, and QUIT and RETR never reach the server.
USER qa@test.example.com
PASS your-password
STAT
LIST
UIDL
TOP 1 0
RETR 1
DELE 1
QUIT
| Command | Does |
|---|---|
STAT | Returns the message count and total size in bytes |
LIST | Returns the number and size of each message |
UIDL | Returns the number and unique id of each message. The ids persist, the numbers don't |
TOP 1 0 | Returns the headers of message 1 and no body lines |
RETR 1 | Returns the whole message |
DELE 1 | Marks it. Nothing is deleted until QUIT, and a dropped connection deletes nothing |
Replies start with +OK or -ERR.
With curl
curl speaks both protocols:
# IMAP: UIDs of unread messages
curl "imaps://imap.smtp.dev/INBOX" --request "UID SEARCH UNSEEN" \
--user "qa@test.example.com:$QA_PASSWORD"
# IMAP: the message with UID 1, raw
curl "imaps://imap.smtp.dev/INBOX;UID=1" --user "qa@test.example.com:$QA_PASSWORD"
# IMAP: only its headers
curl "imaps://imap.smtp.dev/INBOX;UID=1;SECTION=HEADER" \
--user "qa@test.example.com:$QA_PASSWORD"
# POP3: list the messages, then fetch the first
curl "pop3s://pop.smtp.dev/" --user "qa@test.example.com:$QA_PASSWORD"
curl "pop3s://pop.smtp.dev/1" --user "qa@test.example.com:$QA_PASSWORD"
Add -v to see every command and reply.
From Python
imaplib and poplib are in the standard library. Waiting for a message over IMAP:
import email
import imaplib
import os
import ssl
import time
from email import policy
def wait_for_message(to, timeout=30):
with imaplib.IMAP4_SSL("imap.smtp.dev", ssl_context=ssl.create_default_context()) as imap:
imap.login("qa@test.example.com", os.environ["QA_PASSWORD"])
imap.select("INBOX", readonly=True)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
_, data = imap.uid("SEARCH", "TO", f'"{to}"')
uids = data[0].split()
if uids:
_, parts = imap.uid("FETCH", uids[-1], "(BODY.PEEK[])")
return email.message_from_bytes(parts[0][1], policy=policy.default)
time.sleep(2)
raise TimeoutError(f"no message to {to} after {timeout}s")
message = wait_for_message("qa+run42@test.example.com")
print(message["Subject"])
print(message.get_body(preferencelist=("plain",)).get_content())
readonly=True and BODY.PEEK leave the message unread, so a test doesn't change what the next one finds.
imaplib.IMAP4_SSL and poplib.POP3_SSL don't verify the server's certificate unless you pass a context. ssl.create_default_context() turns verification on.Python 3.14 added idle(), which waits for the server to announce a message instead of polling:
with imap.idle(duration=30) as idler:
for kind, data in idler:
if kind == "EXISTS":
break
POP3 can't search, so the script reads each message's headers with TOP and downloads the one it wants:
import os
import poplib
import ssl
from email import message_from_bytes, policy
pop = poplib.POP3_SSL("pop.smtp.dev", context=ssl.create_default_context())
pop.user("qa@test.example.com")
pop.pass_(os.environ["QA_PASSWORD"])
count, size = pop.stat()
for number in range(1, count + 1):
_, lines, _ = pop.top(number, 0)
headers = message_from_bytes(b"\r\n".join(lines), policy=policy.default)
if "qa+run42@test.example.com" in headers.get("To", ""):
_, lines, _ = pop.retr(number)
message = message_from_bytes(b"\r\n".join(lines), policy=policy.default)
print(message["Subject"])
print(message.get_body(preferencelist=("plain",)).get_content())
break
pop.quit()
From Node
With ImapFlow, and mailparser to decode the raw message:
import { ImapFlow } from 'imapflow'
import { simpleParser } from 'mailparser'
const client = new ImapFlow({
host: 'imap.smtp.dev',
port: 993,
secure: true,
auth: { user: 'qa@test.example.com', pass: process.env.QA_PASSWORD },
logger: false,
})
await client.connect()
const lock = await client.getMailboxLock('INBOX')
try {
const uids = await client.search({ to: 'qa+run42@test.example.com' }, { uid: true })
const { source } = await client.fetchOne(uids.at(-1), { source: true }, { uid: true })
const message = await simpleParser(source)
console.log(message.subject, message.text)
}
finally {
lock.release()
}
await client.logout()
The script assumes the message has already arrived. When nothing matches, search returns an empty array and there is nothing to fetch.
ImapFlow sits in IDLE whenever no command is running, and emits exists when the server announces new mail:
await client.mailboxOpen('INBOX')
client.on('exists', ({ count, prevCount }) => {
console.log(`${count - prevCount} new, ${count} in the inbox`)
})
What to Test in a Mail Client
If your product reads mail, these are the cases that break in production:
- Flags changed elsewhere. Mark a message read in Mail or with a
PATCH, and check your IMAP client picks the change up. - Folders. Create a mailbox, move a message into it, and check the client lists both.
- The same message twice. Run a POP3 import twice without deleting.
UIDLis what stops the second run from storing duplicates. - A delete that didn't happen. POP3 applies
DELEatQUIT. Drop the connection mid-session and check the importer doesn't assume the message is gone. - A large message. Send one near the size limit and check the client doesn't time out or load it all into memory.
When It Fails
| Reply | Cause |
|---|---|
NO [AUTHENTICATIONFAILED] Invalid credentials | IMAP login failed: a wrong password, or the username isn't the full address |
-ERR [AUTH] Username and password not accepted | The same, over POP3 |
BAD | IMAP couldn't parse the command: a missing tag, or an unquoted argument with a space |
| Nothing after connecting | Plain text sent to a TLS port. 993 and 995 need TLS from the first byte |
To send the message these examples read, see SMTP Test.
OTP & Magic Links
Read one-time codes and magic links from the real email over a REST API, then test resend, reuse, and single-use links. Playwright specs you can copy.
Nodemailer
Point Nodemailer at a sandbox SMTP server, send from a test, and assert on links, headers, and attachments over REST. Compared with mocks and Ethereal.