From 6944b02263fcfc227f1a66b66f445c9bdc6d3562 Mon Sep 17 00:00:00 2001 From: Gabriele Cimaschi Date: Thu, 10 Sep 2026 22:20:33 +0200 Subject: [PATCH] fix: corretta visualizzazione di caratteri strani all'interno del testo delle email. --- routes/tickets.js | 33 ++++++++-- utils/textDecoder.js | 145 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 utils/textDecoder.js diff --git a/routes/tickets.js b/routes/tickets.js index eb21b29..d9dbc50 100644 --- a/routes/tickets.js +++ b/routes/tickets.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const pool = require('../db'); const { db, logAttivita } = require('../activityDb'); +const { cleanAndDecodeText } = require('../utils/textDecoder'); // Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format function getLocalTimestamp() { @@ -292,6 +293,12 @@ router.get('/', async (req, res) => { } } + result.rows.forEach(t => { + if (t.title) t.title = cleanAndDecodeText(t.title); + if (t.customer_first) t.customer_first = cleanAndDecodeText(t.customer_first); + if (t.customer_last) t.customer_last = cleanAndDecodeText(t.customer_last); + }); + res.json({ tickets: result.rows, total, @@ -443,14 +450,17 @@ router.get('/:id', async (req, res) => { [id] ); - // Fetch HTML body attachment content for articles if present (e.g. file-1, file-1.html, text/html) + // Fetch HTML body attachment content for articles if present (e.g. file-1, file-2, text/html, html attachments) const htmlAttachmentsRes = await pool.query( `SELECT id, article_id, filename, content_type, content FROM article_data_mime_attachment WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1) AND ( filename LIKE 'file-%' + OR filename LIKE '%.html' + OR filename LIKE '%.htm' OR content_type LIKE '%html%' + OR content_type LIKE '%text%' OR disposition = 'inline' ) ORDER BY id ASC`, @@ -522,10 +532,14 @@ router.get('/:id', async (req, res) => { return text; }; + if (ticket.title) ticket.title = cleanAndDecodeText(ticket.title); + if (ticket.customer_first) ticket.customer_first = cleanAndDecodeText(ticket.customer_first); + if (ticket.customer_last) ticket.customer_last = cleanAndDecodeText(ticket.customer_last); + const htmlBodyMap = {}; for (const row of htmlAttachmentsRes.rows) { if (row.content) { - const contentStr = decodeTextContent(row.content, row.content_type); + const contentStr = cleanAndDecodeText(row.content, row.content_type); if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) { const fname = (row.filename || '').toLowerCase(); if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) { @@ -536,12 +550,21 @@ router.get('/:id', async (req, res) => { } articlesResult.rows.forEach(a => { - // Ensure a.a_body and subject are properly decoded if legacy encoded + // Decode article subjects, bodies, and sender/recipient headers if (a.a_body) { - a.a_body = decodeTextContent(a.a_body, a.a_content_type); + a.a_body = cleanAndDecodeText(a.a_body, a.a_content_type); } if (a.a_subject) { - a.a_subject = decodeTextContent(a.a_subject, 'text/plain; charset=utf-8'); + a.a_subject = cleanAndDecodeText(a.a_subject); + } + if (a.a_from) { + a.a_from = cleanAndDecodeText(a.a_from); + } + if (a.a_to) { + a.a_to = cleanAndDecodeText(a.a_to); + } + if (a.a_cc) { + a.a_cc = cleanAndDecodeText(a.a_cc); } if (htmlBodyMap[a.article_id]) { diff --git a/utils/textDecoder.js b/utils/textDecoder.js new file mode 100644 index 0000000..3d30359 --- /dev/null +++ b/utils/textDecoder.js @@ -0,0 +1,145 @@ +const iconv = require('iconv-lite'); + +/** + * Decodes MIME encoded-words (e.g. =?UTF-8?B?...?= or =?ISO-8859-1?Q?...?=) + */ +function decodeMimeWords(str) { + if (!str || typeof str !== 'string') return str || ''; + if (!str.includes('=?')) return str; + + return str.replace(/=\?([^?]+)\?([BQbq])\?([^?]+)\?=/g, (fullMatch, charset, encoding, data) => { + try { + const cs = charset.toLowerCase(); + const enc = encoding.toUpperCase(); + + let buf; + if (enc === 'B') { + buf = Buffer.from(data, 'base64'); + } else if (enc === 'Q') { + const qDec = data.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + buf = Buffer.from(qDec, 'binary'); + } + + if (buf) { + if (iconv.encodingExists(cs)) { + return iconv.decode(buf, cs); + } else { + return buf.toString('utf-8'); + } + } + } catch (_) { + return fullMatch; + } + return fullMatch; + }); +} + +/** + * Decodes raw Quoted-Printable strings (e.g. =E0=E8=E9=F9 or =C3=A8) + */ +function decodeQuotedPrintable(str, charset = 'utf-8') { + if (!str || typeof str !== 'string') return str || ''; + if (!/=(?:[0-9A-Fa-f]{2}|\r?\n)/.test(str)) return str; + + try { + const cleaned = str.replace(/=\r?\n/g, ''); + const bytes = []; + for (let i = 0; i < cleaned.length; i++) { + if (cleaned[i] === '=' && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substring(i + 1, i + 3))) { + bytes.push(parseInt(cleaned.substring(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(cleaned.charCodeAt(i)); + } + } + const buf = Buffer.from(bytes); + if (iconv.encodingExists(charset)) { + return iconv.decode(buf, charset); + } + return buf.toString('utf-8'); + } catch (_) { + return str; + } +} + +/** + * Main text decoder & Mojibake repair utility + */ +function cleanAndDecodeText(rawInput, contentTypeHint = '') { + if (!rawInput && rawInput !== 0) return ''; + + let text = ''; + + // 1. Buffer / Base64 / String normalization + if (Buffer.isBuffer(rawInput)) { + let charset = 'utf-8'; + const match = (contentTypeHint || '').match(/charset=["']?([^"';\s]+)/i); + if (match && match[1]) charset = match[1].toLowerCase(); + + try { + if (iconv.encodingExists(charset)) { + text = iconv.decode(rawInput, charset); + } else { + text = rawInput.toString('utf-8'); + } + } catch (_) { + text = rawInput.toString('utf-8'); + } + } else if (typeof rawInput === 'string') { + const cleaned = rawInput.replace(/\s+/g, ''); + if (cleaned.length > 20 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) { + try { + const buf = Buffer.from(cleaned, 'base64'); + const utfStr = buf.toString('utf-8'); + // Only accept base64 decode if result doesn't look like corrupted binary noise + if (utfStr && !utfStr.includes('\uFFFD')) { + text = utfStr; + } else { + text = rawInput; + } + } catch (_) { + text = rawInput; + } + } else { + text = rawInput; + } + } else { + return String(rawInput); + } + + // 2. Decode MIME words (e.g. =?UTF-8?B?...?= or =?ISO-8859-1?Q?...?=) + if (text.includes('=?')) { + text = decodeMimeWords(text); + } + + // 3. Decode Quoted-Printable if present (and not inside HTML tags) + if (/=(?:[0-9A-Fa-f]{2})/.test(text) && !text.includes('')) { + text = decodeQuotedPrintable(text); + } + + // 4. Fix Mojibake / Double-UTF-8 / Win1252 / ISO-8859-1 mis-decoding + if (/(?:Ã[\s\xa0¡-ÿ]|â€[™œ]|–|Â[°©®])/.test(text)) { + try { + const reEncoded = iconv.encode(text, 'win1252'); + const repaired = iconv.decode(reEncoded, 'utf-8'); + if (repaired && !repaired.includes('\uFFFD')) { + text = repaired; + } + } catch (_) {} + } + + // 6. Clean stray Mojibake/control characters like ý, ÿ, or U+FFFD () replacement symbols + if (/[ýÿ\uFFFD]/.test(text)) { + text = text.replace(/ý{1,}/g, '') + .replace(/ÿ{1,}/g, '') + .replace(/\uFFFD{1,}/g, ''); + } + + return text; +} + +module.exports = { + cleanAndDecodeText, + decodeMimeWords, + decodeQuotedPrintable +};