IMAP vs POP3 for Developers: Ports, Commands, and Test Scripts

IMAP works on the server's copy of a mailbox, POP3 downloads it. Ports, the commands to type into openssl, and curl, Python, and Node scripts for both.

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

ProtocolPortEncryption
IMAP993TLS from the first byte
IMAP143Plain text, upgraded with STARTTLS
POP3995TLS from the first byte
POP3110Plain text, upgraded with STLS
SMTP submission587Plain text, upgraded with STARTTLS
SMTP submission465TLS from the first byte

Those are the standard assignments. RFC 8314 recommends the TLS-first ports for mail clients.

The sandbox listens on these:

ProtocolHostPort
IMAPimap.smtp.dev993, or 143 with STARTTLS
POP3pop.smtp.dev995
SMTPsend.smtp.dev587 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

IMAPPOP3
Where mail livesOn the server. Clients cache itDownloaded. Keeping the server copy is optional
FoldersAny number, plus INBOXThe inbox only
Read and flagged stateStored on the server, shared by every clientNone
SearchOn the server, with SEARCHNone. Download, then filter
Message idsA UID per message, stable within a mailboxA unique id from UIDL. Message numbers change between sessions
New mailPushed while the client waits in IDLESeen at the next login. A session sees the inbox as it was
Part of a messageAny header or MIME partHeaders plus the first n lines, with TOP
DeletingFlag \Deleted, then EXPUNGEDELE, 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
CommandDoes
LOGINLogs in. Put the password in double quotes if it has spaces or punctuation
LIST "" "*"Lists every mailbox
SELECT INBOXOpens a mailbox. The reply has the message count as EXISTS, and UIDVALIDITY
UID SEARCH UNSEENReturns 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), EXPUNGEDeletes 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
CommandDoes
STATReturns the message count and total size in bytes
LISTReturns the number and size of each message
UIDLReturns the number and unique id of each message. The ids persist, the numbers don't
TOP 1 0Returns the headers of message 1 and no body lines
RETR 1Returns the whole message
DELE 1Marks 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. UIDL is what stops the second run from storing duplicates.
  • A delete that didn't happen. POP3 applies DELE at QUIT. 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

ReplyCause
NO [AUTHENTICATIONFAILED] Invalid credentialsIMAP login failed: a wrong password, or the username isn't the full address
-ERR [AUTH] Username and password not acceptedThe same, over POP3
BADIMAP couldn't parse the command: a missing tag, or an unquoted argument with a space
Nothing after connectingPlain 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.