fix: corretta visualizzazione di caratteri strani all'interno del testo delle email.

This commit is contained in:
Gabriele Cimaschi committed 2026-09-10 22:20:33 +02:00
1 parent c1dc86d6cd
commit 6944b02263
2 files changed
+173 -5

No files matched your search

+28 -5
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const pool = require('../db'); const pool = require('../db');
const { db, logAttivita } = require('../activityDb'); const { db, logAttivita } = require('../activityDb');
const { cleanAndDecodeText } = require('../utils/textDecoder');
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format // Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
function getLocalTimestamp() { 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({ res.json({
tickets: result.rows, tickets: result.rows,
total, total,
@@ -443,14 +450,17 @@ router.get('/:id', async (req, res) => {
[id] [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( const htmlAttachmentsRes = await pool.query(
`SELECT id, article_id, filename, content_type, content `SELECT id, article_id, filename, content_type, content
FROM article_data_mime_attachment FROM article_data_mime_attachment
WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1) WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1)
AND ( AND (
filename LIKE 'file-%' filename LIKE 'file-%'
OR filename LIKE '%.html'
OR filename LIKE '%.htm'
OR content_type LIKE '%html%' OR content_type LIKE '%html%'
OR content_type LIKE '%text%'
OR disposition = 'inline' OR disposition = 'inline'
) )
ORDER BY id ASC`, ORDER BY id ASC`,
@@ -522,10 +532,14 @@ router.get('/:id', async (req, res) => {
return text; 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 = {}; const htmlBodyMap = {};
for (const row of htmlAttachmentsRes.rows) { for (const row of htmlAttachmentsRes.rows) {
if (row.content) { 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)) { if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) {
const fname = (row.filename || '').toLowerCase(); const fname = (row.filename || '').toLowerCase();
if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) { 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 => { 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) { 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) { 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]) { if (htmlBodyMap[a.article_id]) {
+145
View File
@@ -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('<html') && !text.includes('<div') && !text.includes('<p>')) {
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
};