Outbound SMTP relay (MTA/MSA) built on Node.js and MongoDB (queue storage).
ββββββββ βββββββ ββββ βββββββββββββββ βββββββββββββ ββββββ
ββββββββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββββ
βββββ βββ βββββββββ βββββββββ βββββββββββ βββ ββββββββ
βββββ βββ βββββββββββββββββββ βββββββββββ βββ ββββββββ
ββββββββββββββββββββ βββββββββββββββββ βββ βββ βββ βββ βββ
ββββββββ βββββββ βββ ββββββββββββββββ βββ βββ βββ βββ
ZoneMTA provides granular control over routing different messages. Trusted senders can be routed through high-speed (more parallel connections) virtual "sending zones" that use high reputation IP addresses, less trusted senders can be routed through slower (less connections) virtual "sending zones" or through IP addresses with less reputation. In addition the server comes packed with features more common to commercial software, ie. message rewriting, IP warm-up or HTTP API for posting messages.
ZoneMTA is comparable to Haraka but unlike Haraka it's for outbound only. Both systems run on Node.js and have a built in plugin system even though the designs are somewhat different. The plugin system (and a lot more as well) for ZoneMTA is inherited from the Nodemailer project and thus do not have direct relations to Haraka.
There's also a web-based administration interface (needs to be installed separately).
- Node.js v16+ for running the app
- MongoDB for storing messages in the queue
- Redis for locking and counters
Assuming Node.js (v16+), MongoDB running on localhost and git. There must be nothing listening on ports 2525 (SMTP), 12080 (HTTP API) and 12081 (internal data channel). All these ports are configurable.
Fetch the ZoneMTA application template
$ git clone git://github.com/zone-eu/zone-mta-template.git
$ cd zone-mta-template
$ npm install eslint --save-dev
$ npm init
$ npm install --production
$ npm start
If everything succeeds then you should have a SMTP relay with no authentication running on localhost port 2525 (does not accept remote connections).
Next you could try to install and configure an additional plugin or edit the default configuration in the config folder.
Web administration console should be installed separately, it is not part of the default installation. See instructions in the ZMTA-WebAdmin page.
Messages are dropped for delivery either by SMTP or HTTP API. Message is processed as a stream, so it shouldn't matter if the message is very large in size (except if a very large message is submitted using the JSON API). This applies also to DKIM body hash calculation β the hash is calculated chunk by chunk as the message stream flows through (actual signature is generated out of the body hash when delivering the message to destination). The incoming stream starts from incoming connection and ends in MongoDB GridFS, so if there's an error in any step between these two, the error is reported back to the client and the message is rejected. If impartial data is stored to GridFS it gets garbage collected after some time (all message bodies without referencing delivery rows are deleted automatically)
Delivering messages to destination (this image is outdated, LevelDB is not used anymore)
- Web interface. See queue status and debug deferred messages through an easy to use web interface (needs to be installed separately).
- Cross platform. You can run ZoneMTA even on Windows
- Fast. Send millions of messages per day
- Connection pooling
- Send large messages with low overhead
- Automatic DKIM signing
- Adds Message-Id and Date headers if missing
- Sending Zone support: send different messages using different IP addresses
- Built-in support for delayed messages. Just use a future value in the Date header and the message is not sent out before that time
- Assign specific recipient domains to specific Sending Zones
- Queue is stored in MongoDB
- Built in IPv6 support
- Reports to Prometheus
- Uses STARTTLS for outgoing messages by default, so no broken padlock images in Gmail
- Smarter bounce handling
- Throttling per Sending Zone connection
- Spam detection using Rspamd
- HTTP API to send messages
- Custom plugins
- Automatic back-off if an IP address gets blacklisted
- Email Address Internationalization (EAI) and SMTPUTF8 extension. Send mail to unicode addresses like Π°Π½Π΄ΡΠΈΡ@ΡΠ°ΠΉΠ»Π΄Π΄Π°ΠΊ.ΠΎΡΠ³
- Delivery to HTTP using POST instead of SMTP
Check the WIKI for more details
Default configuration can be found from default.js. You can override options in your application specific configuration but you do not need to specify these values that you want to keep as default.
All data is processed in chunks without reading the entire message into memory, so it does not matter if the message is 1kB or 1GB in size.
DKIM signing support is built in to ZoneMTA. You can provide DKIM keys using the built in DKIM plugin (see here) or alternatively create your own plugin to handle key management. ZoneMTA calculates all required hashes and is able to sign messages if a key or multiple keys are provided.
You can define as many Sending Zones as you want. Every Sending Zone can have its own local address IP pool that is used to send out messages designated for that Zone (IP addresses are not locked, you can assign the same IP for multiple Zones or multiple times for a single Zone). You can also specify the amount of maximum parallel outgoing connections (per process) for a Sending Zone.
To preselect a Zone to be used for a specific message you can use the X-Sending-Zone
header key
X-Sending-Zone: zone-identifier
For example if you have a Sending Zone called "zone-identifier" set then messages with such header are routed through this Sending Zone.
NB This behavior is enabled by default only for 'api' and 'bounce' zones, see the
allowRoutingHeaders
option in default config for details
You can define specific header values in the Sending Zone configuration with the routingHeaders
option. For example if you want to send messages that contain the header 'X-User-ID' with value '123' then you can configure it like this:
'sending-zone': {
...
routingHeaders: {
'x-user-id': '123'
}
}
Use senderDomains
option in the Zone config to define that all senders with a specific From domain name are routed through this Zone.
'sending-zone': {
...
senderDomains: ['example.com']
}
Use recipientDomains
option in the Zone config to define that all recipients with a specific domain name are routed through this Zone.
'sending-zone': {
...
recipientDomains: ['gmail.com', 'kreata.ee']
}
The routing priority is the following:
- By the
X-Sending-Zone
header - By matching
routingHeaders
headers - By sender domain value in
senderDomains
- By recipient domain value in
recipientDomains
If no routing can be detected, then the "default" zone is used.
IPv6 is supported but not enabled by default. You can enable or disable it per Sending Zone with the ignoreIPv6
option.
You can set connection limits for recipient domains per Sending Zone. For example if you have set max 2 connections to a specific domain then even if your queue processor has free slots and there are a lot of messages queued for that domain it will not create more connections than allowed.
ZoneMTA tries to guess the reason behind rejecting a message β maybe the message was greylisted or maybe your sending IP is blocked by this recipient. Not every bounce is equal.
If the message hard bounces (or after too many retries for soft bounces) a bounce notification is POSTed to an URL. You can also define that a bounce response is sent to the sender email address. See more here
If the bounce occurred because your sending IP is blacklisted then this IP gets disabled for that MX for the next 6 hours and message is retried from a different IP. You can also disable local IP addresses permanently for specific domains with disabledAddresses
option.
ZoneMTA is an at-least-once delivery system, so messages are deleted from the queue only after positive response from the receiving MX server. If a child starts processing a message the child locks the message and the lock is released automatically if the child dies or master dies. Once normal operations are resumed, the same message can be fetched from the queue again.
Child processes that handle actual delivery keep a TCP connection up against the master process. This connection is used as the data channel for exchanging information about deliveries. If the connection drops for any reason, all current operations are cancelled by the child and non-delivered messages are re-queued by the master. This behavior should limit the possibility of multiple deliveries of the same message. Multiple deliveries can still happen if the process or connection dies exactly on the moment when the MX server acknowledges the message and the notification does not get propagated to the master. This risk of multiple deliveries is preferred over losing messages completely.
Messages might get lost if the database gets into a corrupted state and it is not possible to recover data from it.
You can assign a new IP to the IP pool using lower load share than other addresses by using ratio
option (value in the range of 0 and 1 where 0 means that this IP is never used and 1 means that only this IP is used)
in default.js
file:
{
pools: {
default: [
{name: 'host1.example.com', address: '1.2.3.1'},
{name: 'host2.example.com', address: '1.2.3.2'},
{name: 'host3.example.com', address: '1.2.3.3'},
// the next address gets only 5% of the messages to handle
{name: 'warmup.example.com', address: '1.2.3.4', ratio: 1/20}
]
}
}
or in pools.toml
file:
[[ipWarmPool]]
address="1.2.3.1"
name="host1.example.com"
[[ipWarmPool]]
address="1.2.3.2"
name="host2.example.com"
[[ipWarmPool]]
address="1.2.3.3"
name="warmup.example.com"
ratio="0.05"
Once your IP address is warm enough then you can either increase the load ratio for it or remove the parameter entirely to share load evenly between all addresses. Be aware though that every time you change pool structure it mixes up the address resolving, so a message that is currently deferred for greylisting does not get the same IP address that it previously used and thus might get greylisted again.
Instead of delivering messages to SMTP you can POST messages to HTTP. In this case you need to set http option for a delivery to true and also set targetUrl property which is the URL the message is POSTed to as a file upload. These changes can be done for example in a plugin.
app.addHook('sender:fetch', (delivery, next) => {
delivery.http = true;
delivery.targetUrl = 'http://requestb.in/1ed6q7l1';
next();
});
You can start up multiple ZoneMTA servers that share the same MongoDB backend. In this case you have to edit the queue/instanceId configuration option though, every instance needs its own immutable ID. This value is used to lock deferred messages to specific sender instance.
You can post a JSON structure to a HTTP endpoint (if enabled) and it will be converted into a rfc822 formatted message and delivered to destination. The JSON structure follows Nodemailer email config (see here) except that file and url access is disabled β you can't define an attachment that loads its contents from a file path or from an url, you need to provide the file contents as base64 encoded string.
You can provide the authenticated username with X-Authenticated-User
header and originating IP with X-Originating-IP
header, both values are optional.
curl -H "Content-Type: application/json" -H "X-Authenticated-User: andris" -H "X-Originating-IP: 123.123.123.123" -X POST http://localhost:8080/send -d '{
"from": "sender@example.com",
"to": "recipient1@example.com, recipient2@example.com",
"subject": "hello",
"text": "hello world!"
}'
In the same manner you could upload raw rfc822 message for delivery. In this case the sender and recipient info would be fetched from the message.
curl -H "Content-Type: message/rfc822" -H "X-Authenticated-User: andris" -H "X-Originating-IP: 123.123.123.123" -X POST http://localhost:8080/send-raw -d 'From: sender@example.com
To: recipient1@example.com, recipient2@example.com
Subject: Hello!
Hello world'
You can check the current state of a sending zone (for example "default") with the following query
curl http://localhost:8080/counter/zone/default
The response includes counters about queued and deferred messages
{
"active": {
"rows": 13
},
"deferred": {
"rows": 17
}
}
You can check counters for all zones with:
curl http://localhost:8080/counter/zone/
You can list the first 1000 messages queued or deferred for a queue
curl http://localhost:8080/queued/active/default
Replace active with deferred to get the list of deferred messages.
The response includes an array of messages
{
"list": [
{
"id": "157ca04cd5c000ddea",
"zone": "default",
"recipient": "example@example.com"
}
]
}
If you know the queue id (for example 1578a823de00009fbb) then you can check the current status with the following query
curl http://localhost:8080/message/1578a823de00009fbb
The response includes general information about the message and lists all recipients that are current queued (about to be sent) or deferred (are scheduled to send in the future). This does not include messages already sent or bounced.
{
"meta": {
"id": "1578a823de00009fbb",
"interface": "feeder",
"from": "sender@example.com",
"to": ["recipient1@example.com", "recipient2@example.com"],
"origin": "127.0.0.1",
"originhost": "[127.0.0.1]",
"transhost": "foo",
"transtype": "ESMTP",
"time": 1475497588281,
"dkim": {
"hashAlgo": "sha256",
"bodyHash": "HAuESLcsVfL2FGQCUtFOwTL6Ax18XDXZO2vOeAz+DpI="
},
"headers": [
{
"key": "date",
"line": "Date: Mon, 03 Oct 2016 12:26:32 +0000"
},
{
"key": "from",
"line": "From: Sender <sender@example.com>"
},
{
"key": "message-id",
"line": "Message-ID: <95dc84ae-ff9e-4e95-aa75-8ee707bc018d@example.com>"
},
{
"key": "subject",
"line": "subject: test"
}
],
"messageId": "<95dc84ae-ff9e-4e95-aa75-8ee707bc018d@example.com>",
"date": "Mon, 03 Oct 2016 12:26:32 +0000",
"parsedEnvelope": {
"from": "sender@example.com",
"to": [],
"cc": [],
"bcc": [],
"replyTo": false,
"sender": false
},
"bodySize": 3458,
"created": 1475497593204
},
"messages": [
{
"id": "1578a823de00009fbb",
"seq": "002",
"zone": "default",
"recipient": "recipient1@example.com",
"status": "DEFERRED",
"deferred": {
"first": 1475499253068,
"count": 2,
"last": 1475499774161,
"next": 1475501274161,
"response": "450 4.3.2 Service currently unavailable"
}
}
]
}
If you know the queue id (for example 1578a823de00009fbb) then you can fetch the entire message contents
curl http://localhost:8080/fetch/1578a823de00009fbb
The response is a message/rfc822 message. It does not include a Received header for ZoneMTA or a DKIM signature header, these are added when sending out the message.
Content-Type: text/plain
From: sender@example.com
To: exmaple@example.com
Subject: testmessage
Message-ID: <4f7e73c3-009c-48c2-4b45-1cf20b2fe6d3@example.com>
Date: Sat, 15 Oct 2016 20:24:54 +0000
MIME-Version: 1.0
Hello world! This is a test message
...
ZoneMTA allows basic recipient suppression where messages to specific recipient addresses or domains are silently dropped. Suppressed messages do not generate bounce messages.
To see the currently suppressed addresses/domains, make a HTTP call to /suppressionlist
curl http://localhost:8080/suppressionlist
The result is an JSON array
{
"suppressed": [
{
"id": "58da63cc77ebe70b883bec2d",
"address": "suppressed@address.com"
},
{
"id": "58da641f77ebe70b883bec2e",
"domain": "suppressed-domain.com"
}
]
}
You can add suppression entries by address or domain
Suppress an email address
curl -XPOST http://localhost:8080/suppressionlist -H 'Content-Type: application/json' -d '{
"address": "suppressed@address.com"
}'
With the result
{
"suppressed": {
"id": "58da63cc77ebe70b883bec2d",
"address": "suppressed@address.com"
}
}
Suppress a domain
curl -XPOST http://localhost:8080/suppressionlist -H 'Content-Type: application/json' -d '{
"domain": "suppressed-domain.com"
}'
With the result
{
"suppressed": {
"id": "58da641f77ebe70b883bec2e",
"domain": "suppressed-domain.com"
}
}
You can delete suppression entries by entry ID
curl -XDELETE http://localhost:8080/suppressionlist -H 'Content-Type: application/json' -d '{
"id": "58da641f77ebe70b883bec2e"
}'
With the result
{
"deleted": "58da641f77ebe70b883bec2e"
}
ZoneMTA automatically collects and exposes metrics for Prometheus
curl http://localhost:8080/metrics
In your Prometheus config, the server should be linked like this:
static_configs:
- targets: ['localhost:8080']
The exposed metrics include a lot of different data but the most important ones would be the following:
zonemta_delivery_status
exposes counters for delivery statuses. There are 3 different result
label values
result="delivered"
β count of deliveries accepted by remote MXresult="rejected"
β count of deliveries that hard bouncedresult="deferred"
β count of deliveries that soft bounced
zonemta_message_push
exposes a counter about stored emails. This counter includes the count of messages accepted for delivery.
zonemta_message_drop
exposes a counter about emails that were not accepted for delivery (rejected as spam, rejected by plugins, failed to store messages to db etc.)
zonemta_queue_size
exposes gauges about current size of the queue. There are 2 type
labels available:
type="queued"
β count of deliveries waiting to be delivered on the first occasiontype="deferred"
β count of deliveries waiting to be delivered on some later time
zonemta_blacklisted
exposes a gauge about currently blacklisted domain:localAddress combos. This value is reset to 0 whenever ZoneMTA master process is restarted. Additionally the blacklist information is cached for 6 hours.
zonemta_connection_reuses
exposes a counter about how often a connections are reused since the last restart. Every time a connection gets reused the counter will be incremented.
Example queries:
sum(rate(zonemta_connection_reuses[15s])*60)
This creates a graph of connection reuses per minute (if you have multiple instances)
zonemta_connection_pool_size
exposes a gauge about the connection pool size of this instance. Every time a connection gets pooled or is getting removed from pool the gauge will be incremented or decremented.
Example queries:
zonemta_connection_pool_size
This creates a graph of connection pool size per instance (if you have multiple instances)
sum(zonemta_connection_pool_size)
This can be used to create a gauge in grafana which shows the overall connection pool size of the cluster
check-bounces
Cli command that reads a SMTP error response from stdin and returns bounce information
$ echo "552-5.7.0 This message was blocked because its content presents a potential
552-5.7.0 security issue. Please visit
552-5.7.0 http://support.google.com/mail/bin/answer.py?answer=6590 to review our
552 5.7.0 message content and attachment content guidelines. cp3si16622595oec.101 - gsmtp" | check-bounce
> data : 552-5.7.0 This message was blocked because its content presents a potential
> 552-5.7.0 security issue. Please visit
> 552-5.7.0 http://support.google.com/mail/bin/answer.py?answer=6590 to review our
> 552 5.7.0 message content and attachment content guidelines. cp3si16622595oec.101 - gsmtp
> action : reject
> message : Suspicious attachment
> category : virus
> code : 552
> status : 5.7.0
Currently it is possible to limit active connections against a domain and you can limit sending speed per connection (eg. 10 messages/min per connection) but you can't limit sending speed per domain. If you have set 3 processes, 5 connections and limit sending with 10 messages / minute then what you actually get is 3 * 5 * 10 = 150
messages per minute for a Sending Zone.
In production you probably would want to allow Node.js to use more memory, so you should probably start the app with --max-old-space-size
option
node --max-old-space-size=8192 app.js
This is mostly needed if you want to allow large SMTP envelopes on submission (eg. someone wants to send mail to 10 000 recipients at once) as all recipient data is gathered in memory and copied around before storing to the queue.
For speedier DNS resolving there are two options. First (the default) is to cache DNS responses by ZoneMTA in Redis using the dnscache module. For better performance it would probably be better to use a dedicated DNS server, mostly because DNS caching is hard and it is better to leave it to software that is built for this.
dnsmasq on localhost has worked great for us. The dns options for ZoneMTA would look like this if you are using local DNS cache like dnsmasq or similar:
"dns": {
"caching": false,
"nameservers": ["127.0.0.1"]
}
By default is called as zone-queue
.
Queue entries. Each recipient has a separate queue entry.
id
: email queue IDseq
: incrementing recipient counterdomain
: target domainsendingZone
: assigned sending zone for this deliveryassigned
: Zone-MTA instance ID that is processing this message. "no" means unassigned.recipient
: target email addresslocked
: iftrue
, then the message is currently processed for deliverylockTime
: when message processing startedqueued
: The message will not be processed for delivery until this timecreated
: the time the queue entry was added
By default is called as mail.files
.
GridFS database. Each email has a single entry in mail.files, and 1...N entries in mailqueue collection.
_id
,length
,chunkSize
,uploadDate
,filename
, andcontentType
are GridFS-specific valuesmetadata
: contains email informationcreated
: when the email was added to the queuedata
:id
: email queue IDfrom
: email sender addressto
: list of all recipients from to/cc/bcc fieldsinterface
: Which component added this email to the queuetranstype
: How was the email added to the queue (SMTP or API)time
: same asmetadata.created
but milliseconds timestampdkim
: DKIM signing infouserId
: WildDuck user ID of the senderuser
: Authentication usernamereason
: why the message was added to the queue (user submit, autoreply, forward)origin
: IP address of the senderheaders
: all email headers in correct orderparsedHeaders
: structured headers for easier usagemessageId
: Message-ID value of the emaildate
: Date header of the message. If the email is scheduled to be sent in the future, then this header should be the actual delivery time. It is not the time the email was added to the queue.bodySize
: email byte size without headers
European Union Public License 1.2 (details) or later
ZoneMTA is created and maintained in the European Union, licensed under EUPL and its authors have no relations to the US, thus there can not be any infringements of US-based patents.