@carlgo11/smtp-server
Advanced tools
Comparing version 0.0.6 to 0.0.7
{ | ||
"name": "@carlgo11/smtp-server", | ||
"version": "0.0.6", | ||
"version": "0.0.7", | ||
"description": "Simple lightweight SMTP server written in Node.js", | ||
@@ -5,0 +5,0 @@ "main": "src/core/SMTPServer.js", |
@@ -11,6 +11,2 @@ import Response from '../models/Response.js'; | ||
export function clearCommands() { | ||
commandHandlers = {}; | ||
} | ||
export async function handleCommand(message, session) { | ||
@@ -21,3 +17,3 @@ const command = message.split(' ')[0].toUpperCase(); | ||
if (handler) { | ||
await handler(args, session) | ||
await handler(args, session); | ||
} else { | ||
@@ -24,0 +20,0 @@ session.unknownCommands += 1; |
@@ -26,2 +26,3 @@ import context from '../core/ServerContext.js'; | ||
cleanup(false); | ||
session.socket.end(); | ||
}, DATA_TIMEOUT); | ||
@@ -39,3 +40,3 @@ }; | ||
session.transitionTo( | ||
result ? session.states.DATA_DONE:session.states.RCPT_TO, | ||
result ? session.states.DATA_DONE : session.states.RCPT_TO | ||
); | ||
@@ -51,12 +52,15 @@ }; | ||
// Pass the message data to the consumer's onDATA handler | ||
context.onDATA(messageData, session).then((result) => { | ||
if (result instanceof Response) session.send(result); | ||
else session.send('Message accepted', [250, 2, 6, 0]); | ||
context | ||
.onDATA(messageData, session) | ||
.then((result) => { | ||
if (result instanceof Response) session.send(result); | ||
else session.send('Message accepted', [250, 2, 6, 0]); | ||
cleanup(true); | ||
}).catch((result) => { | ||
if (result instanceof Response) session.send(result); | ||
else session.send(`${result || 'Message rejected'}`, [550, 5, 1, 0]); | ||
cleanup(false); | ||
}); | ||
cleanup(true); | ||
}) | ||
.catch((result) => { | ||
if (result instanceof Response) session.send(result); | ||
else session.send(`${result || 'Message rejected'}`, [550, 5, 1, 0]); | ||
cleanup(false); | ||
}); | ||
}; | ||
@@ -71,3 +75,3 @@ | ||
resetTimeout(); | ||
dataBuffer += chunk.toString(); | ||
dataBuffer += chunk.toString(session.utf8 ? 'utf-8' : 'ascii'); | ||
@@ -77,4 +81,5 @@ // Check for maximum message size | ||
session.send( | ||
'Message size exceeds fixed maximum message size', | ||
[552, 5, 3, 4]); | ||
'Message size exceeds fixed maximum message size', | ||
[552, 5, 3, 4] | ||
); | ||
cleanup(false); | ||
@@ -81,0 +86,0 @@ return; |
@@ -7,3 +7,2 @@ import context from '../core/ServerContext.js'; | ||
export default function EHLO(args, session) { | ||
if (!session.isValidTransition(['NEW', 'STARTTLS'])) | ||
@@ -17,3 +16,3 @@ return session.send(new Response(null, 503, [5, 5, 1])); | ||
if(!isValidEHLO(domain)) | ||
if (!isValidEHLO(domain)) | ||
return session.send(new Response(null, 501, [5, 5, 2])); | ||
@@ -25,11 +24,12 @@ | ||
context.onEHLO(domain, session).then((result) => { | ||
if (result instanceof Response) | ||
return session.send(result); | ||
if (result instanceof Response) return session.send(result); | ||
session.ehlo = domain; | ||
session.send(`250-${session.greeting} Hello, ${domain}`); | ||
const { extensions } = context; | ||
if (session.tls) { | ||
session.send('250-ENHANCEDSTATUSCODES'); | ||
session.send('250-PIPELINING'); | ||
extensions.forEach((extension) => | ||
session.send(`250-${extension.toUpperCase()}`), | ||
); | ||
session.send(`SIZE ${context.maxMessageSize}`, 250); | ||
@@ -40,5 +40,7 @@ } else { | ||
} | ||
}).catch((err) => session.send(err instanceof Response ? | ||
err: | ||
new Response(null, 451, [4, 3, 0]))); | ||
}).catch((err) => | ||
session.send( | ||
err instanceof Response ? err:new Response(null, 451, [4, 3, 0]), | ||
), | ||
); | ||
} |
@@ -7,8 +7,10 @@ import context from '../core/ServerContext.js'; | ||
if (session.state !== session.states.STARTTLS) | ||
return session.send(session.tls ? | ||
new Response(null, 501, [5, 5, 1]): | ||
new Response('Must issue a STARTTLS command first.', 530, [5, 7, 0])); | ||
return session.send( | ||
session.tls | ||
? new Response(null, 501, [5, 5, 1]) | ||
: new Response('Must issue a STARTTLS command first.', 530, [5, 7, 0]) | ||
); | ||
// Validate command is MAIL FROM: | ||
if (args.length !== 1 || !args[0].toUpperCase().startsWith('FROM:')) | ||
if (!args[0].toUpperCase().startsWith('FROM:')) | ||
return session.send(new Response(null, 501, [5, 5, 2])); | ||
@@ -28,18 +30,25 @@ | ||
const [_, ...extensions] = args; | ||
// Wait on external validation | ||
context.onMAILFROM(sender, session).then(result => { | ||
context | ||
.onMAILFROM(sender, session, extensions) | ||
.then((result) => { | ||
// Save the sender's address in the session | ||
session.mailFrom = sender; | ||
// Save the sender's address in the session | ||
session.mailFrom = sender; | ||
// Transition to MAIL_FROM state | ||
session.transitionTo(session.states.MAIL_FROM); | ||
// Transition to MAIL_FROM state | ||
session.transitionTo(session.states.MAIL_FROM); | ||
session.send(result instanceof Response ? | ||
result: | ||
new Response(`Originator <${sender}> ok`, 250, [2, 1, 0])); | ||
}).catch(err => session.send(err instanceof Response ? | ||
err: | ||
new Response(null, 451, [4, 3, 0])), | ||
); | ||
session.send( | ||
result instanceof Response | ||
? result | ||
: new Response(`Originator <${sender}> ok`, 250, [2, 1, 0]) | ||
); | ||
}) | ||
.catch((err) => | ||
session.send( | ||
err instanceof Response ? err : new Response(null, 451, [4, 3, 0]) | ||
) | ||
); | ||
} |
@@ -6,2 +6,2 @@ import Response from '../models/Response.js'; | ||
session.socket.end(); | ||
} | ||
} |
@@ -25,16 +25,23 @@ import context from '../core/ServerContext.js'; | ||
return context.onRCPTTO(recipient, session).then((result) => { | ||
// Save the recipient's address in the session | ||
session.rcptTo.push(recipient); | ||
return context | ||
.onRCPTTO(recipient, session) | ||
.then((result) => { | ||
// Save the recipient's address in the session | ||
session.rcptTo.push(recipient); | ||
// Transition to RCPT_TO state | ||
session.transitionTo(session.states.RCPT_TO); | ||
// Transition to RCPT_TO state | ||
session.transitionTo(session.states.RCPT_TO); | ||
// Send positive response | ||
session.send(result instanceof Response ? | ||
result: | ||
new Response(`Recipient <${recipient}> ok`, 250, [2, 1, 5])); | ||
}).catch(err => session.send(err instanceof Response ? | ||
err: | ||
new Response(null, 451, [4, 1, 1]))); | ||
// Send positive response | ||
session.send( | ||
result instanceof Response | ||
? result | ||
: new Response(`Recipient <${recipient}> ok`, 250, [2, 1, 5]) | ||
); | ||
}) | ||
.catch((err) => | ||
session.send( | ||
err instanceof Response ? err : new Response(null, 451, [4, 1, 1]) | ||
) | ||
); | ||
} |
@@ -1,2 +0,2 @@ | ||
import {handleTLSConnection} from '../core/TLSServer.js'; | ||
import { handleTLSConnection } from '../core/TLSServer.js'; | ||
import Logger from '../utils/Logger.js'; | ||
@@ -3,0 +3,0 @@ import Response from '../models/Response.js'; |
@@ -1,3 +0,3 @@ | ||
import {EventEmitter} from 'events'; | ||
import { EventEmitter } from 'events'; | ||
export default new EventEmitter(); | ||
export default new EventEmitter(); |
@@ -1,2 +0,2 @@ | ||
import {hostname} from 'os'; | ||
import { hostname } from 'os'; | ||
@@ -12,2 +12,3 @@ class ServerContext { | ||
this.timeout = 60 * 1000; | ||
this.extensions = ['ENHANCEDSTATUSCODES', 'PIPELINING']; | ||
// Default hooks | ||
@@ -25,4 +26,4 @@ this.onConnect = async () => {}; | ||
setOptions(options = {}) { | ||
Object.assign(this, options) | ||
} | ||
Object.assign(this, options); | ||
} | ||
} | ||
@@ -29,0 +30,0 @@ |
@@ -6,3 +6,3 @@ import net from 'net'; | ||
import reverseDNS from '../utils/reverseDNS.js'; | ||
import {handleCommand, registerCommand} from '../commands/CommandHandler.js'; | ||
import { handleCommand, registerCommand } from '../commands/CommandHandler.js'; | ||
import context from './ServerContext.js'; | ||
@@ -40,4 +40,4 @@ import events from './Event.js'; | ||
session.rDNS = await reverseDNS(session.clientIP); | ||
Logger.setLevel(context.logLevel) | ||
const rDNS = `<${session.rDNS}>` | ||
Logger.setLevel(context.logLevel); | ||
const rDNS = `<${session.rDNS}>`; | ||
Logger.info(`${session.clientIP} connected ${rDNS}`, session.id); | ||
@@ -58,4 +58,3 @@ | ||
session.send(new Response('Line too long', 500, [5, 5, 2])); | ||
else | ||
handleCommand(message, session); | ||
else handleCommand(message, session); | ||
}); | ||
@@ -70,4 +69,6 @@ | ||
socket.on('error', (err) => { | ||
Logger.error(`Error occurred with ${session.clientIP}: ${err.message}`, | ||
session.id); | ||
Logger.error( | ||
`Error occurred with ${session.clientIP}: ${err.message}`, | ||
session.id | ||
); | ||
activeSessions.delete(session); | ||
@@ -80,5 +81,4 @@ }); | ||
Logger.info('Server is shutting down...'); | ||
server.close(() => { | ||
Logger.info('Server closed, no longer accepting connections.'); | ||
}); | ||
server.close( | ||
() => Logger.info('Server closed, no longer accepting connections.')); | ||
@@ -101,2 +101,2 @@ // Gracefully close all active sessions | ||
export const Listen = events; | ||
export const Log = Logger; | ||
export const Log = Logger; |
@@ -5,7 +5,7 @@ import tls from 'tls'; | ||
import events from './Event.js'; | ||
import {handleCommand} from '../commands/CommandHandler.js'; | ||
import { handleCommand } from '../commands/CommandHandler.js'; | ||
export function handleTLSConnection(session) { | ||
// Create a new TLS socket from the existing socket | ||
const {tlsOptions} = context; | ||
const { tlsOptions } = context; | ||
@@ -44,4 +44,4 @@ const tlsSocket = new tls.TLSSocket(session.socket, { | ||
async function processCommandQueue() { | ||
if (processing) return; // Exit if another command is being processed | ||
processing = true; // Mark processing state | ||
if (processing) return; // Exit if another command is being processed | ||
processing = true; // Mark processing state | ||
@@ -52,4 +52,3 @@ while (commandQueue.length > 0) { | ||
// Skip empty lines (possible with consecutive CRLF) | ||
if (command.length === 0) | ||
continue; | ||
if (command.length === 0) continue; | ||
@@ -93,6 +92,9 @@ Logger.debug(`C: ${command}`, session.id); | ||
case 'ERR_SSL_UNSUPPORTED_PROTOCOL': | ||
Logger.warn(`No shared TLS versions`, session.id); | ||
Logger.warn( | ||
`No shared TLS versions (Client wants ${tlsSocket.getProtocol()})`, | ||
session.id | ||
); | ||
break; | ||
case 'ERR_SSL_NO_SHARED_CIPHER': | ||
Logger.warn(`No shared TLS ciphers`, session.id); | ||
Logger.warn('No shared TLS ciphers.', session.id); | ||
break; | ||
@@ -106,19 +108,18 @@ default: | ||
tlsSocket.on('secure', () => { | ||
// Replace the plain socket with the secure TLS socket | ||
session.socket = tlsSocket; | ||
const protocol = tlsSocket.getProtocol(); | ||
const cipher = tlsSocket.getCipher().standardName; | ||
// Set TLS connection data | ||
session.tls = { | ||
enabled: true, | ||
version: tlsSocket.getProtocol(), // Get the TLS protocol version | ||
cipher: tlsSocket.getCipher().standardName, // Get the cipher info (now it will be defined) | ||
authorized: tlsSocket.getPeerCertificate() || false, // Check if the connection is authorized | ||
version: protocol, // Get the TLS protocol version | ||
cipher, // Get the cipher info (now it will be defined) | ||
}; | ||
Logger.info( | ||
`Connection upgraded to ${tlsSocket.getProtocol()} (${tlsSocket.getCipher().standardName})`, | ||
session.id); | ||
Logger.info(`Connection upgraded to ${protocol} (${cipher})`, session.id); | ||
events.emit('SECURE'); | ||
context.onSecure(session).then(r => r); | ||
context.onSecure(session).then((r) => r); | ||
}); | ||
@@ -125,0 +126,0 @@ |
@@ -32,9 +32,13 @@ const statuses = { | ||
const eStatus = this.enhancedStatus.join('.'); | ||
return `${this.basicStatus}${eStatusCodes ? ` ${eStatus}`: ''} ${this.message}`; | ||
return `${this.basicStatus}${eStatusCodes ? | ||
` ${eStatus}`: | ||
''} ${this.message}`; | ||
} | ||
fetchMessage(enhancedStatus) { | ||
return statuses[enhancedStatus.join('.')] || | ||
statuses[enhancedStatus.slice(1).join('.')]; | ||
return ( | ||
statuses[enhancedStatus.join('.')] || | ||
statuses[enhancedStatus.slice(1).join('.')] | ||
); | ||
} | ||
} | ||
} |
@@ -9,8 +9,9 @@ import os from 'os'; | ||
this.socket = socket; | ||
this.clientIP = socket.remoteAddress.startsWith('::ffff:') ? | ||
socket.remoteAddress.slice(7): | ||
socket.remoteAddress; | ||
this.clientIP = socket.remoteAddress.startsWith('::ffff:') | ||
? socket.remoteAddress.slice(7) | ||
: socket.remoteAddress; | ||
this.greeting = os.hostname(); | ||
this.id = crypto.randomBytes(8).toString('hex'); | ||
this.rDNS = null; | ||
this.utf8 = false; | ||
this.ehlo = null; | ||
@@ -24,10 +25,10 @@ this.unknownCommands = 0; | ||
this.states = { | ||
NEW: 'NEW', // Just connected | ||
NEW: 'NEW', // Just connected | ||
EHLO_RECEIVED: 'EHLO_RECEIVED', // EHLO completed | ||
STARTTLS: 'STARTTLS', // STARTTLS completed | ||
MAIL_FROM: 'MAIL_FROM', // MAIL FROM received | ||
RCPT_TO: 'RCPT_TO', // RCPT TO received | ||
DATA_READY: 'DATA_READY', // Data received | ||
DATA_DONE: 'DATA_DONE', // Data received | ||
QUIT: 'QUIT', // Client has quit | ||
STARTTLS: 'STARTTLS', // STARTTLS completed | ||
MAIL_FROM: 'MAIL_FROM', // MAIL FROM received | ||
RCPT_TO: 'RCPT_TO', // RCPT TO received | ||
DATA_READY: 'DATA_READY', // Data received | ||
DATA_DONE: 'DATA_DONE', // Data received | ||
QUIT: 'QUIT', // Client has quit | ||
}; | ||
@@ -41,22 +42,19 @@ | ||
* @param {String|Error|Response} message | ||
* @param code {Number|Array} | ||
* @param code {Number|Array|undefined} | ||
*/ | ||
send(message, code = undefined) { | ||
let output = ''; | ||
if (message instanceof Response) { | ||
if (message instanceof Response) | ||
output = message.toString(this.tls); | ||
} else if (message instanceof Error) { | ||
else if (message instanceof Error) | ||
output = `${message.responseCode} ${message.message}`; | ||
} else if (code === undefined) { | ||
else if (code === undefined) | ||
output = message; | ||
} else if (code instanceof Array) { | ||
const basic = code.shift(); | ||
const enhanced = code.join('.'); | ||
output = `${basic} ${enhanced} ${message}`; | ||
} else if (Number.isInteger(code)) { | ||
else if (code instanceof Array) | ||
output = `${code.shift()} ${code.join('.')} ${message}`; | ||
else if (Number.isInteger(code)) | ||
output = `${code} ${message}`; | ||
} | ||
Logger.debug(`S: ${output}`, this.id); | ||
this.socket.write(`${output}\r\n`); | ||
} | ||
@@ -77,5 +75,4 @@ | ||
return false; | ||
} else | ||
return this.state === expectedState; | ||
} else return this.state === expectedState; | ||
} | ||
} |
@@ -7,3 +7,3 @@ import context from '../core/ServerContext.js'; | ||
WARN: 'WARN', | ||
ERROR: 'ERROR' | ||
ERROR: 'ERROR', | ||
}; | ||
@@ -13,6 +13,6 @@ | ||
DEBUG: '\x1b[36m', // Cyan | ||
INFO: '\x1b[32m', // Green | ||
WARN: '\x1b[33m', // Yellow | ||
INFO: '\x1b[32m', // Green | ||
WARN: '\x1b[33m', // Yellow | ||
ERROR: '\x1b[31m', // Red | ||
RESET: '\x1b[0m' // Reset color | ||
RESET: '\x1b[0m', // Reset color | ||
}; | ||
@@ -43,3 +43,2 @@ | ||
return `${colors[level]}[${level}]${colors.RESET} ${' '.repeat(5 - level.length)}[${time}]${session}${message}`; | ||
@@ -76,3 +75,3 @@ } | ||
[levels.WARN]: 3, | ||
[levels.ERROR]: 4 | ||
[levels.ERROR]: 4, | ||
}; | ||
@@ -79,0 +78,0 @@ |
@@ -16,2 +16,2 @@ import dns from 'node:dns/promises'; | ||
} | ||
} | ||
} |
@@ -13,3 +13,3 @@ import { isIPv4, isIPv6 } from 'net'; // Node.js built-in module for IP validation | ||
const ip = ehloValue.slice(1, -1); // Remove the brackets | ||
return isIPv4(ip) || isIPv6(ip); // Check if it's a valid IPv4 or IPv6 | ||
return isIPv4(ip) || isIPv6(ip); // Check if it's a valid IPv4 or IPv6 | ||
} | ||
@@ -24,3 +24,6 @@ | ||
// Ensure no label starts or ends with a hyphen and no label is longer than 63 characters | ||
return labels.every(label => !label.startsWith('-') && !label.endsWith('-') && label.length <= 63); | ||
return labels.every( | ||
(label) => | ||
!label.startsWith('-') && !label.endsWith('-') && label.length <= 63 | ||
); | ||
} | ||
@@ -27,0 +30,0 @@ |
69894
20
715