Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
andris9 committed Jul 31, 2016
0 parents commit d401327
Show file tree
Hide file tree
Showing 23 changed files with 4,342 additions and 0 deletions.
70 changes: 70 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use strict';

module.exports = {
rules: {
indent: [2, 4, {
SwitchCase: 1
}],
quotes: [2, 'single'],
'linebreak-style': [2, 'unix'],
semi: [2, 'always'],
strict: [2, 'global'],
eqeqeq: 2,
'dot-notation': 2,
curly: 2,
'no-fallthrough': 2,
'quote-props': [2, 'as-needed'],
'no-unused-expressions': [2, {
allowShortCircuit: true
}],
'no-unused-vars': 2,
'no-undefined': 2,
'handle-callback-err': 2,
'no-new': 2,
'new-cap': 2,
'no-eval': 2,
'no-invalid-this': 2,
radix: [2, 'always'],
'no-use-before-define': [2, 'nofunc'],
'callback-return': [2, ['callback', 'cb', 'done']],
'comma-dangle': [2, 'never'],
'comma-style': [2, 'last'],
'no-regex-spaces': 2,
'no-empty': 2,
'no-duplicate-case': 2,
'no-empty-character-class': 2,
'no-redeclare': [2, {
builtinGlobals: true
}],
'block-scoped-var': 2,
'no-sequences': 2,
'no-throw-literal': 2,
'no-useless-call': 2,
'no-useless-concat': 2,
'no-void': 2,
yoda: 2,
'no-undef': 2,
'global-require': 2,
'no-var': 2,
'no-bitwise': 2,
'no-lonely-if': 2,
'no-mixed-spaces-and-tabs': 2,
'arrow-body-style': [2, 'as-needed'],
'arrow-parens': [2, 'as-needed'],
'prefer-arrow-callback': 2,
'object-shorthand': 2,
'prefer-spread': 2
},
env: {
es6: true,
node: true
},
extends: 'eslint:recommended',
globals: {
it: true,
describe: true,
beforeEach: true,
afterEach: true
},
fix: true
};
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
.DS_Store
npm-debug.log
queuedata
appendlog
.lev_history
keys/*.pem
test/queuetest
config/production.*
*v8.log
22 changes: 22 additions & 0 deletions Gruntfile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict';

module.exports = function (grunt) {

// Project configuration.
grunt.initConfig({
eslint: {
all: ['lib/**/*.js', 'test/**/*.js', 'config/**/*.js', 'Gruntfile.js', 'app.js']
},

nodeunit: {
all: ['test/**/*-test.js']
}
});

// Load the plugin(s)
grunt.loadNpmTasks('grunt-eslint');
grunt.loadNpmTasks('grunt-contrib-nodeunit');

// Tasks
grunt.registerTask('default', ['eslint', 'nodeunit']);
};
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# ZoneMTA

Tiny outbound SMTP relay built on Node.js and LevelDB.

The goal of this project is to provide granular control over routing different messages. Trusted senders can be routed through high-speed "sending zones" that use high reputation IP addresses, less trusted senders can be routed through slower "sending zones" or through IP addresses with less reputation.

## Features

- Send large messages with low overhead
- Automatic DKIM signing
- Adds Message-Id and Date headers if missing
- Queue is stored in LevelDB
- Sending Zone support: send different messages using different IP addresses
- Assign specific recipient domains to specific Sending Zones
- Built in IPv6 support
- Uses STARTTLS for outgoing messages by default, so no broken padlock images in Gmail
- Smarter bounce handling
- Throttling per Sending Zone connection

## Setup

1. Requirements: Node.js v6+ for running the app + compiler for building leveldb

2. Open Mailqueue folder and install required dependencies: `npm install`

3. Modify configuration script (if you want to allow connections outside localhost make sure the feeder host is not bound to 127.0.0.1)

4. Run the server application: `node app.js`

5. If everything worked then you should have a relay SMTP server running at localhost:2525 (user "test", password "zone", no TLS)

6. You can find the stats about queues at `http://hostname:8080/queue/default` where `default` is the default Sending Zone name. For other zones, replace the identifier in the URL. The queue counters are approximate.

## Install as a service

1. Move zone-mta folder to /opt/zone-mta
2. Copy Systemd service file: `cp ./setup/zone-mta.service /etc/systemd/system/`
3. Enable Systemd service: `systemctl enable zone-mta.service`
4. Start the service: `service zone-mta start`
5. Send a message to application host port 2525, using username 'test' and password 'zone'

## Large message support

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.

## LevelDB backend

Using LeveldDB means that you do not run out of inodes when you have a large queue

## DKIM signing

DKIM signing support is built in to ZoneMTA. All you need to do is to provide signing keys to use it. DKIM private keys are stored in _./keys_ as _{DOMAIN}.{SELECTOR}.pem_.

For example if you want to use a key for "kreata.ee" with selector "test" then this private key should be available at ./keys/kreata.ee.test.pem

DKIM signature is based on the domain name of the From: address or if there is no From: address then by the domain name of the envelope MAIL FROM:. If a matching key can not be found then the message is not signed.

## Sending Zone

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. You can also specify the amount of max parallel outgoing connections for a Sending Zone.

Sending Zone routing is handled by message headers:

```
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. If there is no Sending Zone with that name then "default" Sending Zone is used instead.

You can also pre-assign specific domains to specific Sending Zones, in which case the message is routed to the correct Sending Zone without additional routing headers based on either the sender or recipient domain.

## IPv6 support

IPv6 is supported by default. You can disable it per Sending Zone if you don't need to or can't send messages over IPv6.

## HTTP based authentication

If authentication is required then all clients are authenticated against a HTTP endpoint using Basic access authentication. If the HTTP request succeeds then the user is considered as authenticated.

## Per-Zone domain connection limits

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.

## Bounce handling

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.

## Error Recovery

Child processes keep file based logs of delivered messages. Whenever a child process crashes or master process goes down this log is used to identify messages that are successfully delivered but are still in the queue. This behavior should limit the possibility of multiple deliveries of the same message. Multiple deliveries can still happen if the process dies exactly on the moment when the MX server acknowledges the message and the process is starting to write to the log file. This risk of preferred multiple deliveries is preferred over losing messages completely.

## TODO

### 1\. Domain based throttling

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.

### 2\. Web interface

It should be possible to administer queues using an easy to use web interface.
102 changes: 102 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

// Main application file
// Run as 'node app.js' to start

const config = require('config');
const log = require('npmlog');
const createFeederServer = require('./lib/feeder-server');
const createAPIServer = require('./lib/api-server');
const createMailQueue = require('./lib/mail-queue');
const sendingZone = require('./lib/sending-zone');

log.level = config.log.level;
process.title = 'zone-mta: master process';

let feederServer = createFeederServer();
let apiServer = createAPIServer();
let queue = createMailQueue(config.queue);

// Starts the queueing MTA
feederServer.start(err => {
if (err) {
log.error('Feeder', 'Could not start feeder MTA server');
log.error('Feeder', err);
return process.exit(1);
}
log.info('Feeder', 'Feeder MTA server started');

// Starts the API HTTP REST server that is used by sending processes to fetch messages from the queue
apiServer.start(err => {
if (err) {
log.error('API', 'Could not start API server');
log.error('API', err);
return process.exit(2);
}
log.info('API', 'API server started');

// downgrade user if needed
if (config.group) {
try {
process.setgid(config.group);
log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid());
} catch (E) {
log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message);
}
}
if (config.user) {
try {
process.setuid(config.user);
log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid());
} catch (E) {
log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message);
}
}

// Open LevelDB database and start sender processes
queue.init(err => {
if (err) {
log.error('Queue', 'Could not initialize sending queue');
log.error('Queue', err);
return process.exit(3);
}
log.info('Queue', 'Sending queue initialized');

feederServer.queue = queue;
apiServer.queue = queue;
sendingZone.init(queue);
});
});
});

let stop = () => {
if (queue.closing) {
log.info('Process', 'Force closing...');
return process.exit(0);
}
log.info('Process', 'Server closing down...');
queue.closing = true;

feederServer.close(() => {
// wait until all connections to the feeder SMTP are closed
log.info('Feeder', 'Service closed');
apiServer.close(() => {
// wait until all connections to the API HTTP are closed
log.info('API', 'Service closed');
queue.stop(() => {
// wait until DB is closed
log.info('Queue', 'Service closed');
return process.exit(0);
});
});
});

let forceExitTimer = setTimeout(() => {
log.info('Process', 'Timed out, force closing...');
process.exit(0);
}, 10 * 1000);
forceExitTimer.unref();
};

process.on('SIGINT', () => stop());
process.on('SIGTERM', () => stop());
Loading

0 comments on commit d401327

Please sign in to comment.