Author SHA1 Message Date
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
redlineoandClaude Sonnet 5 f04de700d9 Add Settings button to the bulk sender window
The ribbon button only opened SettingsForm while the archive folder
was unconfigured - once saved once, there was no way back into
settings (domain, templates, sender account, signature path) short of
breaking the saved folder path. Added a "Настройки..." button to
PreviewForm that reopens SettingsForm and rescans with the reloaded
settings afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:35:56 +03:00
redlineoandClaude Sonnet 5 e40d971de0 Fix crash opening bulk sender window (rows null during InitializeComponent)
Real stack trace from the operator's machine pinned it down: WinForms
fires DataGridView.CellValueChanged as a side effect of
DataGridViewColumn.HeaderText assignments inside InitializeComponent(),
i.e. before PreviewForm's constructor has had a chance to run RunScan()
and populate the `rows` field. UpdateSummaryLabel() then hit
rows.Count(...) on a null source. Nothing to do with Outlook/COM at
all - a pure WinForms initialization-order gotcha.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:26:29 +03:00
3 changed files with 201 additions and 11 deletions

No files matched your search

@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Win32; using Microsoft.Win32;
using Outlook = Microsoft.Office.Interop.Outlook; using Outlook = Microsoft.Office.Interop.Outlook;
@@ -38,7 +40,7 @@ namespace BulkArchiveMailer.AddIn
} }
var path = ResolveSignaturePath(signatureName); var path = ResolveSignaturePath(signatureName);
return File.Exists(path) ? File.ReadAllText(path) : null; return File.Exists(path) ? ReadSignatureHtml(path) : null;
} }
catch catch
{ {
@@ -119,5 +121,141 @@ namespace BulkArchiveMailer.AddIn
return null; 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;
}
}
} }
} }
@@ -30,6 +30,7 @@ namespace BulkArchiveMailer.AddIn.UI
private System.Windows.Forms.Button btnCheckAllReady; private System.Windows.Forms.Button btnCheckAllReady;
private System.Windows.Forms.Button btnUncheckAll; private System.Windows.Forms.Button btnUncheckAll;
private System.Windows.Forms.Button btnRescan; private System.Windows.Forms.Button btnRescan;
private System.Windows.Forms.Button btnSettings;
private System.Windows.Forms.ProgressBar progressSend; private System.Windows.Forms.ProgressBar progressSend;
private System.Windows.Forms.Label lblProgressStatus; private System.Windows.Forms.Label lblProgressStatus;
@@ -56,6 +57,7 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnCheckAllReady = new System.Windows.Forms.Button(); this.btnCheckAllReady = new System.Windows.Forms.Button();
this.btnUncheckAll = new System.Windows.Forms.Button(); this.btnUncheckAll = new System.Windows.Forms.Button();
this.btnRescan = new System.Windows.Forms.Button(); this.btnRescan = new System.Windows.Forms.Button();
this.btnSettings = new System.Windows.Forms.Button();
this.progressSend = new System.Windows.Forms.ProgressBar(); this.progressSend = new System.Windows.Forms.ProgressBar();
this.lblProgressStatus = new System.Windows.Forms.Label(); this.lblProgressStatus = new System.Windows.Forms.Label();
@@ -70,7 +72,7 @@ namespace BulkArchiveMailer.AddIn.UI
// lblPasswordReminder // lblPasswordReminder
// //
this.lblPasswordReminder.Location = new System.Drawing.Point(12, 10); this.lblPasswordReminder.Location = new System.Drawing.Point(12, 10);
this.lblPasswordReminder.Size = new System.Drawing.Size(900, 20); this.lblPasswordReminder.Size = new System.Drawing.Size(1018, 20);
this.lblPasswordReminder.Text = "Напоминание: пароль от архивов сообщается получателям отдельно (вручную, не через это письмо и не через этот инструмент)."; this.lblPasswordReminder.Text = "Напоминание: пароль от архивов сообщается получателям отдельно (вручную, не через это письмо и не через этот инструмент).";
this.lblPasswordReminder.ForeColor = System.Drawing.Color.DarkRed; this.lblPasswordReminder.ForeColor = System.Drawing.Color.DarkRed;
this.lblPasswordReminder.Font = new System.Drawing.Font(this.lblPasswordReminder.Font, System.Drawing.FontStyle.Bold); this.lblPasswordReminder.Font = new System.Drawing.Font(this.lblPasswordReminder.Font, System.Drawing.FontStyle.Bold);
@@ -80,7 +82,7 @@ namespace BulkArchiveMailer.AddIn.UI
// lblSignatureWarning // lblSignatureWarning
// //
this.lblSignatureWarning.Location = new System.Drawing.Point(12, 32); this.lblSignatureWarning.Location = new System.Drawing.Point(12, 32);
this.lblSignatureWarning.Size = new System.Drawing.Size(900, 20); this.lblSignatureWarning.Size = new System.Drawing.Size(1018, 20);
this.lblSignatureWarning.Text = "Подпись не найдена — письма будут отправлены без подписи. Настройте .htm-файл вручную в Настройках."; this.lblSignatureWarning.Text = "Подпись не найдена — письма будут отправлены без подписи. Настройте .htm-файл вручную в Настройках.";
this.lblSignatureWarning.ForeColor = System.Drawing.Color.DarkOrange; this.lblSignatureWarning.ForeColor = System.Drawing.Color.DarkOrange;
this.lblSignatureWarning.Visible = false; this.lblSignatureWarning.Visible = false;
@@ -90,7 +92,7 @@ namespace BulkArchiveMailer.AddIn.UI
// grid // grid
// //
this.grid.Location = new System.Drawing.Point(12, 58); this.grid.Location = new System.Drawing.Point(12, 58);
this.grid.Size = new System.Drawing.Size(900, 420); this.grid.Size = new System.Drawing.Size(1018, 420);
this.grid.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) this.grid.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right))); | System.Windows.Forms.AnchorStyles.Right)));
@@ -215,24 +217,34 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnUncheckAll.UseVisualStyleBackColor = true; this.btnUncheckAll.UseVisualStyleBackColor = true;
this.btnUncheckAll.Click += new System.EventHandler(this.btnUncheckAll_Click); this.btnUncheckAll.Click += new System.EventHandler(this.btnUncheckAll_Click);
//
// btnSettings
//
this.btnSettings.Location = new System.Drawing.Point(416, 540);
this.btnSettings.Size = new System.Drawing.Size(110, 27);
this.btnSettings.Text = "Настройки...";
this.btnSettings.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.btnSettings.UseVisualStyleBackColor = true;
this.btnSettings.Click += new System.EventHandler(this.btnSettings_Click);
// //
// progressSend // progressSend
// //
this.progressSend.Location = new System.Drawing.Point(430, 543); this.progressSend.Location = new System.Drawing.Point(548, 543);
this.progressSend.Size = new System.Drawing.Size(300, 20); this.progressSend.Size = new System.Drawing.Size(300, 20);
this.progressSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); this.progressSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
// //
// lblProgressStatus // lblProgressStatus
// //
this.lblProgressStatus.Location = new System.Drawing.Point(430, 565); this.lblProgressStatus.Location = new System.Drawing.Point(548, 565);
this.lblProgressStatus.Size = new System.Drawing.Size(482, 20); this.lblProgressStatus.Size = new System.Drawing.Size(482, 20);
this.lblProgressStatus.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); this.lblProgressStatus.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
// //
// btnClose // btnClose
// //
this.btnClose.Location = new System.Drawing.Point(720, 540); this.btnClose.Location = new System.Drawing.Point(838, 540);
this.btnClose.Size = new System.Drawing.Size(90, 30); this.btnClose.Size = new System.Drawing.Size(90, 30);
this.btnClose.Text = "Закрыть"; this.btnClose.Text = "Закрыть";
this.btnClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); this.btnClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
@@ -243,7 +255,7 @@ namespace BulkArchiveMailer.AddIn.UI
// //
// btnSend // btnSend
// //
this.btnSend.Location = new System.Drawing.Point(816, 540); this.btnSend.Location = new System.Drawing.Point(934, 540);
this.btnSend.Size = new System.Drawing.Size(96, 30); this.btnSend.Size = new System.Drawing.Size(96, 30);
this.btnSend.Text = "Отправить"; this.btnSend.Text = "Отправить";
this.btnSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); this.btnSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
@@ -256,8 +268,8 @@ namespace BulkArchiveMailer.AddIn.UI
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(924, 582); this.ClientSize = new System.Drawing.Size(1042, 582);
this.MinimumSize = new System.Drawing.Size(760, 420); this.MinimumSize = new System.Drawing.Size(878, 420);
this.Controls.Add(this.lblPasswordReminder); this.Controls.Add(this.lblPasswordReminder);
this.Controls.Add(this.lblSignatureWarning); this.Controls.Add(this.lblSignatureWarning);
this.Controls.Add(this.grid); this.Controls.Add(this.grid);
@@ -266,6 +278,7 @@ namespace BulkArchiveMailer.AddIn.UI
this.Controls.Add(this.btnRescan); this.Controls.Add(this.btnRescan);
this.Controls.Add(this.btnCheckAllReady); this.Controls.Add(this.btnCheckAllReady);
this.Controls.Add(this.btnUncheckAll); this.Controls.Add(this.btnUncheckAll);
this.Controls.Add(this.btnSettings);
this.Controls.Add(this.progressSend); this.Controls.Add(this.progressSend);
this.Controls.Add(this.lblProgressStatus); this.Controls.Add(this.lblProgressStatus);
this.Controls.Add(this.btnClose); this.Controls.Add(this.btnClose);
@@ -58,7 +58,7 @@ namespace BulkArchiveMailer.AddIn.UI
{ {
if (!string.IsNullOrWhiteSpace(settings.SignatureHtmlPath)) 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); return SignatureReader.TryAutoDetectSignatureHtml(outlookApplication);
@@ -128,6 +128,17 @@ namespace BulkArchiveMailer.AddIn.UI
private void UpdateSummaryLabel() private void UpdateSummaryLabel()
{ {
if (rows == null)
{
// Can be invoked before RunScan() ever ran - most notably,
// WinForms fires DataGridView.CellValueChanged as a side
// effect of DataGridViewColumn.HeaderText assignments in
// InitializeComponent(), i.e. before the constructor (and
// therefore RunScan()) has had a chance to populate `rows`.
// Nothing to summarize yet in that case.
return;
}
var ready = rows.Count(r => r.Job.Status == SendStatus.Ready); var ready = rows.Count(r => r.Job.Status == SendStatus.Ready);
var already = rows.Count(r => r.Job.Status == SendStatus.AlreadySent); var already = rows.Count(r => r.Job.Status == SendStatus.AlreadySent);
var errors = rows.Count(r => r.Job.Status == SendStatus.Error); var errors = rows.Count(r => r.Job.Status == SendStatus.Error);
@@ -161,6 +172,34 @@ namespace BulkArchiveMailer.AddIn.UI
RunScan(); RunScan();
} }
private void btnSettings_Click(object sender, EventArgs e)
{
using (var settingsForm = new SettingsForm(outlookApplication))
{
settingsForm.ShowDialog(this);
}
// Regardless of DialogResult (OK or Cancel), reload settings and
// rescan so a changed archive folder, domain, or templates are
// reflected immediately without a separate "Пересканировать"
// click. A closed-without-saving dialog just reloads the same
// settings again, which is harmless.
try
{
settings = AppSettings.Load();
}
catch (Exception ex)
{
MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
signatureHtml = ResolveSignatureHtml();
lblSignatureWarning.Visible = string.IsNullOrEmpty(signatureHtml);
RunScan();
}
private void btnCheckAllReady_Click(object sender, EventArgs e) private void btnCheckAllReady_Click(object sender, EventArgs e)
{ {
foreach (var row in rows) foreach (var row in rows)