spamassassin-client
Version:
SpamAssassin client, that lets you check if an email is spam or ham.
560 lines (448 loc) • 13.9 kB
JavaScript
/*
* spamassassin-client
*
* Copyright 2024, Valerian Saliou
* Author: Valerian Saliou <valerian@valeriansaliou.name>
*/
"use strict";
var net = require("net");
/**
* SpamAssassinClient
* @class
* @classdesc Instanciates a new SpamAssassin client.
* @param {object} options
*/
var SpamAssassinClient = function(options) {
// Sanitize options
options = (options || {});
if (typeof options !== "object") {
throw new Error("Invalid options");
}
if (options.host !== undefined && typeof options.host !== "string") {
throw new Error("Invalid options.host");
}
if (options.port !== undefined && typeof options.port !== "number") {
throw new Error("Invalid options.port");
}
if (options.timeout !== undefined && typeof options.timeout !== "number") {
throw new Error("Invalid options.timeout");
}
// Environment
var hostDefault = "127.0.0.1";
var portDefault = 783;
var timeoutDefault = 10;
var secondsInMilliseconds = 1000;
// Globals
this.__protocolVersion = 1.5;
this.__protocolLineFeed = "\r\n";
this.__bytesLineFeed = Buffer.byteLength(this.__protocolLineFeed);
this.__bufferNewLine = "\n".charCodeAt(0);
this.__bufferCarriage = "\r".charCodeAt(0);
this.__bufferSpace = " ".charCodeAt(0);
this.__regexLineHeader = (
/^SPAMD\/([0-9\.\-]+)\s([0-9]+)\s([0-9A-Z_]+)/
);
this.__regexLineSpam = (
/^Spam:\s(True|False)\s;\s(-?[0-9\.]+)\s\/\s(?:-?[0-9\.]+)/
);
this.__regexLineAttribute = (
/^([0-9A-Za-z-]+):\s/
);
// Storage space
this.__options = {
server : {
host : (options.host || hostDefault),
port : (options.port || portDefault)
},
connection : {
timeout : (
(options.timeout || timeoutDefault) * secondsInMilliseconds
)
}
};
};
/**
* SpamAssassinClient.prototype.check
* @public
* @param {Buffer|string} message
* @return {object} Promise object
*/
SpamAssassinClient.prototype.check = function(message) {
return this.__execute("check", message);
};
/**
* SpamAssassinClient.prototype.symbols
* @public
* @param {Buffer|string} message
* @return {object} Promise object
*/
SpamAssassinClient.prototype.symbols = function(message) {
return this.__execute("symbols", message);
};
/**
* SpamAssassinClient.prototype.report
* @public
* @param {Buffer|string} message
* @return {object} Promise object
*/
SpamAssassinClient.prototype.report = function(message) {
return this.__execute("report", message);
};
/**
* SpamAssassinClient.prototype.ping
* @public
* @return {object} Promise object
*/
SpamAssassinClient.prototype.ping = function() {
return this.__execute("ping");
};
/**
* SpamAssassinClient.prototype.__execute
* @private
* @param {string} command
* @param {Buffer|string} message
* @return {object} Promise object
*/
SpamAssassinClient.prototype.__execute = function(command, message) {
var self = this;
return this.__command(command, message)
.then(function(lines) {
return self.__extract(command, lines);
});
};
/**
* SpamAssassinClient.prototype.__command
* @private
* @param {string} command
* @param {Buffer|string} message
* @return {object} Promise object
*/
SpamAssassinClient.prototype.__command = function(command, message) {
var self = this;
return new Promise(function(resolve, reject) {
// This is an implementation of The SpamAssassin Network Protocol (V1.5)
// Reference: \
// https://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL
// Initialize local variables
var responseData = [],
linesBuffer = "",
fullfilled = false;
// Setup local client
var client = new net.Socket();
client.setTimeout(self.__options.connection.timeout);
// Construct all local event handler functions
// Notice: this is done for traceability reasons, as we would prefer \
// those functions to be named when debugging stack traces.
var fnHandleConnect = function() {
// Emit command header and message
client.write(
command.toUpperCase() + " SPAMC/" + self.__protocolVersion +
self.__protocolLineFeed
);
if (message !== undefined) {
// Message is a string? Convert to a Buffer
if (typeof message === "string") {
message = Buffer.from(message);
}
// Trim leading/trailing new lines from Buffer (if any)
message = self.__trimBuffer(message);
// Calculate content length
// Important: also include the trailing line feed byte size. Failure \
// to do so will result in a content length mismatch!
var contentLength = (
Buffer.byteLength(message) + self.__bytesLineFeed
);
// Write headers
client.write(
"Content-length: " + contentLength + self.__protocolLineFeed
);
client.write(self.__protocolLineFeed);
// Write message (as a native Buffer)
client.write(message);
client.write(self.__protocolLineFeed);
} else {
// Close command flow (required if there is no message)
client.write(self.__protocolLineFeed);
}
};
var fnHandleTimeout = function() {
if (fullfilled === false) {
fullfilled = true;
reject(
new Error("Connection timed out")
);
}
};
var fnHandleError = function(error) {
if (fullfilled === false) {
fullfilled = true;
// Drain buffer and terminate it
linesBuffer = self.__drainLinesBuffer(linesBuffer, responseData, true);
// Got some response data? Treat as success; we may have enough data
// Notice: only handle if we received more than response headers.
if (responseData.length > 1) {
resolve(responseData);
} else {
reject(
new Error("Got error from server: " + error.toString())
);
}
}
};
var fnHandleData = function(data) {
// Append current data chunk
linesBuffer += data.toString();
// Drain buffer
linesBuffer = self.__drainLinesBuffer(linesBuffer, responseData);
};
var fnHandleClose = function() {
if (fullfilled === false) {
fullfilled = true;
// Drain buffer and terminate it
linesBuffer = self.__drainLinesBuffer(linesBuffer, responseData, true);
resolve(responseData);
}
};
// Connect to SpamAssassin server
client.connect({
port : self.__options.server.port,
host : self.__options.server.host
});
// Bind all event listeners
client.on("connect", fnHandleConnect);
client.on("timeout", fnHandleTimeout);
client.on("error", fnHandleError);
client.on("data", fnHandleData);
client.on("close", fnHandleClose);
});
};
/**
* SpamAssassinClient.prototype.__extract
* @private
* @param {string} command
* @param {object} lines
* @return {object} Promise object
*/
SpamAssassinClient.prototype.__extract = function(command, lines) {
// Parse header (initialize result)
var result = this.__extractHeader(lines);
// Extract command result? (extractors might throw if server answers \
// unhandled data)
var fnExtractor = this["__extractCommand_$" + command];
if (typeof fnExtractor === "function") {
fnExtractor.bind(this)(result, lines);
}
return Promise.resolve(result);
};
/**
* SpamAssassinClient.prototype.__extractHeader
* @private
* @param {object} lines
* @return {object} Result data
*/
SpamAssassinClient.prototype.__extractHeader = function(lines) {
var result = {};
// Extract protocol header
var line = (lines[0] || ""),
match = line.match(this.__regexLineHeader);
if (!match) {
throw new Error(
"(header) Unrecognized protocol:\n\n" + this.__traceLines(lines)
);
}
result.code = parseInt(match[2], 10);
result.message = (match[3] || "");
// Assert that header is valid
if (isNaN(result.code) === true) {
throw new Error(
"(header) Invalid response code:\n\n" + this.__traceLines(lines)
);
}
return result;
};
/**
* SpamAssassinClient.prototype.__extractCommand_$check
* @private
* @param {object} result
* @param {object} lines
* @return {undefined}
*/
SpamAssassinClient.prototype.__extractCommand_$check = function(result, lines) {
// Extract protocol check response
// Notice: start at the second line, since the first line (ie. the header) \
// has already been extracted.
for (var i = 1; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith("Spam:") === true) {
var match = line.match(this.__regexLineSpam);
if (match) {
result.spam = ((match[1] === "True") ? true : false);
result.score = parseFloat(match[2]);
// Stop there (match found)
break;
}
}
}
// Assert that response is valid
if (result.spam === undefined || result.score === undefined ||
isNaN(result.score) === true) {
throw new Error(
"($check) Got invalid spam score or status:\n\n" +
this.__traceLines(lines)
);
}
};
/**
* SpamAssassinClient.prototype.__extractCommand_$symbols
* @private
* @param {object} result
* @param {object} lines
* @return {undefined}
*/
SpamAssassinClient.prototype.__extractCommand_$symbols = function(
result, lines
) {
// Extract spam header (same format as check command, therefore we use an \
// alias here)
this.__extractCommand_$check(result, lines);
// Extract symbols from content lines
var contentLines = this.__listContentLines(lines);
// Parse symbols
result.symbols = [];
for (var i = 0; i < contentLines.length; i++) {
var contentLineSymbols = contentLines[i].split(",");
for (var j = 0; j < contentLineSymbols.length; j++) {
var contentLineSymbol = (contentLineSymbols[j] || "").trim();
if (contentLineSymbol) {
result.symbols.push(contentLineSymbol);
}
}
}
};
/**
* SpamAssassinClient.prototype.__extractCommand_$report
* @private
* @param {object} result
* @param {object} lines
* @return {undefined}
*/
SpamAssassinClient.prototype.__extractCommand_$report = function(
result, lines
) {
// Extract spam header (same format as check command, therefore we use an \
// alias here)
this.__extractCommand_$check(result, lines);
// Extract symbols from content lines
var contentLines = this.__listContentLines(lines);
// Aggregate report
result.report = contentLines.join("\n");
};
/**
* SpamAssassinClient.prototype.__listContentLines
* @private
* @param {object} lines
* @return {object} Content lines
*/
SpamAssassinClient.prototype.__listContentLines = function(lines) {
// Acquire content lines
// Notice: start at the second line, since the first line (ie. the header) \
// has already been extracted.
var contentStartIndex = -1;
for (var i = 1; i < lines.length; i++) {
var line = lines[i];
if (this.__regexLineAttribute.test(line) === false) {
contentStartIndex = i;
break;
}
}
if (contentStartIndex > -1) {
return lines.slice(contentStartIndex);
}
return [];
};
/**
* SpamAssassinClient.prototype.__traceLines
* @private
* @param {object} lines
* @return {string} Lines trace
*/
SpamAssassinClient.prototype.__traceLines = function(lines) {
return lines.join(this.__protocolLineFeed);
};
/**
* SpamAssassinClient.prototype.__drainLinesBuffer
* @private
* @param {string} buffer
* @param {object} lines
* @param {boolean} [terminate]
* @return {string} Remainder buffer
*/
SpamAssassinClient.prototype.__drainLinesBuffer = function(
buffer, lines, terminate
) {
// Apply defaults
terminate = (terminate || false);
// Acquire buffer lines
var bufferLines = buffer.split(this.__protocolLineFeed);
// The last buffer line might be unterminated (do not retain it)
var lastBufferLine = (bufferLines.pop() || "");
// Append terminated buffer lines to final lines
for (var i = 0; i < bufferLines.length; i++) {
var bufferLineClean = bufferLines[i].trim();
if (bufferLineClean) {
lines.push(bufferLineClean);
}
}
// Terminate buffer? (append last buffer line)
if (terminate === true) {
var lastBufferLineClean = lastBufferLine.trim();
if (lastBufferLineClean) {
lines.push(lastBufferLineClean);
}
return "";
}
// Return remaining buffer
return lastBufferLine;
};
/**
* SpamAssassinClient.prototype.__isWhitespaceByte
* @private
* @param {number} byte
* @return {boolean} Whether byte is whitespace or not
*/
SpamAssassinClient.prototype.__isWhitespaceByte = function(byte) {
return (
(byte === this.__bufferNewLine || byte === this.__bufferCarriage ||
byte === this.__bufferSpace) ? true : false
);
};
/**
* SpamAssassinClient.prototype.__trimBuffer
* @private
* @param {Buffer} buffer
* @return {Buffer} Trimmed buffer
*/
SpamAssassinClient.prototype.__trimBuffer = function(buffer) {
var trimStart = 0,
trimEnd = buffer.length;
// Find the leftmost non-whitespace byte index
for (var i = 0; i <= buffer.length; i++) {
if (this.__isWhitespaceByte(buffer[i]) !== true) {
trimStart = i;
break;
}
}
// Find the rightmost non-whitespace byte index
for (var j = (buffer.length - 1); j >= 0; j--) {
if (this.__isWhitespaceByte(buffer[j]) !== true) {
trimEnd = (j + 1);
break;
}
}
// Any bytes to trim from the buffer?
if (trimStart > 0 || trimEnd < buffer.length) {
return buffer.slice(trimStart, trimEnd);
}
return buffer;
};
exports.SpamAssassinClient = SpamAssassinClient;