Files
ssh-access-tui/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.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

512 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
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.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;
txtTplSubject.Enabled = hasSelection;
txtTplBody.Enabled = hasSelection;
if (!hasSelection)
{
lstTemplates.Tag = -1;
txtTplDisplayName.Text = "";
txtTplFileSuffix.Text = "";
txtTplSubject.Text = "";
txtTplBody.Text = "";
return;
}
var t = templates[index];
lstTemplates.Tag = index;
txtTplDisplayName.Text = t.DisplayName;
txtTplFileSuffix.Text = t.FileSuffix;
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 = "",
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 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;
}
private void btnCancel_Click(object sender, EventArgs e)
{
DialogResult = DialogResult.Cancel;
Close();
}
}
}