-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement smtp connection and handshake
- 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
1 parent
daeba20
commit d39c932
Showing
12 changed files
with
496 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
Oops, something went wrong.