using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Windows.Forms; using BulkArchiveMailer.Core; using Outlook = Microsoft.Office.Interop.Outlook; namespace BulkArchiveMailer.AddIn.UI { /// /// Archive folder, email domain, signature override, test-mode default, /// and an arbitrary-length, operator-editable list of mail templates /// (master-detail: lstTemplates on the left, editor fields for the /// selected one on the right). Saves straight to /// %APPDATA%\BulkArchiveMailer\config.json via AppSettings. /// public partial class SettingsForm : Form { private readonly Outlook.Application outlookApplication; /// /// In-memory working copy of the template list, edited freely /// (add/remove/rename/reorder-by-selection) until btnSave_Click /// persists it via AppSettings.Save - nothing here touches disk /// until then. /// private List templates = new List(); /// /// Guards against SyncEditorToSelectedTemplate/LoadSelectedTemplateIntoEditor /// re-entrancy while lstTemplates.SelectedIndex is being changed /// programmatically (as opposed to by the operator clicking in the /// list) - the standard pattern for a WinForms master-detail list. /// private bool suppressSelectionSync; public SettingsForm(Outlook.Application outlookApplication) { InitializeComponent(); this.outlookApplication = outlookApplication; Load += SettingsForm_Load; } private void SettingsForm_Load(object sender, EventArgs e) { AppSettingsData settings; try { settings = AppSettings.Load(); } catch (Exception ex) { MessageBox.Show( "Не удалось прочитать config.json (" + AppSettings.ConfigFilePath + "):\n" + ex.ToString() + "\n\nБудут показаны пустые настройки — сохранение перезапишет файл заново.", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning); settings = new AppSettingsData(); } PopulateFrom(settings); } /// ComboBox item: displays a friendly label but carries the SmtpAddress that actually gets saved to config.json. The first item (SmtpAddress == "") represents "let Outlook pick its default account". private sealed class FromAccountItem { public string Label { get; set; } = ""; public string SmtpAddress { get; set; } = ""; public override string ToString() => Label; } private const string DefaultAccountLabel = "(по умолчанию Outlook)"; private void PopulateFrom(AppSettingsData settings) { txtArchiveFolder.Text = settings.ArchiveFolder ?? ""; txtDomain.Text = settings.EmailDomain ?? ""; txtSignaturePath.Text = settings.SignatureHtmlPath ?? ""; chkTestModeDefault.Checked = settings.TestModeDefault; PopulateFromAccountCombo(settings.FromAccountSmtpAddress ?? ""); templates = settings.ToTemplateList(); PopulateTemplateList(selectFirst: true); } /// /// Fills cboFromAccount with "(по умолчанию Outlook)" plus one /// entry per real Outlook.Account in the current session, then /// selects the item matching savedSmtpAddress. If that address no /// longer belongs to any account in this session (removed/renamed /// since it was configured, or Outlook.Application is unavailable /// e.g. under a design-time host), it just falls back to the /// default item instead of throwing - never blocks opening the /// Settings window over this. /// private void PopulateFromAccountCombo(string savedSmtpAddress) { cboFromAccount.Items.Clear(); cboFromAccount.Items.Add(new FromAccountItem { Label = DefaultAccountLabel, SmtpAddress = "" }); if (outlookApplication != null) { foreach (var account in OutlookAccounts.ListAccounts(outlookApplication)) { var label = string.IsNullOrEmpty(account.SmtpAddress) ? account.DisplayName : account.DisplayName + " <" + account.SmtpAddress + ">"; cboFromAccount.Items.Add(new FromAccountItem { Label = label, SmtpAddress = account.SmtpAddress }); } } var selectedIndex = 0; if (!string.IsNullOrEmpty(savedSmtpAddress)) { for (var i = 0; i < cboFromAccount.Items.Count; i++) { var item = (FromAccountItem)cboFromAccount.Items[i]; if (string.Equals(item.SmtpAddress, savedSmtpAddress, StringComparison.OrdinalIgnoreCase)) { selectedIndex = i; break; } } } cboFromAccount.SelectedIndex = selectedIndex; } // --- Template list master-detail ----------------------------------- /// Rebuilds lstTemplates' items from the in-memory `templates` list, preserving (or resetting to the first item) the current selection. private void PopulateTemplateList(bool selectFirst) { suppressSelectionSync = true; try { lstTemplates.Items.Clear(); foreach (var t in templates) { lstTemplates.Items.Add(TemplateListLabel(t)); } if (lstTemplates.Items.Count > 0) { lstTemplates.SelectedIndex = selectFirst ? 0 : Math.Min(lstTemplates.Items.Count - 1, lstTemplates.SelectedIndex); } } finally { suppressSelectionSync = false; } LoadSelectedTemplateIntoEditor(); } private static string TemplateListLabel(MailTemplateDefinition t) => string.IsNullOrWhiteSpace(t.DisplayName) ? "(без названия)" : t.DisplayName; /// /// Rewrites lstTemplates' visible label for the currently-edited /// index from the in-memory list (call right after /// SyncEditorToSelectedTemplate so the list reflects a renamed /// template without a full PopulateTemplateList rebuild, which /// would also disturb the current selection/scroll position). /// private void RefreshEditedTemplateLabel() { if (!(lstTemplates.Tag is int editedIndex) || editedIndex < 0 || editedIndex >= templates.Count) { return; } suppressSelectionSync = true; try { lstTemplates.Items[editedIndex] = TemplateListLabel(templates[editedIndex]); } finally { suppressSelectionSync = false; } } /// Writes the editor fields' current values back into the in-memory list entry that was selected when editing started (tracked via lstTemplates.Tag, see LoadSelectedTemplateIntoEditor). Safe to call with nothing selected/no prior selection. private void SyncEditorToSelectedTemplate() { if (!(lstTemplates.Tag is int editedIndex) || editedIndex < 0 || editedIndex >= templates.Count) { return; } var t = templates[editedIndex]; t.DisplayName = txtTplDisplayName.Text; t.FileSuffix = txtTplFileSuffix.Text.Trim(); t.LoginPattern = txtTplLoginPattern.Text.Trim(); t.Subject = txtTplSubject.Text; t.BodyHtml = txtTplBody.Text; } /// Loads the newly selected list item's values into the editor fields, and records which index the editor now belongs to (lstTemplates.Tag) so a later SyncEditorToSelectedTemplate knows where to write back. private void LoadSelectedTemplateIntoEditor() { var index = lstTemplates.SelectedIndex; var hasSelection = index >= 0 && index < templates.Count; txtTplDisplayName.Enabled = hasSelection; txtTplFileSuffix.Enabled = hasSelection; txtTplLoginPattern.Enabled = hasSelection; txtTplSubject.Enabled = hasSelection; txtTplBody.Enabled = hasSelection; if (!hasSelection) { lstTemplates.Tag = -1; txtTplDisplayName.Text = ""; txtTplFileSuffix.Text = ""; txtTplLoginPattern.Text = ""; txtTplSubject.Text = ""; txtTplBody.Text = ""; return; } var t = templates[index]; lstTemplates.Tag = index; txtTplDisplayName.Text = t.DisplayName; txtTplFileSuffix.Text = t.FileSuffix; txtTplLoginPattern.Text = t.LoginPattern; txtTplSubject.Text = t.Subject; txtTplBody.Text = t.BodyHtml; } /// /// Keeps lstTemplates' visible label in sync as soon as the /// operator finishes editing the name (focus leaves the field), /// without waiting for a selection change or Save. Leave (not /// TextChanged) deliberately: TextChanged would fire re-entrantly /// while LoadSelectedTemplateIntoEditor is itself still in the /// middle of setting the other fields for a newly selected /// template, which could write stale values back. /// private void txtTplDisplayName_Leave(object sender, EventArgs e) { SyncEditorToSelectedTemplate(); RefreshEditedTemplateLabel(); } private void lstTemplates_SelectedIndexChanged(object sender, EventArgs e) { if (suppressSelectionSync) { return; } // Save whatever is currently in the editor back to the // template it belonged to BEFORE loading the newly selected // one - otherwise unsaved edits to the previous selection // would be lost the moment the operator clicks another item. SyncEditorToSelectedTemplate(); LoadSelectedTemplateIntoEditor(); } private void btnAddTemplate_Click(object sender, EventArgs e) { SyncEditorToSelectedTemplate(); var newTemplate = new MailTemplateDefinition { DisplayName = "Новый шаблон", FileSuffix = "", LoginPattern = "", Subject = "", BodyHtml = "", }; templates.Add(newTemplate); PopulateTemplateList(selectFirst: false); suppressSelectionSync = true; try { lstTemplates.SelectedIndex = templates.Count - 1; } finally { suppressSelectionSync = false; } LoadSelectedTemplateIntoEditor(); txtTplDisplayName.Focus(); txtTplDisplayName.SelectAll(); } private void btnRemoveTemplate_Click(object sender, EventArgs e) { var index = lstTemplates.SelectedIndex; if (index < 0 || index >= templates.Count) { return; } var label = TemplateListLabel(templates[index]); var confirm = MessageBox.Show( this, "Удалить шаблон \"" + label + "\"? Несохранённые правки в этом окне будут потеряны только после нажатия \"Сохранить\".", "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Question); if (confirm != DialogResult.Yes) { return; } templates.RemoveAt(index); // The editor currently holds this now-removed template's stale // values - clear its "which index does the editor belong to" // marker so a later SyncEditorToSelectedTemplate (e.g. from the // SelectedIndexChanged this RemoveAt is about to trigger) does // not write those stale values into whatever template happens // to occupy this index next. lstTemplates.Tag = -1; var nextIndex = templates.Count == 0 ? -1 : Math.Min(index, templates.Count - 1); suppressSelectionSync = true; try { lstTemplates.Items.RemoveAt(index); lstTemplates.SelectedIndex = nextIndex; } finally { suppressSelectionSync = false; } LoadSelectedTemplateIntoEditor(); } // --- General tab handlers -------------------------------------- private void btnBrowseFolder_Click(object sender, EventArgs e) { using (var dialog = new FolderBrowserDialog()) { dialog.Description = "Папка с архивами, которые ssh-access-tui выкладывает для рассылки"; if (System.IO.Directory.Exists(txtArchiveFolder.Text)) { dialog.SelectedPath = txtArchiveFolder.Text; } if (dialog.ShowDialog(this) == DialogResult.OK) { txtArchiveFolder.Text = dialog.SelectedPath; } } } private void btnBrowseSignature_Click(object sender, EventArgs e) { using (var dialog = new OpenFileDialog()) { dialog.Title = "Выберите файл подписи"; dialog.Filter = "HTML-подпись (*.htm;*.html)|*.htm;*.html|Все файлы (*.*)|*.*"; if (dialog.ShowDialog(this) == DialogResult.OK) { txtSignaturePath.Text = dialog.FileName; } } } private void btnAutoDetectSignature_Click(object sender, EventArgs e) { if (outlookApplication == null) { MessageBox.Show(this, "Outlook.Application недоступен в этом окне.", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var path = SignatureReader.TryAutoDetectSignaturePath(outlookApplication); if (string.IsNullOrEmpty(path)) { MessageBox.Show( this, "Не удалось определить подпись автоматически (не настроена подпись по умолчанию для новых писем, либо файл не найден). Выберите файл вручную кнопкой \"Обзор...\".", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } txtSignaturePath.Text = path; } private void btnResetDefaults_Click(object sender, EventArgs e) { var confirm = MessageBox.Show( this, "Заменить домен и весь список шаблонов писем значениями по умолчанию (два исходных шаблона)? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.", "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Question); if (confirm != DialogResult.Yes) { return; } var defaults = AppSettings.LoadDefaults(); txtDomain.Text = defaults.EmailDomain; templates = defaults.ToTemplateList(); PopulateTemplateList(selectFirst: true); } private void btnSave_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(txtDomain.Text)) { MessageBox.Show(this, "Домен электронной почты не может быть пустым.", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning); tabControl.SelectedTab = tabGeneral; txtDomain.Focus(); return; } if (!string.IsNullOrWhiteSpace(txtArchiveFolder.Text) && !System.IO.Directory.Exists(txtArchiveFolder.Text)) { var proceed = MessageBox.Show( this, "Указанная папка с архивами не существует:\n" + txtArchiveFolder.Text + "\n\nВсё равно сохранить?", "BulkArchiveMailer", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (proceed != DialogResult.Yes) { tabControl.SelectedTab = tabGeneral; return; } } // Make sure whatever is currently in the editor (possibly never // committed by a selection change, e.g. the operator edited one // template and clicked Save directly) is captured before // validating/saving the list. SyncEditorToSelectedTemplate(); var suffixError = ValidateTemplateSuffixes(); if (suffixError != null) { MessageBox.Show(this, suffixError, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning); tabControl.SelectedTab = tabTemplates; lstTemplates.Focus(); return; } var loginPatternError = ValidateTemplateLoginPatterns(); if (loginPatternError != null) { MessageBox.Show(this, loginPatternError, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning); tabControl.SelectedTab = tabTemplates; lstTemplates.Focus(); return; } var selectedAccount = cboFromAccount.SelectedItem as FromAccountItem; var data = new AppSettingsData { ArchiveFolder = txtArchiveFolder.Text.Trim(), EmailDomain = txtDomain.Text.Trim(), SignatureHtmlPath = txtSignaturePath.Text.Trim(), TestModeDefault = chkTestModeDefault.Checked, FromAccountSmtpAddress = selectedAccount?.SmtpAddress ?? "", }; data.SetTemplates(templates); try { AppSettings.Save(data); } catch (Exception ex) { MessageBox.Show(this, "Не удалось сохранить настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } DialogResult = DialogResult.OK; Close(); } /// /// Every template's FileSuffix must be non-empty, and unique /// case-insensitively among all configured templates (it is /// ArchiveScanner's only way to match a file to a template). /// Returns null if valid, otherwise a message describing exactly /// which template/duplicate is the problem. /// private string ValidateTemplateSuffixes() { for (var i = 0; i < templates.Count; i++) { if (string.IsNullOrWhiteSpace(templates[i].FileSuffix)) { var name = string.IsNullOrWhiteSpace(templates[i].DisplayName) ? $"#{i + 1}" : templates[i].DisplayName; return $"У шаблона \"{name}\" не заполнен суффикс файла — заполните поле \"Суффикс файла\" (например, \"ssh_access\")."; } } var duplicateGroup = templates .GroupBy(t => t.FileSuffix.Trim(), StringComparer.OrdinalIgnoreCase) .FirstOrDefault(g => g.Count() > 1); if (duplicateGroup != null) { var names = string.Join(", ", duplicateGroup.Select(t => string.IsNullOrWhiteSpace(t.DisplayName) ? "(без названия)" : t.DisplayName)); return $"Суффикс файла \"{duplicateGroup.Key}\" используется в нескольких шаблонах ({names}) — суффикс должен быть уникальным."; } return null; } /// /// Every template's LoginPattern (if non-empty) must compile as a /// .NET regular expression - ArchiveScanner treats an uncompilable /// pattern defensively as "never matches" rather than throwing, but /// that would silently turn a typo'd filter into "this template /// never applies to anything", which is worth catching here instead /// while the operator still has the exact field in front of them. /// Returns null if every configured pattern is either empty or /// compiles, otherwise a message naming the offending template and /// the underlying regex error. /// private string ValidateTemplateLoginPatterns() { for (var i = 0; i < templates.Count; i++) { var pattern = templates[i].LoginPattern; if (string.IsNullOrEmpty(pattern)) { continue; } try { _ = new Regex(pattern); } catch (ArgumentException ex) { var name = string.IsNullOrWhiteSpace(templates[i].DisplayName) ? $"#{i + 1}" : templates[i].DisplayName; return $"У шаблона \"{name}\" некорректное регулярное выражение в поле \"Фильтр логина\" (\"{pattern}\"): {ex.Message}"; } } return null; } private void btnCancel_Click(object sender, EventArgs e) { DialogResult = DialogResult.Cancel; Close(); } } }