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
{
///
/// 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.
///
public partial class PreviewForm : Form
{
private readonly Outlook.Application outlookApplication;
private AppSettingsData settings;
private SendLogStore sendLog;
private string signatureHtml;
private BindingList 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) ? File.ReadAllText(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.ToTemplateSet();
var scan = ArchiveScanner.Scan(settings.ArchiveFolder, settings.EmailDomain, templates, sendLog);
rows = new BindingList(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 ?? default,
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);
}
///
/// 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.
///
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();
}
}
}
}
}