NodeMailServer
Guides

Receiving mail

What the server does with a message that arrives on port 25, and which checks you can turn on, off or tighten.

The receiving server (IncomingServer and IncomingMailHandler) listens on port 25 and handles mail from other servers. Authentication is disabled on this port, because senders are other mail servers, not your users.

Which checks run, and what happens when one fails, is set in server/config/mail.config.json. The file reloads when you save it. See Configuration files.

The pipeline

When an email arrives on port 25, it goes through four stages.

1. Connect

  • Optional reverse DNS check (incoming.checks.reverseDns). The client's IP must have a PTR record, and that name must resolve back to the same IP. It is off by default, and when it fails it tags the message.
  • Clients in incoming.trustedNetworks (loopback by default) skip the checks and the rate limit.

2. MAIL FROM

  • Applies a per-IP rate limit (incoming.rateLimit). Going over it counts as a strike against the IP. See Blocking abusive IPs.
  • Rejects .temp@ addresses and known disposable sender addresses (checks.disposableSender).
  • Rejects the sender when its domain has no MX, A or AAAA record (checks.senderDomain). It answers with a temporary 451 if DNS is down, so the sender retries.
  • Accepts a null sender (MAIL FROM:<>) without the domain check, because delivery failure notices (bounces) are sent that way.

3. RCPT TO

  • Rejects the recipient when its domain is not in incoming.recipients.acceptedDomains. An empty list accepts any domain.
  • Rejects the recipient when it is not in incoming.recipients.knownRecipients. An empty list accepts any user.
  • Enforces incoming.maxRecipients.
  • Accepts a recipient at BOUNCE_DOMAIN whatever the two lists say, and hands it to the bounce processor.

4. DATA

  • Enforces incoming.maxMessageSizeBytes.
  • Runs DKIM, SPF and DMARC checks with mailauth, in a worker thread, against the connecting client's IP address and its HELO name, on the original unmodified message.
  • Applies the action you set for each result (see Policy actions).
  • Removes any X-AE-* headers and forged Authentication-Results headers that the sender supplied, then adds the server's own.
  • Adds the incoming.customHeaders after the checks.
  • Seals the message with ARC headers when ARC_PRIVATE_KEY_PATH and ARC_SIGNING_DOMAIN are set. This keeps authentication working when mail is forwarded.
  • Encrypts the final message with PGP for storage when the PGP keys are set. See PGP encryption.

Custom headers are added after the checks

The X-AE-* headers are added only after authentication has finished, so they never break a DKIM signature.

Blocking abusive IPs

An IP that sends more than incoming.rateLimit.maxMessagesPerMinute messages in a minute gets a strike. Each strike blocks it for longer, and the last one blocks it for good:

StrikeDefault blockReply to every connection from that IP
115 minutes421 4.7.0 try again in N seconds
260 minutes421 4.7.0 try again in N seconds
3permanent554 5.7.1 blocked, contact the postmaster

A temporary block is a 421, so a real mail server only waits and retries. A blocked IP is refused as soon as it connects, before any other check runs.

Strikes are kept in the ip_blocks table of the server's SQLite database, MAIL_DATA_DIR/server.db, so a block survives a restart. An IP that stays clean for strikeResetHours (24 by default) starts again from strike 1. Networks in incoming.trustedNetworks are never limited or blocked.

"ipBlocking": {
  "enabled": true,
  "temporaryBlockMinutes": [15, 60],
  "strikeResetHours": 24
}

temporaryBlockMinutes has one entry per temporary block, and the next strike is permanent. [15, 60] gives two temporary blocks, then a permanent one. [] blocks for good on the first strike. With enabled: false, going over the limit only gives short temporary blocks that are kept in memory.

To see and change the list, run the CLI in the server folder, or in the container:

bun src/cli/ipBlocks.ts list
bun src/cli/ipBlocks.ts unblock 203.0.113.7
bun src/cli/ipBlocks.ts block 203.0.113.7 "spam run"
docker compose -f deploy/docker-compose.yml exec inbound bun src/cli/ipBlocks.ts list

Changes apply at once, without a restart. In Docker the file is on the inbound-data volume.

Policy actions

Each SPF, DKIM, DMARC, reverse DNS and sender-domain result has an action:

ActionResult
acceptIgnore the result.
tagAccept the message, and add X-AE-Spam-Flag: YES and X-AE-Policy-Flags to it.
tempfailReply 4xx, so the sender retries later.
rejectReply 5xx with an enhanced status code from RFC 7372.

By default the server rejects mail that fails DMARC with p=reject, tags mail that fails SPF, DKIM or reverse DNS, and accepts everything else. A DNS error never causes a hard reject. It becomes a temporary error. The full list of checks, settings and reply codes is in Configuration files.

Delivery reports

A delivery report (a multipart/report message of type delivery-status) sent to a normal mailbox is delivered as usual. The DSN processor also reads it, matches it to the message you sent, and records it. A report that arrives this way never adds an address to the suppression list, because it cannot be verified. See Bounces.

Settings

SettingWherePurpose
INCOMING_MAIL_HOSTserver/.envHostname the receiving server announces.
ARC_PRIVATE_KEY_PATH, ARC_SIGNING_DOMAIN, ARC_SELECTORserver/.envOptional ARC sealing.
PGP_*server/.envOptional encryption at rest.
incoming.*server/config/mail.config.jsonLimits, checks, accepted domains, trusted networks and custom headers.
GREETING, RELAY_DENIED, DMARC_REJECT and othersserver/config/smtp-messages.jsonThe text and code of each reply.

See Environment variables for defaults.

Components involved

ComponentFilePurpose
IncomingMailHandlerserver/src/services/IncomingMailHandler.tsHandles MAIL FROM, RCPT TO and DATA.
IncomingPolicyserver/src/services/IncomingPolicy.tsThe reverse DNS, sender, recipient, SPF, DKIM and DMARC decisions.
MailAuthserver/src/server/config/MailAuth.tsDKIM verification, SPF, DMARC policy, MTA-STS and ARC sealing.
MailComposerserver/src/server/config/MailComposer.tsBuilds RFC 5322 headers and MIME messages.
DNSCheckerserver/src/server/config/DnsChecker.tsResolves MX, SPF, DKIM, DMARC and TXT records and checks TCP ports.
SpamFilterationserver/src/server/config/SpamFilteration.tsPer-IP rate limiting with temporary blocks for abusive clients.
PGPServiceserver/src/server/config/encryption/PGPService.tsKey generation and message encryption.

Storing the message

When IMAP_LMTP_HOST is set, the checked message (with the authentication and policy headers added above) is handed to the IMAP server over LMTP. It appears in the recipient's mailbox at once, in any mail client that is already open.

  • Mail tagged by a check (X-AE-Spam-Flag: YES) is filed in the recipient's Junk folder instead of the inbox.
  • If every recipient's mailbox takes the message, the server replies 250.
  • If the IMAP server is down or busy, or any recipient hits a temporary error, it replies 450 for the whole message, so the sender retries later and nothing is lost. Mailboxes that already have the message do not store it again: the IMAP server keeps each delivered Message-ID per user in a SQLite file for 24 hours, and drops it when the user deletes the message.
  • If some recipients have a mailbox and others do not (unknown user, or IMAP turned off), the message is accepted and delivered to those who do. The refusals are logged, and the sender is not told.
  • If no recipient has a mailbox, it replies 550 5.1.1. If every mailbox is full, it replies 552.

Without IMAP_LMTP_HOST the server still checks, labels and accepts mail, but does not store it.

On this page