Skip to content

Commit

Permalink
Implement smtp connection and handshake
Browse files Browse the repository at this point in the history
- create SMTPConnection class
- implement command sending and response processing
- add commands mechanism
- implement unsecure connection and handshake
- fallback to HELO if EHLO not supported
- support secure connections and connection upgrade
- add plain and login auth methods

PR-URL: #8
  • Loading branch information
srg-kostyrko committed Aug 16, 2022
1 parent daeba20 commit d39c932
Show file tree
Hide file tree
Showing 12 changed files with 496 additions and 19 deletions.
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@

## [Unreleased][unreleased]

- Implement smtp connection and handshake
- Organize response codes and errors
- Fallback to HELO if EHLO not supported
- Support secure connections and connection upgrade
- Add plain and login auth methods
- Add data sending
- Add error handling and connection close processing
- Add `run` command to ensure uninterrupted sequence of commands

## [0.0.0][] - 2022-05-07

- Library stucture
- Library structure

[unreleased]: https://github.com/metarhia/metamail/compare/v0.0.0...HEAD
[0.0.0]: https://github.com/metarhia/metamail/releases/tag/v0.0.0
29 changes: 29 additions & 0 deletions lib/async-lock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';

class Lock {
#active = false;
#queue = [];

enter() {
return new Promise((resolve) => {
const start = () => {
this.#active = true;
resolve();
};
if (!this.#active) {
start();
return;
}
this.#queue.push(start);
});
}

leave() {
if (!this.#active) return;
this.#active = false;
const next = this.#queue.pop();
if (next) next();
}
}

module.exports = { Lock };
29 changes: 29 additions & 0 deletions lib/smtp/smtp-auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';

const {
authPlain,
authLogin,
authLoginUser,
authLoginPassword,
} = require('./smtp-commands.js');

const authMethods = {
PLAIN: (connection, auth) =>
connection.send(authPlain(auth.user, auth.password)),
LOGIN: (connection, auth) =>
connection.sendSequence(async () => {
await connection.send(authLogin());
await connection.send(authLoginUser(auth.user));
await connection.send(authLoginPassword(auth.password));
}),
};

const smptLogin = (connection, auth, supportedMethods) => {
for (const methods of supportedMethods) {
const runner = authMethods[methods];
if (runner) return runner(connection, auth);
}
throw new Error('No supported auth methods');
};

module.exports = { smptLogin };
12 changes: 12 additions & 0 deletions lib/smtp/smtp-codes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

const SMTP_CODES = Object.freeze({
seviceReady: '220',
completed: '250',
authSuccessful: '235',
authContinue: '334',
waitingForInput: '354',
serviceNotAvaliable: '421',
});

module.exports = { SMTP_CODES };
84 changes: 84 additions & 0 deletions lib/smtp/smtp-commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use strict';

const { SMTP_CODES } = require('./smtp-codes.js');
const { processSMTPExtensions } = require('./smtp-utils.js');

const base64Decode = (text) => Buffer.from(text, 'utf-8').toString('base64');

const greet = () => ({
type: 'GREET',
text: '',
successCodes: [SMTP_CODES.seviceReady],
});

const ehlo = (name) => ({
type: 'EHLO',
text: `EHLO ${name}`,
successCodes: [SMTP_CODES.completed],
process: (payload) => processSMTPExtensions(payload.slice(1)),
});

const helo = (name) => ({
type: 'HELO',
text: `HELO ${name}`,
successCodes: [SMTP_CODES.completed],
});

const starttls = () => ({
type: 'STARTTLS',
text: 'STARTTLS',
successCodes: [SMTP_CODES.seviceReady],
});

const authPlain = (user, pass) => {
const credentials = `\u0000${user}\u0000${pass}`;
const auth = base64Decode(credentials);
return {
type: 'AUTH',
text: `AUTH PLAIN ${auth}`,
successCodes: [SMTP_CODES.authSuccessful],
};
};

const authLogin = () => ({
type: 'AUTH',
text: `AUTH LOGIN`,
successCodes: [SMTP_CODES.authContinue],
});

const authLoginUser = (user) => ({
type: 'AUTH',
text: base64Decode(user),
successCodes: [SMTP_CODES.authContinue],
});

const authLoginPassword = (password) => ({
type: 'AUTH',
text: base64Decode(password, 'utf-8'),
successCodes: [SMTP_CODES.authSuccessful],
});

const data = () => ({
type: 'DATA',
text: 'DATA',
successCodes: [SMTP_CODES.waitingForInput],
});

const stream = () => ({
type: 'STREAM',
text: '',
successCodes: [SMTP_CODES.completed],
});

module.exports = {
greet,
ehlo,
helo,
starttls,
data,
stream,
authPlain,
authLogin,
authLoginUser,
authLoginPassword,
};
135 changes: 135 additions & 0 deletions lib/smtp/smtp-connection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use strict';

const net = require('net');
const tls = require('tls');
const { EventEmitter } = require('events');
const { SMTPDataStream } = require('./smtp-data-stream.js');
const { parseSmtpResponse, defaultHostname } = require('./smtp-utils.js');
const commands = require('./smtp-commands.js');
const { Lock } = require('../async-lock.js');

const SMTP_DEFAULT_POST = 25;
const SMTP_SECURE_PORT = 465;

const { greet, ehlo, helo, starttls, data, stream } = commands;

class SMTPConnection extends EventEmitter {
#host;
#port = SMTP_DEFAULT_POST;
#name = defaultHostname;

#socket = null;
#active = false;
#smtpExtensions = {};

#connectionLock = new Lock();
#sequenceLock = new Lock();
#commandLock = new Lock();

constructor({ host, port, name }) {
super();
this.#host = host;
if (port) this.#port = port;
if (name) this.#name = name;
}

get supportedAuthMethods() {
return this.#smtpExtensions.AUTH || [];
}

async connect() {
await this.#connectionLock.enter();
try {
if (this.#active) return;
await this.sendSequence(async () => {
this.#configureSocket();
await this.send(greet());
await this.#handshake();
this.#active = true;
this.emit('active');
});
} finally {
this.#connectionLock.leave();
}
}

async #handshake() {
try {
this.#smtpExtensions = await this.send(ehlo(this.#name));
if (this.#smtpExtensions.STARTTLS && !this.#socket.encrypted) {
await this.send(starttls());
this.#upgradeConnection();
this.#smtpExtensions = await this.send(ehlo(this.#name));
}
} catch (err) {
if (err.isTerminating) throw err;
await this.send(helo(this.#name));
}
}

async send({ type, text, successCodes, process }) {
await this.#commandLock.enter();
if (text) this.#socket.write(Buffer.from(text + '\r\n', 'utf-8'));
try {
const res = await parseSmtpResponse(this.#socket, type, successCodes);
return process ? process(res) : res;
} finally {
this.#commandLock.leave();
}
}

async sendSequence(runner) {
await this.#sequenceLock.enter();
try {
return await runner();
} finally {
this.#sequenceLock.leave();
}
}

async sendData(sourceStream) {
return this.sendSequence(async () => {
await this.send(data());
const promise = this.send(stream());
const dataStream = new SMTPDataStream();
sourceStream.pipe(dataStream).pipe(this.#socket, { end: false });
return promise;
});
}

#configureSocket() {
const isSecurePort = this.#port === SMTP_SECURE_PORT;
const connector = isSecurePort ? tls : net;
this.#socket = connector.connect({
host: this.#host,
port: this.#port,
});
this.#socket.once('connect', () => this.#socket.setKeepAlive(true));
this.#socket.once('close', () => this.#reset());
}

#upgradeConnection() {
const plainSocket = this.#socket;
plainSocket.removeAllListeners();
this.#socket = tls.connect({
host: this.#host,
port: this.#port,
socket: plainSocket,
});
this.#socket.once('connect', () => this.#socket.setKeepAlive(true));
this.#socket.once('close', () => this.#reset());
plainSocket.resume();
}

#reset() {
if (!this.#active) return;
this.#active = false;
if (!this.#socket?.destroyed) {
this.#socket.destroy();
this.#socket = null;
}
this.emit('reset');
}
}

module.exports = { SMTPConnection };
54 changes: 54 additions & 0 deletions lib/smtp/smtp-data-stream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

const { Transform } = require('stream');

const DOT = 0x2e;
const LF = 0x0a;
const CR = 0x0d;

class SMTPDataStream extends Transform {
#lastByte = 0;

_transform(chunk, _encoding, done) {
if (!chunk || !chunk.length) {
done();
return;
}
if (typeof chunk === 'string') chunk = Buffer.from(chunk);
this.push(this.#prepareChunk(chunk));
this.#lastByte = chunk[chunk.length - 1];
done();
}

#prepareChunk(chunk) {
const chunks = [];
let lastPos = 0;
for (let i = 0; i < chunk.length; i++) {
const currentByte = chunk[i];
const prevByte = i ? chunk[i - 1] : this.#lastByte;
if (currentByte === DOT && prevByte !== LF) {
// escape dot on line start
chunks.push(chunk.slice(lastPos, i + 1));
chunks.push(Buffer.from('.'));
lastPos = i + 1;
} else if (currentByte === LF && prevByte !== CR) {
// make sure that only <CR><LF> sequences are used for linebreaks
chunks.push(chunk.slice(lastPos, i));
chunks.push(Buffer.from('\r\n'));
lastPos = i + 1;
}
}
if (!chunks.length) return chunk;
if (lastPos < chunk.length) chunks.push(chunk.slice(lastPos));
return Buffer.concat(chunks);
}

_flush(done) {
if (this.#lastByte === LF) this.push(Buffer.from('.\r\n'));
else if (this.#lastByte === CR) this.push(Buffer.from('\n.\r\n'));
else this.push(Buffer.from('\r\n.\r\n'));
done();
}
}

module.exports = { SMTPDataStream };

0 comments on commit d39c932

Please sign in to comment.