diff --git a/src/haraka-plugins/queue/store_message.js b/src/haraka-plugins/queue/store_message.js index 1076161..56d808c 100644 --- a/src/haraka-plugins/queue/store_message.js +++ b/src/haraka-plugins/queue/store_message.js @@ -3,43 +3,57 @@ exports.register = function () { const MessageService = require("../../services/MessageService"); const path = require('path'); const DailyStats = require(path.join(__dirname, '../../../db/models/DailyStats')); + const MimeDecoder = require(path.join( + __dirname, + "../../../utils/mimeDecoder" + )); - plugin.store_message = async function (next, connection) { - const transaction = connection.transaction; - if (!transaction) return next(); + plugin.store_message = async function (next, connection) { + const transaction = connection.transaction; + if (!transaction) return next(); - try { - // Get the body content by processing the message stream buffers - let body = ''; - - if (transaction.message_stream && transaction.message_stream._queue) { - // Convert the buffer to string - const fullMessage = Buffer.from(transaction.message_stream._queue[0]).toString('utf8'); - - // Split on double newline to separate headers and body - const parts = fullMessage.split('\r\n\r\n'); - if (parts.length > 1) { - // Get everything after the headers - body = parts.slice(1).join('\r\n\r\n').trim(); + try { + // Get the body content by processing the message stream buffers + let body = ""; + + if (transaction.message_stream && transaction.message_stream._queue) { + // Convert the buffer to string + const fullMessage = Buffer.from( + transaction.message_stream._queue[0] + ).toString("utf8"); + + // Split on double newline to separate headers and body + const parts = fullMessage.split("\r\n\r\n"); + if (parts.length > 1) { + // Get everything after the headers + body = parts.slice(1).join("\r\n\r\n").trim(); + } } + + const rawSubject = transaction.header + ? transaction.header.get("subject") + : ""; + const decodedSubject = MimeDecoder.decode(rawSubject); + + const messageData = { + from: transaction.mail_from.address(), + to: transaction.rcpt_to.map((addr) => addr.address()).join(", "), + subject: decodedSubject, + body: body, + headers: transaction.header ? transaction.header.headers_decoded : {}, + }; + + await MessageService.store(messageData); + await DailyStats.incrementMessageCount(); + next(); + } catch (error) { + connection.logerror( + plugin, + `Failed to store message: ${error.message}` + ); + next(); } - - const messageData = { - from: transaction.mail_from.address(), - to: transaction.rcpt_to.map((addr) => addr.address()).join(", "), - subject: transaction.header ? transaction.header.get('subject') : '', - body: body, - headers: transaction.header ? transaction.header.headers_decoded : {}, - }; - - await MessageService.store(messageData); - await DailyStats.incrementMessageCount(); - next(); - } catch (error) { - connection.logerror(plugin, `Failed to store message: ${error.message}`); - next(); - } - }; + }; plugin.register_hook('data_post', 'store_message'); }; \ No newline at end of file diff --git a/src/utils/mimeDecoder.js b/src/utils/mimeDecoder.js new file mode 100644 index 0000000..f26e93e --- /dev/null +++ b/src/utils/mimeDecoder.js @@ -0,0 +1,87 @@ +// mimeDecoder.js + +/** + * Decodes MIME encoded-word format strings commonly used in email headers + * Handles both Base64 and Quoted-Printable encoding methods + * Format: =?charset?encoding?encoded-text?= + */ +class MimeDecoder { + /** + * Decodes a complete MIME encoded-word string + * @param {string} str - The MIME encoded string + * @returns {string} - The decoded string + */ + static decode(str) { + if (!str) return ""; + + // Remove any newlines that might be present + str = str.replace(/\n/g, ""); + + // Regular expression to match MIME encoded-word format + const mimeRegex = /=\?([^?]+)\?([BQbq])\?([^?]*)\?=/g; + + return str.replace(mimeRegex, (match, charset, encoding, text) => { + try { + if (encoding.toUpperCase() === "B") { + // Handle Base64 encoding + const buffer = Buffer.from(text, "base64"); + return buffer.toString(this.normalizeCharset(charset)); + } else if (encoding.toUpperCase() === "Q") { + // Handle Quoted-Printable encoding + return this.decodeQuotedPrintable(text, charset); + } + return text; + } catch (error) { + console.error("Error decoding MIME string:", error); + return match; // Return original string if decoding fails + } + }); + } + + /** + * Normalizes charset names to Node.js compatible charset encodings + * @param {string} charset - The charset name from the MIME string + * @returns {string} - Normalized charset name + */ + static normalizeCharset(charset) { + const charsetMap = { + utf8: "utf8", + "utf-8": "utf8", + "iso-8859-1": "latin1", + "iso8859-1": "latin1", + "windows-1252": "latin1", + }; + + return charsetMap[charset.toLowerCase()] || charset; + } + + /** + * Decodes Quoted-Printable encoded text + * @param {string} text - The Quoted-Printable encoded text + * @param {string} charset - The character set of the encoded text + * @returns {string} - Decoded text + */ + static decodeQuotedPrintable(text, charset) { + // Replace underscore with space (per QP spec) + text = text.replace(/_/g, " "); + + // Replace hex encoded characters + text = text.replace(/=([0-9A-F]{2})/gi, (match, hex) => { + try { + return Buffer.from(hex, "hex").toString(this.normalizeCharset(charset)); + } catch (error) { + return match; + } + }); + + return text; + } +} + +// Example usage: +/* +const decodedSubject = MimeDecoder.decode('=?utf-8?B?TUVHQS1FbWFpbGJlc3TDpHRpZ3VuZyBlcmZvcmRlcmxpY2g=?='); +console.log(decodedSubject); // Outputs: MEGA-Emailbestätigung erforderlich +*/ + +module.exports = MimeDecoder;