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 };