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
{
///
/// 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.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;
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;
}
///
/// 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 = "",
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();
}
///
/// 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;
}
private void btnCancel_Click(object sender, EventArgs e)
{
DialogResult = DialogResult.Cancel;
Close();
}
}
}