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 { /// /// 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\<version>\Common\MailSettings, /// value "NewSignature"), then load the matching .htm file from /// %APPDATA%\Microsoft\Signatures\<name>.htm ourselves. /// /// This is the only file in the solution that touches the Windows /// registry - deliberately kept out of BulkArchiveMailer.Core. /// public static class SignatureReader { /// /// 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. /// 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; } } /// Same lookup, but returns the resolved .htm path instead of its contents - useful for SettingsForm to show/prefill the path. 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(); 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; } /// /// 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; } } } }