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.Message, "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() { 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 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(); } } } } }