Skip to content

How Email Works

chrisholloway5 edited this page Sep 8, 2026 · 2 revisions

How Email Works

This page began as a chapter of the 6.2.10 manual and has been corrected for 6.2.24, then rewritten against the 6.2.28 source on 8 September 2026. The Control Panel's pages are grouped differently now, so the paths below use today's groups; the TLS ports 465, 993 and 995 exist only after you create them on the TCP/IP ports page (a fresh install seeds 25, 587, 110 and 143); and everything added since 6.2.10 is in Changes-Since-6210. Where a value here disagrees with the Settings Reference, which is generated from the code, the reference is right.

Skip this chapter if you already know it. If you do not, the rest of this wiki will make far more sense with it.

Delivering a message

You send mail to alice@example.com. Here is what happens:

sequenceDiagram
    autonumber
    participant MC as Your mail client
    participant YS as Your hMailServer
    participant DNS as DNS
    participant RS as The recipient mail server
    participant A as Alice

    MC->>YS: SMTP submission on port 587 - you log in first
    YS-->>MC: 250 Queued
    YS->>DNS: Who receives mail for example.com?
    DNS-->>YS: MX record - mail.example.com, priority 10
    YS->>DNS: A and AAAA for mail.example.com, plus TLSA and MTA-STS policy
    YS->>RS: SMTP delivery on port 25 - no login, TLS if offered
    RS-->>YS: 250 OK
    Note over RS: Stored in the recipient mailbox
    A->>RS: IMAP on port 993
    RS-->>A: The message
Loading

Two facts do most of the explaining:

  1. Port 25 has no password. Any server on the internet may connect to your port 25 and offer you mail for your own domains. That is how email works — it has to be open, or nobody could write to you. This is also why spam exists, and why DNS for Your Domain, Stopping Spam and Security Hardening matter.
  2. Your users use a different door. Port 587 (submission) requires a login. Never let an unauthenticated stranger send mail through you to the outside world — that is an open relay, and it will get your server blacklisted within hours. hMailServer refuses this by default.

A submission, command by command

This is a real session on port 587 against a STARTTLS required listener, with the checks hMailServer runs at each step. Drawn from Server/SMTP/SMTPConnection.cpp.

sequenceDiagram
    autonumber
    participant C as Mail client
    participant S as hMailServer

    C->>S: TCP connect
    Note right of S: A PTR lookup for the client's IP starts on a worker thread now,<br/>so the Received header does not wait for it later
    S-->>C: 220 MAIL01 ESMTP

    C->>S: EHLO laptop.example.net
    S-->>C: 250-SIZE 20971520 … 250-STARTTLS … 250 HELP
    Note right of S: No AUTH mechanism is offered yet if the port or the IP range requires TLS

    C->>S: STARTTLS
    S-->>C: 220 Ready to start TLS
    C-->>S: TLS handshake
    Note right of S: Session resets - HELO host, credentials and any transaction are discarded.<br/>The client must EHLO again, per RFC 3207 section 4.2
    C->>S: EHLO laptop.example.net
    S-->>C: 250-AUTH LOGIN SCRAM-SHA-256 SCRAM-SHA-256-PLUS …

    C->>S: AUTH PLAIN then the base64 credential
    Note right of S: Account looked up, lockout checked before the password,<br/>stored hash verified, then app passwords if the account is enrolled in TOTP
    S-->>C: 235 2.7.0 authenticated.

    C->>S: MAIL FROM: you@yourcompany.com SIZE=48210
    Note right of S: Disk space, sender syntax, Send-As permission,<br/>per-IP submission rate, SIZE against the domain limit, sending quota
    S-->>C: 250 2.1.0 OK

    C->>S: RCPT TO: alice@example.com
    Note right of S: Deliverability, relay permission for this IP range,<br/>authentication requirement, greylisting, recipient expansion
    S-->>C: 250 2.1.5 OK

    C->>S: DATA
    Note right of S: OnSMTPData script event fires here — it can still refuse
    S-->>C: 354 OK, send.
    C->>S: headers, body, then CRLF dot CRLF
    Note right of S: Accept and save run on a worker thread -<br/>post-transmission spam tests, spam headers, signature,<br/>List headers, Authentication-Results, archive, save
    S-->>C: 250 2.0.0 Queued (0.412 seconds)

    C->>S: QUIT
    S-->>C: 221 goodbye
Loading

The (0.412 seconds) in the final reply is real: the server times the accept-and-save stage and reports it. A number that climbs into whole seconds is the first sign of a slow spam test, a stalled virus scanner or a saturated work queue — see Diagnosing Stalled Mail.

The checks, in the order the server runs them

The order matters, because it decides when a sender is told no — before the message body is transferred, or after.

# Stage What is checked Reply when it fails
1 Any command On a STARTTLS required port, everything but NOOP, EHLO, STARTTLS and QUIT 530 Must issue STARTTLS first.
2 MAIL FROM Free disk space — MinimumFreeDiskSpaceMB, default 100 452 4.3.1 Insufficient system storage — temporary, so the sender holds the message
3 MAIL FROM Syntax; a blank sender is allowed only if Allow empty sender address is on 550 The address is not valid. / 550 Sender address must be specified.
4 MAIL FROM Send-As: the authenticated account owns or was granted this address — only when SmtpAuthenticatedSenderCheck=1 (default 0) 550 5.7.1 Sender address rejected
5 MAIL FROM Per-IP rate: MaxSubmissionsPerIPPerMinute (default 0, unlimited) 421 Too many messages from your IP address
6 MAIL FROM ESMTP parameters: SIZE, AUTH, BODY, SMTPUTF8, RET, ENVID. Anything else is refused rather than ignored 501 for a malformed value, 550 Unsupported ESMTP extension for an unknown one
7 MAIL FROM Pre-transmission spam tests, when DNSBLChecksAfterMailFrom=1 (the default) 550 5.7.1 with the failing test's message
8 MAIL FROM Declared SIZE against the domain's maximum message size (default 20480 KB) 552 5.3.4 Message size exceeds fixed maximum message size
9 RCPT TO Is the address deliverable at all? A database that cannot answer is told apart from an address that does not exist 550 Unknown user, or 451 4.3.2 Unable to verify the recipient at the moment
10 RCPT TO Mailbox full 452 4.2.2temporary, so the sending server holds the mail rather than bouncing it
11 RCPT TO Relay permission for this IP range (see the next section) 550 Delivery is not allowed to this address.
12 RCPT TO Authentication required for this combination and not supplied 530 SMTP authentication is required.
13 RCPT TO Greylisting, when it is on (default off) 451 Please try again later.
14 DATA The OnSMTPData script event 554 5.7.1 Rejected, or 453 4.7.0 for a temporary refusal
15 end of data Actual size, bare LF check, post-transmission spam tests 552 5.3.4, 554 5.6.0 Rejected - Message containing bare LF's, 554 5.7.1
16 end of data The OnAcceptMessage script event, then the database write 554 5.7.1, or 451 4.3.0 … could not be saved. Please retry later.

Two conventions in that table. An ESMTP session — one where the client greeted with EHLO rather than HELO — has an RFC 3463 enhanced status code inserted after the numeric one, so 530 Must issue STARTTLS first. is really 530 5.7.0 Must issue STARTTLS first., and a plain 550 becomes 550 5.7.1. And a 4xx reply always means "come back later" while a 5xx means "never": the difference is what decides whether a legitimate sender's message waits in their queue or is destroyed, which is why a full mailbox is 452 and not 550.

Everything numbered 2 to 13 happens before a single byte of the message crosses the wire. That is deliberate: refusing early costs the sender nothing and costs you nothing, and it generates no bounce to an address the spammer only claimed to be.

Who may send through you

This is the single decision that separates a mail server from an open relay. hMailServer makes it per IP range (Access & abuse protection → IP ranges), from a two-by-two grid.

flowchart TD
  A["RCPT TO accepted so far"] --> B{"Is the sender address<br/>one of your own domains,<br/>or a route address?"}
  B -->|yes| C{"Is the recipient local?"}
  B -->|no| D{"Is the recipient local?"}
  C -->|yes| E["Local to local"]
  C -->|no| F["Local to remote"]
  D -->|yes| G["Remote to local"]
  D -->|no| H["Remote to remote"]

  E --> I{"Allowed for this IP range?"}
  F --> I
  G --> I
  H --> I
  I -->|no| J["550 Delivery is not allowed to this address."]
  I -->|yes| K{"Does the range require<br/>SMTP authentication<br/>for this combination?"}
  K -->|yes, and not authenticated| L["530 SMTP authentication is required."]
  K -->|otherwise| M["250 2.1.5 OK"]
Loading
  • Remote to local is inbound mail from the internet. It must be allowed, and must not require authentication, or nobody can write to you.
  • Remote to remote is the open-relay box. On the default Internet range it is off. Leave it off.
  • Local to remote is your own users sending outward. Allowed, but with authentication required — which is why port 587 needs a login.

The IP ranges page: each range with its address span, priority, and the connection, relaying and authentication rules that apply to clients inside it

Ranges are matched by priority, highest first, so a narrow range for your own network can sit in front of the broad Internet range without editing it.

There is one more consequence buried here: if the client authenticated, or if it sends as one of your own domains from a range that does not require authentication to do so, this server counts itself the submission server for the message (RFC 6409) and adds the headers a submission server adds. For everyone else it is a relay, and a relay adds trace fields only.

After "250 Queued": the delivery pipeline

Acceptance and delivery are separate. The 250 means the message is on disk and in the database; delivery happens on the delivery threads afterwards.

flowchart TD
  Q["Message in the delivery queue"] --> P1["OnDeliveryStart script event"]
  P1 -->|cancelled| X["Deleted, and logged as rejected"]
  P1 --> P2["Virus scanning"]
  P2 -->|infected| X2["Handled per your anti-virus action"]
  P2 -->|unscannable, and AVFailAction is 1| HOLD["Held, retried later"]
  P2 --> P3["Global rules"]
  P3 --> P4["OnDeliverMessage script event"]
  P4 --> P5["DKIM signing - first attempt only"]
  P5 --> P6["Mirroring - first attempt only"]
  P6 --> LD["Local delivery - account rules, Sieve, forwarding,<br/>distribution lists, public folders"]
  LD --> RM{"Any recipients left<br/>outside this server?"}
  RM -->|no| DONE["Delivered"]
  RM -->|yes| ED["External delivery - routes or MX lookup,<br/>MTA-STS, DANE, TLS, then SMTP"]
  ED --> OK{"Accepted by the<br/>receiving server?"}
  OK -->|yes| DONE
  OK -->|temporary failure| RETRY["Rescheduled - 4 tries,<br/>60 minutes apart by default"]
  RETRY --> ED
  OK -->|permanent failure, or retries exhausted| DSN["RFC 3464 delivery status<br/>notification to the sender"]
Loading

DKIM signing and mirroring run only on the first attempt (retry count = 0), so a message is never signed twice or mirrored twice. The retry count and interval are on Mail flow & delivery → Delivery of e-mail (Number of delivery retries, Minutes between retries); a route may override them for its own domains.

Where the filters sit

Anti-spam runs in two phases, and which phase a test belongs to is fixed by the test, not by configuration. The pipeline stops early once the score has already passed the higher of your two thresholds, so a message condemned by a cheap local test never pays for an expensive remote one.

Order Test Phase Score when it fails Default on a fresh install
1 Blocked senders — first, because it is the cheapest verdict there is pre-transmission the entry's own score list is empty
2 DNS blacklists (DNSBL) pre-transmission per list, seeded 3 zen.spamhaus.org and bl.spamcop.net are seeded but inactive
3 HELO host pre-transmission 2 off (ascheckhostinhelo 0)
4 PTR / reverse DNS pre-transmission 1 off (ascheckptr 0)
5 MX records for the sender domain pre-transmission 2 off (usemxchecks 0)
6 SPF pre-transmission 3 off (usespf 0)
7 SURBL post-transmission per list, seeded 3 multi.surbl.org is seeded but inactive
8 DKIM verification post-transmission 5 off (ASDKIMVerificationEnabled 0)
9 ARC — before DMARC, because its only output offsets the DMARC score post-transmission a negative offset off (ASArcFilteringEnabled 0)
10 DMARC post-transmission 5 on (ASDMARCEnabled 1)
11 SpamAssassin post-transmission as scored off (spamassassinenabled 0); spamassassinport seeded 783
12 Filter hook — last, because it is the most expensive post-transmission as returned, refusing at FilterHookRejectScore (100) off (FilterHookUrl empty)

DMARC being on while SPF and DKIM verification are off is not a contradiction: the DMARC test evaluates SPF and DKIM itself, for its own purposes, whether or not either is enabled as a scored test of its own. So a default install does judge inbound mail against the sender's published DMARC policy — it simply does not add a separate SPF or DKIM score on top.

Greylisting is not a scored test: it is a separate 451 at RCPT TO, off by default. Two thresholds decide what a score means — mark (default 5) adds spam headers and sets the spam flag, delete (default 20) refuses the message, or quarantines it when the quarantine is on and there is a message to hold. A quarantined message is answered 250, because a false positive that the sender believes was delivered is recoverable without backscatter; a refused one is answered 550 or 554.

Anti-spam does not run at all for an authenticated session, for a whitelisted sender, or for an IP range with spam protection switched off. See Stopping Spam and Stopping Viruses.

Reading mail back: IMAP

IMAP is a state machine, and almost every "the client says the folder is empty" problem is a client sitting in the wrong state. This is hMailServer's, drawn from Server/IMAP/IMAPConnection.cpp and the command handlers beside it.

stateDiagram-v2
    state "Not authenticated" as NA
    state "Authenticated" as AU
    state "Selected" as SE
    state "Idling" as IDL

    [*] --> NA : TCP connect, then the OK greeting
    NA --> NA : STARTTLS, then re-read CAPABILITY
    NA --> NA : CAPABILITY, NOOP, ID
    NA --> AU : LOGIN or AUTHENTICATE succeeds
    NA --> NA : LOGIN fails - tarpit, then NO
    NA --> [*] : 10 failures on one connection - BYE

    AU --> AU : LIST, LSUB, CREATE, DELETE, RENAME, SUBSCRIBE, STATUS, APPEND, GETQUOTA, SETACL
    AU --> SE : SELECT or EXAMINE
    SE --> AU : CLOSE or UNSELECT
    SE --> SE : FETCH, STORE, SEARCH, SORT, THREAD, COPY, MOVE, EXPUNGE, REPLACE
    SE --> IDL : IDLE
    IDL --> SE : DONE, or any command

    AU --> NA : UNAUTHENTICATE - user discarded, TLS and ENABLEd extensions kept
    SE --> NA : UNAUTHENTICATE

    AU --> [*] : LOGOUT
    SE --> [*] : LOGOUT
Loading

COMPRESS DEFLATE (new in 6.2.28) is a self-transition available in any state while IMAPCompressionEnabled=1 and the connection is not already compressed. A second COMPRESS is refused with [COMPRESSIONACTIVE], and STARTTLS is refused once compression is active.

SELECT is where the numbers come from. hMailServer answers it with EXISTS, RECENT, FLAGS, [UIDVALIDITY], [UNSEEN], [UIDNEXT] and [MAILBOXID], plus [HIGHESTMODSEQ] once the session has enabled CONDSTORE. A session that has enabled IMAP4rev2 gets no RECENT and no [UNSEEN], because RFC 9051 removed them.

IMAP or POP3?

  • IMAP keeps mail on the server; every device sees the same mailbox, folders and read/unread state, and the server pushes changes to an idle client. Use IMAP.
  • POP3 downloads mail to one device and (usually) deletes it from the server. It has no folders, no shared flags and no push — a client that wants to look responsive must poll, and each poll costs a TCP connection, a TLS handshake and a password verification. hMailServer advertises EXPIRE NEVER on POP3, which is the honest answer: nothing anywhere deletes a delivered message because of its age, so "leave mail on server" means for good.
IMAP POP3
Mail lives on the server on one device
Folders yes, and shared/public folders too INBOX only
Read/unread, flags shared across devices per device
New-mail notification IDLE pushes it polling only, with an optional LOGIN-DELAY
Server-side search yes no
Concurrent sessions many one at a time — a second gets -ERR [IN-USE]
Default ports here 143, and 993 once you create it 110, and 995 once you create it

The three things that make mail arrive

A message you send is judged before it is delivered. Three DNS records decide whether it lands in the inbox or the spam folder — or is rejected outright:

  • SPF — a list of the servers allowed to send mail for your domain.
  • DKIM — a cryptographic signature proving the message really came from you and was not altered.
  • DMARC — a policy saying what to do when SPF and DKIM disagree, and where to send reports.

DNS for Your Domain sets all three up. Without them your mail will go to spam. This is the single most common reason a new mail server "doesn't work". Note the asymmetry: hMailServer signs your outgoing mail with DKIM as soon as you configure a key, but checking SPF on incoming mail is off by default — the record you publish is for other people's servers, and the check you enable is for yours.


Clone this wiki locally