NodeMailServer
Guides

Sending mail

Submit mail on port 587 or with the JSON API, how headers are added, how the outbound queue retries, and how mail is delivered.

Mail clients and your own apps send mail in one of two ways:

  • SMTP on port 587, with authentication.
  • HTTP, with the JSON send API.

Both end in the same pipeline, so Bcc handling, headers, DKIM signing and the queue behave the same.

Connect a client or app

SettingValue
ServerYour OUTGOING_MAIL_HOST, for example mail.example.com
Port587
AuthenticationRequired. LOGIN is supported. XOAUTH2 is rejected.

The pipeline

When a client submits mail on port 587:

  1. AUTH. LOGIN authentication is required.
  2. MAIL FROM. Validates the sender and checks for relay.
  3. RCPT TO. Checks each recipient against outgoing.maxRecipients.
  4. DATA. Processes the message:
    • Recipients come only from the SMTP envelope (the RCPT TO commands), so Bcc works and is never shown to other recipients.
    • Any Bcc: header is removed. The rest of the message is left unchanged.
    • The server adds headers, signs the message with DKIM, writes it to the outbound queue, and replies 250 Message queued as <id>.

The JSON send API builds the MIME message first, then runs the same steps.

Outgoing headers

The server adds these to every message before signing it:

HeaderWhenSetting
X-AE-Queue-Id: <id>Alwaysnone
X-AE-Bounce-Address: bounce+<id>@BOUNCE_DOMAINWhen BOUNCE_DOMAIN is setoutgoing.bounceHeader.enabled, outgoing.bounceHeader.name
Your own headersWhen outgoing.customHeaders.enabled is trueoutgoing.customHeaders.headers

Set your own headers in server/config/mail.config.json. Values can use {queueId} and {sender}:

"customHeaders": {
  "enabled": true,
  "headers": {
    "X-Campaign": "spring-{queueId}",
    "List-Unsubscribe": "<mailto:unsubscribe@yourdomain.com?subject={queueId}>"
  }
}

A header set here replaces a header with the same name sent by the client. Header names are checked when the file loads, and a bad name keeps the previous settings. The bounce header name is set in the same way, in outgoing.bounceHeader.name.

The headers are added before DKIM signing, so they are covered by the signature.

The outbound queue

Queued mail is kept in MAIL_SPOOL_DIR, so it survives a restart. Each recipient is tracked on its own:

ResultWhat happens
DeliveredRemoved from the queue.
Permanent failure (5xx)Removed and logged. The sender gets a bounce report.
Temporary failure (4xx, timeout, connection refused)Retried on the queue.retryDelaysMinutes schedule, by default after 1, 5, 15, 30, 60 and 120 minutes, then every 240 minutes, until queue.maxAgeHours (default 48) has passed.
Address on the suppression listFails straight away without a delivery attempt.

queue.maxConcurrent (default 10) sets how many deliveries run at the same time. All queue settings are in Configuration files.

Outbox and Sent

When the IMAP server is connected (IMAP_LMTP_HOST), mail a user sends on port 587 shows up in their mailbox:

  1. When the message is queued, a copy goes into the user's Outbox folder.
  2. When the queue is done and at least one recipient got it, the copy moves to Sent.
  3. If no recipient got it, the copy stays in Outbox and the user gets the usual bounce report.

Mail clients such as Thunderbird also save their own copy in Sent. The server sees that copy (by its Message-ID) and removes the Outbox copy instead of moving it, so Sent never has the message twice. The move waits at least 5 seconds after the message was queued, which gives the client time to save its copy first. A message that arrives without a Message-ID gets one, so the copies can be matched.

The copy goes to the account that logged in. While the login on 587 is still the placeholder (a user name that is not an email address), it goes to the envelope sender instead. Senders without an IMAP mailbox are skipped. Mail sent through the JSON API is not copied.

Turn it off with outgoing.imapOutbox.enabled: false in mail.config.json. With the relay or KumoMTA drivers, "sent" means the message was handed to that MTA.

How mail is delivered

OUTBOUND_DRIVER decides where the queue sends each message:

  • direct delivers to the recipient's MX server from this machine. This is the default.
  • go sends through the Go MTA service.
  • relay submits to another SMTP server, such as KumoMTA.
  • kumo-http posts to KumoMTA's HTTP API.

See Outbound drivers for the settings of each, and for what KumoMTA needs.

MTA-STS

Direct delivery follows the recipient domain's MTA-STS policy. In enforce mode the server requires TLS with a valid certificate and only uses an MX host that the policy allows. Relay connections verify the certificate by default, and OUTBOUND_RELAY_TLS changes that.

Programmatic use

The direct delivery engine is the NodeMailerMTA class. See the NodeMailerMTA API for sendMail, sendViaRelay and the result format.

Components involved

ComponentFilePurpose
OutgoingMailHandlerserver/src/services/OutgoingMailHandler.tsAuthentication, parsing and queueing for port 587.
submitOutgoingserver/src/services/OutgoingPipeline.tsStrips Bcc, adds headers, signs with DKIM and queues. Shared by port 587 and the JSON API.
OutboundQueueserver/src/server/mta/OutboundQueue.tsThe disk queue, retries and per-recipient results.
Driversserver/src/server/mta/drivers/index.tsdirect, go, relay and kumo-http.
NodeMailerMTAserver/src/server/mta/node-mta.tsBuilds the message, signs with DKIM, resolves MX records and delivers.
MailComposerserver/src/server/config/MailComposer.tsRFC 5322 headers and MIME.

On this page