├── .npmignore ├── bin └── smtp.js ├── .gitignore ├── .travis.yml ├── parser.js ├── example ├── server.js └── index.js ├── test ├── index.js └── test.js ├── server ├── index.js └── connection.js ├── package.json ├── LICENSE ├── README.md └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | 2 | *.log 3 | 4 | node_modules/ -------------------------------------------------------------------------------- /bin/smtp.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.log 3 | yarn.lock 4 | 5 | node_modules/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" -------------------------------------------------------------------------------- /parser.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = fn => { 3 | let buffer = '', parts; 4 | return chunk => { 5 | buffer += chunk; 6 | parts = buffer.split('\r\n'); 7 | buffer = parts.pop(); 8 | parts.forEach(fn); 9 | } 10 | }; -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | const smtp = require('..'); 2 | 3 | const server = smtp.createServer(); 4 | 5 | server.on('client', client => { 6 | console.log('Client connected'); 7 | client.on('QUIT', () => { 8 | console.log('Client disconnect'); 9 | }); 10 | }); 11 | 12 | server.on('message', message => { 13 | console.log('Incoming Message:', message); 14 | }); 15 | 16 | 17 | server.listen(2525); -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | const smtp = require('..'); 2 | 3 | smtp.send({ 4 | from: 'mail@lsong.org', 5 | to: 'hi@lsong.org', 6 | subject: 'hello world', 7 | body: 'This is a test message, do not reply.' 8 | }, { 9 | // tls: true, 10 | // port: 2525, 11 | // host: 'localhost' 12 | }).then(res => { 13 | console.log(res); 14 | }, err => { 15 | console.error('smtp send error:', err); 16 | }); 17 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const smtp = require('..'); 2 | const test = require('./test'); 3 | const assert = require('assert'); 4 | 5 | test('smtp#send', async () => { 6 | const [ res ] = await smtp.send({ 7 | from: 'mail@lsong.org', 8 | to: 'hi@lsong.org', 9 | subject: 'hello world', 10 | body: 'This is a test message, do not reply.' 11 | }); 12 | assert.equal(res.code, 221, res.msg); 13 | }); -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const tcp = require('net'); 2 | const Connection = require('./connection'); 3 | /** 4 | * SMTP Server 5 | * @docs https://tools.ietf.org/html/rfc5321 6 | */ 7 | class SMTPServer extends tcp.Server { 8 | constructor(options) { 9 | super(options, socket => { 10 | const client = new Connection(socket); 11 | this.emit('client', client); 12 | }); 13 | this.name = 'Mail Server'; 14 | this.on('client', client => { 15 | client.response(220, this.name); 16 | client.on('message', message => { 17 | this.emit('message', message, client); 18 | }); 19 | }); 20 | return Object.assign(this, options); 21 | } 22 | } 23 | 24 | module.exports = SMTPServer; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smtp2", 3 | "version": "0.0.5", 4 | "description": "simple smtp client and server for node.js", 5 | "main": "index.js", 6 | "directories": { 7 | "example": "example" 8 | }, 9 | "scripts": { 10 | "start": "node server.js", 11 | "test": "node test" 12 | }, 13 | "bin": { 14 | "smtp2": "./bin/smtp.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/song940/node-smtp.git" 19 | }, 20 | "keywords": [ 21 | "mail", 22 | "smtp", 23 | "smtp-server" 24 | ], 25 | "author": "lsong", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/song940/node-smtp/issues" 29 | }, 30 | "homepage": "https://github.com/song940/node-smtp#readme", 31 | "dependencies": { 32 | "mime2": "latest" 33 | }, 34 | "devDependencies": {} 35 | } 36 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const { inspect } = require('util'); 2 | const { AssertionError } = require('assert'); 3 | /** 4 | * super tiny testing framework 5 | * 6 | * @author Liu song 7 | * @github https://github.com/song940 8 | */ 9 | const test = async (title, fn) => { 10 | try { 11 | await fn(); 12 | console.log(color(` ✔ ${title}`, 32)); 13 | } catch (err) { 14 | console.error(color(` ✘ ${title}`, 31)); 15 | console.log(); 16 | if (err instanceof AssertionError) { 17 | console.log(color(` ${err.message}`, 31)); 18 | console.error(color(` expected: ${inspect(err.expected)}`, 32)); 19 | console.error(color(` actual: ${inspect(err.actual)}`, 31)); 20 | } else { 21 | console.log(color(` ${err.stack}`, 31)); 22 | } 23 | console.log(); 24 | } 25 | }; 26 | 27 | function color(str, c) { 28 | return "\x1b[" + c + "m" + str + "\x1b[0m"; 29 | }; 30 | 31 | module.exports = test; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 lsong 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## smtp2 2 | 3 | > simple smtp client and server for node.js 4 | 5 | [![smtp2](https://img.shields.io/npm/v/smtp2.svg)](https://npmjs.org/smtp2) 6 | [![Build Status](https://travis-ci.org/song940/smtp2.svg?branch=master)](https://travis-ci.org/song940/smtp2) 7 | 8 | ### Installation 9 | 10 | ```bash 11 | $ npm install smtp2 12 | ``` 13 | 14 | ### Example 15 | 16 | smtp client: 17 | 18 | ```js 19 | smtp.send({ 20 | from: 'mail@lsong.org', 21 | to: 'hi@lsong.org', 22 | subject: 'hello world', 23 | body: 'This is a test message, do not reply.' 24 | }).then(res => console.log(res)); 25 | ``` 26 | 27 | smtp server: 28 | 29 | ```js 30 | const smtp = require('smtp2'); 31 | 32 | const server = smtp.createServer(); 33 | 34 | server.on('client', client => { 35 | console.log('Client connected'); 36 | client.on('QUIT', () => { 37 | console.log('Client disconnect'); 38 | }); 39 | }); 40 | 41 | server.on('message', message => { 42 | console.log('Incoming Message:', message); 43 | }); 44 | 45 | server.listen(2525); 46 | ``` 47 | 48 | ### Contributing 49 | - Fork this Repo first 50 | - Clone your Repo 51 | - Install dependencies by `$ npm install` 52 | - Checkout a feature branch 53 | - Feel free to add your features 54 | - Make sure your features are fully tested 55 | - Publish your local branch, Open a pull request 56 | - Enjoy hacking <3 57 | 58 | ### MIT 59 | 60 | This work is licensed under the [MIT license](./LICENSE). 61 | 62 | --- -------------------------------------------------------------------------------- /server/connection.js: -------------------------------------------------------------------------------- 1 | const Message = require('mime2'); 2 | const EventEmitter = require('events'); 3 | const createParser = require('../parser'); 4 | 5 | const createReader = done => { 6 | let buffer = ''; 7 | const e = (c, dot) => 8 | `${c}`.split(Message.CRLF).some(x => x === dot) 9 | return chunk => { 10 | buffer += chunk; 11 | e(buffer, '.') && done(buffer); 12 | } 13 | } 14 | 15 | class Connection extends EventEmitter { 16 | constructor(socket) { 17 | super(); 18 | this.socket = socket; 19 | this.socket.on('error', err => { 20 | this.emit('error', err); 21 | }); 22 | this.parser = createParser(line => { 23 | const m = line.match(/^(\S+)(?:\s+(.*))?$/); 24 | if (!m) return console.warn('Invalid message', line); 25 | const cmd = m[1].toUpperCase(); 26 | const parameter = m[2]; 27 | // console.log(cmd, parameter); 28 | this.exec(cmd, parameter); 29 | this.emit(cmd, parameter); 30 | this.emit('command', cmd, parameter); 31 | }); 32 | this.message = {}; 33 | this.socket.on('data', this.parser); 34 | } 35 | write(buffer) { 36 | this.socket.write(buffer); 37 | return this; 38 | } 39 | response(code, message) { 40 | return this.write(`${code} ${message}\r\n`); 41 | } 42 | close() { 43 | this.socket.end(); 44 | return this; 45 | } 46 | exec(cmd, parameter) { 47 | switch (cmd) { 48 | case 'HELO': 49 | case 'EHLO': 50 | this.message = {}; 51 | this.message.hostname = parameter; 52 | this.response(250, 'OK'); 53 | break; 54 | case 'MAIL': 55 | const i = parameter.indexOf(':'); 56 | this.message.from = Message.parseAddress( 57 | parameter.substr(i + 1)); 58 | this.response(250, 'OK'); 59 | break; 60 | case 'RCPT': 61 | const j = parameter.indexOf(':'); 62 | (this.message['recipients'] || 63 | (this.message['recipients'] = [])).push( 64 | Message.parseAddress(parameter.substr(j + 1))); 65 | this.response(250, 'OK'); 66 | break; 67 | case 'DATA': 68 | const reader = createReader(content => { 69 | // console.log(content); 70 | this.socket.on('data', this.parser); 71 | this.socket.removeListener('data', reader); 72 | const size = Buffer.byteLength(content); 73 | this.response(250, `OK ${size} bytes received`); 74 | this.message.content = Message.parse(content); 75 | this.emit('message', this.message); 76 | }); 77 | this.socket.on('data', reader); 78 | this.socket.removeListener('data', this.parser); 79 | this.response(354, 'start input end with . (dot)'); 80 | break; 81 | case 'QUIT': 82 | this.response(221, 'Bye'); 83 | this.close(); 84 | break; 85 | default: 86 | break; 87 | } 88 | } 89 | } 90 | 91 | module.exports = Connection; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const dns = require('dns'); 2 | const net = require('net'); 3 | const tls = require('tls'); 4 | const util = require('util'); 5 | const assert = require('assert'); 6 | const Message = require('mime2'); 7 | const EventEmitter = require('events'); 8 | const createParser = require('./parser'); 9 | 10 | const debug = util.debuglog('smtp2'); 11 | 12 | const createReader = fn => { 13 | const r1 = /^(\d+)-(.+)$/; 14 | const r2 = /^(\d+)\s(.+)$/; 15 | let results = []; 16 | return createParser(line => { 17 | if (r1.test(line)) { 18 | const m = line.match(r1); 19 | results.push(m[2]); 20 | } 21 | if (r2.test(line)) { 22 | const m = line.match(r2); 23 | results.push(m[2]); 24 | fn(m[1], results); 25 | results = []; 26 | } 27 | }); 28 | } 29 | 30 | class SMTP extends EventEmitter { 31 | constructor(options) { 32 | super(); 33 | Object.assign(this, { 34 | port: 25 35 | }, options); 36 | } 37 | resolve(domain) { 38 | const { host } = this; 39 | if (host) return Promise.resolve([host]); 40 | const resolveMx = util.promisify(dns.resolveMx); 41 | return resolveMx(domain) 42 | .catch(() => []) 43 | .then(records => { 44 | debug('MX records:', records); 45 | return records 46 | .sort((a, b) => a.priority - b.priority) 47 | .map(mx => mx.exchange) 48 | .concat([domain]); 49 | }); 50 | } 51 | open(host, port) { 52 | port = port || this.port; 53 | if (~host.indexOf(':')) { 54 | [host, port] = host.split(':'); 55 | } 56 | return new Promise((resolve, reject) => { 57 | const tcp = this.tls ? tls : net; 58 | const socket = tcp.connect(port, host, () => resolve(socket)); 59 | socket.once('error', reject); 60 | }); 61 | } 62 | connect(domain) { 63 | const tryConnect = async hosts => { 64 | for (const host of hosts) { 65 | try { 66 | const socket = await this.open(host); 67 | debug(`MX connection created: ${host}`); 68 | return socket; 69 | } catch (e) { 70 | debug(`Error on connectMx for: ${host}`, e); 71 | } 72 | } 73 | throw new Error('can not connect to any SMTP server'); 74 | }; 75 | return this 76 | .resolve(domain) 77 | .then(tryConnect); 78 | } 79 | post(host, from, recipients, body) { 80 | const expect = (code, res) => { 81 | assert.equal(res.code, code, res.msg.join('\r\n')); 82 | }; 83 | function* process(sock) { 84 | let res = yield; 85 | expect(220, res); 86 | if (/ESMTP/.test(res.msg[0])) { 87 | res = yield `EHLO ${from.host}`; 88 | } else { 89 | res = yield `HELO ${from.host}`; 90 | } 91 | expect(250, res); 92 | res = yield `MAIL FROM: <${from.address}>`; 93 | expect(250, res); 94 | for (const rcpt of recipients) { 95 | res = yield `RCPT TO: <${rcpt.address}>`; 96 | expect(250, res); 97 | } 98 | res = yield 'DATA'; 99 | expect(354, res); 100 | debug('send:==>\n' + body); 101 | sock.write(`${body}\r\n\r\n`); 102 | res = yield '.'; 103 | expect(250, res); 104 | res = yield 'QUIT'; 105 | expect(221, res); 106 | return res; 107 | } 108 | return this 109 | .connect(host) 110 | .then(socket => new Promise((resolve, reject) => { 111 | const gen = process(socket); 112 | gen.next(); 113 | const reader = createReader((code, msg) => { 114 | debug('->', code, msg); 115 | const { done, value } = gen.next({ code, msg }); 116 | if (done) return resolve(value); 117 | if (value) { 118 | debug('send:', value); 119 | socket.write(`${value}\r\n`); 120 | } 121 | }); 122 | socket.on('error', reject); 123 | socket.on('data', reader); 124 | })); 125 | } 126 | send(headers, body) { 127 | const recipients = []; 128 | const message = new Message(headers, body); 129 | if (message.to) recipients.push(message.to); 130 | if (message.cc) recipients.push(message.cc); 131 | if (message.bcc) recipients.push(message.bcc); 132 | const groupByHost = recipients.reduce((groupByHost, addr) => { 133 | (groupByHost[addr.host] || 134 | (groupByHost[addr.host] = [])).push(addr); 135 | return groupByHost; 136 | }, {}); 137 | return Promise.all(Object.keys(groupByHost).map(domain => 138 | this.post(domain, message.from, groupByHost[domain], message.toString()))); 139 | } 140 | } 141 | 142 | SMTP.send = ({ body, ...headers }, options) => { 143 | const smtp = new SMTP(options); 144 | return smtp.send(headers, body); 145 | }; 146 | 147 | SMTP.Server = require('./server'); 148 | SMTP.createServer = (options, handler) => { 149 | return new SMTP.Server(options, handler); 150 | }; 151 | 152 | module.exports = SMTP; 153 | --------------------------------------------------------------------------------