The add/remove template buttons sat directly below the fixed-height list with only 6px of margin to the tab's bottom edge - enough on this dev machine but not on the operator's real one (different DPI/font scale), so the buttons rendered off the visible area. Moved them above the list instead (visibility no longer depends on how much space is left at the bottom) and gave the whole "Шаблоны" tab substantially more vertical room throughout. Also added an optional per-template login regex filter (MailTemplateDefinition.LoginPattern): once a file's suffix matches a template, its login must also match that template's pattern (if any) or the row surfaces as a clear Error in the preview instead of silently going through - addresses "непонятно что будет работать". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
562 lines
23 KiB
C#
562 lines
23 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public partial class SettingsForm : Form
|
||
{
|
||
private readonly Outlook.Application outlookApplication;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private List<MailTemplateDefinition> templates = new List<MailTemplateDefinition>();
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>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".</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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 -----------------------------------
|
||
|
||
/// <summary>Rebuilds lstTemplates' items from the in-memory `templates` list, preserving (or resetting to the first item) the current selection.</summary>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>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.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>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.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
}
|
||
}
|