Files
redlineoandClaude Sonnet 5 cb3b273741 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
<meta charset> tag, then falls back to Encoding.Default (the actual
system code page) before ever assuming UTF-8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:45:37 +03:00

262 lines
9.5 KiB
C#

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;
namespace BulkArchiveMailer.AddIn
{
/// <summary>
/// Reads the user's default "new message" Outlook signature HTML from
/// disk. Outlook Interop has no API that exposes "the currently
/// selected default signature" - the only reliable way to get at it
/// is: look up its *name* in the registry
/// (HKCU\Software\Microsoft\Office\&lt;version&gt;\Common\MailSettings,
/// value "NewSignature"), then load the matching .htm file from
/// %APPDATA%\Microsoft\Signatures\&lt;name&gt;.htm ourselves.
///
/// This is the only file in the solution that touches the Windows
/// registry - deliberately kept out of BulkArchiveMailer.Core.
/// </summary>
public static class SignatureReader
{
/// <summary>
/// Best-effort auto-detection. Never throws - returns null if the
/// registry value, the version-specific key, or the .htm file is
/// missing for any reason, so the caller can fall back to letting
/// the operator pick a signature file manually instead of crashing
/// the add-in.
/// </summary>
public static string TryAutoDetectSignatureHtml(Outlook.Application application)
{
try
{
var signatureName = TryReadSignatureName(application);
if (string.IsNullOrEmpty(signatureName))
{
return null;
}
var path = ResolveSignaturePath(signatureName);
return File.Exists(path) ? ReadSignatureHtml(path) : null;
}
catch
{
return null;
}
}
/// <summary>Same lookup, but returns the resolved .htm path instead of its contents - useful for SettingsForm to show/prefill the path.</summary>
public static string TryAutoDetectSignaturePath(Outlook.Application application)
{
try
{
var signatureName = TryReadSignatureName(application);
if (string.IsNullOrEmpty(signatureName))
{
return null;
}
var path = ResolveSignaturePath(signatureName);
return File.Exists(path) ? path : null;
}
catch
{
return null;
}
}
public static string ResolveSignaturePath(string signatureName)
{
var signaturesFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Microsoft",
"Signatures");
return Path.Combine(signaturesFolder, signatureName + ".htm");
}
private static string TryReadSignatureName(Outlook.Application application)
{
var candidateVersions = new List<string>();
try
{
// application.Version looks like "16.0" and lines up
// exactly with the Office registry hive layout, so prefer
// it over the fixed fallback list below.
var version = application?.Version;
if (!string.IsNullOrEmpty(version))
{
candidateVersions.Add(version);
}
}
catch
{
// Interop call failed for some reason - fall through to
// the fixed list.
}
foreach (var fallback in new[] { "16.0", "15.0", "14.0" })
{
if (!candidateVersions.Contains(fallback))
{
candidateVersions.Add(fallback);
}
}
foreach (var version in candidateVersions)
{
using (var key = Registry.CurrentUser.OpenSubKey(
$@"Software\Microsoft\Office\{version}\Common\MailSettings"))
{
var value = key?.GetValue("NewSignature") as string;
if (!string.IsNullOrEmpty(value))
{
return value;
}
}
}
return null;
}
/// <summary>
/// Reads a signature .htm file with the encoding it was actually
/// written in, instead of assuming UTF-8 the way
/// <see cref="File.ReadAllText(string)"/> 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 &lt;meta charset&gt; 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. &lt;meta charset=...&gt; or &lt;meta http-equiv="Content-Type" content="...charset=..."&gt;
/// 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, <see cref="File.ReadAllText(string)"/>.
/// </summary>
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(
@"<meta\b[^>]*charset\s*=\s*[""']?\s*([A-Za-z0-9_\-]+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static Encoding TryDetectMetaCharsetEncoding(byte[] bytes)
{
try
{
// <meta charset=...> 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;
}
}
}
}