Files
ssh-access-tui/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs
T
redlineoandClaude Sonnet 5 804b9610ca Generalize mail templates from a fixed pair to an operator-managed list
ArchiveKind was a hardcoded two-value enum (ssh_access/config_update),
wired through FilenameParser's regex, MailTemplateSet's two fixed
slots, and SettingsForm's two fixed tabs. Operators wanted arbitrary
custom templates (own file-name suffix, subject, body) without a code
change per new archive kind ssh-access-tui might start producing.

Replaced with MailTemplateDefinition (DisplayName/FileSuffix/Subject/
BodyHtml) in an open-ended list: FilenameParser now accepts any
"{login}_{suffix}.7z" and ArchiveScanner matches the suffix against
the configured list; SendJob.Kind/SendLogEntry.Kind became plain
strings; SettingsForm gained a template list (+/- buttons, editor
panel) replacing the two static tabs. ssh_access/config_update are now
just the two default entries in that list, not special-cased.

Handles the two backward-compat edges that matter for a tool already
in real use: old config.json's object-shaped "templates" migrates to
the new list shape transparently on first load (DataContractJsonSerializer
turned out to silently produce an empty list rather than throw on the
old shape, so detection is structural, not exception-based), and old
send-log.csv rows with PascalCase Kind values (SshAccess/ConfigUpdate)
still dedupe correctly against the new snake_case suffixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 11:26:31 +03:00

380 lines
14 KiB
C#

using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using BulkArchiveMailer.Core;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace BulkArchiveMailer.AddIn.UI
{
/// <summary>
/// Scans the configured archive folder, shows one row per login (plus
/// unrecognized files) in a grid, and sends the checked rows through
/// MailSender. This is the only form that actually touches Outlook
/// COM (via the Outlook.Application passed into its constructor) or
/// writes to the send log.
/// </summary>
public partial class PreviewForm : Form
{
private readonly Outlook.Application outlookApplication;
private AppSettingsData settings;
private SendLogStore sendLog;
private string signatureHtml;
private BindingList<PreviewRow> rows;
public PreviewForm(Outlook.Application outlookApplication)
{
InitializeComponent();
this.outlookApplication = outlookApplication ?? throw new ArgumentNullException(nameof(outlookApplication));
Load += PreviewForm_Load;
}
private void PreviewForm_Load(object sender, EventArgs e)
{
try
{
settings = AppSettings.Load();
}
catch (Exception ex)
{
MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
Close();
return;
}
chkTestMode.Checked = settings.TestModeDefault;
signatureHtml = ResolveSignatureHtml();
lblSignatureWarning.Visible = string.IsNullOrEmpty(signatureHtml);
RunScan();
}
private string ResolveSignatureHtml()
{
try
{
if (!string.IsNullOrWhiteSpace(settings.SignatureHtmlPath))
{
return File.Exists(settings.SignatureHtmlPath) ? SignatureReader.ReadSignatureHtml(settings.SignatureHtmlPath) : null;
}
return SignatureReader.TryAutoDetectSignatureHtml(outlookApplication);
}
catch
{
return null; // never block preview over a signature problem
}
}
private void RunScan()
{
if (string.IsNullOrWhiteSpace(settings.ArchiveFolder) || !Directory.Exists(settings.ArchiveFolder))
{
MessageBox.Show(
this,
"Папка с архивами не настроена или не существует:\n" + settings.ArchiveFolder,
"BulkArchiveMailer",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
Close();
return;
}
var logPath = Path.Combine(settings.ArchiveFolder, "send-log.csv");
sendLog = new SendLogStore(logPath);
var templates = settings.ToTemplateList();
var scan = ArchiveScanner.Scan(settings.ArchiveFolder, settings.EmailDomain, templates, sendLog);
rows = new BindingList<PreviewRow>(scan.AllRows.Select(job => new PreviewRow(job)).ToList());
grid.DataSource = rows;
UpdateSummaryLabel();
}
private void ApplyRowStyles()
{
foreach (DataGridViewRow row in grid.Rows)
{
if (!(row.DataBoundItem is PreviewRow previewRow))
{
continue;
}
row.Cells[colSelect.Index].ReadOnly = !previewRow.Job.IsSelectable;
switch (previewRow.Job.Status)
{
case SendStatus.Error:
row.DefaultCellStyle.BackColor = System.Drawing.Color.MistyRose;
break;
case SendStatus.Skipped:
row.DefaultCellStyle.BackColor = System.Drawing.Color.WhiteSmoke;
row.DefaultCellStyle.ForeColor = System.Drawing.Color.Gray;
break;
case SendStatus.AlreadySent:
row.DefaultCellStyle.BackColor = System.Drawing.Color.LightYellow;
break;
default:
row.DefaultCellStyle.BackColor = grid.DefaultCellStyle.BackColor;
row.DefaultCellStyle.ForeColor = grid.DefaultCellStyle.ForeColor;
break;
}
}
}
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);
var skipped = rows.Count(r => r.Job.Status == SendStatus.Skipped);
var checkedCount = rows.Count(r => r.IsChecked);
lblSummary.Text = $"Готово: {ready} Уже отправлено: {already} Ошибки: {errors} Пропущено: {skipped} | Отмечено к отправке: {checkedCount}";
}
private void grid_DataBindingComplete(object sender, DataGridViewBindingCompleteEventArgs e)
{
ApplyRowStyles();
UpdateSummaryLabel();
}
private void grid_CurrentCellDirtyStateChanged(object sender, EventArgs e)
{
if (grid.IsCurrentCellDirty && grid.CurrentCell is DataGridViewCheckBoxCell)
{
grid.CommitEdit(DataGridViewDataErrorContexts.Commit);
}
}
private void grid_CellValueChanged(object sender, DataGridViewCellEventArgs e)
{
UpdateSummaryLabel();
}
private void btnRescan_Click(object sender, EventArgs e)
{
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)
{
if (row.Job.IsSelectable)
{
row.IsChecked = true;
}
}
grid.Refresh();
UpdateSummaryLabel();
}
private void btnUncheckAll_Click(object sender, EventArgs e)
{
foreach (var row in rows)
{
row.IsChecked = false;
}
grid.Refresh();
UpdateSummaryLabel();
}
private void btnClose_Click(object sender, EventArgs e)
{
Close();
}
private void btnSend_Click(object sender, EventArgs e)
{
var toSend = rows.Where(r => r.IsChecked && r.Job.IsSelectable).ToList();
if (toSend.Count == 0)
{
MessageBox.Show(this, "Не выбрано ни одной строки для отправки.", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var testMode = chkTestMode.Checked;
var confirmText = testMode
? $"Сохранить {toSend.Count} писем в Черновики (тестовый режим, реальная отправка НЕ произойдёт)?"
: $"Отправить {toSend.Count} писем? Это реальная отправка получателям.";
if (MessageBox.Show(this, confirmText, "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
{
return;
}
btnSend.Enabled = false;
btnRescan.Enabled = false;
progressSend.Minimum = 0;
progressSend.Maximum = toSend.Count;
progressSend.Value = 0;
var mailSender = new MailSender(outlookApplication, signatureHtml, settings.FromAccountSmtpAddress);
var sentCount = 0;
var failedCount = 0;
foreach (var row in toSend)
{
lblProgressStatus.Text = $"Обработка: {row.Job.Email} ({row.Job.KindDisplay})...";
// No async/await here on purpose: Outlook COM objects are
// STA-thread-affine, and MailSender.SendOne must run on
// this UI thread. DoEvents keeps the form responsive
// (progress bar, ability to see the current item) during
// what is otherwise a synchronous per-item loop.
Application.DoEvents();
var outcome = mailSender.SendOne(row.Job, testMode);
try
{
sendLog.Append(new SendLogEntry
{
TimestampUtc = DateTime.UtcNow,
Login = row.Job.Login,
Email = row.Job.Email,
Kind = row.Job.Kind ?? "",
ArchivePath = row.Job.ArchivePath ?? "",
FileSha256 = row.Job.FileSha256 ?? "",
Status = outcome.Success ? (testMode ? SendLogStatus.Draft : SendLogStatus.Sent) : SendLogStatus.Failed,
Error = outcome.Success ? "" : outcome.ErrorMessage,
});
}
catch (Exception logEx)
{
// A logging failure must not be confused with a send
// failure, but it also must not silently disappear -
// surface it in the comment column.
row.ResultText = (outcome.Success ? "Отправлено, но" : "Ошибка отправки, и") + " не удалось записать в журнал: " + logEx.Message;
row.IsChecked = false;
grid.Refresh();
continue;
}
if (outcome.Success)
{
sentCount++;
row.ResultText = testMode ? "Сохранено в Черновики" : "Отправлено";
}
else
{
failedCount++;
row.ResultText = "Ошибка: " + outcome.ErrorMessage;
}
row.IsChecked = false;
progressSend.Value++;
grid.Refresh();
}
btnSend.Enabled = true;
btnRescan.Enabled = true;
lblProgressStatus.Text = "Готово.";
UpdateSummaryLabel();
MessageBox.Show(
this,
$"Отправлено/сохранено: {sentCount}\nОшибок: {failedCount}\n\nЖурнал: {sendLog.CsvPath}",
"Массовая рассылка — итог",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
}
/// <summary>
/// UI-only wrapper around a Core SendJob: adds the checkbox state
/// and post-send result text, neither of which belong in the
/// portable Core model.
/// </summary>
private sealed class PreviewRow
{
public PreviewRow(SendJob job)
{
Job = job;
// AlreadySent starts unchecked by default (per spec) but
// remains selectable if the operator wants to resend it
// anyway (e.g. recipient claims they never got it).
IsChecked = job.Status == SendStatus.Ready;
}
public SendJob Job { get; }
public bool IsChecked { get; set; }
public string ResultText { get; set; } = "";
public string Login => Job.Login;
public string Email => Job.Email;
public string KindDisplay => Job.KindDisplay;
public string FileName => Job.FileName;
public string StatusDisplay => ToRussian(Job.Status);
public string Comment => !string.IsNullOrEmpty(ResultText) ? ResultText : (Job.ErrorMessage ?? "");
private static string ToRussian(SendStatus status)
{
switch (status)
{
case SendStatus.Ready:
return "Готово";
case SendStatus.AlreadySent:
return "Уже отправлено";
case SendStatus.Error:
return "Ошибка";
case SendStatus.Skipped:
return "Пропущено";
default:
return status.ToString();
}
}
}
}
}