From cb3b2737418644ee23f6f6769fe1f952cdfcf342 Mon Sep 17 00:00:00 2001 From: redlineo Date: Fri, 24 Jul 2026 10:45:37 +0300 Subject: [PATCH] Fix mojibake in signature: detect real encoding instead of assuming UTF-8 Outlook writes signature .htm files in the system ANSI code page (windows-1251 on Russian Windows) without a BOM, but File.ReadAllText defaults to UTF-8 for BOM-less files - mangling Cyrillic into replacement characters. ReadSignatureHtml now checks BOM, then a tag, then falls back to Encoding.Default (the actual system code page) before ever assuming UTF-8. Co-Authored-By: Claude Sonnet 5 --- .../SignatureReader.cs | 140 +++++++++++++++++- .../BulkArchiveMailer.AddIn/UI/PreviewForm.cs | 2 +- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/SignatureReader.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/SignatureReader.cs index 1a5915f..6aa6adc 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/SignatureReader.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/SignatureReader.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; +using System.Text.RegularExpressions; using Microsoft.Win32; using Outlook = Microsoft.Office.Interop.Outlook; @@ -38,7 +40,7 @@ namespace BulkArchiveMailer.AddIn } var path = ResolveSignaturePath(signatureName); - return File.Exists(path) ? File.ReadAllText(path) : null; + return File.Exists(path) ? ReadSignatureHtml(path) : null; } catch { @@ -119,5 +121,141 @@ namespace BulkArchiveMailer.AddIn return null; } + + /// + /// Reads a signature .htm file with the encoding it was actually + /// written in, instead of assuming UTF-8 the way + /// does for BOM-less files. + /// + /// Outlook writes signature .htm files in the system ANSI code + /// page (windows-1251 on a Russian Windows) without a BOM and + /// usually without a reliable <meta charset> tag, so blindly + /// reading as UTF-8 mangles Cyrillic into replacement characters. + /// + /// Detection order: + /// 1. BOM (UTF-8 / UTF-16 LE / UTF-16 BE / UTF-32 LE / UTF-32 BE) - trusted if present. + /// 2. <meta charset=...> or <meta http-equiv="Content-Type" content="...charset=..."> + /// found in the first ~1KB, if the named encoding is recognized. + /// 3. Encoding.Default (the system ANSI code page) - matches + /// Outlook's legacy signature file format. + /// + /// Never throws: any failure along the way falls back to the + /// previous behavior, . + /// + internal static string ReadSignatureHtml(string path) + { + try + { + var bytes = File.ReadAllBytes(path); + + var bomEncoding = DetectBomEncoding(bytes, out var preambleLength); + if (bomEncoding != null) + { + return bomEncoding.GetString(bytes, preambleLength, bytes.Length - preambleLength); + } + + var metaEncoding = TryDetectMetaCharsetEncoding(bytes); + if (metaEncoding != null) + { + return metaEncoding.GetString(bytes, 0, bytes.Length); + } + + return Encoding.Default.GetString(bytes, 0, bytes.Length); + } + catch + { + try + { + return File.ReadAllText(path); + } + catch + { + return null; + } + } + } + + private static Encoding DetectBomEncoding(byte[] bytes, out int preambleLength) + { + // UTF-32 BOMs must be checked before UTF-16 ones: the UTF-32 LE + // BOM (FF FE 00 00) starts with the same two bytes as the + // UTF-16 LE BOM (FF FE). + if (bytes.Length >= 4 && bytes[0] == 0x00 && bytes[1] == 0x00 && bytes[2] == 0xFE && bytes[3] == 0xFF) + { + preambleLength = 4; + return new UTF32Encoding(bigEndian: true, byteOrderMark: true); + } + + if (bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0x00 && bytes[3] == 0x00) + { + preambleLength = 4; + return new UTF32Encoding(bigEndian: false, byteOrderMark: true); + } + + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + { + preambleLength = 3; + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) + { + preambleLength = 2; + return new UnicodeEncoding(bigEndian: true, byteOrderMark: true); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) + { + preambleLength = 2; + return new UnicodeEncoding(bigEndian: false, byteOrderMark: true); + } + + preambleLength = 0; + return null; + } + + private static readonly Regex MetaCharsetRegex = new Regex( + @"]*charset\s*=\s*[""']?\s*([A-Za-z0-9_\-]+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static Encoding TryDetectMetaCharsetEncoding(byte[] bytes) + { + try + { + // tags are always plain ASCII, so + // Latin1 (single byte, 1:1 with the raw bytes) is a safe, + // lossless way to look for them regardless of the file's + // real encoding. + var headLength = Math.Min(bytes.Length, 1024); + var head = Encoding.GetEncoding("ISO-8859-1").GetString(bytes, 0, headLength); + + var match = MetaCharsetRegex.Match(head); + if (!match.Success) + { + return null; + } + + var charsetName = match.Groups[1].Value.Trim(); + if (string.IsNullOrEmpty(charsetName)) + { + return null; + } + + try + { + return Encoding.GetEncoding(charsetName); + } + catch + { + // Unrecognized charset name - let the caller fall back + // to Encoding.Default instead. + return null; + } + } + catch + { + return null; + } + } } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs index a53af63..b115008 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs @@ -58,7 +58,7 @@ namespace BulkArchiveMailer.AddIn.UI { if (!string.IsNullOrWhiteSpace(settings.SignatureHtmlPath)) { - return File.Exists(settings.SignatureHtmlPath) ? File.ReadAllText(settings.SignatureHtmlPath) : null; + return File.Exists(settings.SignatureHtmlPath) ? SignatureReader.ReadSignatureHtml(settings.SignatureHtmlPath) : null; } return SignatureReader.TryAutoDetectSignatureHtml(outlookApplication);