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
redlineoandClaude Sonnet 5 f50624023a Show full exception detail in error dialogs instead of just Message
An operator hit "Значение не может быть неопределенным. Имя параметра:
source" (ArgumentNullException, ParamName=source) opening the bulk
sender window on a real Outlook install. A full read-through of every
LINQ call reachable from that path found nothing that could receive a
null source, and this dev machine has no Outlook to reproduce against
- so the next occurrence needs a real stack trace to diagnose instead
of guessing from ex.Message alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:19:09 +03:00
5 changed files with 205 additions and 15 deletions

No files matched your search

@@ -60,7 +60,7 @@ namespace BulkArchiveMailer.AddIn
catch (Exception ex)
{
MessageBox.Show(
"Не удалось открыть окно массовой рассылки: " + ex.Message,
"Не удалось открыть окно массовой рассылки: " + ex.ToString(),
"BulkArchiveMailer",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
@@ -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;
}
/// <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 btnUncheckAll;
private System.Windows.Forms.Button btnRescan;
private System.Windows.Forms.Button btnSettings;
private System.Windows.Forms.ProgressBar progressSend;
private System.Windows.Forms.Label lblProgressStatus;
@@ -56,6 +57,7 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnCheckAllReady = new System.Windows.Forms.Button();
this.btnUncheckAll = 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.lblProgressStatus = new System.Windows.Forms.Label();
@@ -70,7 +72,7 @@ namespace BulkArchiveMailer.AddIn.UI
// lblPasswordReminder
//
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.ForeColor = System.Drawing.Color.DarkRed;
this.lblPasswordReminder.Font = new System.Drawing.Font(this.lblPasswordReminder.Font, System.Drawing.FontStyle.Bold);
@@ -80,7 +82,7 @@ namespace BulkArchiveMailer.AddIn.UI
// lblSignatureWarning
//
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.ForeColor = System.Drawing.Color.DarkOrange;
this.lblSignatureWarning.Visible = false;
@@ -90,7 +92,7 @@ namespace BulkArchiveMailer.AddIn.UI
// grid
//
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)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
@@ -215,24 +217,34 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnUncheckAll.UseVisualStyleBackColor = true;
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
//
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.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
//
// 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.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
//
// 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.Text = "Закрыть";
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
//
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.Text = "Отправить";
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.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(924, 582);
this.MinimumSize = new System.Drawing.Size(760, 420);
this.ClientSize = new System.Drawing.Size(1042, 582);
this.MinimumSize = new System.Drawing.Size(878, 420);
this.Controls.Add(this.lblPasswordReminder);
this.Controls.Add(this.lblSignatureWarning);
this.Controls.Add(this.grid);
@@ -266,6 +278,7 @@ namespace BulkArchiveMailer.AddIn.UI
this.Controls.Add(this.btnRescan);
this.Controls.Add(this.btnCheckAllReady);
this.Controls.Add(this.btnUncheckAll);
this.Controls.Add(this.btnSettings);
this.Controls.Add(this.progressSend);
this.Controls.Add(this.lblProgressStatus);
this.Controls.Add(this.btnClose);
@@ -39,7 +39,7 @@ namespace BulkArchiveMailer.AddIn.UI
}
catch (Exception ex)
{
MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.Message, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
Close();
return;
}
@@ -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);
@@ -128,6 +128,17 @@ namespace BulkArchiveMailer.AddIn.UI
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 already = rows.Count(r => r.Job.Status == SendStatus.AlreadySent);
var errors = rows.Count(r => r.Job.Status == SendStatus.Error);
@@ -161,6 +172,34 @@ namespace BulkArchiveMailer.AddIn.UI
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)
{
foreach (var row in rows)
@@ -31,7 +31,7 @@ namespace BulkArchiveMailer.AddIn.UI
catch (Exception ex)
{
MessageBox.Show(
"Не удалось прочитать config.json (" + AppSettings.ConfigFilePath + "):\n" + ex.Message +
"Не удалось прочитать config.json (" + AppSettings.ConfigFilePath + "):\n" + ex.ToString() +
"\n\nБудут показаны пустые настройки — сохранение перезапишет файл заново.",
"BulkArchiveMailer",
MessageBoxButtons.OK,
@@ -231,7 +231,7 @@ namespace BulkArchiveMailer.AddIn.UI
}
catch (Exception ex)
{
MessageBox.Show(this, "Не удалось сохранить настройки: " + ex.Message, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show(this, "Не удалось сохранить настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}