├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── doc └── plugins │ ├── auth-core.md │ ├── connect-core.md │ ├── data-core.md │ ├── mail-core.md │ ├── queue-core.md │ ├── queue-maildir.md │ ├── queue-relay.md │ ├── queue-spamd.md │ ├── rcpt-core.md │ ├── rcpt-dnsbl.md │ ├── rcpt-relay.md │ └── starttls-core.md ├── example └── authentication.js ├── keys ├── ca.pem ├── cert.pem └── key.pem ├── package.json ├── plugins ├── auth │ └── core.js ├── connect │ └── core.js ├── data │ └── core.js ├── ehlo │ └── core.js ├── helo │ └── core.js ├── help │ └── core.js ├── mail │ └── core.js ├── noop │ └── core.js ├── queue │ ├── core.js │ ├── maildir.js │ ├── relay.js │ └── spamd.js ├── quit │ └── core.js ├── rcpt │ ├── core.js │ ├── dnsbl.js │ └── relay.js ├── rset │ └── core.js ├── starttls │ └── core.js ├── timeout │ └── core.js └── unrecognized │ └── core.js ├── src ├── index.js ├── smtp-client.js ├── smtp-logger.js ├── smtp-relay.js ├── smtp-server.js ├── smtp-session.js ├── smtp-stream.js └── smtp-util.js └── test ├── 1-server.js ├── 2-server-timeout.js ├── 3-relay-simple.js ├── 4-relay-temporary-error.js ├── 5-api-send.js └── assets └── gtube.msg /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # App Garbage 11 | .idea 12 | .DS_Store 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 31 | node_modules 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10.15.1" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 mofux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mail-io 2 | 3 | [![Build Status](https://travis-ci.org/mofux/mail-io.png?branch=master)](https://travis-ci.org/mofux/mail-io) 4 | 5 | This module provides an easy way to implement your own SMTP server. 6 | It supports plugins and can be easily extended. 7 | 8 | It also supports mail relay features. 9 | 10 | ## usage 11 | 12 | You can create a server like this: 13 | 14 | ```javascript 15 | 16 | // Load the module 17 | const mio = require('mail-io'); 18 | 19 | // Create a new SMTP server 20 | const server = new mio.Server({ ... options ... }); 21 | 22 | // Listen for new client connections 23 | server.on('session', (session) => { 24 | 25 | // Register a custom authentication handler 26 | session.on('auth', (req, res) => { 27 | 28 | // Only accept user "john" with password "doe" 29 | if (req.user && req.user.username === 'john' && req.user.password === 'doe') { 30 | return res.accept(250, 'OK'); 31 | } else { 32 | return res.reject(535, 'Authentication failed'); 33 | } 34 | 35 | }); 36 | 37 | }); 38 | 39 | // Register a global handler to reject all recipients that do not belong 40 | // to "foo.com" domain 41 | server.addHandler('rcpt', { 42 | name: 'my-rcpt-handler', 43 | before: 'dnsbl', 44 | handler: (req, res) => { 45 | 46 | if (req.to.split('@')[1] === 'foo.com') { 47 | res.accept(); 48 | } else { 49 | res.reject(502, 'We only accept mail for the "foo.com" domain'); 50 | } 51 | 52 | } 53 | }); 54 | 55 | // Listen on port 25 56 | server.listen(25); 57 | 58 | ``` 59 | 60 | ## command handlers 61 | 62 | You can register command handlers with `session.on('command', function(req, res) {...})` for any command, which will get called when the command is issued by the client and previous handlers (if any) have accepted the request. 63 | In the background, the handler will be pushed to the end of the handler queue, so it will only get called if all previous handlers for this command accepted with `res.accept`. 64 | Note that the `core` plugin always gets precedence over any other plugins, which makes sure that internally used plugins execute first. 65 | 66 | A command handler is a function that gets passed two objects, `req` and `res`. 67 | 68 | ### **req** object 69 | 70 | The `req` object contains the issued command and the data, information about the session and the plugin specific configuration (if provided). The req object is shared between all handlers of the same command. It contains the following attributes: 71 | 72 | **command** 73 | 74 | the `command`object contains the `cmd` property which contains the name of the command in lowercase letters, e.g. `mail` or `rcpt`. It also contains a `data` property which is a string that contains the `data` that came after the command. 75 | 76 | **session** 77 | 78 | the `session` object is initialized upon a client connection and is shared between all handlers. It contains the following attributes: 79 | 80 | - `id`: a unique id for every client session 81 | - `transaction`: the id of the current transaction, starting at `0` and getting increased after every successful `data` command 82 | - `accepted`: a map that contains all commands as the key that have been accepted. The value is the status code. **NOTE** this is very useful to check if a command has been completed 83 | - `rejected`: same as `accepted`, but for commands that have been rejected 84 | - `envelope`: a map that contains the mail envelope data, like `to *(array)*` and `from *(string)*` 85 | - `client`: an object containing information about the connected client, like `address` and `hostname` 86 | - `config`: a reference to the options passed to the `createServer` constructor 87 | - `connection`: the underlying `smtp-stream` connection. `connection.socket` contains the raw net socket of the connection. 88 | - `handlers`: a map that contains all registered command handlers. You shouldn't have to mess around with it, but just in case :) 89 | - `data`: a map containing the commands as the key and a map of handlers as the value, which in turn contain the data that was set using `res.set`. You should not access this data directly and rather use `res.get` to obtain the data 90 | - `secure`: a boolean indicating if the connection is using TLS. 91 | - `counters`: a map of counters that are used internally to track failed login and command attempts 92 | - `log`: a map that contains logging functions for different levels. You should NOT use this and rather use `res.log` instead. 93 | 94 | **config** 95 | 96 | Contains the handler specific configuration that was passed via the `options` in the `createServer` constructor. 97 | For example, to pass a custom DNSBL blacklist server to `rcpt/dnsbl` plugin, the options object may look like this: 98 | 99 | ```javascript 100 | const server = new mio.Server ({ 101 | plugins: { 102 | 'rcpt/dnsbl': { 103 | blacklist: 'zen.spamhaus.org' 104 | } 105 | } 106 | }, function() {...}) 107 | ``` 108 | 109 | **more** 110 | 111 | Some `core` plugins extend the `req` object, so you may have additional data available on the `req` object. For example the `auth/core` plugin adds `req.user` to the request if the `auth` request was completed successfully. `mail/core` adds `req.from` and `rcpt/core` adds `req.to`. 112 | 113 | 114 | ### **res** object 115 | 116 | The `res` object contains methods that can be used to respond to the command, and to add additional information to the session. 117 | **IMPORTANT** You *MUST* call either `res.accept`, `res.reject` or `res.end` exactly once during the execution of your handler code, otherwise the connection will be left hanging. 118 | 119 | It contains the following methods: 120 | 121 | **accept**(``, ``) 122 | 123 | Accepts the request. You can pass an optional response `code` and `message` which will be used when sending the response to the client. Note that if there are remaining handlers for this command, they will be called afterwards and may also change the code and message, or even reject the request. This means that accepting a request with `res.accept` does not guarantee that this response will be sent to the client as is. 124 | If you do not pass a response code or message, the default `code` is `250` and the default `message` is `OK` 125 | 126 | **reject**(`code`, `message`) 127 | 128 | If you `reject` a request, the response is immediately sent to the client and no more handlers for this command will be executed. You **MUST** to provide a `code` and a `message` when you reject. You should make sure that the `code` is appropriate for the command you are rejecting, otherwise, the client may misunderstand your answer. 129 | 130 | **end**(``, ``) 131 | 132 | When calling `end`, the connection to the client will be closed. No more command handlers will be executed. You can pass an optional `code` and `message` to the `end` method, which will write the response to the client before closing the connection. 133 | 134 | **write**(`message`) 135 | 136 | In some situations it is required to send a `message` to the client. For example, the `ehlo` commands sends a list of supported features to the client before `accept`ing the request. Write does not end the current command handler and you are still required to either `accept`, `reject` or `end` the request before the execution can continue. 137 | 138 | **read**(`callback`) 139 | 140 | Sometimes, you may need additional information from the client (see `auth/core`) that has to be read within the same command handler. For this case, use `res.read` and provide a callback function, that will be called back with the data received from the client. 141 | 142 | **set**(`data`) 143 | 144 | If you want to share data with other handlers, `set` will allow you to do that. You can pass any `data`, which other handlers can get using `res.get`. 145 | 146 | **get**(`handler`) 147 | 148 | If you want to access data from another command handler, you can use `get`. `handler` is a string that accepts two flavors: 149 | 150 | - `command`: gets all stored data for a specific `command`. The returned object will be a map containing the name of the handler as the key and their stored data as the value. 151 | - `command/handler`: returns the data stored by that handler 152 | 153 | As an example, the `spamd` plugin is registered to the `queue` command. When it receives a spam score, it will be saved using `res.set(score)`. If you want to access that data, you could call `res.get('queue/spamd')`, which would directly return the score, or you could call `res.get('queue')` which would return an object with `spamd` as a key and the score as the value. 154 | 155 | > Note: you can also access the same data via `req.session.data.queue.spamd`. 156 | 157 | **log** `` 158 | 159 | `res.log` is an object that contains functions for the logging levels `info`, `warn`, `error`, `verbose` and `debug`. If you want to log some information, you should use these logging functions. They accept a `message` as the first argument and any `data` as the following arguments, that will be printed as separate lines using `util.inspect`. 160 | 161 | -------------------------------------------------------------------------------- /doc/plugins/auth-core.md: -------------------------------------------------------------------------------- 1 | # auth/core 2 | 3 | This `core` plugin adds support for the `AUTH` command for the login methods `PLAIN` and `LOGIN`. 4 | 5 | Once the client has provided all the authentication data needed, it sets `req.user` to an object containing the clear text `username` and `password` fields. 6 | If there are no more `auth` handlers, clients will be treated as authenticated as there is no logic to check if a user should pass or not. 7 | 8 | If you want to implement your own authentication logic, register to the `auth` event and check `req.user` to match your user base. 9 | If you want to reject the `auth` request, call `res.reject(535, 'authentication failed')` to signal that the authentication failed. 10 | If you want to accept the `auth` request, call `res.accept(, )` with an optional `code` and `message`. 11 | If you do not provide a code and a message to the `res.accept` function, a `235 authentication successful` message will be sent back to the client. 12 | 13 | > Note: to check if a client was authenticated in any other command handler, you should check `req.session.accepted.auth`, which will be set once a client was successfully authenticated. 14 | 15 | By default (if not altered by your server configuration) sending (relaying) mails to external domains is only allowed by authenticated users. In additional, DNSBL lookups are disabled if a client is authenticated to make sure users sending from your domain are not getting blocked. 16 | 17 | ## example 18 | 19 | In this example, we only let user `John` with password `Doe` pass: 20 | 21 | ```javascript 22 | 23 | var mailio = require('mail-io'); 24 | var server = mailio.createServer({...}, function(session) { 25 | 26 | session.on('auth', function(req, res) { 27 | 28 | // this server is for John only 29 | if (req.user.username !== 'John' && req.user.password !== 'Doe') { 30 | return res.reject(535, 'only John is allowed to authenticate'); 31 | } else { 32 | return res.accept(235, 'welcome John'); 33 | } 34 | 35 | }); 36 | 37 | }); 38 | 39 | ``` 40 | -------------------------------------------------------------------------------- /doc/plugins/connect-core.md: -------------------------------------------------------------------------------- 1 | # connect/core 2 | 3 | This `core` plugin adds support for the `connect` event that is triggered whenever a client connects and is responsible for sending the initial greeting. 4 | 5 | You can configure the maximum connection count in the server configuration using `limits.maxConnections` (defaults to `100`). 6 | If the amount of active client connections to the server exceeds `limits.maxConnections`, 7 | the client will be disconnected with a `421 too many client connections` error. 8 | 9 | ## example 10 | 11 | If you want to add custom connect logic, you can do it like this: 12 | 13 | ```javascript 14 | 15 | var mailio = require('mail-io'); 16 | var server = mailio.createServer({...}, function(session) { 17 | 18 | session.on('connect', function(req, res) { 19 | 20 | // only accept clients that have an ip in the 192.168.0.x range 21 | if (req.session.client.address.indexOf('192.168.0.') === -1) { 22 | res.end(521, 'we only accept clients from our local subnet, sorry'); 23 | } else { 24 | res.accept(); 25 | } 26 | 27 | }); 28 | 29 | }); 30 | 31 | ``` 32 | -------------------------------------------------------------------------------- /doc/plugins/data-core.md: -------------------------------------------------------------------------------- 1 | # data/core 2 | 3 | This `core` plugin add support for the `DATA` command. 4 | It makes sure that all preconditions are satisfied (`EHLO`/`HELO`, `MAIL`, `RCPT`) and then streams the data to a temporary file. 5 | It also adds a `Received` header to the mail content. 6 | Once the client has finished sending the message, it will emit the `queue` event, that can be used to send the final response code 7 | back to the client. 8 | 9 | > Note: if you want to process any data coming from the client, it is best to listen for the `queue` event. You should rarely every 10 | > have to listen for the `data` event as this handler is implementing the low level protocol specifics for receiving the data. -------------------------------------------------------------------------------- /doc/plugins/mail-core.md: -------------------------------------------------------------------------------- 1 | # mail/core 2 | 3 | This `core` plugin provides basic address parsing and validation for the `MAIL` command. It also supports the `SIZE` extension 4 | and rejects the `MAIL` command if the size exceeds the limit provided in the server configuration in `limits.messageSize`. 5 | 6 | If the from address and the size are okay, it adds the parsed address as `req.from` to the request object, so following 7 | `MAIL` handlers can use it. 8 | -------------------------------------------------------------------------------- /doc/plugins/queue-core.md: -------------------------------------------------------------------------------- 1 | # queue/core 2 | 3 | This `core` plugin adds support for the `queue` event, emitted once the `data/core` plugin has received all data from the client. 4 | 5 | It adds `file` to the request object, which is available to all following `queue` handlers. 6 | `file` is the path to the temporary file created by the `data/core` plugin. This file contains the RAW content of the message (including headers) 7 | received by the client. 8 | 9 | It also adds `mail` to the request object, which is the parsed mail content including headers and attachment (parsed using mailparser) 10 | 11 | If no more `queue` listeners are available, this plugin will always accept the request with the message `250 OK`, which indicates to the client 12 | that the data has been successfully processed. Once all `queue` listeners have been processed, the `data/core` plugin makes sure to delete the 13 | temporary message file referenced in `res.file`, so please don't rely on the file always being existent. 14 | 15 | > Note: if you want to process, modify or store the message that you received from the client, the `queue` event is the right place to go. 16 | 17 | # example 18 | 19 | In this example, we will parse the message using the excellent `mailparser` library, and we will save the message to a database if the 20 | subject equals `save me`. 21 | 22 | ```javascript 23 | 24 | var mailio = require('mail-io'); 25 | var server = mailio.createServer({}, function(session) { 26 | 27 | session.on('queue', function(req, res) { 28 | 29 | var MailParser = require('mailparser').MailParser; 30 | var mailparser = new MailParser(); 31 | var fs = require('fs'); 32 | 33 | // create a read stream to the temporary file created by the 'data/core' plugin 34 | var messageStream = fs.createReadStream(req.file); 35 | 36 | mailparser.on('end', function(mail) { 37 | 38 | // the mail object now contains the parsed mail object 39 | if (mail.subject === 'save me') { 40 | 41 | // do whatever is necessary to save the mail to your db backend 42 | db.save(mail, function(err) { 43 | 44 | // if there was an error saving the mail to the database, let the client know, so it can resubmit the message or notify 45 | // the sender about the failed transaction 46 | if (err) { 47 | res.reject(451, 'failed to persist message'); 48 | } else { 49 | res.accept(); 50 | } 51 | 52 | }); 53 | 54 | } else { 55 | 56 | // reject the message 57 | res.reject(554, 'I will only store mails with subject "save me"'); 58 | 59 | }; 60 | 61 | }); 62 | 63 | // stream the message content to the mail parser 64 | messageStream.pipe(mailparser); 65 | 66 | }); 67 | 68 | }); 69 | 70 | ``` -------------------------------------------------------------------------------- /doc/plugins/queue-maildir.md: -------------------------------------------------------------------------------- 1 | # queue/maildir 2 | 3 | This plugin adds support for storing messages in the popular `maildir` format (see http://en.wikipedia.org/wiki/Maildir). This plugin is enabled by default. 4 | When a message is queued, the plugin checks if the recipient or sender of the message belongs to the domains served by the server. 5 | If they do, the server calls the extend function (if configured) to determine the target mailboxes. 6 | If the mail was sent by a local user, the message will be stored inside the "Sent" folder of that mailbox. 7 | If the mail is received by a local user, the spam score of the message will be checked, and the message will be saved to the 8 | "Inbox" or the "Junk" folder of the recipient. If you want to use this plugin, you should configure the `mailDir` configuration option 9 | to point to a persistent store location in your system (by default mails are stored to `/tmp/mail-io-maildir`). The mailDir path setting uses placeholders 10 | for username (`%n`) and domain (`%d`) that will be replaced with the user and domain part extracted from the target address. 11 | 12 | The following plugin specific configuration options are available: 13 | 14 | ```javascript 15 | 16 | { 17 | ... server configuration ..., 18 | plugins: { 19 | 'queue/maildir': { 20 | // maildir storage location. %n will be replaced with the username and %d with the domain name, parsed from the address 21 | mailDir: path.join(os.tmpDir(), 'mail-io-maildir', '%d', '%n'), 22 | // an optional function that will be called for every address matching a local domain. it has to call back with an array of addresses 23 | // of the target mailboxes 24 | extend: function(address, cb) { cb([address]) } 25 | } 26 | } 27 | 28 | ``` 29 | 30 | ## understanding the extend function 31 | 32 | Let's say you want to implement distribution lists. So, if a mail is sent to distribution list address `group@example.com`, you want the message being stored 33 | in the inbox of group members `john@example.com` and `jane@example.com`. In this case you would supply the extend function as follows: 34 | 35 | ```javascript 36 | 37 | { 38 | ... server configuration ..., 39 | plugins: { 40 | 'queue/maildir': { 41 | extend: function(address, cb) { 42 | var groups = { 43 | 'group@example.com': ['john@example.com', 'jane@example.com'] 44 | }; 45 | if (groups[address]) { 46 | // address belongs to a distribution list, return member addresses 47 | cb(groups[address]); 48 | } else { 49 | // address does not belong to a distribution list 50 | cb(address); 51 | } 52 | } 53 | } 54 | } 55 | 56 | 57 | ``` 58 | 59 | Now, if a message is sent to your distribution list, the maildir plugin will save the message to the inbox of John and Jane, and not to the 60 | inbox of 'group'. 61 | 62 | Another use case is to only store mails for accounts that really exist in your system. To prevent mails from being stored for accounts that do not exist, 63 | you could implement the extend function like this: 64 | 65 | ```javascript 66 | 67 | { 68 | ... server configuration ..., 69 | plugins: { 70 | 'queue/maildir': { 71 | extend: function(address, cb) { 72 | 73 | var accounts = ['john@example.com', 'jane@example.com']; 74 | 75 | // only save mail for known accounts 76 | if (accounts.indexOf(address) === -1) { 77 | // address is not in the list of accounts, 78 | // return an empty list 79 | return cb([]); 80 | } else { 81 | // address is in the list of known accounts, 82 | // return the address 83 | return cb(address); 84 | } 85 | 86 | } 87 | } 88 | } 89 | 90 | 91 | ``` 92 | 93 | > Note: remember to account for special addresses like `mailer-daemon`, `postmaster` and `admin` that are widely used in the internet to send 94 | > administrative messages to your domain 95 | -------------------------------------------------------------------------------- /doc/plugins/queue-relay.md: -------------------------------------------------------------------------------- 1 | # queue/relay 2 | 3 | This plugin adds support for relaying (sending) messages to foreign SMTP servers. 4 | 5 | Once the message has arrived, this plugin checks if it should relay the message to a foreign SMTP server. 6 | If it is allowed to relay, it will try to send the message to the target server. If the message can not be submitted 7 | without an error, it will either queue the message for another delivery attempt or, if the error returned from the receiving 8 | server is permanent (error code > 500), will send a non deliverable report (NDR) to the sender of the message. If the message 9 | cannot be successfully submitted after a specified amount of time, it will give up and send a NDR to the sender. 10 | 11 | By default, relaying is only enabled for the following scenarios: 12 | 13 | - the client is authenticated 14 | - the `from` address belongs to a local domain (configured in the server configuration `domains` array) 15 | - the `to` address does not belong to a local domain 16 | 17 | 18 | General relay configuration options can be passed to the server in the server configuration using the 'relay' object: 19 | 20 | ```javascript 21 | 22 | { 23 | ... server configuration..., 24 | relay: { 25 | // should we relay messages? 26 | enabled: true, 27 | // allow relay to foreign domains if the sender is not authenticated? 28 | allowUnauthenticated: false, 29 | // do we relay mail from senders that do not belong to our served domains (config.domains)? 30 | openRelay: false, 31 | // the hostname used to identify to the receiving server in the "EHLO" command, defaults to os.hostname() 32 | hostname: os.hostname(), 33 | // the directory used to store queued messages, defaults to /tmp/mail-io-queue 34 | queueDir: path.join(os.tmpDir(), 'mail-io-queue'), 35 | // the amount of hours that we will try to resubmit the message if the submission fails with a temporary error 36 | // defaults to 48 hours 37 | retryHours: 48, 38 | // the base interval in seconds, that specifies how long to wait until we try to resubmit a failed message 39 | // this inverval will be multiplied with the square of failed attempts, so the time between resubmissions 40 | // will increase with every failed attempt. defaults to 60 seconds 41 | retryBaseInterval: 60, 42 | // the maximum amount of concurrent transactions, defaults to 5 43 | concurrentTransactions: 5 44 | } 45 | 46 | ``` 47 | -------------------------------------------------------------------------------- /doc/plugins/queue-spamd.md: -------------------------------------------------------------------------------- 1 | # queue/spamd 2 | 3 | This plugin adds support for spam detection using SpamAssassin. The plugin is enabled by default. 4 | It tries to connect to the SpamAssassin daemon on localhost on port 783. It sets a spam report using `res.set`, which can 5 | be retrieved by following plugins using `res.get('queue/spamd'). The plugin itself does always `accept` the request, no matter 6 | how bad the score is. It is up for the following plugins to decide how to react to the spam score. 7 | 8 | The score object that can be retrieved using `res.get('queue/spamd')` looks like this: 9 | 10 | ```javascript 11 | 12 | { 13 | spam: true, 14 | score: 6.2, 15 | baseScore: 5 16 | } 17 | 18 | ``` 19 | 20 | If the connection to the SpamAssassin daemon fails or a timeout occurs, the spam object will look like this: 21 | 22 | ``` 23 | 24 | { 25 | spam: false, 26 | score: 0, 27 | baseScore: 5 28 | } 29 | 30 | ``` 31 | 32 | You can configure the base score (every score above the base score will result in the `spam` attribute being true) using 33 | the plugin specific configuration: 34 | 35 | ```javascript 36 | 37 | { 38 | ... server configuration ..., 39 | plugins: { 40 | 'queue/spamd': { 41 | // any message that scores higher will be treated as spam! defaults to 5 42 | baseScore: 5 43 | } 44 | } 45 | } 46 | 47 | ``` 48 | -------------------------------------------------------------------------------- /doc/plugins/rcpt-core.md: -------------------------------------------------------------------------------- 1 | # rcpt/core 2 | 3 | This `core` plugin adds support for the `RCPT` command. It makes sure that all preconditions are met, tries to parse the recipient 4 | and if everything is okay adds the recipient to the `req` object as `req.to`, so following plugins can work with it. 5 | 6 | The `rcpt` handler is the perfect place to perform checks on the client and the message envelope and either accept or reject any further communication, because at 7 | this point the whole mail envelope is populated. 8 | 9 | So, for example, if you want to reject any mail that is not send to the address `me@example.com`, you could write a handler like this: 10 | 11 | ```javascript``` 12 | 13 | var mailio = require('mail-io'); 14 | var server = mailio.createServer({..}, function(session) { 15 | 16 | session.on('rcpt', function(req, res) { 17 | 18 | if (req.to !== 'me@example.com') { 19 | res.reject(551, 'I am only accepting mail to "me@example.com"'); 20 | } else { 21 | res.accept(); 22 | } 23 | 24 | }); 25 | 26 | }); 27 | 28 | ``` -------------------------------------------------------------------------------- /doc/plugins/rcpt-dnsbl.md: -------------------------------------------------------------------------------- 1 | # rcpt/dnsbl 2 | 3 | This plugin provides support for DNS blacklisting. It looks up a specified blacklist server (by default `zen.spamhaus.org`) and stops a client 4 | from sending any data if it is listed by rejecting the `RCPT` command with a detailed error message why the client got blacklisted. 5 | This plugin is enabled by default. 6 | 7 | > Note: some blacklisting servers (like spamhaus.org) do not resolve blacklist entries properly when using the Google Public DNS servers. 8 | > To work around this issue, the plugin uses the OpenDNS server for DNS lookups by default. 9 | 10 | You can change the plugin settings in the plugin specific configuration of your server configuration: 11 | 12 | ```javascript 13 | 14 | { 15 | ... server configuration ..., 16 | plugins: { 17 | 'rcpt/dnsbl': { 18 | // the blacklist service to use for DNSBL filtering 19 | blacklist: 'zen.spamhaus.org', 20 | // the dns server used to resolve the listing 21 | // note: when using google public dns servers, some dnsbl services like spamhaus won't resolve properly 22 | // so you can set a different dns resolver here 23 | resolver: '208.67.222.222' 24 | } 25 | } 26 | } 27 | 28 | ``` 29 | -------------------------------------------------------------------------------- /doc/plugins/rcpt-relay.md: -------------------------------------------------------------------------------- 1 | # rcpt/relay 2 | 3 | This plugin makes sure to only accept mail that complies to the relay configuration setting. 4 | By default, it only accepts a mail in the following cases: 5 | - sender or recipient or both belong to a local domain (`config.domains`) 6 | - if the recipient belongs to a remote domain, the client has to be authenticated 7 | - if the sender belongs to a local domain, the client has to be authenticated 8 | 9 | 10 | General relay configuration options can be passed to the server in the server configuration using the 'relay' object: 11 | 12 | ```javascript 13 | 14 | { 15 | ... server configuration..., 16 | relay: { 17 | // should we relay messages? 18 | enabled: true, 19 | // allow relay to foreign domains if the sender is not authenticated? 20 | allowUnauthenticated: false, 21 | // do we relay mail from senders that do not belong to our served domains (config.domains)? 22 | openRelay: false, 23 | // the hostname used to identify to the receiving server in the "EHLO" command, defaults to os.hostname() 24 | hostname: os.hostname(), 25 | // the directory used to store queued messages, defaults to /tmp/mail-io-queue 26 | queueDir: path.join(os.tmpDir(), 'mail-io-queue'), 27 | // the amount of hours that we will try to resubmit the message if the submission fails with a temporary error 28 | // defaults to 48 hours 29 | retryHours: 48, 30 | // the base interval in seconds, that specifies how long to wait until we try to resubmit a failed message 31 | // this inverval will be multiplied with the square of failed attempts, so the time between resubmissions 32 | // will increase with every failed attempt. defaults to 60 seconds 33 | retryBaseInterval: 60, 34 | // the maximum amount of concurrent transactions, defaults to 5 35 | concurrentTransactions: 5 36 | } 37 | 38 | ``` 39 | -------------------------------------------------------------------------------- /doc/plugins/starttls-core.md: -------------------------------------------------------------------------------- 1 | # starttls/core 2 | 3 | This `core` plugin provides support for the `STARTTLS` command. 4 | It allows a client to upgrade an insecure connection using `STARTTLS`. 5 | 6 | > Note, by default we deliver a self-signed certificate that should be only used for testing purposes. 7 | > If you run the server in production, you should setup your own certificates by providing 'tls' object to the server configuration. 8 | 9 | The tls configuration object in the server configuration by default looks like this: 10 | 11 | ```javascript 12 | 13 | { 14 | ... server configuration ..., 15 | tls: { 16 | key: fs.readFileSync(__dirname + '/../keys/key.pem'), 17 | cert: fs.readFileSync(__dirname + '/../keys/cert.pem'), 18 | ca: fs.readFileSync(__dirname + '/../keys/ca.pem'), 19 | ciphers: 'ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA', 20 | honorCipherOrder: true 21 | } 22 | } 23 | 24 | ``` 25 | -------------------------------------------------------------------------------- /example/authentication.js: -------------------------------------------------------------------------------- 1 | const mio = require('../src/index.js'); 2 | const server = new mio.Server({}, (session) => { 3 | 4 | session.on('auth', function(req, res) { 5 | 6 | // make sure tester/tester gets through 7 | if (req.user && req.user.username === 'tester' && req.user.password === 'tester') { 8 | res.accept(); 9 | } else { 10 | res.reject(552, 'authentication failed'); 11 | } 12 | 13 | }); 14 | 15 | }); 16 | 17 | server.listen(2525, () => console.log('SMTP server up on port 2525')); -------------------------------------------------------------------------------- /keys/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDXDCCAsWgAwIBAgIJAKL0UG+mRkSPMA0GCSqGSIb3DQEBBQUAMH0xCzAJBgNV 3 | BAYTAlVLMRQwEgYDVQQIEwtBY2tuYWNrIEx0ZDETMBEGA1UEBxMKUmh5cyBKb25l 4 | czEQMA4GA1UEChMHbm9kZS5qczEdMBsGA1UECxMUVGVzdCBUTFMgQ2VydGlmaWNh 5 | dGUxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0wOTExMTEwOTUyMjJaFw0yOTExMDYw 6 | OTUyMjJaMH0xCzAJBgNVBAYTAlVLMRQwEgYDVQQIEwtBY2tuYWNrIEx0ZDETMBEG 7 | A1UEBxMKUmh5cyBKb25lczEQMA4GA1UEChMHbm9kZS5qczEdMBsGA1UECxMUVGVz 8 | dCBUTFMgQ2VydGlmaWNhdGUxEjAQBgNVBAMTCWxvY2FsaG9zdDCBnzANBgkqhkiG 9 | 9w0BAQEFAAOBjQAwgYkCgYEA8d8Hc6atq78Jt1HLp9agA/wpQfsFvkYUdZ1YsdvO 10 | kL2janjwHQgMMCy/Njal3FUEW0OLPebKZUJ8L44JBXSlVxU4zyiiSOWld8EkTetR 11 | AVT3WKQq3ud+cnxv7g8rGRQp1UHZwmdbZ1wEfAYq8QjYx6m1ciMgRo7DaDQhD29k 12 | d+UCAwEAAaOB4zCB4DAdBgNVHQ4EFgQUL9miTJn+HKNuTmx/oMWlZP9cd4QwgbAG 13 | A1UdIwSBqDCBpYAUL9miTJn+HKNuTmx/oMWlZP9cd4ShgYGkfzB9MQswCQYDVQQG 14 | EwJVSzEUMBIGA1UECBMLQWNrbmFjayBMdGQxEzARBgNVBAcTClJoeXMgSm9uZXMx 15 | EDAOBgNVBAoTB25vZGUuanMxHTAbBgNVBAsTFFRlc3QgVExTIENlcnRpZmljYXRl 16 | MRIwEAYDVQQDEwlsb2NhbGhvc3SCCQCi9FBvpkZEjzAMBgNVHRMEBTADAQH/MA0G 17 | CSqGSIb3DQEBBQUAA4GBADRXXA2xSUK5W1i3oLYWW6NEDVWkTQ9RveplyeS9MOkP 18 | e7yPcpz0+O0ZDDrxR9chAiZ7fmdBBX1Tr+pIuCrG/Ud49SBqeS5aMJGVwiSd7o1n 19 | dhU2Sz3Q60DwJEL1VenQHiVYlWWtqXBThe9ggqRPnCfsCRTP8qifKkjk45zWPcpN 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /keys/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDXDCCAsWgAwIBAgIJAKL0UG+mRkSPMA0GCSqGSIb3DQEBBQUAMH0xCzAJBgNV 3 | BAYTAlVLMRQwEgYDVQQIEwtBY2tuYWNrIEx0ZDETMBEGA1UEBxMKUmh5cyBKb25l 4 | czEQMA4GA1UEChMHbm9kZS5qczEdMBsGA1UECxMUVGVzdCBUTFMgQ2VydGlmaWNh 5 | dGUxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0wOTExMTEwOTUyMjJaFw0yOTExMDYw 6 | OTUyMjJaMH0xCzAJBgNVBAYTAlVLMRQwEgYDVQQIEwtBY2tuYWNrIEx0ZDETMBEG 7 | A1UEBxMKUmh5cyBKb25lczEQMA4GA1UEChMHbm9kZS5qczEdMBsGA1UECxMUVGVz 8 | dCBUTFMgQ2VydGlmaWNhdGUxEjAQBgNVBAMTCWxvY2FsaG9zdDCBnzANBgkqhkiG 9 | 9w0BAQEFAAOBjQAwgYkCgYEA8d8Hc6atq78Jt1HLp9agA/wpQfsFvkYUdZ1YsdvO 10 | kL2janjwHQgMMCy/Njal3FUEW0OLPebKZUJ8L44JBXSlVxU4zyiiSOWld8EkTetR 11 | AVT3WKQq3ud+cnxv7g8rGRQp1UHZwmdbZ1wEfAYq8QjYx6m1ciMgRo7DaDQhD29k 12 | d+UCAwEAAaOB4zCB4DAdBgNVHQ4EFgQUL9miTJn+HKNuTmx/oMWlZP9cd4QwgbAG 13 | A1UdIwSBqDCBpYAUL9miTJn+HKNuTmx/oMWlZP9cd4ShgYGkfzB9MQswCQYDVQQG 14 | EwJVSzEUMBIGA1UECBMLQWNrbmFjayBMdGQxEzARBgNVBAcTClJoeXMgSm9uZXMx 15 | EDAOBgNVBAoTB25vZGUuanMxHTAbBgNVBAsTFFRlc3QgVExTIENlcnRpZmljYXRl 16 | MRIwEAYDVQQDEwlsb2NhbGhvc3SCCQCi9FBvpkZEjzAMBgNVHRMEBTADAQH/MA0G 17 | CSqGSIb3DQEBBQUAA4GBADRXXA2xSUK5W1i3oLYWW6NEDVWkTQ9RveplyeS9MOkP 18 | e7yPcpz0+O0ZDDrxR9chAiZ7fmdBBX1Tr+pIuCrG/Ud49SBqeS5aMJGVwiSd7o1n 19 | dhU2Sz3Q60DwJEL1VenQHiVYlWWtqXBThe9ggqRPnCfsCRTP8qifKkjk45zWPcpN 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /keys/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXQIBAAKBgQDx3wdzpq2rvwm3Ucun1qAD/ClB+wW+RhR1nVix286QvaNqePAd 3 | CAwwLL82NqXcVQRbQ4s95splQnwvjgkFdKVXFTjPKKJI5aV3wSRN61EBVPdYpCre 4 | 535yfG/uDysZFCnVQdnCZ1tnXAR8BirxCNjHqbVyIyBGjsNoNCEPb2R35QIDAQAB 5 | AoGBAJNem9C4ftrFNGtQ2DB0Udz7uDuucepkErUy4MbFsc947GfENjDKJXr42Kx0 6 | kYx09ImS1vUpeKpH3xiuhwqe7tm4FsCBg4TYqQle14oxxm7TNeBwwGC3OB7hiokb 7 | aAjbPZ1hAuNs6ms3Ybvvj6Lmxzx42m8O5DXCG2/f+KMvaNUhAkEA/ekrOsWkNoW9 8 | 2n3m+msdVuxeek4B87EoTOtzCXb1dybIZUVv4J48VAiM43hhZHWZck2boD/hhwjC 9 | M5NWd4oY6QJBAPPcgBVNdNZSZ8hR4ogI4nzwWrQhl9MRbqqtfOn2TK/tjMv10ALg 10 | lPmn3SaPSNRPKD2hoLbFuHFERlcS79pbCZ0CQQChX3PuIna/gDitiJ8oQLOg7xEM 11 | wk9TRiDK4kl2lnhjhe6PDpaQN4E4F0cTuwqLAoLHtrNWIcOAQvzKMrYdu1MhAkBm 12 | Et3qDMnjDAs05lGT72QeN90/mPAcASf5eTTYGahv21cb6IBxM+AnwAPpqAAsHhYR 13 | 9h13Y7uYbaOjvuF23LRhAkBoI9eaSMn+l81WXOVUHnzh3ZwB4GuTyxMXXNOhuiFd 14 | 0z4LKAMh99Z4xQmqSoEkXsfM4KPpfhYjF/bwIcP5gOei 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mail-io", 3 | "description": "SMTP server", 4 | "author": "Thomas Zilz", 5 | "version": "2.1.0", 6 | "main": "src/index.js", 7 | "repository": "https://github.com/mofux/mail-io", 8 | "license": "MIT", 9 | "scripts": { 10 | "test": "mocha" 11 | }, 12 | "dependencies": { 13 | "async": "^2.5.0", 14 | "colors": "^1.0.3", 15 | "extend": "^3.0.1", 16 | "ip-address": "^5.1.0", 17 | "lodash": "^4.17.4", 18 | "mailparser": "^0.6.2", 19 | "mkdirp": "^0.5.0", 20 | "moment-timezone": "^0.5.13", 21 | "native-dns": "^0.7.0", 22 | "node-uuid": "^1.4.3", 23 | "nodemailer": "^4.1.0", 24 | "smtp-connection": "^4.0.2", 25 | "topsort": "0.0.2" 26 | }, 27 | "devDependencies": { 28 | "mocha": "^3.5.3", 29 | "should": "^13.0.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /plugins/auth/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | author: 'Thomas Zilz', 4 | description: 'adds support for AUTH PLAIN and AUTH LOGIN', 5 | handler: function(req, res) { 6 | 7 | // module dependencies 8 | let _ = require('lodash'); 9 | 10 | // after an AUTH command has been successfully completed, no more AUTH commands may be issued in the same session 11 | // after a successful AUTH command completes, a server MUST reject any further AUTH commands with a 503 reply. 12 | if (req.session.accepted.auth) return res.reject(503, 'Already authenticated'); 13 | 14 | // the AUTH command is not permitted during a mail transaction. an AUTH command issued during a mail transaction MUST be rejected with a 503 reply. 15 | if (req.session.accepted.mail) return res.reject(503, 'Not permitted'); 16 | 17 | // make sure proper auth data was sent 18 | if (!req.command.data) return res.reject(501, 'Bad syntax'); 19 | 20 | // check the auth type (LOGIN or PLAIN) 21 | let type = req.command.data.split(' ')[0]; 22 | 23 | // make sure the type is valid 24 | if (!_.isString(type) || !type.length) return res.reject(501, 'Bad syntax'); 25 | 26 | // ignore the case 27 | type = type.toLowerCase(); 28 | 29 | // process the supported authentication types 30 | switch(type) { 31 | 32 | // AUTH PLAIN 33 | case 'plain': 34 | 35 | if (req.command.data.split(' ').length !== 2) return res.reject(501, 'Bad syntax'); 36 | 37 | // plain auth sends one base64 encoded string that contains the username and the password (e.g. user\x00user\x00password); 38 | let data = new Buffer(req.command.data.split(' ')[1], 'base64').toString().split('\x00'); 39 | if (data.length < 2) return res.reject(500, 'Invalid user data'); 40 | 41 | // get the user and password 42 | let username = data.length < 3 ? data[0] : data[1]; 43 | let password = data.length < 3 ? data[1] : data[2]; 44 | 45 | // make sure username and password are set 46 | if (!username || !username.length || !password || !password.length) return res.reject(500, 'Invalid user data'); 47 | 48 | // assign the user to the session 49 | req.user = { 50 | username: username, 51 | password: password 52 | } 53 | 54 | // special handling: the server can send messages to us that have to be implicitly authenticated. 55 | // to do this, it provides a apiUser object that it uses to authenticate. The apiUser object's 56 | // username and password are random strings generated with every server start. 57 | // if the username and password match this apiUser, we will not continue processing the auth 58 | // handlers as they should not care about this implementation detail 59 | if (req.session.server.apiUser && req.session.server.apiUser.username === username && req.session.server.apiUser.password === password) { 60 | res.log.verbose('Authentication succeeded using the api user. no more auth handlers will be called'); 61 | return res.final(235, 'authentication successful (api user)'); 62 | } 63 | 64 | // accept the request 65 | // note: the following 'auth' listeners should check 66 | // for req.user and verify if the username and password 67 | // are valid. if they are not valid, they must call res.reject(535, 'authentication failed') 68 | res.accept(235, 'Authentication successful'); 69 | return; 70 | 71 | // AUTH LOGIN 72 | case 'login': 73 | 74 | // request the username 75 | res.write('334 ' + new Buffer('Username:', 'utf8').toString('base64')); 76 | 77 | // listen for the username to arrive 78 | res.read((data) => { 79 | 80 | // the auth request can be cancelled with a single '*' 81 | if (data.toString().replace(/\r?\n|\r/g, '') === '*') { 82 | return res.reject(501, 'Authentication aborted'); 83 | } 84 | 85 | // this should now have the decoded username 86 | let username = new Buffer(data.toString(), 'base64').toString(); 87 | 88 | // request the password 89 | res.write('334 ' + new Buffer('Password:', 'utf8').toString('base64')); 90 | 91 | // listen for the password to arrive 92 | res.read((data) => { 93 | 94 | // the auth request can be cancelled with a single '*' 95 | if (data.toString().replace(/\r?\n|\r/g, '') === '*') { 96 | return res.reject(501, 'Authentication aborted'); 97 | } 98 | 99 | // this should now have the decoded password 100 | let password = new Buffer(data.toString(), 'base64').toString(); 101 | 102 | // assign the user to the session 103 | req.user = { 104 | username: username, 105 | password: password 106 | } 107 | 108 | // accept the request 109 | // note: the following 'auth' listeners should check 110 | // for req.user and verify if the username and password 111 | // are valid. if they are not valid, they must call res.reject(535, 'authentication failed') 112 | res.accept(235, 'Authentication successful'); 113 | 114 | }); 115 | }); 116 | return; 117 | 118 | default: 119 | // reject any unknown auth methods 120 | return res.reject(501, 'Bad syntax'); 121 | 122 | } 123 | 124 | } 125 | } -------------------------------------------------------------------------------- /plugins/connect/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for "connect" event', 4 | author: 'Thomas Zilz', 5 | handler: (req, res) => { 6 | 7 | // make sure we do not exceed the maximum client connection count 8 | req.session.server.getConnections((err, count) => { 9 | 10 | if (err || count > req.session.config.limits.maxConnections) { 11 | 12 | // error or connection count exceeded, reject the client 13 | res.end(421, req.session.config.hostname + ' too many connected clients, try again in a moment'); 14 | 15 | } else { 16 | 17 | // accept and greet with the hostname 18 | res.accept(220, req.session.config.hostname); 19 | 20 | } 21 | 22 | }); 23 | 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /plugins/data/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for DATA command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // core modules 8 | let os = require('os'); 9 | let path = require('path'); 10 | let fs = require('fs'); 11 | let moment = require('moment-timezone'); 12 | 13 | // make sure we have valid senders and recipients 14 | if ((!req.session.accepted.helo && !req.session.accepted.ehlo)) return res.reject(503, 'Need HELO or EHLO command'); 15 | if (!req.session.accepted.mail) return res.reject(503, 'No valid sender'); 16 | if (!req.session.accepted.rcpt) return res.reject(503, 'No valid recipients'); 17 | 18 | // start the data mode, attach the data stream to 19 | // the request, so the following listeners can use it 20 | req.stream = req.session.connection.startDataMode(); 21 | 22 | // accept 23 | res.accept(354, 'OK'); 24 | 25 | // write the data to a file 26 | let file = path.join(os.tmpdir(), req.session.id + '-' + req.session.transaction + '.msg'); 27 | 28 | // write stream to the tmp file 29 | let fileStream = fs.createWriteStream(file); 30 | 31 | // add received headers 32 | let received = 33 | 'Received: from ' + req.session.client.hostname + ' (' + req.session.client.address + ')\n\t' + 34 | 'by ' + req.session.config.hostname + ' (' + req.session.server.address().address + ') with ' + 35 | (req.session.accepted.ehlo ? 'ESMTP' : 'SMTP') + (req.session.secure ? 'S' : '') + (req.session.accepted.auth ? 'A' : '') + '; ' + 36 | moment().locale('en').format('ddd, DD MMM YYYY HH:mm:ss ZZ') + '\r\n'; 37 | 38 | // write the received header to the top of the file 39 | fileStream.write(received); 40 | 41 | // when data is arriving, stream it to the file 42 | req.stream.on('data', (data) => fileStream.write(data)); 43 | 44 | // stream ended 45 | req.stream.once('end', () => { 46 | 47 | // close the file stream 48 | fileStream.end(); 49 | 50 | // continue in normal mode 51 | req.stream.removeAllListeners(); 52 | req.session.connection.continue(); 53 | 54 | // emit the internal 'queue' event 55 | req.session.emit('queue', file, () => { 56 | 57 | // reset the transaction 58 | req.session.resetTransaction(); 59 | 60 | // increase the transaction 61 | req.session.transaction++; 62 | 63 | // remove the temporary file 64 | fs.exists(file, (exists) => { 65 | if (exists) fs.unlink(file, (err) => { 66 | if (err) res.log.warn('Failed to unlink file "' + file + '": ' + err); 67 | }); 68 | }); 69 | 70 | }, true); 71 | 72 | }); 73 | 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /plugins/ehlo/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for EHLO command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // TODO: implement proper EHLO checks 8 | if (req.command && req.command.data && req.command.data.length) { 9 | 10 | // reset the session, EHLO is essentially the same as RSET 11 | req.session.reset(); 12 | 13 | // write the hostname 14 | res.write('250-' + req.session.config.hostname); 15 | 16 | // write out the supported features 17 | let features = [].concat(req.config.features); 18 | 19 | // remove the STARTTLS feature if the session is already secure 20 | if (req.session.secure && features.indexOf('STARTTLS') !== -1) { 21 | features.splice(features.indexOf('STARTTLS'), 1); 22 | } 23 | 24 | // write out the features 25 | while (features.length > 1) { 26 | res.write('250-' + features[0]); 27 | features.shift(); 28 | } 29 | 30 | // write the last command as accept message 31 | res.accept(250, features[0] || 'OK'); 32 | 33 | } else { 34 | 35 | // bad hostname, reject it 36 | res.reject(501, 'syntax: EHLO hostname'); 37 | 38 | } 39 | 40 | } 41 | } -------------------------------------------------------------------------------- /plugins/helo/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for HELO command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // TODO: implement proper HELO checks 8 | if (req.command && req.command.data && req.command.data.length) { 9 | req.session.reset(); 10 | res.accept(250, req.session.config.hostname); 11 | } else { 12 | res.reject(501, 'syntax: HELO hostname'); 13 | } 14 | 15 | } 16 | } -------------------------------------------------------------------------------- /plugins/help/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for HELP command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | res.accept(214, 'see https://tools.ietf.org/html/rfc5321 for details') 8 | 9 | } 10 | } -------------------------------------------------------------------------------- /plugins/mail/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for MAIL command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // make sure HELO or EHLO were accepted 8 | if (!req.session.accepted.helo && !req.session.accepted.ehlo) return res.reject(503, 'Need HELO or EHLO command'); 9 | 10 | // make sure the command has data 11 | if (!req.command.data) return res.reject(501, 'Incomplete MAIL command'); 12 | 13 | // do not allow multiple mail commands for one transaction 14 | if (req.session.accepted.mail && req.session.transaction < 1) return res.reject(503, 'Nested MAIL command'); 15 | 16 | // the mail command may specify the size of the message like this MAIL FROM: SIZE=1024000 17 | if (req.session.config.limits.messageSize && req.command.data.toLowerCase().indexOf(' size=')) { 18 | 19 | // size has been specified, try to get the message size 20 | let size = parseInt(req.command.data.substring(req.command.data.toLowerCase().indexOf(' size=') + 6)); 21 | if (!isNaN(size) && size > req.session.config.limits.messageSize) return res.reject(552, 'Message size exceeds fixed maximum message size (' + req.session.config.maxMessageSize + ' bytes)'); 22 | 23 | } 24 | 25 | // parse the from 26 | let m = req.command.data.match(/^from\s*:\s*(\S+)(?:\s+(.*))?/i); 27 | if (!m || !m[1] || !m[1].length) return res.reject(501, 'Parse error in mail command'); 28 | let from = m[1] === '<>' ? '<>' : m[1].replace(/^$/, '').toLowerCase(); 29 | 30 | // dispatch the from address 31 | if (from) { 32 | req.from = from; 33 | res.accept(250, 'OK'); 34 | } else { 35 | res.reject(501, 'Bad syntax'); 36 | } 37 | 38 | } 39 | } -------------------------------------------------------------------------------- /plugins/noop/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for NOOP command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | res.accept(250, 'OK'); 8 | 9 | } 10 | 11 | } -------------------------------------------------------------------------------- /plugins/queue/core.js: -------------------------------------------------------------------------------- 1 | let MailParser = require('mailparser').MailParser; 2 | let fs = require('fs'); 3 | 4 | module.exports = { 5 | 6 | description: 'core implementation for the "queue" event', 7 | author: 'Thomas Zilz', 8 | handler: function(req, res) { 9 | 10 | // remember if the request was answered 11 | let answered = false; 12 | 13 | // for easier handling, assign the file to the req 14 | req.file = req.command.data; 15 | 16 | // parse the mail using mailparser 17 | let mailparser = new MailParser({ 18 | streamAttachments: false 19 | }); 20 | 21 | // mailparser finished processing the email 22 | mailparser.once('end', (mail) => { 23 | 24 | // attach the parsed mail object to the request 25 | req.mail = mail; 26 | 27 | if (!answered) { 28 | answered = true; 29 | res.accept(); 30 | } 31 | 32 | }); 33 | 34 | // handle parsing errors 35 | mailparser.once('error', (err) => { 36 | 37 | res.log.warn('Failed to parse email: ', err); 38 | 39 | if (!answered) { 40 | answered = true; 41 | res.reject(451, 'Error while parsing the mail'); 42 | } 43 | 44 | }); 45 | 46 | // create a read stream to the message file 47 | let fileStream = fs.createReadStream(req.file); 48 | 49 | // handle file errors 50 | fileStream.once('error', (err) => { 51 | 52 | res.log.warn('Failed to read file "' + req.file + '": ', err); 53 | 54 | if (!answered) { 55 | answered = true; 56 | res.reject(451, 'Error while processing the mail'); 57 | } 58 | 59 | }); 60 | 61 | // pipe the message content to mailparser 62 | fileStream.pipe(mailparser); 63 | 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /plugins/queue/maildir.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | description: 'stores the message in the maildir format', 3 | author: 'Thomas Zilz', 4 | after: ['spamd'], 5 | handler: async (req, res) => { 6 | 7 | try { 8 | 9 | // module dependencies 10 | let fs = require('fs'); 11 | let path = require('path'); 12 | let uuid = require('node-uuid'); 13 | let SMTPUtil = require('../../src/smtp-util'); 14 | 15 | // a list of sender addresses (after extending them) 16 | let senders = []; 17 | 18 | // a list of recipient mail (after extending them) 19 | let recipients = []; 20 | 21 | // a list of mailboxes 22 | let mailboxes = []; 23 | 24 | // extend function, either from the config or a simple placeholder 25 | let extend = typeof(req.config.extend) === 'function' ? req.config.extend : (address) => { 26 | return [address]; 27 | } 28 | 29 | // only store mails for senders that belong to our domain 30 | if (req.session.config.domains.indexOf(req.session.envelope.from.split('@')[1]) !== -1) { 31 | let extended = await extend(req.session.envelope.from); 32 | if (extended) senders = senders.concat(extended); 33 | } 34 | 35 | // only store mails for recipients that belong to our domain 36 | for (let recipient of req.session.envelope.to) { 37 | if (req.session.config.domains.indexOf(recipient.split('@')[1]) === -1) continue; 38 | let extended = await extend(recipient); 39 | if (extended) recipients = recipients.concat(extended); 40 | } 41 | 42 | // add mailbox entries for senders 43 | senders.forEach((address) => { 44 | mailboxes.push({ 45 | mailDir: req.config.mailDir.replace(/%n/g, address.split('@')[0]).replace(/%d/g, address.split('@')[1]), 46 | folder: '.Sent' 47 | }); 48 | }); 49 | 50 | // add mailbox entries for recipients 51 | recipients.forEach((address) => { 52 | mailboxes.push({ 53 | mailDir: req.config.mailDir.replace(/%n/g, address.split('@')[0]).replace(/%d/g, address.split('@')[1]), 54 | folder: res.get('queue/spamd').spam ? '.Junk' : '' 55 | }); 56 | }); 57 | 58 | // write message to the designated mailboxes 59 | for (let mailbox of mailboxes) { 60 | 61 | // configure mailbox path 62 | mailbox.path = path.join(mailbox.mailDir, mailbox.folder); 63 | res.log.verbose('Storing mail to ' + mailbox.path); 64 | 65 | // create mail dirs if they do not exist yet 66 | let dirs = ['tmp', 'new', 'cur']; 67 | 68 | // create target directories if they do not yet exist 69 | for (let dir of dirs) await SMTPUtil.mkdirp(path.join(mailbox.path, dir)); 70 | 71 | // stream the message to the file 72 | await new Promise((resolve, reject) => { 73 | 74 | // file names for different targets 75 | let filename = new Date().getTime() + '.' + uuid.v1() + '.' + req.session.config.hostname; 76 | let tmpFile = path.join(mailbox.path, 'tmp', filename); 77 | let finalFile = path.join(mailbox.path, 'new', filename); 78 | 79 | // a reference to source mail 80 | let message = fs.createReadStream(req.command.data); 81 | 82 | // try to catch errors (e.g. we cannot read the message) 83 | message.once('error', reject); 84 | 85 | // special handling for sent messages: 86 | // put the mail to the cur instead of the new folder, and 87 | // add the "Seen" (:2,S) attribute to the filename, so that 88 | // a client does not show this message as unread 89 | if (mailbox.folder === '.Sent') { 90 | finalFile = path.join(mailbox.path, 'cur', filename + ':2,S'); 91 | } 92 | 93 | // save the message to the file 94 | let fileStream = fs.createWriteStream(tmpFile); 95 | 96 | // handle errors 97 | fileStream.once('error', reject); 98 | 99 | // stream the message to the tmp folder 100 | message.pipe(fileStream); 101 | 102 | // once the file has been written completely, 103 | // copy it from the tmp folder to the new folder 104 | fileStream.once('finish', () => { 105 | 106 | fs.link(tmpFile, finalFile, (err) => { 107 | 108 | if (err) return reject('Failed to move message from "' + tmpFile + '" to "' + finalFile + '": ' + err); 109 | 110 | // delete the message from tmp 111 | fs.unlink(tmpFile, (err) => { 112 | 113 | return err ? reject('Failed to delete tmp message "' + tmpFile + '": ' + err) : resolve(); 114 | 115 | }); 116 | 117 | }); 118 | 119 | }); 120 | 121 | }); 122 | 123 | } 124 | 125 | // accept 126 | res.accept(250, 'OK'); 127 | 128 | } catch (ex) { 129 | 130 | // log the error and reject 131 | res.log.error('Error while storing message:', err); 132 | res.reject(451, 'Requested action aborted - failed to store message.'); 133 | 134 | } 135 | 136 | } 137 | 138 | } -------------------------------------------------------------------------------- /plugins/queue/relay.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'triggers the "relay" event for every recipient, it\'s up to the user to decide if the message should be relayed', 4 | author: 'Thomas Zilz', 5 | after: ['core'], 6 | handler: async(req, res) => { 7 | 8 | // module dependencies 9 | const fs = require('fs'); 10 | const config = req.session.config.relay || null; 11 | 12 | // is the relay feature enabled? 13 | if (!config || !config.enabled) return res.accept(); 14 | 15 | // do we require authentication before we can relay 16 | if (!config.allowUnauthenticated && !req.session.accepted.auth) return res.accept(); 17 | 18 | // do we relay for senders that do not belong to our served domains? 19 | if (!config.openRelay && req.session.config.domains.indexOf(req.session.envelope.from.split('@')[1]) === -1) return res.accept(); 20 | 21 | // relay to recipients that are not local 22 | for (let to of req.session.envelope.to) { 23 | 24 | // check if the domain is a domain served by us 25 | let local = req.session.config.domains.includes(String(to.split('@')[1]).toLowerCase()); 26 | 27 | // domain is not local, relay to it 28 | if (!local) { 29 | 30 | // relay it 31 | res.log.verbose(`Relaying mail to "${to}". Local domains: ${req.session.config.domains.join(', ')}`); 32 | await req.session.relay.add({ from: req.session.envelope.from, to: to }, fs.createReadStream(req.file), req.mail.headers).catch((err) => { 33 | 34 | // error adding the mail to the relay 35 | res.log.error('Failed to submit message to relay queue: ', err); 36 | 37 | }); 38 | 39 | } 40 | 41 | } 42 | 43 | // accept anyway 44 | return res.accept(); 45 | 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /plugins/queue/spamd.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'check mail content against spamassassin (needs to be installed)', 4 | author: 'Thomas Zilz', 5 | after: ['core'], 6 | handler: function(req, res) { 7 | 8 | // module dependencies 9 | let fs = require('fs'); 10 | 11 | // checks the message against the spamassassin daemon 12 | let report = function(message, cb/*err, result*/) { 13 | 14 | let spamd = require('net').createConnection(783); 15 | let done = false; 16 | let response = { 17 | code: -1, 18 | message: 'FAILED', 19 | spam: false, 20 | score: 0, 21 | baseScore: 5, 22 | matches: [], 23 | report: [] 24 | }; 25 | 26 | // if the connection times out, we return an error 27 | spamd.setTimeout(10 * 1000, () => { 28 | 29 | done = true; 30 | return cb('connection to spamd timed out'); 31 | 32 | }); 33 | 34 | // once connected, send the request 35 | spamd.once('connect', () => { 36 | 37 | spamd.write('REPORT SPAMC/1.5\r\n'); 38 | spamd.write('\r\n'); 39 | message.on('data', (data) => spamd.write(data)); 40 | message.once('end', () => spamd.end('\r\n')); 41 | 42 | }); 43 | 44 | // catch service errors 45 | spamd.once('error', (err) => { 46 | if (!done) { 47 | done = true; 48 | return cb(err); 49 | } 50 | }); 51 | 52 | // flag that remembers if the very first data has been received 53 | let first = true; 54 | 55 | // process the spamd response data 56 | spamd.on('data', (data) => { 57 | 58 | let lines = data.toString().split('\r\n'); 59 | lines.forEach((line) => { 60 | if (first) { 61 | first = false; 62 | let result = line.match(/SPAMD\/([0-9\.\-]+)\s([0-9]+)\s([0-9A-Z_]+)/); 63 | if (result) { 64 | response.code = parseInt(result[2], 10); 65 | response.message = result[3]; 66 | } 67 | } else { 68 | result = line.match(/Spam:\s(True|False|Yes|No)\s;\s([0-9\.]+)\s\/\s([0-9\.]+)/); 69 | if (result) { 70 | response.spam = result[1] == 'True' || result[1] == 'Yes' ? true : false; 71 | response.score = parseFloat(result[2]); 72 | response.baseScore = parseFloat(result[3]); 73 | } 74 | if (!result) { 75 | result = line.match(/([A-Z0-9\_]+)\,/g); 76 | if (result) response.matches = response.matches.concat(result.map(function(item) { 77 | return item.substring(0, item.length - 1); 78 | })); 79 | } 80 | if (!result) { 81 | result = line.match(/(\s|-)([0-9\.]+)\s([A-Z0-9\_]+)\s([^:]+)\:\s([^\n]+)/g); 82 | if (result) { 83 | response.report = response.report.concat(result.map(function(item) { 84 | item = item.replace(/\n([\s]*)/, ' '); 85 | let matches = item.match(/(\s|-)([0-9\.]+)\s([A-Z0-9\_]+)\s([^:]+)\:\s([^\s]+)/); 86 | return { 87 | score: matches && matches[2] ? matches[2] : 0, 88 | name: matches && matches[3] ? matches[3] : null, 89 | description: matches && matches[4] ? matches[4].replace(/^\s*([\S\s]*)\b\s*$/, '$1') : null, 90 | type: matches && matches[5] ? matches[5] : null 91 | } 92 | })); 93 | } 94 | } 95 | } 96 | }); 97 | 98 | }); 99 | 100 | // process the data once the connection is closed 101 | spamd.once('close', () => { 102 | if (!done) { 103 | done = true; 104 | return cb(null, response); 105 | } 106 | }); 107 | 108 | } 109 | 110 | // run the report 111 | report(fs.createReadStream(req.command.data), (err, data) => { 112 | 113 | // set a spam score and publishes the result, even if an error occured, 114 | // in which case the score will always be 0 115 | let result = { score: data && data.score ? data.score : 0, baseScore: req.config.baseScore || 5, err: err || null }; 116 | result.spam = result.score >= result.baseScore; 117 | res.set(result); 118 | 119 | if (err) { 120 | if (err.code === 'ECONNREFUSED') { 121 | res.log.verbose('Unable to connect to spamd on port 783'); 122 | } else { 123 | res.log.warn('spamd encountered an error: ', err) 124 | } 125 | } else { 126 | res.log.verbose((result.spam ? 'spam!' : 'no spam!') + ' (' + result.score + '/' + result.baseScore + ')'); 127 | } 128 | 129 | res.accept(); 130 | 131 | }); 132 | 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /plugins/quit/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for QUIT command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // disconnect the client 8 | res.end(221, 'bye'); 9 | 10 | } 11 | 12 | } -------------------------------------------------------------------------------- /plugins/rcpt/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for RCPT command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // make sure mail command was already issued 8 | if (!req.session.accepted.mail) return res.reject(503, 'need MAIL command'); 9 | 10 | // make sure a recipient has been passed 11 | if (!req.command.data) return res.reject(501, 'incomplete RCPT command'); 12 | 13 | // make sure the amount of recipients does not grow beyond limit 14 | if (req.session.envelope.to.length >= (req.session.config.limits.maxRecipients || 100)) return res.reject(502, 'too many recipients'); 15 | 16 | // parse the rcpt 17 | let m = req.command.data.match(/^to\s*:\s*(\S+)(?:\s+(.*))?/i); 18 | if (!m) return res.reject(501, 'parse error in rcpt command'); 19 | let to = m[1].replace(/^$/, '').toLowerCase(); 20 | 21 | // dispatch the rcpt address 22 | if (to) { 23 | req.to = to; 24 | res.accept(250, 'OK'); 25 | } else { 26 | res.reject(501, 'bad syntax'); 27 | } 28 | 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /plugins/rcpt/dnsbl.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for RCPT command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // module dependencies 8 | let net = require('net'); 9 | let dns = require('native-dns'); 10 | let Address6 = require('ip-address').Address6; 11 | 12 | // skip the check if the sender is authenticated 13 | if (req.session.accepted.auth) { 14 | res.log.verbose('Client authenticated. Skipping dnsbl lookup.'); 15 | return res.accept(); 16 | } 17 | 18 | // get the sender ip 19 | let ip = req.session.client.address; 20 | 21 | // do not perform a lookup for loopback addresses (useful for testing) 22 | if (ip && ip.indexOf('127.0.0.1') !== -1) { 23 | res.log.verbose('Client ip is a loopback address. Skipping dnsbl lookup.'); 24 | return res.accept(); 25 | } 26 | 27 | // get the blacklist names 28 | let blacklist = req.config.blacklist || 'zen.spamhaus.org'; 29 | 30 | // stores the reversed ip address 31 | let reversed = null; 32 | 33 | // check the address 34 | if (net.isIPv4(ip)) { 35 | 36 | // reverse the address by splitting the dots 37 | reversed = ip.split('.').reverse().join('.'); 38 | 39 | } else if (ip.indexOf('::ffff:') === 0 && ip.split('.').length === 4) { 40 | 41 | // ipv6 representation of an ipv6 address 42 | reversed = ip.replace('::ffff:', '').split('.').reverse().join('.'); 43 | 44 | } else if (net.isIPv6(ip)) { 45 | 46 | reversed = new Address6(ip).reverseForm({ omitSuffix: true }); 47 | 48 | } 49 | 50 | // if we were not able to reverse the address, accept 51 | if (!reversed) { 52 | res.log.verbose('Unable to parse ip address "' + ip + '"'); 53 | return res.accept(); 54 | } 55 | 56 | // perform a DNS A record lookup for that entry 57 | let record = reversed + '.' + blacklist; 58 | 59 | // perform the dns lookup 60 | dns.resolve(record, 'A', req.config.resolver || null, (err, codes) => { 61 | 62 | // if an error occurred (most likely NXDOMAIN which is the expected response if the host is not listed) 63 | // or if now addresses where returned, we can accept the request 64 | if (err || !codes) { 65 | res.log.verbose('DNSBL lookup for "' + record +'" did not resolve. assuming ip to be ok.') 66 | return res.accept(); 67 | } 68 | 69 | // query additional txt information which may contain more information 70 | // about the block reason 71 | dns.resolve(record, 'TXT', req.config.resolver || null, function(err, infos) { 72 | res.log.verbose('DNSBL lookup for "' + record + '" resolved. rejecting client [' + ip + '].' + (infos ? ' reason: ' + infos.join(';') : '')); 73 | res.reject(550, 'Service unavailable; Client host [' + ip + '] blocked using ' + blacklist + '; ' + (infos ? infos.join(';') : '')); 74 | }); 75 | 76 | }); 77 | 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /plugins/rcpt/relay.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'checks if the mail has to be relayed', 4 | author: 'Thomas Zilz', 5 | after: ['dnsbl'], 6 | handler: function(req, res) { 7 | 8 | // get the configuration of the queue/relay plugin 9 | let config = req.session.config.relay || null; 10 | 11 | // if there is no relay configuration or the relaying feature was not enabled, continue as normal 12 | if (!config) return res.accept(); 13 | 14 | // check if the relaying feature is enabled 15 | if (!config.enabled) return res.accept(); 16 | 17 | // with the relay enabled, check if the mail needs to be relayed 18 | let fromLocal = req.session.config.domains.indexOf(req.session.envelope.from.split('@')[1]) !== -1; 19 | let toLocal = req.session.config.domains.indexOf(req.to.split('@')[1]) !== -1; 20 | 21 | if (!toLocal) { 22 | 23 | // message has to be relayed to the foreign recipient 24 | // make sure a user is authenticated before allowing relay access 25 | if (!config.allowUnauthenticated && !req.session.accepted.auth) return res.reject(502, 'relay access denied'); 26 | 27 | // if the sender is not local and we are not an open relay, complain 28 | if (!config.openRelay && !fromLocal) return res.reject(502, 'relay access denied'); 29 | 30 | } else if (fromLocal && toLocal && !config.allowUnauthenticated && !req.session.accepted.auth) { 31 | 32 | // do not allow mail relay between local users if no user is authenticated 33 | return res.reject(502, 'relay access denied'); 34 | 35 | } 36 | 37 | // if we made it until here it is safe to accept 38 | res.accept(); 39 | 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /plugins/rset/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for RSET command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // reset the current transaction 8 | req.session.resetTransaction(); 9 | res.accept(250, 'OK'); 10 | 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /plugins/starttls/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for STARTTLS command', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // make sure the session is not already secured 8 | if (req.session.secure === true) return res.reject(554, 'tls already active'); 9 | 10 | // remove the old socket 11 | req.session.connection.socket.unpipe(req.session.connection); 12 | 13 | // ignore any commands as long as the session is upgrading 14 | req.session.connection.busy = true; 15 | 16 | // accept the tls request 17 | res.accept(220, 'OK'); 18 | 19 | // initialize tls 20 | let tls = require('tls'); 21 | 22 | let ctx = tls.createSecureContext(req.session.config.tls); 23 | let opts = { 24 | secureContext: ctx, 25 | isServer: true, 26 | server: req.session.connection.server 27 | }; 28 | 29 | // remember old event handlers, then remove them 30 | let events = req.session.connection.socket._events; 31 | req.session.connection.socket.removeAllListeners(); 32 | 33 | // upgrade the connection 34 | let socket = new tls.TLSSocket(req.session.connection.socket, opts); 35 | let base = req.session.connection.socket; 36 | 37 | // add idle timeout 38 | socket.setTimeout(req.session.config.limits.idleTimeout); 39 | 40 | // add socket.close method, which really closes the connection 41 | socket.close = function(data) { 42 | 43 | // only continue if the socket is not already destroyed 44 | if (socket.destroyed) return base.close(); 45 | 46 | // destroy immediately if no data is passed, or if the socket is not writeable 47 | if (!data || !socket.writable) { 48 | 49 | socket.end(); 50 | socket.destroy(); 51 | base.close(); 52 | 53 | } 54 | 55 | // write the data to the socket, then destroy it 56 | socket.write(data, function() { 57 | 58 | // end the socket 59 | socket.end(); 60 | 61 | // destroy the socket 62 | socket.destroy(); 63 | 64 | // close the base socket 65 | base.close(); 66 | 67 | }); 68 | 69 | }; 70 | 71 | // assign the old event handlers to the new socket 72 | socket._events = events; 73 | 74 | // catch error events that happen before the upgrade is done 75 | socket.on('clientError', (err) => { 76 | res.log.warn('error while upgrading the connection to TLS: ', err); 77 | }); 78 | 79 | // wait for the socket to be upgraded 80 | socket.once('secure', () => { 81 | 82 | // reset the session and connect the new tls socket 83 | req.session.reset(); 84 | req.session.secure = true; 85 | req.session.connection.busy = false; 86 | req.session.connection.socket = socket; 87 | req.session.connection.socket.pipe(req.session.connection); 88 | 89 | }); 90 | 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /plugins/timeout/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation for "timeout" event', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // end the client connection 8 | res.end(451, 'idle timeout (' + (req.session.config.limits.idleTimeout / 1000) + 's) expired - closing connection'); 9 | 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /plugins/unrecognized/core.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | description: 'core implementation to handle unrecognized commands', 4 | author: 'Thomas Zilz', 5 | handler: function(req, res) { 6 | 7 | // increase the counter for unrecognized commands 8 | req.session.counters.unrecognizedCommands++; 9 | 10 | // disconnect the session if too many unrecognized commands were sent, 11 | // otherwise send back a reject message 12 | if (req.session.counters.unrecognizedCommands > req.session.config.limits.unrecognizedCommands) { 13 | res.end(554, 'error: too many unrecognized commands'); 14 | } else { 15 | res.reject(502, 'command not recognized'); 16 | } 17 | 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Server: require('./smtp-server'), 3 | Client: require('./smtp-client'), 4 | Logger: require('./smtp-logger'), 5 | Relay: require('./smtp-relay'), 6 | Session: require('./smtp-session'), 7 | Util: require('./smtp-util'), 8 | Stream: require('./smtp-stream') 9 | } -------------------------------------------------------------------------------- /src/smtp-client.js: -------------------------------------------------------------------------------- 1 | // external dependencies 2 | const _ = require('lodash'); 3 | const extend = require('extend'); 4 | const colors = require('colors/safe'); 5 | const uuid = require('node-uuid'); 6 | const hostname = require('os').hostname(); 7 | const SMTPConnection = require('smtp-connection'); 8 | 9 | // internal dependencies 10 | const SMTPLogger = require('./smtp-logger'); 11 | 12 | /** 13 | * A SMTP client that is used internally for relaying and 14 | * also for sending emails through the server `sendMail` API 15 | */ 16 | class SMTPClient { 17 | 18 | /** 19 | * Creates a new SMTP client 20 | * 21 | * @param {object} config 22 | * The client configuration 23 | * 24 | * @param {string} [config.name = os.hostname] 25 | * The name that is announced in the HELO/EHLO 26 | * 27 | * @param {object} [config.tls] 28 | * Optional, additional TLS settings 29 | * 30 | * @param {number} [config.greetingTimeout = 120s] 31 | * The time in ms the client will wait for the server to greet 32 | * before giving up the connection 33 | * 34 | * @param {number} [config.socketTimeout = 120s] 35 | * The time in ms the client will maintain a connection while 36 | * no data is received on the socket 37 | * 38 | * @param {number} [config.connectionTimeout = 120s] 39 | * The time the client will wait for the connection to be established 40 | * before giving up 41 | * 42 | * @param {object} [config.logger] 43 | * An optional object with the level as the key, and the logging function 44 | * as the value 45 | * 46 | * @param {boolean} [config.debug = true] 47 | * If true, the client will log 48 | * 49 | * @param {string} [config.identity = "client"] 50 | * A name of the client identity, will be added to the log 51 | */ 52 | constructor(config) { 53 | 54 | // merge the default configuration with the config passed 55 | config = this.config = extend(true, { 56 | name: hostname, 57 | tls: { 58 | rejectUnauthorized: false 59 | }, 60 | greetingTimeout: 120 * 1000, 61 | socketTimeout: 120 * 1000, 62 | connectionTimeout: 120 * 1000, 63 | debug: true, 64 | logger: { 65 | info: console.log, 66 | warn: console.log, 67 | error: console.log, 68 | verbose: console.log, 69 | debug: console.log 70 | }, 71 | identity: 'client' 72 | }, config); 73 | 74 | // create a logger for the client 75 | this.logger = new SMTPLogger(config.logger); 76 | 77 | } 78 | 79 | /** 80 | * Sends a message 81 | * 82 | * @param {object} envelope 83 | * The message envelope 84 | * 85 | * @param {string} envelope.from 86 | * The sender address 87 | * 88 | * @param {string|array} envelope.to 89 | * The recipeint address, on an array of recipient addresses 90 | * 91 | * @param {number} [envelope.size] 92 | * An optional value of the predicted size of the message in bytes. This value is used if the server supports the SIZE extension (RFC1870) 93 | * 94 | * @param {boolean} [envelope.use8BitMime = false] 95 | * If true then inform the server that this message might contain bytes outside 7bit ascii range 96 | * 97 | * @param {object} [envelope.dsn] 98 | * Optional DSN options (Delivery Status Notification), see: 99 | * https://www.lifewire.com/what-is-dsn-delivery-status-notification-for-smtp-email-3860942 100 | * 101 | * @param {string} [envelope.dsn.ret] 102 | * Return either the full message ‘FULL’ or only headers ‘HDRS’ 103 | * 104 | * @param {string} [envelope.dsn.envid] 105 | * Sender’s ‘envelope identifier’ for tracking 106 | * 107 | * @param {string|array} [envelope.dsn.notify] 108 | * When to send a DSN. Multiple options are OK - array or comma delimited. 109 | * NEVER must appear by itself. Available options: ‘NEVER’, ‘SUCCESS’, ‘FAILURE’, ‘DELAY’ 110 | * 111 | * @param {string} [envelope.dsn.orcpt] 112 | * Original recipient 113 | * 114 | * @param {string|Buffer|stream.Readable} message 115 | * Either a String, Buffer or a Stream. All newlines are converted to \r\n and all dots are escaped automatically, no need to convert anything before. 116 | */ 117 | async send(envelope, message) { 118 | 119 | // get hold of the config and logger 120 | let config = extend(true, {}, this.config); 121 | let logger = this.logger; 122 | 123 | // unique id to identify the message in the log 124 | let id = uuid.v1(); 125 | 126 | // create a logger 127 | Object.keys(logger.levels).forEach((level) => { 128 | 129 | // create a logger for every level 130 | config.logger[level] = (entry, message) => { 131 | 132 | // only log on client and server events 133 | if (!entry || !_.isString(message) || !['client', 'server'].includes(entry.tnx)) return; 134 | 135 | // log every line of the message separately 136 | message.split('\n').forEach((line) => { 137 | if (!line.trim().length) return; 138 | let code = line.split(' ')[0]; 139 | let data = line; 140 | if (code && code.length === 3) data = line.split(' ').slice(1).join(' '); 141 | logger.log('protocol', id, null, config.identity, entry.tnx === 'server' ? 'in' : 'out', entry.tnx === 'server' ? 'in' : 'out', { 142 | code: code && code.length === 3 ? code : undefined, 143 | message: data 144 | }); 145 | }); 146 | 147 | } 148 | 149 | }); 150 | 151 | // wrap the connection logic into a promise, which makes it easier 152 | // to deal with connection events 153 | return new Promise((resolve, reject) => { 154 | 155 | // create a new SMTP connection using the passed configuration 156 | let connection = new SMTPConnection(config); 157 | 158 | // called when we finished or failed 159 | let done = (err) => { 160 | 161 | // log the error 162 | if (err) { 163 | 164 | // use the logger to report back 165 | logger.log('warn', id, null, config.identity, 'error', null, { message: `Failed to deliver message for ${[].concat(envelope.to).join(', ')}: `, data: err.message || err }); 166 | 167 | } 168 | 169 | // end the connection 170 | connection.quit(); 171 | connection.close(); 172 | err ? reject(err) : resolve(); 173 | 174 | } 175 | 176 | // catch errors (e.g. timeout) 177 | connection.on('error', (err) => done(err)); 178 | 179 | // run the client workflow through an async wrapper, this makes 180 | // it much easier to deal with the branched logic 181 | (async () => { 182 | 183 | // connect 184 | await new Promise((res, rej) => connection.connect((err) => err ? rej(err) : res())); 185 | 186 | // login 187 | if (config.login) await new Promise((res, rej) => connection.login(config.login, (err) => err ? rej(err) : res())); 188 | 189 | // send 190 | await new Promise((res, rej) => connection.send(envelope, message, (err) => err ? rej(err) : res())); 191 | 192 | })().then(() => done()).catch((err) => done(err)); 193 | 194 | }); 195 | 196 | } 197 | 198 | } 199 | 200 | module.exports = SMTPClient; -------------------------------------------------------------------------------- /src/smtp-logger.js: -------------------------------------------------------------------------------- 1 | const colors = require('colors/safe'); 2 | const util = require('util'); 3 | const _ = require('lodash'); 4 | 5 | /** 6 | * Pretty prints transaction related log information using 7 | * your favourite logger. Defaults to use console.log. 8 | */ 9 | class SMTPLogger { 10 | 11 | /** 12 | * Creates a new logger. 13 | * 14 | * @param {object} [logger] 15 | * An object where the key is the log level, and the value 16 | * is a function that can be used for logging. Defaults to 17 | * console.log if not specified. 18 | */ 19 | constructor(logger) { 20 | 21 | // fallback to console.log if logger is not passed 22 | if (!_.isObject(logger)) logger = {}; 23 | 24 | // the default log functions to use 25 | this.logger = { 26 | info: logger.info || console.log, 27 | warn: logger.warn || console.log, 28 | error: logger.error || console.log, 29 | debug: logger.debug || console.log, 30 | verbose: logger.verbose || console.log, 31 | protocol: logger.protocol || console.log 32 | }; 33 | 34 | // a list of supported logging levels, and how they should look like 35 | this.levels = { 36 | info: { sign: 'I', fg: colors.green, bg: colors.bgGreen }, 37 | warn: { sign: 'W', fg: colors.yellow, bg: colors.bgYellow }, 38 | error: { sign: 'E', fg: colors.red, bg: colors.bgRed }, 39 | verbose: { sign: 'V', fg: colors.magenta, bg: colors.bgMagenta }, 40 | debug: { sign: 'D', fg: colors.cyan, bg: colors.bgCyan }, 41 | protocol: { sign: 'P', fg: colors.blue, bg: colors.bgBlue } 42 | } 43 | 44 | } 45 | 46 | /** 47 | * Creates formatted logging output 48 | * @param {string} level 49 | * The logging verbosity, any of 'info', 'warn', 'error', 'verbose', 'debug' 50 | * 51 | * @param {string} id 52 | * The identified of the logged context, usually a message id 53 | * 54 | * @param {string} [subId] 55 | * A sub-identifier of the logged context, usually a transaction id 56 | * 57 | * @param {string} event 58 | * The name of the event that triggered the logging 59 | * 60 | * @param {string} [subEvent] 61 | * The name of the sub-event that triggered the logging 62 | * 63 | * @param {string} [type] 64 | * The type of the logged context, can be "in", "out" or anything else 65 | * 66 | * @param {string|object} data 67 | * The data that will be logged. can be either a string or an object containing "code", "message" and "data" 68 | * 69 | * @param {boolean} [dim=false] 70 | * If true, the logged line will be dimmed 71 | */ 72 | log(level, id, subId, event, subEvent, type, data, dim) { 73 | 74 | let levels = this.levels; 75 | let output = ''; 76 | 77 | // add a logging level indicator up front 78 | output += colors.white(levels[level].bg(' ' + levels[level].sign + ' ') + ' '); 79 | 80 | // log the id and optionally the subId 81 | output += id + colors.grey('#') + (subId || '0') + ' '; 82 | 83 | // add the event and sub event, create a fixed padding for the following text 84 | output += colors.green(event) + (subEvent ? colors.grey('/' + subEvent) : colors.grey(' ')); 85 | output = (output + ' ').split('').slice(0, 115).join(''); 86 | 87 | // add a type indicator 88 | switch (type) { 89 | case 'in': output += colors.red(' < '); break; 90 | case 'out': output += colors.green(' > '); break; 91 | case 'up': output += levels[level].fg(' ^ '); break; 92 | case 'down': output += levels[level].fg(' v '); break; 93 | case 'line': output += levels[level].fg(' | '); break; 94 | default: output += levels[level].fg(' \u2055 '); break; 95 | } 96 | 97 | // log the data 98 | if (_.isObject(data) && (data.code || data.message)) { 99 | 100 | // log optional code 101 | if (data.code) { 102 | let code = parseInt(data.code); 103 | output += ' ' + (code < 300 ? colors.cyan(code) : code < 500 ? colors.yellow(code) : colors.red(code)); 104 | } 105 | 106 | // log optional message 107 | if (data.message) output += ' ' + data.message; 108 | 109 | } else if (!_.isUndefined(data)) { 110 | 111 | // log the data 112 | output += ' '; 113 | output += data; 114 | 115 | } 116 | 117 | // dim the output 118 | if (dim) output = colors.dim(output); 119 | 120 | // log it using the logger 121 | this.logger[level](output); 122 | 123 | // log additional data if provided 124 | if (data && !_.isUndefined(data.data)) { 125 | 126 | let additional = [].concat(data.data); 127 | additional.forEach((item) => { 128 | 129 | if (item instanceof Error) { 130 | let lines = (item.stack || JSON.stringify(item)).split('\n'); 131 | lines.forEach((line, i) => { 132 | this.log(level, id, subId, event, subEvent, i === 0 ? 'up' : i === lines.length - 1 ? 'down' : 'line', { message: line }, dim); 133 | }); 134 | } else if (_.isObject(item)) { 135 | let lines = util.inspect(item).split('\r\n'); 136 | lines.forEach((line, i) => { 137 | this.log(level, id, subId, event, subEvent, i === 0 ? 'up' : i === lines.length - 1 ? 'down' : 'line', { message: line }, dim); 138 | }); 139 | } else { 140 | this.log(level, id, subId, event, subEvent, 'up', { message: item }, dim); 141 | } 142 | 143 | }); 144 | 145 | } 146 | 147 | } 148 | 149 | } 150 | 151 | module.exports = SMTPLogger; -------------------------------------------------------------------------------- /src/smtp-relay.js: -------------------------------------------------------------------------------- 1 | // external dependencies 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const os = require('os'); 5 | const dns = require('dns'); 6 | const _ = require('lodash'); 7 | const mkdirp = require('mkdirp'); 8 | const util = require('util'); 9 | const extend = require('extend'); 10 | const uuid = require('node-uuid'); 11 | 12 | // internal dependencies 13 | const SMTPLogger = require('./smtp-logger'); 14 | const SMTPClient = require('./smtp-client'); 15 | const SMTPUtil = require('./smtp-util'); 16 | 17 | /** 18 | * Provides a facility to relay messages using a filesytem backed 19 | * queue that persists throughout restarts. 20 | */ 21 | class SMTPRelay { 22 | 23 | /** 24 | * Creates a new relay 25 | * 26 | * @param {SMTPServer} server 27 | * The server that initiates this relay 28 | */ 29 | constructor(server) { 30 | 31 | // reference server 32 | this.server = server; 33 | 34 | // merge relay configuration with defaults 35 | let config = this.config = extend(true, { 36 | // the hostname to announce during the HELO / EHLO command 37 | hostname: os.hostname(), 38 | // the temporary directory used to queue messages 39 | queueDir: path.join(os.tmpdir(), 'mail-io-queue'), 40 | // the maximum amount of time we will try to deliver a mail before 41 | // sending an NDR 42 | retryHours: 48, 43 | // retry sending failed mails every x seconds (defaults to 1 minute) 44 | // with every failed attempt, this interval is multiplicated with the power of 45 | // failed attempts, so the time between the retry operations will increase with every attempt 46 | retryBaseInterval: 60, 47 | // the amount of concurrent transactions 48 | concurrentTransactions: 5, 49 | // the default port to connect to when establishing a connection with a foreign SMTP server (best used for testing) 50 | smtpPort: 25, 51 | // the logger to use 52 | logger: server.config.logger 53 | }, server.config.relay); 54 | 55 | // create local logging facility 56 | this.logger = new SMTPLogger(server.config.logger); 57 | this.log = {}; 58 | Object.keys(this.logger.levels).forEach((level) => { 59 | this.log[level] = (mail, action, code, data, mode, dim) => { 60 | this.logger.log(level, mail.id, mail.failures.length, 'relay', action, mode, { code: code, message: data }, dim); 61 | } 62 | }); 63 | 64 | // create the internal queue, due queued items will call this.process 65 | let cfg = { concurrency: this.config.concurrentTransactions || 5, interval: 1000 }; 66 | this.queue = SMTPUtil.queue(cfg, (mail, cb) => { 67 | this.process(mail).then(() => cb()).catch((err) => cb(err)) 68 | }); 69 | 70 | 71 | 72 | } 73 | 74 | /** 75 | * Starts the relay daemon 76 | * 77 | * @return {Promise} 78 | * A promise that resolves once the daemon is initialized, or rejects 79 | * if starting the daemon has failed 80 | */ 81 | async start() { 82 | 83 | // simply reload 84 | return this.reload(); 85 | 86 | } 87 | 88 | /** 89 | * Reloads the active mail queue 90 | */ 91 | async reload() { 92 | 93 | // try to create the queue directory if it does not yet exist 94 | if (!(await SMTPUtil.exists(this.config.queueDir))) { 95 | await SMTPUtil.mkdirp(this.config.queueDir); 96 | } 97 | 98 | // read all queued mails from the queue dir 99 | let files = await SMTPUtil.readDir(this.config.queueDir); 100 | 101 | // load all files 102 | for (let file of files) { 103 | 104 | // skip files that are not ending on .meta.info 105 | if (file.split('.')[2] !== 'info') continue; 106 | 107 | // resolve the full path to the file 108 | let infoFile = path.join(this.config.queueDir, file); 109 | 110 | try { 111 | 112 | // parse the file contents and schedule it 113 | let content = await SMTPUtil.readFile(infoFile); 114 | let mail = JSON.parse(content); 115 | this.queue.schedule(0, mail); 116 | 117 | } catch (ex) { 118 | 119 | // failed to read or parse the file 120 | throw new Error(`Failed to parse mail stats from file "${infoFile}": ${ex.message || ex}`); 121 | 122 | } 123 | 124 | } 125 | 126 | } 127 | 128 | /** 129 | * Processes a mail from the queue 130 | * 131 | * @param {object} mail 132 | * The mail object to process 133 | * 134 | * @return {Promise} 135 | * A promise that resolves once the mail was processed, or reject with an error if failed 136 | */ 137 | async process(mail) { 138 | 139 | // check if the mail is still deliverable 140 | if (new Date(mail.created) > new Date(new Date().getTime() - (this.config.retryHours * 60 * 60 * 1000))) { 141 | 142 | try { 143 | 144 | // log it 145 | this.log.verbose(mail, 'process', null, `Sending mail to "${mail.envelope.to}"`); 146 | 147 | // try to send it and then remove it from the queue 148 | await this.send(mail); 149 | await this.remove(mail); 150 | return; 151 | 152 | } catch (err) { 153 | 154 | // failed to send the mail 155 | mail.failures.push({ date: new Date(), err: err }); 156 | 157 | // mail was not submitted successfully, check if the failure is permanent 158 | if (err.permanent) { 159 | 160 | // permanent error, send a ndr back to the sender and remove the mail 161 | await this.ndr(mail); 162 | await this.remove(mail); 163 | 164 | } else { 165 | 166 | // error is not permanent, update the mail and resubmit it to the queue 167 | let retry = (mail.failures.length || 1) * (mail.failures.length || 1) * this.config.retryBaseInterval; 168 | this.log.warn(mail, 'retry', null, 'Temporary error - trying again in ' + retry + 's', 'warn'); 169 | mail.updated = new Date(); 170 | await this.update(mail); 171 | this.queue.schedule(retry * 1000, mail); 172 | 173 | } 174 | 175 | } 176 | 177 | } else { 178 | 179 | // log it 180 | this.log.verbose(mail, 'ndr', null, `Sending NDR for undeliverable mail to "${mail.envelope.from}"`); 181 | 182 | // we have tried long enough, lets give up 183 | await this.ndr(mail); 184 | await this.remove(mail); 185 | 186 | } 187 | 188 | } 189 | 190 | /** 191 | * Sends the mail 192 | */ 193 | async send(mail) { 194 | 195 | // make sure the mail object is valid 196 | if (!mail || !mail.envelope || !mail.envelope.from || !mail.envelope.to) throw new Error({ permanent: true, msg: 'Cannot send message because it contains an invalid envelope' }); 197 | 198 | // get recipients 199 | let recipients = [].concat(mail.envelope.to || []); 200 | 201 | // run a mail transaction for every recipient 202 | await Promise.all(recipients.map((to) => { 203 | 204 | return (async () => { 205 | 206 | // resolve the recipient's mail server 207 | let domain = to.split('@')[1]; 208 | if (!domain) throw new Error({ permanent: true, msg: `Invalid domain for recipient "${to}"` }); 209 | 210 | // get the mx hosts for this domain 211 | let hosts = await SMTPUtil.resolveDNS(domain, 'MX').catch(() => []); 212 | 213 | // if we failed to resolve the mx hosts, use the domain name instead 214 | if (!_.isArray(hosts) || !hosts.length) hosts = [{ priority: 10, exchange: domain }]; 215 | 216 | // validate entries and sort them by lowest priority first 217 | hosts = hosts.filter(function (host) { 218 | return host && _.isString(host.exchange) && host.exchange.length; 219 | }).sort(function (a, b) { 220 | if (!_.isNumber(b)) return -1; 221 | if (!_.isNumber(a)) return 1; 222 | if (a.priority < b.priority) return -1; 223 | if (a.priority > b.priority) return 1; 224 | return 0; 225 | }).map(function (host) { 226 | return host.exchange; 227 | }); 228 | 229 | // holds intermediate errors 230 | let errors = []; 231 | 232 | // try hosts in chain 233 | for (let host of hosts) { 234 | 235 | // get the ip address of the host 236 | let targets = await SMTPUtil.resolveDNS(host, 'A').catch((err) => ({ error: err })); 237 | 238 | // if we failed to resolve, skip to the next host 239 | if (!targets || !targets[0] || targets.error) { 240 | 241 | errors.push({ permanent: false, msg: `[${host}]: Failed to resolve A record for host "${host}": ${targets && targets.error ? targets.error : 'hostname not resolvable'}` }); 242 | continue; 243 | 244 | } 245 | 246 | // get a reference to the target 247 | let target = targets[0]; 248 | 249 | // configure the smtp client 250 | let client = new SMTPClient({ 251 | name: this.config.hostname, 252 | host: target, 253 | port: this.config.smtpPort || 25, 254 | logger: this.config.logger, 255 | identity: 'relay' 256 | }); 257 | 258 | // try to send it 259 | try { 260 | 261 | // send the mail 262 | await client.send(mail.envelope, fs.createReadStream(mail.file)); 263 | 264 | // success! return here, which should skip the remaining code paths 265 | return; 266 | 267 | } catch (err) { 268 | 269 | // add the error to the stack 270 | err.responseCode ? 271 | errors.push({ permanent: err.responseCode >= 500, msg: `[${target}]:' ${err.response}` }) : 272 | errors.push({ permanent: false, msg: '[' + target + ']:' + err.message }); 273 | 274 | } 275 | 276 | } 277 | 278 | // if we made it until there, the mail was not sent successfully 279 | // and we failed to deliver the message to any of the mx targets 280 | let error = { permanent: true, msg: errors.length ? '' : 'failed to resolve any target hosts' }; 281 | 282 | // iterate the errors and add them 283 | errors.forEach(function (err, i) { 284 | 285 | // if there was a non-permanent error, we will want to retry sending later again 286 | if (!err.permanent) error.permanent = false; 287 | 288 | // add a break for multiple host errors 289 | if (i > 0) error.msg += '\n'; 290 | 291 | // add the error message text 292 | error.msg += err.msg; 293 | 294 | }); 295 | 296 | // throw the error(s) 297 | throw new Error(error); 298 | 299 | })(); 300 | 301 | })); 302 | 303 | } 304 | 305 | /** 306 | * Adds a message to the sending queue 307 | */ 308 | async add(envelope, message, headers) { 309 | 310 | // verify envelope data 311 | if (!_.isObject(envelope)) throw new Error('Invalid envelope passed, expected an object'); 312 | if (!_.isString(envelope.from) || !envelope.from.length) throw new Error('Invalid sender'); 313 | if (!envelope.to || !envelope.to.length) throw new Error('Invalid recipients'); 314 | if (!message || !message.pipe) throw new Error('Invalid message, expecting a readable stream'); 315 | 316 | // create the queue directory if it does not yet exist 317 | if (!(await SMTPUtil.exists(this.config.queueDir))) await SMTPUtil.mkdirp(this.config.queueDir); 318 | 319 | // get recipients 320 | let recipients = [].concat(envelope.to); 321 | 322 | // create a queued message for every recipient 323 | for (let to of recipients) { 324 | 325 | let id = uuid.v1(); 326 | let messageFile = path.join(this.config.queueDir, `${id}.msg`); 327 | let metaFile = path.join(this.config.queueDir, `${id}.msg.info`); 328 | 329 | // write the message to the queue folder 330 | await new Promise((resolve, reject) => { 331 | 332 | // write the message to the queue folder 333 | let messageStream = fs.createWriteStream(messageFile); 334 | 335 | message 336 | .once('error', () => reject(`Failed to write message to file ${messageFile}: ${err}`)) 337 | .once('end', () => resolve()) 338 | .pipe(messageStream); 339 | 340 | }); 341 | 342 | // collect meta file information 343 | let meta = { 344 | id: id, 345 | file: messageFile, 346 | meta: metaFile, 347 | envelope: { 348 | from: envelope.from, 349 | to: to 350 | }, 351 | headers: headers, 352 | created: new Date(), 353 | updated: null, 354 | failures: [] 355 | }; 356 | 357 | // write the meta file 358 | await SMTPUtil.writeFile(metaFile, JSON.stringify(meta)); 359 | 360 | // schedule it 361 | this.queue.schedule(0, meta); 362 | this.log.verbose(meta, 'queue', null, `Queued mail to "${to}" for delivery`); 363 | 364 | } 365 | 366 | } 367 | 368 | /** 369 | * Updates the mail meta data on the disk 370 | */ 371 | async update(mail) { 372 | 373 | return SMTPUtil.writeFile(mail.meta, JSON.stringify(mail)); 374 | 375 | } 376 | 377 | /** 378 | * Removes the mail 379 | */ 380 | async remove(mail) { 381 | 382 | try { 383 | 384 | // unlink the files 385 | await SMTPUtil.unlink(mail.file); 386 | await SMTPUtil.unlink(mail.meta); 387 | 388 | } catch (ex) { 389 | 390 | // swallow the error but log it 391 | console.log(`Failed to remove mail meta files ${mail.file} or ${mail.meta}: ${ex.message || ex}`); 392 | 393 | } 394 | 395 | } 396 | 397 | /** 398 | * Sends a non deliverable report (NDR) to the sender 399 | */ 400 | async ndr(mail) { 401 | 402 | // we will not send an ndr if the message sent to us was already bounced (or in any way automatically generated) 403 | if (mail.headers && mail.headers['auto-submitted']) { 404 | this.log.info(mail, 'ndr', null, `Will not send ndr to "${mail.envelope.from}" because the mail was automatically generated.`); 405 | return; 406 | } 407 | 408 | // log it 409 | this.log.verbose(mail, 'ndr', null, `Sending ndr to "${mail.envelope.from}"`); 410 | 411 | // compose the message 412 | const message = { 413 | identity: 'ndr', 414 | from: `Mail Delivery System `, 415 | to: mail.envelope.from, 416 | headers: { 417 | 'Auto-Submitted': 'auto-replied' 418 | }, 419 | subject: `Mail delivery to ${[].concat(mail.envelope.to).join(', ')} failed: returning message to sender`, 420 | text: 421 | 'This message was created automatically by mail delivery software.\r\n\r\n' + 422 | 'A message that you sent could not be delivered to one or more of its recipients. ' + 423 | 'This is a permanent error. The following address(es) failed:\r\n\r\n' + 424 | [].concat(mail.envelope.to).join(', ') + '\r\n' + 425 | mail.failures[mail.failures.length - 1].err.msg + '\r\n\r\n' + 426 | '----- A copy of the original message is attached -----', 427 | attachments: [{ 428 | filename: 'message.txt', 429 | path: mail.file 430 | }] 431 | } 432 | 433 | // send the mail using the server api 434 | return this.server.sendMail(message); 435 | 436 | } 437 | 438 | } 439 | 440 | module.exports = SMTPRelay; -------------------------------------------------------------------------------- /src/smtp-server.js: -------------------------------------------------------------------------------- 1 | // external dependencies 2 | const _ = require('lodash'); 3 | const extend = require('extend'); 4 | const net = require('net'); 5 | const tls = require('tls'); 6 | const os = require('os'); 7 | const fs = require('fs'); 8 | const util = require('util'); 9 | const path = require('path'); 10 | const uuid = require('node-uuid'); 11 | const nodemailer = require('nodemailer'); 12 | 13 | // internal dependencies 14 | const SMTPUtil = require('./smtp-util'); 15 | const SMTPClient = require('./smtp-client'); 16 | const SMTPSession = require('./smtp-session'); 17 | const SMTPRelay = require('./smtp-relay'); 18 | 19 | /** 20 | * The SMTP server instance 21 | */ 22 | class SMTPServer extends net.Server { 23 | 24 | /** 25 | * Creates a new SMTP server instance. 26 | * 27 | * @param {object} config 28 | * The SMTP server configuration, with the following options: 29 | * 30 | * @param {array} config.domains 31 | * The domains that are served by this server instance. 32 | * This is used for relay protection. 33 | * 34 | * @param {string} [config.hostname=os.hostname()] 35 | * The hostname of this server that is announced in the HELO and EHLO 36 | * 37 | * @param {string} [config.greeting="mail-io"] 38 | * The greeting that is sent from the server on client connection 39 | * 40 | * @param {object} [config.handlers={}] 41 | * A list of additional command handlers. This should be a map with "event" 42 | * as the key and an array of handler definition objects as the value. 43 | * Handler definition objects have to look like this: 44 | * { name: 'myhandler', requires: ['some/dependency'], handler: async (req, res) => ... } 45 | * 46 | * @param {object} [config.tls] 47 | * The TLS configuration 48 | * 49 | * @param {string|Buffer} [config.tls.key] 50 | * The TLS key, will use a test key by default 51 | * 52 | * @param {string|Buffer} [config.tls.cert] 53 | * The TLS Certificate, will use a test cert by default 54 | * 55 | * @param {string|Buffer} [config.tls.ca] 56 | * The TLS CA, will use a test ca by default 57 | * 58 | * @param {string} [config.tls.ciphers] 59 | * A string with supported ciphers 60 | * 61 | * @param {boolean} [config.tls.honorCipherOrder=true] 62 | * Should we use the order of the cyphers that from the cyphers string 63 | * 64 | * @param {object} [config.relay] 65 | * The relay configuration 66 | * 67 | * @param {boolean} [config.relay.enabled=false] 68 | * Should we enable relaying mail 69 | * 70 | * @param {string} [config.relay.hostname=os.hostname()] 71 | * The hostname that is used during the HELO/EHLO greeting when relaying 72 | * 73 | * @param {string} [config.relay.queueDir="{tmpdir}/mail-io-queue"] 74 | * The directory where queued emails will be stored 75 | * 76 | * @param {number} [config.relay.retryHours=48] 77 | * The amount of hours we will try to submit failed messages until we finally give up 78 | * 79 | * @param {number} [config.relay.retryBaseInterval=60] 80 | * The base interval between retries in seconds. This number will be multiplied with 81 | * the failed attempts to avoid spamming 82 | * 83 | * @param {number} [config.relay.concurrentTransactions=5] 84 | * The maximum number of concurrent relay transactions 85 | * 86 | * @param {boolean} [config.relay.allowUnauthenticated=false] 87 | * Allow relay to foreign domains or between local domains if the sender is not 88 | * authenticated 89 | * 90 | * @param {boolean} [config.relay.openRelay=false] 91 | * Allow to relay mail from senders that do not belong to our served domains (config.domains) 92 | * 93 | * @param {number} [config.relay.smtpPort=25] 94 | * The port to use when connecting to a foreign SMTP server, defaults to 25 95 | * 96 | * @param {object} [config.plugins] 97 | * Allows to overwrite the plugin configuration. Key should be the name of the plugin 98 | * and the value an object with the configuration for that plugin, e.g.: 99 | * { 100 | * "ehlo/core": { 101 | * features: ['STARTTLS', 'AUTH LOGIN PLAIN', '8BITMIME', 'PIPELINING', 'SIZE'] 102 | * } 103 | * } 104 | * 105 | * @param {object} [config.limits] 106 | * Allows to configure server imposed limits 107 | * 108 | * @param {number} [config.limits.idleTimeout=60000] 109 | * The maximum time in ms a connection can idle before getting disconnected 110 | * 111 | * @param {number} [config.limits.messageSize=100MB] 112 | * The maximum message size in bytes 113 | * 114 | * @param {number} [config.limits.authFailures=5] 115 | * The maximum number of authentication failures before the client is disconnected 116 | * 117 | * @param {number} [config.limits.unrecognizedCommands=5] 118 | * The maximum number of unrecognized commands before the client is disconnected 119 | * 120 | * @param {number} [config.limits.maxConnections=100] 121 | * The maximum number of concurrent inbound connections 122 | * 123 | * @param {number} [config.limits.maxRecipients=100] 124 | * The maximum number of recipients allowed 125 | * 126 | * @param {object} [config.logger] 127 | * An object with the debug levels as the key, and the logging function 128 | * as the value. Supported keys are "debug", "verbose", "info", "warn", "error" 129 | */ 130 | constructor(config, cb/*session*/) { 131 | 132 | // initialize net server 133 | super(); 134 | 135 | // the configuration, merged with the config that is passed 136 | config = this.config = extend(true, { 137 | // the hostname for the greeting 138 | hostname: os.hostname(), 139 | // the greeting message 140 | greeting: 'mail-io', 141 | // a list of additional command handlers 142 | // handlers is a map with 'event' as the key and an array of handler definition objects as the value 143 | // handler definition objects have to look like this: 144 | // { name: 'myhandler', requires: ['some/dependency'], handler: function(req, res) {...} } 145 | handlers: {}, 146 | // a list of domains served by this host 147 | // - defaults to the domain name parsed from the hostname, or the hostname if no domain part was found) 148 | domains: [os.hostname().split('.').length > 1 ? os.hostname().split('.').slice(1).join('.') : os.hostname()], 149 | // relay settings 150 | relay: { 151 | // should we relay messages? 152 | enabled: true, 153 | // the hostname used during the HELO/EHLO greeting when relaying 154 | hostname: os.hostname(), 155 | // the directory to store mails in until they are delivered 156 | queueDir: path.join(os.tmpdir(), 'mail-io-queue'), 157 | // how many hours should we try to submit failed messages until finally giving up 158 | retryHours: 48, 159 | // the base interval between retries in seconds 160 | retryBaseInterval: 60, 161 | // the maximum amount of concurrent relay transactions 162 | concurrentTransactions: 5, 163 | // allow relay to foreign domains or between local domains if the sender is not authenticated? 164 | allowUnauthenticated: false, 165 | // do we relay mail from senders that do not belong to our served domains (config.domains)? 166 | openRelay: false, 167 | // the default port to connect to when establishing a connection with a foreign SMTP server (best used for testing) 168 | smtpPort: 25 169 | }, 170 | // plugin configuration 171 | plugins: { 172 | 'ehlo/core': { 173 | // a list of supported SMTP extensions 174 | features: ['STARTTLS', 'AUTH LOGIN PLAIN', '8BITMIME', 'PIPELINING', 'SIZE'] 175 | }, 176 | 'rcpt/dnsbl': { 177 | // the blacklist service to use for DNSBL filtering 178 | blacklist: 'zen.spamhaus.org', 179 | // the dns server used to resolve the listing 180 | // note: when using google public dns servers, some dnsbl services like spamhaus won't resolve properly 181 | // so you can set a different dns resolver here 182 | resolver: '208.67.222.222' 183 | }, 184 | 'queue/spamd': { 185 | // messages that score higher than the baseScore will be treated as spam 186 | baseScore: 5 187 | }, 188 | 'queue/maildir': { 189 | // maildir storage location. %n will be replaced with the username and %d with the domain name 190 | mailDir: path.join(os.tmpdir(), 'mail-io-maildir', '%d', '%n') 191 | } 192 | }, 193 | limits: { 194 | // the maximum time in ms a connection can idle before getting disconnected 195 | idleTimeout: 60 * 1000, 196 | // the maximum size of a message 197 | messageSize: 100 * 1024 * 1024, 198 | // the maximum number of authentication failures before the client is disconnected 199 | authFailures: 5, 200 | // the maximum number of unrecognized commands before the client is disconnected 201 | unrecognizedCommands: 5, 202 | // the maximum amount of concurrent client connections 203 | maxConnections: 100, 204 | // the maximum number of recipients allowed 205 | maxRecipients: 100 206 | }, 207 | // the logger to use 208 | logger: { 209 | debug: console.log, 210 | verbose: console.log, 211 | info: console.log, 212 | warn: console.log, 213 | error: console.log 214 | }, 215 | // tls configuration, we use our test certs by default, 216 | // customers should use their own certs! 217 | tls: { 218 | key: fs.readFileSync(path.join(__dirname, '..', 'keys', 'key.pem')), 219 | cert: fs.readFileSync(path.join(__dirname, '..', 'keys', 'cert.pem')), 220 | ca: fs.readFileSync(path.join(__dirname, '..', 'keys', 'ca.pem')), 221 | honorCipherOrder: true, 222 | requestOCSP: false 223 | } 224 | }, config); 225 | 226 | // register handlers 227 | this.handlers = SMTPUtil.getHandlers(config.handlers, config); 228 | 229 | // initialize the relay 230 | this.relay = new SMTPRelay(this); 231 | 232 | // start the relay 233 | if (config.relay.enabled) { 234 | this.relay.start().catch((err) => console.log('Error starting SMTP relay:', err)); 235 | } 236 | 237 | // generate an apiUser object that is implicitly allowed to send mails authenticated 238 | this.apiUser = { 239 | username: uuid.v4(), 240 | password: uuid.v4() 241 | }; 242 | 243 | // dispatch connections 244 | this.on('connection', (socket) => { 245 | 246 | // set an idle timeout for the socket 247 | socket.setTimeout(this.config.limits.idleTimeout); 248 | 249 | // add socket.close method, which really closes the connection 250 | socket.close = (data) => { 251 | 252 | // only continue if the socket is not already destroyed 253 | if (socket.destroyed) return; 254 | 255 | // destroy immediately if no data is passed, or if the socket is not writeable 256 | if (!data || !socket.writable) { 257 | socket.end(); 258 | return socket.destroy(); 259 | }; 260 | 261 | // write the data to the socket, then destroy it 262 | socket.write(data, () => { 263 | socket.end(); 264 | socket.destroy(); 265 | }); 266 | 267 | }; 268 | 269 | // initialize the smtp session 270 | new SMTPSession(socket, this, (session) => { 271 | 272 | // run the session callback listener 273 | if (_.isFunction(cb)) cb(session); 274 | 275 | // emit the session 276 | this.emit('session', session) 277 | 278 | }); 279 | 280 | }); 281 | 282 | } 283 | 284 | /** 285 | * The port the server is listening on. Only available 286 | * after the listening event was emitted. 287 | */ 288 | get port() { 289 | 290 | const addr = this.address(); 291 | return addr ? addr.port : null; 292 | 293 | } 294 | 295 | /** 296 | * Adds a command hanlder to the server 297 | * 298 | * @param {string} event 299 | * The name of the command / event 300 | * 301 | * @param {object} definition 302 | * An object with the handler description: 303 | * 304 | * @param {string} definition.name 305 | * The name of the handler, will be used for logging and 306 | * can later be referenced by via "/" 307 | * 308 | * @param {function} definition.handler 309 | * The handler callback function that will be invoked with the 310 | * "req" and "res" objects. 311 | */ 312 | addHandler(event, definition) { 313 | 314 | if (!_.isString(event)) throw new Error('event must be a string'); 315 | if (!_.isObject(definition)) throw new Error('handler definition must be an object'); 316 | if (!_.isString(definition.name)) throw new Error('definition has to provide a "name" for the handler'); 317 | if (!_.isFunction(definition.handler)) throw new Error('definition has to provide a "handler" function'); 318 | 319 | if (!_.isArray(this.handlers[event])) this.handlers[event] = []; 320 | this.handlers[event].push(definition); 321 | SMTPUtil.sortHandlers(this.handlers); 322 | 323 | } 324 | 325 | /** 326 | * Sends a mail through the server. Using this function, the connection 327 | * will be implicitly authencticated, so you don't have to worry about 328 | * the connection itself. 329 | * 330 | * @param {object} message 331 | * The message object, should be compatible the nodemailer api 332 | * 333 | * @return {Promise} 334 | * A promise that either resolves if the mail was sent successfully, or 335 | * gets rejected with an error if we failed to deliver the mail. 336 | */ 337 | async sendMail(message) { 338 | 339 | // make sure a valid looking message was passed 340 | if (!_.isObject(message)) throw new TypeError('sendMail: message must be an object'); 341 | 342 | // create a new transport (we do this to get a proper message) 343 | const transport = nodemailer.createTransport({ 344 | 345 | // send implementation 346 | send: (mail, cb) => { 347 | 348 | // create a new smtp client to send it out 349 | let client = new SMTPClient({ 350 | identity: message.identity || 'api', 351 | host: '127.0.0.1', 352 | port: this.port, 353 | logger: this.config.logger, 354 | login: { 355 | user: this.apiUser.username, 356 | pass: this.apiUser.password 357 | } 358 | }); 359 | 360 | // send the mail via the client 361 | client.send(mail.data.envelope || mail.message.getEnvelope(), mail.message.createReadStream()).then(() => cb()).catch((err) => cb(err)); 362 | 363 | } 364 | 365 | }); 366 | 367 | // send the mail using our transport 368 | return new Promise((resolve, reject) => transport.sendMail(message, (err) => { 369 | err ? reject(err) : resolve() 370 | })); 371 | 372 | } 373 | 374 | } 375 | 376 | module.exports = SMTPServer; -------------------------------------------------------------------------------- /src/smtp-session.js: -------------------------------------------------------------------------------- 1 | // external dependencies 2 | const _ = require('lodash'); 3 | const dns = require('dns'); 4 | const uuid = require('node-uuid'); 5 | 6 | // internal dependencies 7 | const SMTPLogger = require('./smtp-logger'); 8 | const SMTPStream = require('./smtp-stream'); 9 | 10 | /** 11 | * Represents a SMTP session. Whenever a client connects, 12 | * a new session will be spawned. The session handles the lifecycle 13 | * of the client connection, parses the SMTP commands and dispatches 14 | * them to the registered handlers. 15 | */ 16 | class SMTPSession { 17 | 18 | /** 19 | * Creates a new session. 20 | * 21 | * @param {net.Socket} socket 22 | * The raw net socket that is created on client connection 23 | * 24 | * @param {SMTPServer} server 25 | * The SMTP server instance 26 | * 27 | * @param {function} [cb] 28 | * An optional callback that will be called with the session 29 | * once the session is ready 30 | */ 31 | constructor(socket, server, cb/*session*/) { 32 | 33 | // make cb a noop function if not provided 34 | if (!_.isFunction(cb)) cb = () => {}; 35 | 36 | this.socket = socket; 37 | this.server = server; 38 | 39 | // create a new SMTP parser 40 | this.connection = new SMTPStream(); 41 | this.connection.socket = socket; 42 | this.connection.busy = true; 43 | this.connection.closed = false; 44 | 45 | // unique session id 46 | this.id = uuid.v1(); 47 | 48 | // information about the connected client 49 | this.client = { 50 | hostname: '[' + socket.remoteAddress + ']', 51 | address: socket.remoteAddress && socket.remoteAddress.indexOf('::ffff:') === 0 && socket.remoteAddress.split('.').length === 4 ? socket.remoteAddress.replace('::ffff:', '') : socket.remoteAddress 52 | }; 53 | 54 | // the id of the current transaction, will be increased after every DATA signal 55 | this.transaction = 0; 56 | 57 | // a reference to the relay to use 58 | this.relay = server.relay; 59 | 60 | // a reference to the configuration 61 | this.config = server.config; 62 | 63 | // session handlers, we clone them to make sure 64 | // they will not get altered throughout the way 65 | this.handlers = _.cloneDeep(server.handlers); 66 | 67 | // indicates if the session is tls encrypted 68 | this.secure = false; 69 | 70 | // a list of data that can be used by plugins to store 71 | // session specific data - key is command/plugin 72 | this.data = {}; 73 | 74 | // session related counters 75 | this.counters = { 76 | authFailures: 0, 77 | unrecognizedCommands: 0 78 | }; 79 | 80 | // create a logger 81 | this.logger = new SMTPLogger(server.config.logger); 82 | 83 | // create a session.log service 84 | this.log = {}; 85 | 86 | // create a logging function for every level 87 | Object.keys(this.logger.levels).forEach((level) => { 88 | this.log[level] = (cmd, plugin, message, dim) => { 89 | this.logger.log(level, this.id, this.transaction, cmd, plugin, null, message, dim); 90 | }; 91 | }); 92 | 93 | // special protocol logging 94 | this.log.protocol = (cmd, plugin, code, data, mode, dim) => { 95 | this.logger.log('protocol', this.id, this.transaction, cmd, plugin, mode, { code: code, message: data }, dim); 96 | } 97 | 98 | // register event handlers 99 | this.register(); 100 | 101 | // reset the session 102 | this.reset(); 103 | 104 | // make sure we have a remote address of the client, otherwise something seems to be going wrong 105 | if (!this.client.address || !this.client.address.length) { 106 | this.log.warn('connect', 'core', 'Client provides no ip address, disconnecting'); 107 | return socket.close(); 108 | } 109 | 110 | // resolve the remote hostname 111 | dns.reverse(this.client.address, (err, hostnames) => { 112 | 113 | // remember hostname 114 | this.client.hostname = !err && hostnames && hostnames.length ? hostnames[0] : '[' + this.client.address + ']'; 115 | 116 | // call the listener with this session 117 | if (_.isFunction(cb)) cb(this); 118 | 119 | // emit the connect event 120 | this.emit('connect', this.client.hostname, () => { 121 | 122 | // flag the connection as not being busy 123 | this.connection.busy = false; 124 | 125 | // command handler 126 | this.connection.oncommand = (command, cb) => { 127 | 128 | // the read response handler installs a $readHandler 129 | // function on the connection. if it is present 130 | // we have to call the $read handler 131 | if (_.isFunction(this.connection.$readHandler)) { 132 | return this.connection.$readHandler(command.toString(), cb); 133 | } 134 | 135 | // parse the command into a name and a data part 136 | let cmd = { 137 | name: command.toString().split(' ')[0], 138 | data: command.toString().split(' ').splice(1).join(' ') 139 | }; 140 | 141 | // emit the command and data 142 | this.emit(cmd.name, cmd.data, (rejected, accepted) => { 143 | if (_.isFunction(cb)) cb(); 144 | }); 145 | 146 | } 147 | 148 | // connect the socket to the SMTP parser 149 | socket.pipe(this.connection); 150 | 151 | }, true); 152 | 153 | }); 154 | 155 | } 156 | 157 | /** 158 | * Resets the whole session 159 | */ 160 | reset() { 161 | 162 | // a list of commands that were accepted 163 | if (!this.accepted) this.accepted = {}; 164 | 165 | // a list of commands that were rejected 166 | this.rejected = {}; 167 | 168 | // the envelope of the session 169 | this.envelope = { 170 | from: null, 171 | to: [] 172 | }; 173 | 174 | // the authenticated user of the session 175 | this.user = null; 176 | 177 | } 178 | 179 | /** 180 | * Resets the current transaction 181 | */ 182 | resetTransaction() { 183 | 184 | // reset the session envelope 185 | this.envelope.from = null; 186 | this.envelope.to = []; 187 | 188 | // reset some of the accepted commands (the ones that are transaction related) 189 | delete this.accepted['rcpt']; 190 | delete this.accepted['mail']; 191 | delete this.accepted['data']; 192 | delete this.accepted['queue']; 193 | 194 | } 195 | 196 | /** 197 | * Prepares a socket 198 | * 199 | * @param {net.Socket|tls.Socket} socket 200 | * The socket to register 201 | */ 202 | register(socket) { 203 | 204 | // use default socket if no socket is passed 205 | if (!socket) socket = this.connection.socket; 206 | 207 | // idle timeout, emit to the session 208 | socket.once('timeout', () => { 209 | this.emit('timeout', null, function () { }, true); 210 | }); 211 | 212 | // handle socket close 213 | socket.once('close', () => { 214 | this.connection.closed = true; 215 | }); 216 | 217 | // handle socket errors 218 | socket.on('error', (err) => { 219 | if (err.code === 'ECONNRESET' || err.code === 'EPIPE') { 220 | this.close(); 221 | } else { 222 | this.log.warn('session', 'error', err); 223 | } 224 | }); 225 | 226 | } 227 | 228 | /** 229 | * Closes the connection 230 | * 231 | * @param {number} [code] 232 | * The code to end the connecion with, if omitted nothing 233 | * will be sent when closing the connection 234 | * 235 | * @param {string} [message] 236 | * The message to end the connection with, if omitted nothing 237 | * will be sent when closing the connection 238 | */ 239 | close(code, message) { 240 | 241 | // do nothing if the connection is already closed 242 | if (this.connection.closed) return; 243 | 244 | // close the connection, write the end message 245 | this.connection.socket.close(code && message ? code + ' ' + message + '\r\n' : null); 246 | this.connection.oncommand = function () { }; 247 | this.connection.closed = true; 248 | this.log.verbose('session', 'close', { message: 'Closed session with ' + this.client.hostname }); 249 | 250 | } 251 | 252 | /** 253 | * Listen to an event (command) 254 | * 255 | * @param {string} event 256 | * The name of the event to listen to 257 | * 258 | * @param {function} handler 259 | * The handler function, signature is (req, res) 260 | */ 261 | on(event, handler) { 262 | 263 | if (!_.isString(event) || !event.length) throw new TypeError('event must be a string and cannot be empty'); 264 | if (!_.isFunction(handler)) throw new TypeError('handler must be a function'); 265 | if (!_.isArray(this.handlers[event])) this.handlers[event] = []; 266 | this.handlers[event].push({ name: 'on-' + event + '-' + this.handlers[event].length, handler: handler }); 267 | 268 | } 269 | 270 | /** 271 | * Emit an event (command) 272 | * 273 | * @param {string} command 274 | * The name of the event / command, accessible to listeners 275 | * via req.command.name 276 | * 277 | * @param {mixed} [data] 278 | * Data that is passed with the event, accessible to listeners 279 | * via req.command.data 280 | * 281 | * @param {function} cb 282 | * Callback function that will be called once the 283 | * listeners have been processed. This is useful if you want to 284 | * get notified once the command has been processed. 285 | * 286 | * @param {boolean} [internal=false] 287 | * Used internally to signal that the event that was fired is an 288 | * internal event, and not an officially supported command 289 | */ 290 | emit(command, data, cb, internal) { 291 | 292 | // get a short ref to the connection 293 | let connection = this.connection; 294 | 295 | // split the command 296 | let cmd = { 297 | name: command || 'unrecognized', 298 | data: data 299 | }; 300 | 301 | // make sure the command is case insensitive 302 | if (_.isString(cmd.name)) cmd.name = cmd.name.toLowerCase(); 303 | 304 | // ignore any commands when connection is busy 305 | if (connection.busy && !internal) return cb({ reason: 'busy', code: null, message: null }); 306 | 307 | // make sure there are handlers for this command, otherwise redirect to the unrecognized handler 308 | if (!this.handlers[cmd.name] || (!internal && ['connect', 'queue', 'timeout', 'relay', 'unrecognized'].indexOf(cmd.name) !== -1)) { 309 | cmd.name = 'unrecognized'; 310 | cmd.data = { name: command, data: data }; 311 | } 312 | 313 | // log the client command 314 | this.log.protocol(cmd.name, null, null, command + (_.isString(data) ? ' ' + data : ''), 'in', internal); 315 | 316 | // get the handlers 317 | let handlers = _.isArray(this.handlers[cmd.name.toLowerCase()]) ? [].concat(this.handlers[cmd.name.toLowerCase()]) : []; 318 | 319 | // request object that will be passed to the handlers. 320 | // handlers can add properties to this object, so following handlers 321 | // may work with them 322 | let req = { 323 | command: cmd, 324 | session: this 325 | } 326 | 327 | // accept string 328 | let accepted = [250, 'OK']; 329 | 330 | // runs the command handler 331 | let handle = () => { 332 | 333 | try { 334 | 335 | // check if there are any handlers remaining 336 | if (handlers.length === 0) { 337 | 338 | // accept the message 339 | this.log.protocol(cmd.name, null, accepted[0], accepted[1], 'out'); 340 | this.accepted[cmd.name] = accepted[0]; 341 | if (connection.socket.writable) connection.socket.write(accepted[0] + ' ' + accepted[1] + '\r\n'); 342 | 343 | // set session specific data 344 | switch (cmd.name) { 345 | case 'rcpt': 346 | if (req.to && this.envelope.to.indexOf(req.to) === -1) this.envelope.to.push(req.to); 347 | break; 348 | case 'mail': 349 | if (req.from) this.envelope.from = req.from; 350 | break; 351 | case 'auth': 352 | if (req.user) this.user = req.user; 353 | break; 354 | } 355 | 356 | // all handlers processed, run the callback with the final code and message 357 | return cb(null, { reason: 'accept', code: accepted[0], message: accepted[1] }); 358 | 359 | } else { 360 | 361 | // get the next handler in the chain 362 | let handler = handlers.shift(); 363 | 364 | // add the plugin specific config to the request 365 | req.config = this.config.plugins && this.config.plugins[cmd.name + '/' + handler.name] ? this.config.plugins[cmd.name + '/' + handler.name] : {}; 366 | 367 | // response object that will be passed to the handler 368 | let res = { 369 | 370 | // accepts the command and replies with a status code and message 371 | accept: (code, message) => { 372 | 373 | if (code) accepted[0] = code; 374 | if (message) accepted[1] = message; 375 | this.log.protocol(cmd.name, handler.name, accepted[0], accepted[1], 'out', true); 376 | handle(); 377 | 378 | }, 379 | 380 | // accepts the command and ends the command handler chain 381 | final: (code, message) => { 382 | 383 | this.log.protocol(cmd.name, handler.name, code, message, 'out'); 384 | this.accepted[cmd.name] = code || true; 385 | if (code && message && connection.socket.writable) connection.socket.write(code + ' ' + message + '\r\n'); 386 | return cb(null, { reason: 'ok', code: code || null, message: message || null }); 387 | 388 | }, 389 | 390 | // rejects the command and replies with a status code and message 391 | reject: (code, message) => { 392 | 393 | if (!code || !message) throw new Error('Cannot reject without a code and a message'); 394 | this.log.protocol(cmd.name, handler.name, code, message, 'out'); 395 | this.rejected[cmd.name] = code; 396 | 397 | // special case: count the auth failures and end the session if to many auth failures happened 398 | if (cmd.name === 'auth') { 399 | this.counters.authFailures++; 400 | if (this.counters.authFailures > this.config.limits.authFailures) { 401 | this.close(554, 'Error: Too many failed authentications'); 402 | return cb({ reason: 'reject', code: 554, message: 'Error: Too many failed authentications' });; 403 | } 404 | } 405 | 406 | if (connection.socket.writable) connection.socket.write(code + ' Error: ' + message + '\r\n'); 407 | cb({ reason: 'reject', code: code, message: message }); 408 | 409 | }, 410 | 411 | // reads the next line of data and outputs the data 412 | read: (onData) => { 413 | 414 | // install a handler that will be called when existent 415 | connection.$readHandler = (data, next) => { 416 | 417 | // log the data 418 | this.log.protocol(cmd.name, handler.name, null, data.toString(), 'in'); 419 | 420 | // replace the original callback with the callback returned from the read 421 | cb = next; 422 | 423 | // remove the read handler 424 | connection.$readHandler = null; 425 | 426 | // call the read listener with the data 427 | onData(data); 428 | 429 | } 430 | 431 | // run the callback, so the next command will be read 432 | cb({ reason: 'read', code: null, message: null }); 433 | 434 | }, 435 | 436 | // write to the connection 437 | // you still have to call accept, reject or end 438 | write: (data) => { 439 | 440 | this.log.protocol(cmd.name, handler.name, data.split(' ').length == 2 ? data.split(' ')[0] : null, data.split(' ').length == 2 ? data.split(' ')[1] : data, 'out'); 441 | if (connection.socket.writable) connection.socket.write(data + '\r\n'); 442 | 443 | }, 444 | 445 | // ends the client connection 446 | end: (code, message) => { 447 | 448 | this.log.protocol(cmd.name, handler.name, code, message, 'out'); 449 | this.rejected[cmd.name] = code || true; 450 | this.close(code, message); 451 | return cb({ reason: 'end', code: code, message: message }); 452 | 453 | }, 454 | 455 | // stores session specific data 456 | set: (value) => { 457 | 458 | if (!_.isObject(this.data[cmd.name])) this.data[cmd.name] = {}; 459 | this.data[cmd.name][handler.name] = value; 460 | 461 | }, 462 | 463 | // retrieves session specific data 464 | get: (handler) => { 465 | 466 | if (!_.isString(handler)) return; 467 | let cmd = handler.split('/')[0]; 468 | let plugin = handler.split('/')[1]; 469 | if (cmd && plugin) { 470 | return !_.isUndefined(this.data[cmd]) && !_.isUndefined(this.data[cmd][plugin]) ? this.data[cmd][plugin] : null; 471 | } else if (cmd) { 472 | return this.data[cmd] || null; 473 | } 474 | 475 | }, 476 | 477 | // logs a message for the specific handler 478 | log: (() => { 479 | 480 | let log = {}; 481 | Object.keys(this.logger.levels).forEach((level) => { 482 | log[level] = (message, ...args) => { 483 | this.logger.log(level, this.id, this.transaction, cmd.name, handler.name, null, { message: message, data: args.length ? args : undefined}, false); 484 | } 485 | }); 486 | return log; 487 | 488 | })() 489 | 490 | }; 491 | 492 | // call the handler 493 | try { 494 | handler.handler(req, res); 495 | } catch (ex) { 496 | res.log.error('Caught exception, disconnecting client: ', ex); 497 | res.end(500, 'Internal server error'); 498 | } 499 | 500 | } 501 | 502 | } catch (ex) { 503 | 504 | // Uncaught error, not good. log it 505 | this.log.error('handler', 'uncaught', { message: `Unhandled error in a handler for command "${cmd.name}": ${ex.message || ex}`, data: ex }); 506 | this.close(500, 'Internal server error'); 507 | cb({ reason: 'end', code: 500, message: 'Internal server error' }); 508 | 509 | } 510 | 511 | } 512 | 513 | // start to handle the command 514 | handle(); 515 | 516 | } 517 | 518 | } 519 | 520 | module.exports = SMTPSession; -------------------------------------------------------------------------------- /src/smtp-stream.js: -------------------------------------------------------------------------------- 1 | // forked 12.09.2017 from: https://github.com/andris9/smtp-server/blob/master/lib/smtp-stream.js 2 | const stream = require('stream'); 3 | const Writable = stream.Writable; 4 | const PassThrough = stream.PassThrough; 5 | 6 | /** 7 | * Incoming SMTP stream parser. Detects and emits commands. If switched to 8 | * data mode, emits unescaped data events until final . 9 | * 10 | * @constructor 11 | * @param {Object} [options] Optional Stream options object 12 | */ 13 | class SMTPStream extends Writable { 14 | constructor(options) { 15 | // init Writable 16 | super(options); 17 | 18 | // Indicates if the stream is currently in data mode 19 | this._dataMode = false; 20 | // Output stream for the current data mode 21 | this._dataStream = null; 22 | // How many bytes are allowed for a data stream 23 | this._maxBytes = Infinity; 24 | // How many bytes have been emitted to data stream 25 | this.dataBytes = 0; 26 | // Callback to run once data mode is finished 27 | this._continueCallback = false; 28 | // unprocessed chars from the last parsing iteration (used in command mode) 29 | this._remainder = ''; 30 | // unprocessed bytes from the last parsing iteration (used in data mode) 31 | this._lastBytes = false; 32 | 33 | this.closed = false; 34 | // once the input stream ends, flush all output without expecting the newline 35 | this.on('finish', () => this._flushData()); 36 | } 37 | 38 | /** 39 | * Placeholder command handler. Override this with your own. 40 | */ 41 | oncommand(/* command, callback */) { 42 | throw new Error('Command handler is not set'); 43 | } 44 | 45 | /** 46 | * Switch to data mode and return output stream. The dots in the stream are unescaped. 47 | * 48 | * @returns {Stream} Data stream 49 | */ 50 | startDataMode(maxBytes) { 51 | this._dataMode = true; 52 | this._maxBytes = (maxBytes && Number(maxBytes)) || Infinity; 53 | this.dataBytes = 0; 54 | this._dataStream = new PassThrough(); 55 | 56 | return this._dataStream; 57 | } 58 | 59 | /** 60 | * Call this once data mode is over and you have finished processing the data stream 61 | */ 62 | continue() { 63 | if (typeof this._continueCallback === 'function') { 64 | this._continueCallback(); 65 | this._continueCallback = false; 66 | } else { 67 | // indicate that the 'continue' was already called once the stream actually ends 68 | this._continueCallback = true; 69 | } 70 | } 71 | 72 | // PRIVATE METHODS 73 | 74 | /** 75 | * Writable._write method. 76 | */ 77 | _write(chunk, encoding, next) { 78 | if (!chunk || !chunk.length) { 79 | return next(); 80 | } 81 | 82 | let data; 83 | let pos = 0; 84 | let newlineRegex; 85 | 86 | let called = false; 87 | let done = (...args) => { 88 | if (called) { 89 | return; 90 | } 91 | called = true; 92 | next(...args); 93 | }; 94 | 95 | if (this.closed) { 96 | return done(); 97 | } 98 | 99 | if (!this._dataMode) { 100 | newlineRegex = /\r?\n/g; 101 | data = this._remainder + chunk.toString('binary'); 102 | 103 | let readLine = () => { 104 | let match; 105 | let line; 106 | let buf; 107 | 108 | // check if the mode is not changed 109 | if (this._dataMode) { 110 | buf = new Buffer(data.substr(pos), 'binary'); 111 | this._remainder = ''; 112 | return this._write(buf, 'buffer', done); 113 | } 114 | 115 | // search for the next newline 116 | // exec keeps count of the last match with lastIndex 117 | // so it knows from where to start with the next iteration 118 | if ((match = newlineRegex.exec(data))) { 119 | line = data.substr(pos, match.index - pos); 120 | pos += line.length + match[0].length; 121 | } else { 122 | this._remainder = pos < data.length ? data.substr(pos) : ''; 123 | return done(); 124 | } 125 | 126 | this.oncommand(new Buffer(line, 'binary'), readLine); 127 | }; 128 | 129 | // start reading lines 130 | readLine(); 131 | } else { 132 | this._feedDataStream(chunk, done); 133 | } 134 | } 135 | 136 | /** 137 | * Processes a chunk in data mode. Escape dots are removed and final dot ends the data mode. 138 | */ 139 | _feedDataStream(chunk, done) { 140 | let i; 141 | let endseq = new Buffer('\r\n.\r\n'); 142 | let len; 143 | let handled; 144 | let buf; 145 | 146 | if (this._lastBytes && this._lastBytes.length) { 147 | chunk = Buffer.concat([this._lastBytes, chunk], this._lastBytes.length + chunk.length); 148 | this._lastBytes = false; 149 | } 150 | 151 | len = chunk.length; 152 | 153 | // check if the data does not start with the end terminator 154 | if (!this.dataBytes && len >= 3 && Buffer.compare(chunk.slice(0, 3), new Buffer('.\r\n')) === 0) { 155 | this._endDataMode(false, chunk.slice(3), done); 156 | return; 157 | } 158 | 159 | // check if the first symbol is a escape dot 160 | if (!this.dataBytes && len >= 2 && chunk[0] === 0x2e && chunk[1] === 0x2e) { 161 | chunk = chunk.slice(1); 162 | len--; 163 | } 164 | 165 | // seek for the stream ending 166 | for (i = 2; i < len - 2; i++) { 167 | // if the dot is the first char in a line 168 | if (chunk[i] === 0x2e && chunk[i - 1] === 0x0a) { 169 | // if the dot matches end terminator 170 | if (Buffer.compare(chunk.slice(i - 2, i + 3), endseq) === 0) { 171 | if (i > 2) { 172 | buf = chunk.slice(0, i); 173 | this.dataBytes += buf.length; 174 | this._endDataMode(buf, chunk.slice(i + 3), done); 175 | } else { 176 | this._endDataMode(false, chunk.slice(i + 3), done); 177 | } 178 | 179 | return; 180 | } 181 | 182 | // check if the dot is an escape char and remove it 183 | if (chunk[i + 1] === 0x2e) { 184 | buf = chunk.slice(0, i); 185 | 186 | this._lastBytes = false; // clear remainder bytes 187 | this.dataBytes += buf.length; // increment byte counter 188 | 189 | // emit what we already have and continue without the dot 190 | if (this._dataStream.writable) { 191 | this._dataStream.write(buf); 192 | } 193 | 194 | return setImmediate(() => this._feedDataStream(chunk.slice(i + 1), done)); 195 | } 196 | } 197 | } 198 | 199 | // keep the last bytes 200 | if (chunk.length < 4) { 201 | this._lastBytes = chunk; 202 | } else { 203 | this._lastBytes = chunk.slice(chunk.length - 4); 204 | } 205 | 206 | // if current chunk is longer than the remainder bytes we keep for later emit the available bytes 207 | if (this._lastBytes.length < chunk.length) { 208 | buf = chunk.slice(0, chunk.length - this._lastBytes.length); 209 | this.dataBytes += buf.length; 210 | 211 | // write to stream but stop if need to wait for drain 212 | if (this._dataStream.writable) { 213 | handled = this._dataStream.write(buf); 214 | if (!handled) { 215 | this._dataStream.once('drain', done); 216 | } else { 217 | return done(); 218 | } 219 | } else { 220 | return done(); 221 | } 222 | } else { 223 | // nothing to emit, continue with the input stream 224 | return done(); 225 | } 226 | } 227 | 228 | /** 229 | * Flushes remaining bytes 230 | */ 231 | _flushData() { 232 | let line; 233 | if (this._remainder && !this.closed) { 234 | line = this._remainder; 235 | this._remainder = ''; 236 | this.oncommand(new Buffer(line, 'binary')); 237 | } 238 | } 239 | 240 | /** 241 | * Ends data mode and returns to command mode. Stream is not resumed before #continue is called 242 | */ 243 | _endDataMode(chunk, remainder, callback) { 244 | if (this._continueCallback === true) { 245 | this._continueCallback = false; 246 | // wait until the stream is actually over and then continue 247 | this._dataStream.once('end', callback); 248 | } else { 249 | this._continueCallback = () => this._write(remainder, 'buffer', callback); 250 | } 251 | 252 | this._dataStream.byteLength = this.dataBytes; 253 | this._dataStream.sizeExceeded = this.dataBytes > this._maxBytes; 254 | 255 | if (chunk && chunk.length && this._dataStream.writable) { 256 | this._dataStream.end(chunk); 257 | } else { 258 | this._dataStream.end(); 259 | } 260 | 261 | this._dataMode = false; 262 | this._remainder = ''; 263 | this._dataStream = null; 264 | } 265 | } 266 | 267 | // Expose to the world 268 | module.exports = SMTPStream; -------------------------------------------------------------------------------- /src/smtp-util.js: -------------------------------------------------------------------------------- 1 | // dependencies 2 | const _ = require('lodash'); 3 | const fs = require('fs'); 4 | const dns = require('dns'); 5 | const path = require('path'); 6 | const mkdirp = require('mkdirp'); 7 | const topsort = require('topsort'); 8 | 9 | /** 10 | * Static utility functions that are used thoughout the lib 11 | */ 12 | class SMTPUtil { 13 | 14 | /** 15 | * Promise based fs.readFile wrapper 16 | * 17 | * @param {string} path 18 | * The path to the file to read 19 | * 20 | * @return {Promise} 21 | * A promise that resolves with the content of the file, 22 | * or gets rejected with an error 23 | */ 24 | static readFile(path) { 25 | 26 | return new Promise((resolve, reject) => { 27 | fs.readFile(path, (err, data) => err ? reject(err) : resolve(data.toString())); 28 | }); 29 | 30 | } 31 | 32 | /** 33 | * Promise based fs.writeFile wrapper 34 | * 35 | * @param {string} path 36 | * The path to the file to write 37 | * 38 | * @param {string} data 39 | * The data to write 40 | * 41 | * @return {Promise} 42 | * A promise that resolves once the file is written, 43 | * or gets rejected with an error 44 | */ 45 | static writeFile(path, data) { 46 | 47 | return new Promise((resolve, reject) => { 48 | fs.writeFile(path, data, (err) => err ? reject(err) : resolve()); 49 | }); 50 | 51 | } 52 | 53 | /** 54 | * Promise based fs.readdir wrapper 55 | * 56 | * @param {string} path 57 | * The path of the directory to read 58 | * 59 | * @return {Promise} 60 | * A promise that resolves with an array of contents, 61 | * or gets rejected with an error 62 | */ 63 | static readDir(path) { 64 | 65 | return new Promise((resolve, reject) => { 66 | fs.readdir(path, (err, data) => err ? reject(err) : resolve(data)); 67 | }); 68 | 69 | } 70 | 71 | /** 72 | * Promise based mkdirp. 73 | * Creates a directory recursively 74 | * 75 | * @param {string} path 76 | * The path of the directory to create 77 | * 78 | * @return {Promise} 79 | * A promise that resolves once done, 80 | * or gets rejected with an error 81 | */ 82 | static mkdirp(path) { 83 | 84 | return new Promise((resolve, reject) => { 85 | mkdirp(path, (err) => err ? reject(err) : resolve()); 86 | }); 87 | 88 | } 89 | 90 | /** 91 | * Promise based unlink. 92 | * Unlinks a file from the file system. 93 | * 94 | * @param {string} path 95 | * The path to the file to unlink 96 | * 97 | * @return {Promise} 98 | * A promise that resolves once done, 99 | * or gets rejected with an error 100 | */ 101 | static unlink(path) { 102 | 103 | return new Promise((resolve, reject) => { 104 | fs.unlink(path, (err) => err ? reject(err) : resolve()); 105 | }); 106 | 107 | } 108 | 109 | /** 110 | * Promise based fs.exists wrapper 111 | * 112 | * @param {string} path 113 | * The path to check for existence 114 | * 115 | * @return {Promise} 116 | * A promise that resolves with true if the path exists, 117 | * otherwise false 118 | */ 119 | static exists(path) { 120 | 121 | return new Promise((resolve, reject) => { 122 | fs.exists(path, (exists) => resolve(exists)) 123 | }); 124 | 125 | } 126 | 127 | /** 128 | * Promise based dns.resolve 129 | */ 130 | static resolveDNS(host, type) { 131 | 132 | return new Promise((resolve, reject) => { 133 | dns.resolve(host, type, (err, res) => err ? reject(err) : resolve(res)); 134 | }); 135 | 136 | } 137 | 138 | /** 139 | * Loads and returns the built-in command handlers 140 | */ 141 | static getHandlers(handlers, config) { 142 | 143 | // the directory path of our core plugins 144 | let corePluginDir = path.join(__dirname, '..', 'plugins'); 145 | 146 | // the handlers that will be returned later on 147 | let plugins = _.isObject(handlers) ? _.cloneDeep(handlers) : {}; 148 | 149 | // get all plugins 150 | let commands = fs.readdirSync(corePluginDir); 151 | 152 | // go over the commands, make sure it is a folder 153 | commands.forEach((command) => { 154 | 155 | let stats = fs.statSync(path.join(corePluginDir, command)); 156 | if (stats.isDirectory()) { 157 | 158 | // scan the command dir for plugins 159 | let commandDir = path.join(corePluginDir, command); 160 | let commands = fs.readdirSync(commandDir); 161 | 162 | // check if the plugin is a file ending on .js 163 | commands.forEach((pluginName) => { 164 | 165 | // get path to the plugin file 166 | let pluginFile = path.join(commandDir, pluginName); 167 | if (pluginName.split('.')[pluginName.split('.').length - 1] !== 'js') return; 168 | 169 | // if it is a file, require it 170 | if (fs.statSync(pluginFile).isFile()) { 171 | if (!_.isArray(plugins[command])) plugins[command] = []; 172 | let module = require(pluginFile); 173 | 174 | // a module has to expose an object containing a handler function 175 | if (!_.isObject(module)) return console.log(`Plugin "${pluginName}" does not expose an object. ignoring.`); 176 | if (!_.isFunction(module.handler)) return console.log(`Plugin "${pluginName}" does not expose a "handle" function. ignoring.`); 177 | plugins[command].push({ name: pluginName.split('.')[0], handler: module.handler, after: module.after || module.requires || [], before: module.before || [] }); 178 | } 179 | 180 | }); 181 | 182 | } 183 | 184 | }); 185 | 186 | // remove handlers that have been disabled in the plugin config 187 | Object.keys(plugins).forEach((command) => { 188 | 189 | plugins[command].forEach((handler, i) => { 190 | 191 | if (config && config.plugins && config.plugins[command + '/' + handler.name] === false) { 192 | // plugin has been disabled, remove it from the plugins list 193 | plugins[command].splice(i, 1); 194 | } 195 | 196 | }); 197 | 198 | }); 199 | 200 | // sort the handlers by their dependencies 201 | SMTPUtil.sortHandlers(plugins); 202 | 203 | // return the handlers 204 | return plugins; 205 | 206 | } 207 | 208 | /** 209 | * Sorts the handlers by their dependency 210 | * 211 | * @param {Object} handlers 212 | * A list of handlers, with the event as the key and an 213 | * array of handler definitions as value 214 | */ 215 | static sortHandlers(handlers) { 216 | 217 | Object.keys(handlers).forEach((command) => { 218 | 219 | // sort modules by their dependencies 220 | // uses the topsort algorithm to get 221 | // the dependency chain right 222 | let edges = []; 223 | 224 | handlers[command].forEach((handler) => { 225 | [].concat(handler.after || []).forEach((dep) => { 226 | // if the dependency was provided as cmd/plugin, ignore the cmd/ part 227 | if (dep && dep.split('/')[1]) dep = dep.split('/')[1]; 228 | edges.push([dep, handler.name]); 229 | }); 230 | [].concat(handler.before || []).forEach((dep) => { 231 | // if the dependency was provided as cmd/plugin, ignore the cmd/ part 232 | if (dep && dep.split('/')[1]) dep = dep.split('/')[1]; 233 | edges.push([handler.name, dep]); 234 | }); 235 | 236 | // add an implicit dependency to the core module, 237 | // so the core plugins always run first 238 | edges.push(['core', handler.name]); 239 | 240 | }); 241 | 242 | let sorted = []; 243 | let unsorted = []; 244 | 245 | handlers[command].forEach((handler) => { 246 | let idx = topsort(edges).indexOf(handler.name); 247 | idx === -1 ? unsorted.push(handler) : sorted[idx] = handler; 248 | }); 249 | 250 | handlers[command] = unsorted.concat(sorted); 251 | 252 | }); 253 | 254 | } 255 | 256 | /** 257 | * Provides a queue that can run schedule jobs, respecting concurrency 258 | * 259 | * @param {object} [opts] 260 | * Options to configure "concurrency" and "interval" 261 | * 262 | * @param {number} [opts.concurrency=10] 263 | * The number of concurrent tasks that are allowed to be run in parallel 264 | * 265 | * @param {number} [opts.interval=100] 266 | * The time in milliseconds that we will check for queue updates 267 | * 268 | * @param {function} cb 269 | * A callback that will be called with the current task once it is 270 | * ready to be executed 271 | * 272 | * @return {object} 273 | * The queue manager object 274 | */ 275 | static queue(opts, cb) { 276 | 277 | // the queue that will be returned 278 | let q = { 279 | 280 | uid: Math.random(), 281 | 282 | // the interval id that the queue runs on 283 | id: setInterval(() => { 284 | 285 | // make sure the queue can execute 286 | if (q.running.length >= q.concurrency) return; 287 | 288 | // run tasks 289 | q.tasks.forEach(function (task) { 290 | 291 | // make sure the task is allowed to run 292 | if (q.running.length >= q.concurrency) return; 293 | if (task.running) return; 294 | if (task.time > new Date().getTime()) return; 295 | 296 | // if we got until here, the task qualifies for execution 297 | task.running = true; 298 | q.running.push(task); 299 | 300 | // run the queue listeners to start processing the task 301 | cb(task.task, (err) => { 302 | 303 | if (err) console.log('Error while processing queued task:', err); 304 | 305 | // remove the task 306 | q.running.splice(q.running.indexOf(task), 1); 307 | q.tasks.splice(q.tasks.indexOf(task), 1); 308 | 309 | }); 310 | 311 | }); 312 | 313 | }, opts && opts.interval ? opts.interval : 1000), 314 | 315 | // the configured concurrency 316 | concurrency: opts && opts.concurrency ? opts.concurrency : 10, 317 | 318 | // queued tasks 319 | tasks: [], 320 | 321 | // running tasks 322 | running: [], 323 | 324 | // schedule a task 325 | schedule: (offset, task) => { 326 | q.tasks.push({ time: new Date().getTime() + offset, task: task, running: false }); 327 | }, 328 | 329 | // stop the queue 330 | kill: () => { 331 | clearInterval(q.id); 332 | q.tasks = []; 333 | } 334 | 335 | }; 336 | 337 | return q; 338 | } 339 | 340 | } 341 | 342 | module.exports = SMTPUtil; -------------------------------------------------------------------------------- /test/1-server.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | 3 | const should = require('should'); 4 | const net = require('net'); 5 | const tls = require('tls'); 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const SMTPServer = require('../src/smtp-server'); 9 | 10 | // set to true to enable debug output 11 | const debug = true; 12 | 13 | // temporary data 14 | const data = {}; 15 | 16 | // handlers used for both server types 17 | const handlers = { 18 | 'auth': [{ 19 | name: 'auth-test', 20 | after: ['core'], 21 | handler: (req, res) => { 22 | data.user = req.user; 23 | if (req.user.username !== 'user' || req.user.password !== 'password') return res.reject(535, 'authentication failed'); 24 | res.accept(); 25 | } 26 | }], 27 | 'queue': [{ 28 | name: 'queue-test', 29 | after: ['core'], 30 | handler: (req, res) => { 31 | data.mail = req.mail; 32 | res.accept(); 33 | } 34 | }] 35 | } 36 | 37 | let server, smtp; 38 | 39 | describe('server tests', function() { 40 | 41 | this.timeout(1000); 42 | 43 | it('should initialize server', (done) => { 44 | 45 | server = new SMTPServer({ 46 | logger: { 47 | verbose: debug ? console.log : () => {} 48 | }, 49 | domains: ['localhost'], 50 | handlers: handlers 51 | }, (session) => { 52 | data.session = session; 53 | }); 54 | 55 | should(server.port).equal(null); 56 | 57 | server.listen(2625, (err) => { 58 | should(server.port).equal(2625); 59 | should(server.config).be.ok; 60 | done(err); 61 | }); 62 | 63 | }); 64 | 65 | it('should greet on smtp', (done) => { 66 | 67 | smtp = net.connect({ port: 2625 }, (err) => { 68 | smtp.once('data', (data) => { 69 | data.toString().should.startWith('220 '); 70 | done(); 71 | }); 72 | }); 73 | 74 | }); 75 | 76 | it('should have one server connection', (done) => { 77 | server.getConnections((err, count) => { 78 | should(err).not.be.ok; 79 | should(count).equal(1); 80 | done(err); 81 | }); 82 | }); 83 | 84 | it('should reject empty command', (done) => { 85 | smtp.write('\r\n'); 86 | smtp.once('data', (data) => { 87 | data.toString().should.startWith('502 '); 88 | done(); 89 | }); 90 | }); 91 | 92 | it('should reject unknown commands', (done) => { 93 | smtp.write('UNKOWN COMMAND\r\n'); 94 | smtp.once('data', (data) => { 95 | data.toString().should.startWith('502 '); 96 | done(); 97 | }); 98 | }); 99 | 100 | it('should start login authentication', (done) => { 101 | smtp.write('AUTH LOGIN\r\n'); 102 | smtp.once('data', (data) => { 103 | data.toString().should.startWith('334'); 104 | done(); 105 | }); 106 | }); 107 | 108 | it('should accept login user', (done) => { 109 | smtp.write(new Buffer('user').toString('base64') + '\r\n'); 110 | smtp.once('data', (data) => { 111 | data.toString().should.startWith('334'); 112 | done(); 113 | }); 114 | }); 115 | 116 | it('should accept login password', (done) => { 117 | smtp.write(new Buffer('password').toString('base64') + '\r\n'); 118 | smtp.once('data', (data) => { 119 | data.toString().should.startWith('235'); 120 | done(); 121 | }); 122 | }); 123 | 124 | it('should provide valid req.user to auth login handler', (done) => { 125 | data.user.should.be.type('object'); 126 | data.user.username.should.equal('user'); 127 | data.user.password.should.equal('password'); 128 | done(); 129 | }); 130 | 131 | it('should reject a second auth', (done) => { 132 | smtp.write('AUTH PLAIN\r\n'); 133 | smtp.once('data', (data) => { 134 | data.toString().should.startWith('503'); 135 | done(); 136 | }); 137 | }); 138 | 139 | it('should provide valid req.user to auth plain handler', (done) => { 140 | data.user.should.be.type('object'); 141 | data.user.username.should.equal('user'); 142 | data.user.password.should.equal('password'); 143 | done(); 144 | }); 145 | 146 | it('should reject mail before helo', (done) => { 147 | smtp.write('MAIL FROM: \r\n'); 148 | smtp.once('data', (data) => { 149 | data.toString().should.startWith('503 '); 150 | done(); 151 | }); 152 | }); 153 | 154 | it('should reject rcpt before mail', (done) => { 155 | smtp.write('RCPT TO: \r\n'); 156 | smtp.once('data', (data) => { 157 | data.toString().should.startWith('503 '); 158 | done(); 159 | }); 160 | }); 161 | 162 | it('should reject data before rcpt', (done) => { 163 | smtp.write('DATA\r\n'); 164 | smtp.once('data', (data) => { 165 | data.toString().should.startWith('503 '); 166 | done(); 167 | }); 168 | }); 169 | 170 | it('should reject empty helo', (done) => { 171 | smtp.write('HELO\r\n'); 172 | smtp.once('data', (data) => { 173 | data.toString().should.startWith('501 '); 174 | done(); 175 | }); 176 | }); 177 | 178 | it('should accept helo with hostname', (done) => { 179 | smtp.write('HELO localhost\r\n'); 180 | smtp.once('data', (data) => { 181 | data.toString().should.startWith('250 '); 182 | done(); 183 | }); 184 | }); 185 | 186 | it('should list STARTTLS on ehlo for unsecure connections', (done) => { 187 | smtp.write('EHLO localhost\r\n'); 188 | let foundSTARTTLS = false; 189 | let check = (data) => { 190 | data.toString().should.startWith('250'); 191 | if (data.toString().indexOf('STARTTLS') !== -1) foundSTARTTLS = true; 192 | if (data.toString().indexOf('250 ') !== -1) { 193 | foundSTARTTLS.should.be.ok; 194 | done(); 195 | } else { 196 | smtp.once('data', check); 197 | } 198 | } 199 | smtp.once('data', check); 200 | }); 201 | 202 | it('should upgrade the connection on STARTTLS', (done) => { 203 | smtp.write('STARTTLS\r\n'); 204 | smtp.once('data', (res) => { 205 | res.toString().should.startWith('220'); 206 | let ctx = tls.createSecureContext(data.session.config.tls); 207 | smtp = tls.connect({ secureContext: ctx, socket: smtp }); 208 | smtp.once('secure', () => { 209 | let foundSTARTTLS = false; 210 | let check = (data) => { 211 | data.toString().should.startWith('250'); 212 | if (data.toString().indexOf('STARTTLS') !== -1) foundSTARTTLS = true; 213 | if (data.toString().indexOf('250 ') !== -1) { 214 | foundSTARTTLS.should.not.be.ok; 215 | done(); 216 | } else { 217 | smtp.once('data', check); 218 | } 219 | } 220 | smtp.once('data', check); 221 | smtp.write('EHLO localhost\r\n'); 222 | }); 223 | }); 224 | }); 225 | 226 | it('should have one server connection after STARTTLS', (done) => { 227 | server.getConnections((err, count) => { 228 | should(err).not.be.ok; 229 | should(count).equal(1); 230 | done(err); 231 | }); 232 | }); 233 | 234 | it('should retain session data after STARTTLS', (done) => { 235 | should(data.session.accepted.helo).be.ok; 236 | should(data.session.accepted.ehlo).be.ok; 237 | should(data.session.accepted.auth).be.ok; 238 | done(); 239 | }); 240 | 241 | it('should accept plain auth', (done) => { 242 | // clear last login 243 | delete data.session.user; 244 | delete data.session.accepted.auth; 245 | smtp.write('AUTH PLAIN ' + new Buffer('user\x00user\x00\password').toString('base64') + '\r\n'); 246 | smtp.once('data', (res) => { 247 | res.toString().should.startWith('235'); 248 | should(data.session.accepted.auth).be.ok; 249 | done(); 250 | }); 251 | }); 252 | 253 | it('should reject empty mail', (done) => { 254 | smtp.write('MAIL\r\n'); 255 | smtp.once('data', (data) => { 256 | data.toString().should.startWith('501'); 257 | done(); 258 | }); 259 | }); 260 | 261 | it('should reject incomplete mail', (done) => { 262 | smtp.write('MAIL FROM: \r\n'); 263 | smtp.once('data', (data) => { 264 | data.toString().should.startWith('501'); 265 | done(); 266 | }); 267 | }); 268 | 269 | it('should accept bounce mail (<>)', (done) => { 270 | smtp.write('MAIL FROM: <>\r\n'); 271 | smtp.once('data', (res) => { 272 | res.toString().should.startWith('250'); 273 | should(data.session.envelope.from).be.ok; 274 | data.session.envelope.from.should.equal('<>'); 275 | data.session.accepted.mail.should.be.ok; 276 | done(); 277 | }); 278 | }); 279 | 280 | it('should not accept nested mail', (done) => { 281 | smtp.write('MAIL FROM: <>\r\n'); 282 | smtp.once('data', (res) => { 283 | res.toString().should.startWith('503'); 284 | should(data.session.rejected.mail).be.ok; 285 | done(); 286 | }); 287 | }); 288 | 289 | it('should accept mail without <>', (done) => { 290 | data.session.accepted.mail = false; 291 | data.session.envelope.from = null; 292 | smtp.write('MAIL FROM: test@localhost\r\n'); 293 | smtp.once('data', (res) => { 294 | res.toString().should.startWith('250'); 295 | data.session.envelope.from.should.equal('test@localhost'); 296 | data.session.accepted.mail.should.be.ok; 297 | done(); 298 | }); 299 | }); 300 | 301 | it('should reject empty rcpt', (done) => { 302 | smtp.write('RCPT\r\n'); 303 | smtp.once('data', (res) => { 304 | res.toString().should.startWith('501'); 305 | should(data.session.accepted.rcpt).not.be.ok; 306 | done(); 307 | }); 308 | }); 309 | 310 | it('should reject incomplete rcpt', (done) => { 311 | smtp.write('RCPT TO: \r\n'); 312 | smtp.once('data', (res) => { 313 | res.toString().should.startWith('501'); 314 | should(data.session.accepted.rcpt).not.be.ok; 315 | done(); 316 | }); 317 | }); 318 | 319 | it('should accept rcpt without <>', (done) => { 320 | smtp.write('RCPT TO: test@localhost\r\n'); 321 | smtp.once('data', (res) => { 322 | res.toString().should.startWith('250'); 323 | data.session.envelope.to.indexOf('test@localhost').should.not.equal(-1); 324 | data.session.accepted.rcpt.should.be.ok; 325 | done(); 326 | }); 327 | }); 328 | 329 | it('should accept additional rcpt', (done) => { 330 | smtp.write('RCPT TO: test2@localhost\r\n'); 331 | smtp.once('data', (res) => { 332 | res.toString().should.startWith('250'); 333 | data.session.envelope.to.indexOf('test2@localhost').should.not.equal(-1); 334 | data.session.envelope.to.length.should.equal(2); 335 | data.session.accepted.rcpt.should.be.ok; 336 | done(); 337 | }); 338 | }); 339 | 340 | it('should not add duplicate recipients', (done) => { 341 | smtp.write('RCPT TO: test2@localhost\r\n'); 342 | smtp.once('data', (res) => { 343 | res.toString().should.startWith('250'); 344 | data.session.envelope.to.indexOf('test2@localhost').should.not.equal(-1); 345 | data.session.envelope.to.length.should.equal(2); 346 | done(); 347 | }); 348 | }); 349 | 350 | it('should not relay unauthenticated for local sender and local recipient', (done) => { 351 | data.session.accepted.mail = 250; 352 | data.session.envelope.from = 'test@localhost'; 353 | delete data.session.accepted.auth; 354 | delete data.session.rejected.auth; 355 | delete data.session.user; 356 | smtp.write('RCPT TO: \r\n'); 357 | smtp.once('data', (res) => { 358 | res.toString().should.startWith('502'); 359 | done(); 360 | }); 361 | }); 362 | 363 | it('should not relay unauthenticated for local sender and remote recipient', (done) => { 364 | data.session.accepted.mail = 250; 365 | data.session.envelope.from = 'test@localhost'; 366 | delete data.session.accepted.auth; 367 | delete data.session.rejected.auth; 368 | delete data.session.user; 369 | smtp.write('RCPT TO: \r\n'); 370 | smtp.once('data', (res) => { 371 | res.toString().should.startWith('502'); 372 | done(); 373 | }); 374 | }); 375 | 376 | it('should relay unauthenticated for remote sender and local recipient', (done) => { 377 | data.session.accepted.mail = 250; 378 | data.session.envelope.from = 'test@remote'; 379 | delete data.session.accepted.auth; 380 | delete data.session.rejected.auth; 381 | delete data.session.user; 382 | smtp.write('RCPT TO: \r\n'); 383 | smtp.once('data', (res) => { 384 | res.toString().should.startWith('250'); 385 | done(); 386 | }); 387 | }); 388 | 389 | it('should relay authenticated for local sender and remote recipient', (done) => { 390 | data.session.accepted.mail = 250; 391 | data.session.envelope.from = 'test@localhost'; 392 | data.session.accepted.auth = 235; 393 | data.session.user = { username: 'username', password: 'password' }; 394 | delete data.session.rejected.auth; 395 | smtp.write('RCPT TO: \r\n'); 396 | smtp.once('data', (res) => { 397 | res.toString().should.startWith('250'); 398 | done(); 399 | }); 400 | }); 401 | 402 | it('should relay authenticated for local sender and local recipient', (done) => { 403 | data.session.accepted.mail = 250; 404 | data.session.envelope.from = 'test@localhost'; 405 | data.session.accepted.auth = 235; 406 | data.session.user = { username: 'username', password: 'password' }; 407 | delete data.session.rejected.auth; 408 | smtp.write('RCPT TO: \r\n'); 409 | smtp.once('data', (res) => { 410 | res.toString().should.startWith('250'); 411 | done(); 412 | }); 413 | }); 414 | 415 | it('should not relay authenticated for remote sender and remote recipient', (done) => { 416 | data.session.accepted.mail = 250; 417 | data.session.envelope.from = 'test@remote'; 418 | data.session.accepted.auth = 235; 419 | data.session.user = { username: 'username', password: 'password' }; 420 | delete data.session.rejected.auth; 421 | smtp.write('RCPT TO: \r\n'); 422 | smtp.once('data', (res) => { 423 | res.toString().should.startWith('502'); 424 | done(); 425 | }); 426 | }); 427 | 428 | it('should relay authenticated for remote sender and local recipient', (done) => { 429 | data.session.accepted.mail = 250; 430 | data.session.envelope.from = 'test@remote'; 431 | data.session.accepted.auth = 235; 432 | data.session.user = { username: 'username', password: 'password' }; 433 | delete data.session.rejected.auth; 434 | smtp.write('RCPT TO: \r\n'); 435 | smtp.once('data', (res) => { 436 | res.toString().should.startWith('250'); 437 | done(); 438 | }); 439 | }); 440 | 441 | it('should accept data', (done) => { 442 | smtp.write('DATA\r\n'); 443 | smtp.once('data', (res) => { 444 | res.toString().should.startWith('354'); 445 | done(); 446 | }); 447 | }); 448 | 449 | it('should write gtube message', (done) => { 450 | let file = fs.createReadStream(__dirname + '/assets/gtube.msg'); 451 | file.pipe(smtp, { end: false }); 452 | file.once('end', () => { 453 | smtp.write('\r\n.\r\n'); 454 | smtp.once('data', (res) => { 455 | res.toString().should.startWith('250'); 456 | done(); 457 | }); 458 | }); 459 | }); 460 | 461 | it('should have increased the transaction id to 1', () => { 462 | data.session.transaction.should.equal(1); 463 | }); 464 | 465 | it('should have reset the envelope', () => { 466 | should(data.session.accepted.mail).be.not.ok; 467 | should(data.session.accepted.rcpt).be.not.ok; 468 | should(data.session.accepted.data).be.not.ok; 469 | should(data.session.accepted.queue).be.not.ok; 470 | }); 471 | 472 | it('should have a spamd score', () => { 473 | should(data.session.data.queue.spamd.score).be.type('number'); 474 | }); 475 | 476 | it('should have a parsed mail object', () => { 477 | should(data.mail).be.type('object'); 478 | data.mail.from[0].address.should.equal('test@localhost'); 479 | should(data.mail.headers).be.type('object'); 480 | }); 481 | 482 | it('should accept mail in second transaction', (done) => { 483 | smtp.write('MAIL FROM: \r\n'); 484 | smtp.once('data', (res) => { 485 | res.toString().should.startWith('250'); 486 | data.session.accepted 487 | done(); 488 | }); 489 | }); 490 | 491 | it('should accept rcpt in second transaction', (done) => { 492 | smtp.write('RCPT TO: \r\n'); 493 | smtp.once('data', (res) => { 494 | res.toString().should.startWith('250'); 495 | done(); 496 | }); 497 | }); 498 | 499 | it('should accept data in second transaction', (done) => { 500 | smtp.write('DATA\r\n'); 501 | smtp.once('data', (res) => { 502 | res.toString().should.startWith('354'); 503 | done(); 504 | }); 505 | }); 506 | 507 | it('should write gtube message in second transaction', (done) => { 508 | let file = fs.createReadStream(path.join(__dirname, 'assets' , 'gtube.msg')); 509 | file.pipe(smtp, { end: false }); 510 | file.once('end', () => { 511 | smtp.write('\r\n.\r\n'); 512 | smtp.once('data', (res) => { 513 | res.toString().should.startWith('250'); 514 | done(); 515 | }); 516 | }); 517 | }); 518 | 519 | it('should have increased the transaction id to 2', () => { 520 | data.session.transaction.should.equal(2); 521 | }); 522 | 523 | it('should accept mail in third transaction', (done) => { 524 | smtp.write('MAIL FROM: \r\n'); 525 | smtp.once('data', (res) => { 526 | res.toString().should.startWith('250'); 527 | data.session.accepted 528 | done(); 529 | }); 530 | }); 531 | 532 | it('should accept rcpt in third transaction', (done) => { 533 | smtp.write('RCPT TO: \r\n'); 534 | smtp.once('data', (res) => { 535 | res.toString().should.startWith('250'); 536 | done(); 537 | }); 538 | }); 539 | 540 | it('should RSET the transaction', (done) => { 541 | smtp.write('RSET\r\n'); 542 | smtp.once('data', (res) => { 543 | res.toString().should.startWith('250'); 544 | should(data.session.user).be.ok; 545 | should(data.session.accepted.ehlo).be.ok; 546 | should(data.session.accepted.auth).be.ok; 547 | should(data.session.accepted.mail).be.not.ok; 548 | should(data.session.accepted.rcpt).be.not.ok; 549 | should(data.session.accepted.data).be.not.ok; 550 | should(data.session.accepted.queue).be.not.ok; 551 | done(); 552 | }); 553 | }); 554 | 555 | it('should accept mail after RSET', (done) => { 556 | smtp.write('MAIL FROM: \r\n'); 557 | smtp.once('data', (res) => { 558 | res.toString().should.startWith('250'); 559 | data.session.accepted 560 | done(); 561 | }); 562 | }); 563 | 564 | it('should accept rcpt after RSET', (done) => { 565 | smtp.write('RCPT TO: \r\n'); 566 | smtp.once('data', (res) => { 567 | res.toString().should.startWith('250'); 568 | done(); 569 | }); 570 | }); 571 | 572 | it('smtp client should quit', (done) => { 573 | smtp.write('QUIT\r\n'); 574 | smtp.once('data', (res) => { 575 | res.toString().should.startWith('221'); 576 | done(); 577 | }); 578 | }); 579 | 580 | it('should be disconnected', (done) => { 581 | data.session.connection.closed.should.be.true; 582 | done(); 583 | }); 584 | 585 | it('smtp server should have no open connections', (done) => { 586 | server.getConnections((err, count) => { 587 | if (err) return done(err); 588 | count.should.equal(0); 589 | done(); 590 | }); 591 | }); 592 | 593 | }); 594 | 595 | }() 596 | -------------------------------------------------------------------------------- /test/2-server-timeout.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | 3 | let should = require('should'); 4 | let net = require('net'); 5 | let tls = require('tls'); 6 | let SMTPServer = require('../src/smtp-server.js'); 7 | 8 | // set to true to enable debug output 9 | let debug = true; 10 | 11 | describe('server idle disconnect', function() { 12 | 13 | this.timeout(10000); 14 | 15 | let server = new SMTPServer({ 16 | port: 2725, 17 | logger: { verbose: debug ? console.log : function() {} }, 18 | domains: ['localhost'], 19 | limits: { 20 | idleTimeout: 1000 21 | } 22 | }); 23 | 24 | server.listen(2725); 25 | 26 | let client = net.connect({port: 2725}); 27 | 28 | it('should greet on smtp', function(done) { 29 | 30 | client.once('data', function(data) { 31 | data.toString().should.startWith('220 '); 32 | done(); 33 | }); 34 | 35 | }); 36 | 37 | it('should upgrade the connection on STARTTLS', (done) => { 38 | client.write('STARTTLS\r\n'); 39 | client.once('data', (res) => { 40 | res.toString().should.startWith('220'); 41 | let ctx = tls.createSecureContext(server.config.tls); 42 | client = tls.connect({ secureContext: ctx, socket: client }); 43 | client.once('secure', () => { 44 | let foundSTARTTLS = false; 45 | let check = (data) => { 46 | data.toString().should.startWith('250'); 47 | if (data.toString().indexOf('STARTTLS') !== -1) foundSTARTTLS = true; 48 | if (data.toString().indexOf('250 ') !== -1) { 49 | foundSTARTTLS.should.not.be.ok; 50 | done(); 51 | } else { 52 | client.once('data', check); 53 | } 54 | } 55 | client.once('data', check); 56 | client.write('EHLO localhost\r\n'); 57 | }); 58 | }); 59 | }); 60 | 61 | it('should get disconnected within 1 second idle', function(done) { 62 | 63 | let answered = false; 64 | 65 | // expect the disconnect message next 66 | client.once('data', function(data) { 67 | if (answered) return; 68 | answered = true; 69 | data.toString().should.startWith('451 '); 70 | done(); 71 | }); 72 | 73 | // wait 2 seconds, we should be disconnected after one already 74 | setTimeout(function() { 75 | 76 | if (answered) return; 77 | done(new Error('not disconnected after 2 seconds!')); 78 | 79 | }, 2000); 80 | 81 | }); 82 | 83 | it('smtps server should have no open connections', function(done) { 84 | 85 | server.getConnections(function(err, count) { 86 | if (err) return done(err); 87 | count.should.equal(0); 88 | done(); 89 | }); 90 | 91 | }); 92 | 93 | }); 94 | 95 | }(); 96 | -------------------------------------------------------------------------------- /test/3-relay-simple.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ABOUT THIS TEST: 3 | * ------------------------------- 4 | * This test will create two server instances, one for the domain "localhost" 5 | * and the other for the domain "remote". Goal of the test is to 6 | * connect via client to the instance hosting "remote" and sending a 7 | * message from "user@remote" to "user@localhost". 8 | * This will cause the "remote" server to relay the message to "localhost". 9 | * To confirm this is working, we are listening for the "queue" event on "localhost", 10 | * which should be triggered if "remote" successfully relayed the message to "localhost" 11 | */ 12 | module.exports = function() { 13 | 14 | let should = require('should'); 15 | let net = require('net'); 16 | let tls = require('tls'); 17 | let fs = require('fs'); 18 | let path = require('path'); 19 | let os = require('os'); 20 | let colors = require('colors/safe'); 21 | let SMTPServer = require('../src/smtp-server.js'); 22 | 23 | // enable debug output 24 | let debug = true; 25 | let config = {}; 26 | 27 | // generate config 28 | ['localhost', 'remote'].forEach((c) => { 29 | config[c] = { 30 | domains: [c], 31 | logger: {}, 32 | relay: { 33 | queueDir: path.join(os.tmpdir(), `mail-io-queue-test-${c}-${Date.now()}`) , 34 | smtpPort: c === 'remote' ? 2323 : 25 35 | } 36 | }; 37 | ['debug', 'verbose', 'info', 'protocol', 'error', 'warn'].forEach((l) => { 38 | config[c].logger[l] = (...args) => { 39 | if (typeof args[0] !== 'string') console.log(args[0]); 40 | console.log(colors.bgCyan(colors.white(` ${c.toUpperCase().slice(0, 5)} `)) + args[0]); 41 | } 42 | }); 43 | }); 44 | 45 | // function will be overwritten by our test. 46 | // function is called when the 'queue' event is triggered on localhost 47 | let onLocalhostQueue = null; 48 | 49 | // create "localhost" domain server 50 | let localhost = new SMTPServer(config.localhost, (session) => { 51 | localhost = session; 52 | session.on('queue', onLocalhostQueue); 53 | }).listen(2323); 54 | 55 | // create "remote" domain server 56 | let remote = new SMTPServer(config.remote, (session) => { 57 | remote = session 58 | }).listen(2324); 59 | 60 | describe('simple relay test', function() { 61 | 62 | this.timeout('10000'); 63 | 64 | let client = net.connect({ host: 'localhost', port: 2324 }); 65 | 66 | it('should connect', function(done) { 67 | client.once('data', function(res) { 68 | res.toString().should.startWith('220'); 69 | done(); 70 | }); 71 | }); 72 | 73 | it('should relay mail to user@localhost', (done) => { 74 | 75 | let message = 'Hello user@localhost, how are you?'; 76 | 77 | client.write('EHLO client\r\n'); 78 | client.write('AUTH PLAIN dXNlcgB1c2VyAHBhc3N3b3Jk\r\n'); 79 | client.write('MAIL FROM: user@remote\r\n'); 80 | client.write('RCPT TO: user@localhost\r\n'); 81 | client.write('DATA\r\n'); 82 | client.write(message + '\r\n.\r\n'); 83 | 84 | // hook to 'queue' listener on localhost 85 | onLocalhostQueue = (req, res) => { 86 | localhost.accepted.data.should.be.ok; 87 | localhost.envelope.from.should.equal('user@remote'); 88 | localhost.envelope.to[0].should.equal('user@localhost'); 89 | fs.readFileSync(req.command.data).toString().trim().should.endWith(message); 90 | res.accept(); 91 | done(); 92 | } 93 | 94 | }); 95 | 96 | }); 97 | 98 | }() 99 | -------------------------------------------------------------------------------- /test/4-relay-temporary-error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ABOUT THIS TEST: 3 | * ------------------------------- 4 | * This test will create two server instances, one for the domain "localhost" 5 | * and the other for the domain "remote". Goal of the test is to 6 | * connect via client to the instance hosting "remote" and sending a 7 | * message from "user@remote" to "user@localhost" and "admin@localhost". 8 | * This will cause the "remote" server to relay the message to "localhost" twice. 9 | * To confirm this is working, we are listening for the "queue" event on "localhost", 10 | * which should be triggered if "remote" successfully relayed the message to "localhost". 11 | * Additionally, we will trigger a temporary error message on the "localhost" server 12 | * for the first rcpt command arriving, which should cause the relay to retry the 13 | * delivery 14 | */ 15 | module.exports = function() { 16 | 17 | let should = require('should'); 18 | let SMTPServer = require('../src/smtp-server'); 19 | let net = require('net'); 20 | let tls = require('tls'); 21 | let fs = require('fs'); 22 | let path = require('path'); 23 | let os = require('os'); 24 | let colors = require('colors/safe'); 25 | let localhost = null; 26 | let remote = null; 27 | 28 | // enable debug output 29 | let debug = true; 30 | let config = {}; 31 | 32 | // generate config 33 | ['localhost', 'remote'].forEach((c) => { 34 | config[c] = { 35 | domains: [c], 36 | logger: {}, 37 | relay: { 38 | queueDir: path.join(os.tmpdir(), `mail-io-queue-test-${c}-${Date.now()}`), 39 | smtpPort: c === 'remote' ? 2328 : 25, 40 | retryBaseInterval: 1 41 | } 42 | }; 43 | ['debug', 'verbose', 'info', 'protocol', 'error', 'warn'].forEach((l) => { 44 | config[c].logger[l] = (...args) => { 45 | if (typeof args[0] !== 'string') console.log(args[0]); 46 | console.log(colors.bgCyan(colors.white(` ${c.toUpperCase().slice(0, 5)} `)) + args[0]); 47 | } 48 | }); 49 | }); 50 | 51 | // function will be overwritten by our test. 52 | // function is called when the 'queue' event is triggered on localhost 53 | let onLocalhostQueue = null; 54 | let failed = 0; 55 | 56 | // create "localhost" domain server 57 | new SMTPServer(config.localhost, (session) => { 58 | localhost = session; 59 | session.on('queue', onLocalhostQueue); 60 | session.on('rcpt', (req, res) => { 61 | // reject on the first try 62 | if (failed >= 1) { 63 | return res.accept(); 64 | } else { 65 | failed++; 66 | return res.reject(431, 'temporary error message'); 67 | } 68 | }); 69 | }).listen(2328); 70 | 71 | // create "remote" domain server 72 | new SMTPServer(config.remote, (session) => { 73 | remote = session 74 | }).listen(2329); 75 | 76 | describe('relay temporary failure test', function() { 77 | 78 | this.timeout('20000'); 79 | 80 | let client = net.connect({ host: 'localhost', port: 2329 }); 81 | 82 | it('should connect', (done) => { 83 | client.once('data', (res) => { 84 | res.toString().should.startWith('220'); 85 | done(); 86 | }); 87 | }); 88 | 89 | it('should relay mail to user@localhost and admin@localhost', (done) => { 90 | 91 | let message = 'Hello user@localhost, how are you?'; 92 | 93 | client.write('EHLO client\r\n'); 94 | client.write('AUTH PLAIN dXNlcgB1c2VyAHBhc3N3b3Jk\r\n'); 95 | client.write('MAIL FROM: user@remote\r\n'); 96 | client.write('RCPT TO: user@localhost\r\n'); 97 | client.write('RCPT TO: admin@localhost\r\n'); 98 | client.write('DATA\r\n'); 99 | client.write(message + '\r\n.\r\n'); 100 | 101 | let users = ['admin@localhost', 'user@localhost']; 102 | 103 | // hook to 'queue' listener on localhost 104 | onLocalhostQueue = (req, res) => { 105 | localhost.accepted.data.should.be.ok; 106 | localhost.envelope.from.should.equal('user@remote'); 107 | localhost.envelope.to[0].should.endWith('@localhost'); 108 | users.splice(users.indexOf(localhost.envelope.to[0]), 1); 109 | fs.readFileSync(req.command.data).toString().trim().should.endWith(message); 110 | res.accept(); 111 | 112 | // hook to 'queue' listener on localhost 113 | onLocalhostQueue = (req, res) => { 114 | localhost.accepted.data.should.be.ok; 115 | localhost.envelope.from.should.equal('user@remote'); 116 | localhost.envelope.to[0].should.equal(users[0]); 117 | fs.readFileSync(req.command.data).toString().trim().should.endWith(message); 118 | res.accept(); 119 | done(); 120 | } 121 | } 122 | 123 | }); 124 | 125 | }); 126 | 127 | }() 128 | -------------------------------------------------------------------------------- /test/5-api-send.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ABOUT THIS TEST: 3 | * ------------------------------- 4 | * This test will create two servers, localhost and remote 5 | * and then we will try to send an email from remote to 6 | * two recipients at localhost 7 | */ 8 | module.exports = function () { 9 | 10 | let should = require('should'); 11 | let SMTPServer = require('../src/smtp-server'); 12 | let net = require('net'); 13 | let tls = require('tls'); 14 | let fs = require('fs'); 15 | let path = require('path'); 16 | let os = require('os'); 17 | let colors = require('colors/safe'); 18 | 19 | // enable debug output 20 | let debug = true; 21 | let config = {}; 22 | 23 | // generate config 24 | ['localhost', 'remote'].forEach((c) => { 25 | config[c] = { 26 | domains: [c], 27 | logger: {}, 28 | throwOnError: true, 29 | relay: { 30 | queueDir: path.join(os.tmpdir(), `mail-io-queue-test-${c}-${Date.now()}`), 31 | smtpPort: c === 'remote' ? 2333 : 25, 32 | retryBaseInterval: 1 33 | } 34 | }; 35 | ['debug', 'verbose', 'info', 'protocol', 'error', 'warn'].forEach((l) => { 36 | config[c].logger[l] = (...args) => { 37 | if (typeof args[0] !== 'string') console.log(args[0]); 38 | console.log(colors.bgCyan(colors.white(` ${c.toUpperCase().slice(0, 5)} `)) + args[0]); 39 | } 40 | }); 41 | }); 42 | 43 | // function will be overwritten by our test. 44 | // function is called when the 'queue' event is triggered on localhost 45 | let onLocalhostQueue = null; 46 | let failed = 0; 47 | 48 | // create "localhost" domain server 49 | let localhost = new SMTPServer(config.localhost, (session) => { 50 | session.on('queue', onLocalhostQueue); 51 | }).listen(2333); 52 | 53 | // create "remote" domain server 54 | let remote = new SMTPServer(config.remote).listen(2332); 55 | 56 | describe('api send mail from remote to localhost', function () { 57 | 58 | this.timeout(10000); 59 | 60 | it('should send the mail via api', () => { 61 | 62 | return remote.sendMail({ 63 | from: 'user@remote', 64 | to: ['user@localhost', 'user2@localhost'], 65 | subject: 'Test', 66 | html: '

Hello from remote

' 67 | }); 68 | 69 | }); 70 | 71 | let cqueue = []; 72 | 73 | it('should receive two messages', (done) => { 74 | 75 | let received = []; 76 | onLocalhostQueue = function(req, res) { 77 | should(received.indexOf(req.session.envelope.to)).equal(-1); 78 | received = received.concat(req.session.envelope.to); 79 | should(req.session.envelope.from).equal('user@remote'); 80 | should(req.session.envelope.to.length).equal(1); 81 | res.accept(); 82 | if (received.length === 2) return done(); 83 | 84 | } 85 | 86 | }); 87 | 88 | it('should have no connections', (done) => { 89 | 90 | localhost.getConnections((err, count) => { 91 | should(count).equal(0); 92 | remote.getConnections((err, count) => { 93 | should(count).equal(0); 94 | done(); 95 | }); 96 | }); 97 | 98 | }); 99 | 100 | }); 101 | 102 | }() 103 | -------------------------------------------------------------------------------- /test/assets/gtube.msg: -------------------------------------------------------------------------------- 1 | From: Tester 2 | Content-Type: multipart/alternative; 3 | boundary="Apple-Mail=_872043AF-1DA2-4CAB-A04C-CC61CAD82D58" 4 | X-Smtp-Server: localhost:thomas 5 | Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X 6 | Message-Id: 7 | X-Universally-Unique-Identifier: 7B72F302-DA73-41D9-A34D-F03F3CC8DE17 8 | Date: Tue, 31 Mar 2015 15:37:27 +0200 9 | To: test@localhost 10 | Mime-Version: 1.0 (Mac OS X Mail 8.2 \(2070.6\)) 11 | 12 | 13 | --Apple-Mail=_872043AF-1DA2-4CAB-A04C-CC61CAD82D58 14 | Content-Transfer-Encoding: 7bit 15 | Content-Type: text/plain; 16 | charset=us-ascii 17 | 18 | XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X 19 | 20 | 21 | --Apple-Mail=_872043AF-1DA2-4CAB-A04C-CC61CAD82D58 22 | Content-Transfer-Encoding: 7bit 23 | Content-Type: text/html; 24 | charset=us-ascii 25 | 26 |
XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X

27 | --Apple-Mail=_872043AF-1DA2-4CAB-A04C-CC61CAD82D58-- 28 | --------------------------------------------------------------------------------