diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs index b115008..6dfa1c3 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs @@ -86,7 +86,7 @@ namespace BulkArchiveMailer.AddIn.UI var logPath = Path.Combine(settings.ArchiveFolder, "send-log.csv"); sendLog = new SendLogStore(logPath); - var templates = settings.ToTemplateSet(); + var templates = settings.ToTemplateList(); var scan = ArchiveScanner.Scan(settings.ArchiveFolder, settings.EmailDomain, templates, sendLog); rows = new BindingList(scan.AllRows.Select(job => new PreviewRow(job)).ToList()); @@ -277,7 +277,7 @@ namespace BulkArchiveMailer.AddIn.UI TimestampUtc = DateTime.UtcNow, Login = row.Job.Login, Email = row.Job.Email, - Kind = row.Job.Kind ?? default, + Kind = row.Job.Kind ?? "", ArchivePath = row.Job.ArchivePath ?? "", FileSha256 = row.Job.FileSha256 ?? "", Status = outcome.Success ? (testMode ? SendLogStatus.Draft : SendLogStatus.Sent) : SendLogStatus.Failed, diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs index 132100a..4a4b517 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs @@ -16,8 +16,7 @@ namespace BulkArchiveMailer.AddIn.UI private System.Windows.Forms.TabControl tabControl; private System.Windows.Forms.TabPage tabGeneral; - private System.Windows.Forms.TabPage tabSshAccess; - private System.Windows.Forms.TabPage tabConfigUpdate; + private System.Windows.Forms.TabPage tabTemplates; private System.Windows.Forms.Label lblArchiveFolder; private System.Windows.Forms.TextBox txtArchiveFolder; @@ -36,17 +35,20 @@ namespace BulkArchiveMailer.AddIn.UI private System.Windows.Forms.Label lblFromAccount; private System.Windows.Forms.ComboBox cboFromAccount; - private System.Windows.Forms.Label lblSshSubject; - private System.Windows.Forms.TextBox txtSshSubject; - private System.Windows.Forms.Label lblSshBody; - private System.Windows.Forms.TextBox txtSshBody; - private System.Windows.Forms.Label lblSshHint; + private System.Windows.Forms.ListBox lstTemplates; + private System.Windows.Forms.Button btnAddTemplate; + private System.Windows.Forms.Button btnRemoveTemplate; - private System.Windows.Forms.Label lblCfgSubject; - private System.Windows.Forms.TextBox txtCfgSubject; - private System.Windows.Forms.Label lblCfgBody; - private System.Windows.Forms.TextBox txtCfgBody; - private System.Windows.Forms.Label lblCfgHint; + private System.Windows.Forms.Label lblTplDisplayName; + private System.Windows.Forms.TextBox txtTplDisplayName; + private System.Windows.Forms.Label lblTplFileSuffix; + private System.Windows.Forms.TextBox txtTplFileSuffix; + private System.Windows.Forms.Label lblTplFileSuffixHint; + private System.Windows.Forms.Label lblTplSubject; + private System.Windows.Forms.TextBox txtTplSubject; + private System.Windows.Forms.Label lblTplBody; + private System.Windows.Forms.TextBox txtTplBody; + private System.Windows.Forms.Label lblTplHint; private System.Windows.Forms.Button btnSave; private System.Windows.Forms.Button btnCancel; @@ -56,8 +58,7 @@ namespace BulkArchiveMailer.AddIn.UI { this.tabControl = new System.Windows.Forms.TabControl(); this.tabGeneral = new System.Windows.Forms.TabPage(); - this.tabSshAccess = new System.Windows.Forms.TabPage(); - this.tabConfigUpdate = new System.Windows.Forms.TabPage(); + this.tabTemplates = new System.Windows.Forms.TabPage(); this.lblArchiveFolder = new System.Windows.Forms.Label(); this.txtArchiveFolder = new System.Windows.Forms.TextBox(); @@ -76,17 +77,20 @@ namespace BulkArchiveMailer.AddIn.UI this.lblFromAccount = new System.Windows.Forms.Label(); this.cboFromAccount = new System.Windows.Forms.ComboBox(); - this.lblSshSubject = new System.Windows.Forms.Label(); - this.txtSshSubject = new System.Windows.Forms.TextBox(); - this.lblSshBody = new System.Windows.Forms.Label(); - this.txtSshBody = new System.Windows.Forms.TextBox(); - this.lblSshHint = new System.Windows.Forms.Label(); + this.lstTemplates = new System.Windows.Forms.ListBox(); + this.btnAddTemplate = new System.Windows.Forms.Button(); + this.btnRemoveTemplate = new System.Windows.Forms.Button(); - this.lblCfgSubject = new System.Windows.Forms.Label(); - this.txtCfgSubject = new System.Windows.Forms.TextBox(); - this.lblCfgBody = new System.Windows.Forms.Label(); - this.txtCfgBody = new System.Windows.Forms.TextBox(); - this.lblCfgHint = new System.Windows.Forms.Label(); + this.lblTplDisplayName = new System.Windows.Forms.Label(); + this.txtTplDisplayName = new System.Windows.Forms.TextBox(); + this.lblTplFileSuffix = new System.Windows.Forms.Label(); + this.txtTplFileSuffix = new System.Windows.Forms.TextBox(); + this.lblTplFileSuffixHint = new System.Windows.Forms.Label(); + this.lblTplSubject = new System.Windows.Forms.Label(); + this.txtTplSubject = new System.Windows.Forms.TextBox(); + this.lblTplBody = new System.Windows.Forms.Label(); + this.txtTplBody = new System.Windows.Forms.TextBox(); + this.lblTplHint = new System.Windows.Forms.Label(); this.btnSave = new System.Windows.Forms.Button(); this.btnCancel = new System.Windows.Forms.Button(); @@ -94,8 +98,7 @@ namespace BulkArchiveMailer.AddIn.UI this.tabControl.SuspendLayout(); this.tabGeneral.SuspendLayout(); - this.tabSshAccess.SuspendLayout(); - this.tabConfigUpdate.SuspendLayout(); + this.tabTemplates.SuspendLayout(); this.SuspendLayout(); // @@ -108,8 +111,7 @@ namespace BulkArchiveMailer.AddIn.UI | System.Windows.Forms.AnchorStyles.Right))); this.tabControl.TabPages.AddRange(new System.Windows.Forms.TabPage[] { this.tabGeneral, - this.tabSshAccess, - this.tabConfigUpdate}); + this.tabTemplates}); // // tabGeneral @@ -195,97 +197,115 @@ namespace BulkArchiveMailer.AddIn.UI this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; // - // tabSshAccess + // tabTemplates // - this.tabSshAccess.Text = "Шаблон: новый доступ"; - this.tabSshAccess.Padding = new System.Windows.Forms.Padding(12); - this.tabSshAccess.UseVisualStyleBackColor = true; - this.tabSshAccess.Controls.Add(this.lblSshSubject); - this.tabSshAccess.Controls.Add(this.txtSshSubject); - this.tabSshAccess.Controls.Add(this.lblSshBody); - this.tabSshAccess.Controls.Add(this.txtSshBody); - this.tabSshAccess.Controls.Add(this.lblSshHint); + this.tabTemplates.Text = "Шаблоны"; + this.tabTemplates.Padding = new System.Windows.Forms.Padding(12); + this.tabTemplates.UseVisualStyleBackColor = true; + this.tabTemplates.Controls.Add(this.lstTemplates); + this.tabTemplates.Controls.Add(this.btnAddTemplate); + this.tabTemplates.Controls.Add(this.btnRemoveTemplate); + this.tabTemplates.Controls.Add(this.lblTplDisplayName); + this.tabTemplates.Controls.Add(this.txtTplDisplayName); + this.tabTemplates.Controls.Add(this.lblTplFileSuffix); + this.tabTemplates.Controls.Add(this.txtTplFileSuffix); + this.tabTemplates.Controls.Add(this.lblTplFileSuffixHint); + this.tabTemplates.Controls.Add(this.lblTplSubject); + this.tabTemplates.Controls.Add(this.txtTplSubject); + this.tabTemplates.Controls.Add(this.lblTplBody); + this.tabTemplates.Controls.Add(this.txtTplBody); + this.tabTemplates.Controls.Add(this.lblTplHint); - // lblSshSubject - this.lblSshSubject.Location = new System.Drawing.Point(15, 20); - this.lblSshSubject.Size = new System.Drawing.Size(200, 20); - this.lblSshSubject.Text = "Тема письма:"; + // lstTemplates + this.lstTemplates.Location = new System.Drawing.Point(15, 20); + this.lstTemplates.Size = new System.Drawing.Size(190, 329); + this.lstTemplates.IntegralHeight = false; + this.lstTemplates.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left))); + this.lstTemplates.SelectedIndexChanged += new System.EventHandler(this.lstTemplates_SelectedIndexChanged); - // txtSshSubject - this.txtSshSubject.Location = new System.Drawing.Point(15, 43); - this.txtSshSubject.Size = new System.Drawing.Size(610, 23); + // btnAddTemplate + this.btnAddTemplate.Location = new System.Drawing.Point(15, 355); + this.btnAddTemplate.Size = new System.Drawing.Size(90, 27); + this.btnAddTemplate.Text = "+ Добавить"; + this.btnAddTemplate.UseVisualStyleBackColor = true; + this.btnAddTemplate.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.btnAddTemplate.Click += new System.EventHandler(this.btnAddTemplate_Click); - // lblSshBody - this.lblSshBody.Location = new System.Drawing.Point(15, 78); - this.lblSshBody.Size = new System.Drawing.Size(200, 20); - this.lblSshBody.Text = "Тело письма (HTML):"; + // btnRemoveTemplate + this.btnRemoveTemplate.Location = new System.Drawing.Point(115, 355); + this.btnRemoveTemplate.Size = new System.Drawing.Size(90, 27); + this.btnRemoveTemplate.Text = "Удалить"; + this.btnRemoveTemplate.UseVisualStyleBackColor = true; + this.btnRemoveTemplate.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.btnRemoveTemplate.Click += new System.EventHandler(this.btnRemoveTemplate_Click); - // txtSshBody - this.txtSshBody.Location = new System.Drawing.Point(15, 101); - this.txtSshBody.Size = new System.Drawing.Size(610, 300); - this.txtSshBody.Multiline = true; - this.txtSshBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; - this.txtSshBody.AcceptsReturn = true; - this.txtSshBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + // lblTplDisplayName + this.lblTplDisplayName.Location = new System.Drawing.Point(220, 20); + this.lblTplDisplayName.Size = new System.Drawing.Size(200, 20); + this.lblTplDisplayName.Text = "Название:"; + + // txtTplDisplayName + this.txtTplDisplayName.Location = new System.Drawing.Point(220, 43); + this.txtTplDisplayName.Size = new System.Drawing.Size(410, 23); + this.txtTplDisplayName.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right)); + this.txtTplDisplayName.Leave += new System.EventHandler(this.txtTplDisplayName_Leave); + + // lblTplFileSuffix + this.lblTplFileSuffix.Location = new System.Drawing.Point(220, 78); + this.lblTplFileSuffix.Size = new System.Drawing.Size(200, 20); + this.lblTplFileSuffix.Text = "Суффикс файла:"; + + // txtTplFileSuffix + this.txtTplFileSuffix.Location = new System.Drawing.Point(220, 101); + this.txtTplFileSuffix.Size = new System.Drawing.Size(200, 23); + + // lblTplFileSuffixHint + this.lblTplFileSuffixHint.Location = new System.Drawing.Point(220, 127); + this.lblTplFileSuffixHint.Size = new System.Drawing.Size(410, 20); + this.lblTplFileSuffixHint.Text = "Файл: {login}_<суффикс>.7z"; + this.lblTplFileSuffixHint.ForeColor = System.Drawing.SystemColors.GrayText; + + // lblTplSubject + this.lblTplSubject.Location = new System.Drawing.Point(220, 160); + this.lblTplSubject.Size = new System.Drawing.Size(200, 20); + this.lblTplSubject.Text = "Тема письма:"; + + // txtTplSubject + this.txtTplSubject.Location = new System.Drawing.Point(220, 183); + this.txtTplSubject.Size = new System.Drawing.Size(410, 23); + this.txtTplSubject.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right)); + + // lblTplBody + this.lblTplBody.Location = new System.Drawing.Point(220, 218); + this.lblTplBody.Size = new System.Drawing.Size(200, 20); + this.lblTplBody.Text = "Тело письма (HTML):"; + + // txtTplBody + this.txtTplBody.Location = new System.Drawing.Point(220, 241); + this.txtTplBody.Size = new System.Drawing.Size(410, 160); + this.txtTplBody.Multiline = true; + this.txtTplBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtTplBody.AcceptsReturn = true; + this.txtTplBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - // lblSshHint - this.lblSshHint.Location = new System.Drawing.Point(15, 408); - this.lblSshHint.Size = new System.Drawing.Size(610, 20); - this.lblSshHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}"; - this.lblSshHint.ForeColor = System.Drawing.SystemColors.GrayText; - this.lblSshHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - - // - // tabConfigUpdate - // - this.tabConfigUpdate.Text = "Шаблон: обновление конфига"; - this.tabConfigUpdate.Padding = new System.Windows.Forms.Padding(12); - this.tabConfigUpdate.UseVisualStyleBackColor = true; - this.tabConfigUpdate.Controls.Add(this.lblCfgSubject); - this.tabConfigUpdate.Controls.Add(this.txtCfgSubject); - this.tabConfigUpdate.Controls.Add(this.lblCfgBody); - this.tabConfigUpdate.Controls.Add(this.txtCfgBody); - this.tabConfigUpdate.Controls.Add(this.lblCfgHint); - - // lblCfgSubject - this.lblCfgSubject.Location = new System.Drawing.Point(15, 20); - this.lblCfgSubject.Size = new System.Drawing.Size(200, 20); - this.lblCfgSubject.Text = "Тема письма:"; - - // txtCfgSubject - this.txtCfgSubject.Location = new System.Drawing.Point(15, 43); - this.txtCfgSubject.Size = new System.Drawing.Size(610, 23); - - // lblCfgBody - this.lblCfgBody.Location = new System.Drawing.Point(15, 78); - this.lblCfgBody.Size = new System.Drawing.Size(200, 20); - this.lblCfgBody.Text = "Тело письма (HTML):"; - - // txtCfgBody - this.txtCfgBody.Location = new System.Drawing.Point(15, 101); - this.txtCfgBody.Size = new System.Drawing.Size(610, 300); - this.txtCfgBody.Multiline = true; - this.txtCfgBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; - this.txtCfgBody.AcceptsReturn = true; - this.txtCfgBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - - // lblCfgHint - this.lblCfgHint.Location = new System.Drawing.Point(15, 408); - this.lblCfgHint.Size = new System.Drawing.Size(610, 20); - this.lblCfgHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}"; - this.lblCfgHint.ForeColor = System.Drawing.SystemColors.GrayText; - this.lblCfgHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + // lblTplHint + this.lblTplHint.Location = new System.Drawing.Point(220, 408); + this.lblTplHint.Size = new System.Drawing.Size(410, 20); + this.lblTplHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}"; + this.lblTplHint.ForeColor = System.Drawing.SystemColors.GrayText; + this.lblTplHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); // // btnSave / btnCancel / btnResetDefaults // this.btnResetDefaults.Location = new System.Drawing.Point(12, 494); this.btnResetDefaults.Size = new System.Drawing.Size(220, 30); - this.btnResetDefaults.Text = "Сбросить домен/шаблоны по умолчанию"; + this.btnResetDefaults.Text = "Сбросить шаблоны по умолчанию"; this.btnResetDefaults.UseVisualStyleBackColor = true; this.btnResetDefaults.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); this.btnResetDefaults.Click += new System.EventHandler(this.btnResetDefaults_Click); @@ -326,10 +346,8 @@ namespace BulkArchiveMailer.AddIn.UI this.tabControl.ResumeLayout(false); this.tabGeneral.ResumeLayout(false); this.tabGeneral.PerformLayout(); - this.tabSshAccess.ResumeLayout(false); - this.tabSshAccess.PerformLayout(); - this.tabConfigUpdate.ResumeLayout(false); - this.tabConfigUpdate.PerformLayout(); + this.tabTemplates.ResumeLayout(false); + this.tabTemplates.PerformLayout(); this.ResumeLayout(false); } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs index bfedfde..04613d5 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Windows.Forms; using BulkArchiveMailer.Core; using Outlook = Microsoft.Office.Interop.Outlook; @@ -7,13 +9,31 @@ namespace BulkArchiveMailer.AddIn.UI { /// /// Archive folder, email domain, signature override, test-mode default, - /// and the two editable mail templates. Saves straight to + /// 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(); @@ -61,11 +81,8 @@ namespace BulkArchiveMailer.AddIn.UI PopulateFromAccountCombo(settings.FromAccountSmtpAddress ?? ""); - var templates = settings.ToTemplateSet(); - txtSshSubject.Text = templates.SshAccess.Subject; - txtSshBody.Text = templates.SshAccess.BodyHtml; - txtCfgSubject.Text = templates.ConfigUpdate.Subject; - txtCfgBody.Text = templates.ConfigUpdate.BodyHtml; + templates = settings.ToTemplateList(); + PopulateTemplateList(selectFirst: true); } /// @@ -111,6 +128,211 @@ namespace BulkArchiveMailer.AddIn.UI 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()) @@ -168,7 +390,7 @@ namespace BulkArchiveMailer.AddIn.UI { var confirm = MessageBox.Show( this, - "Заменить домен и оба шаблона писем значениями по умолчанию? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.", + "Заменить домен и весь список шаблонов писем значениями по умолчанию (два исходных шаблона)? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.", "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Question); @@ -180,11 +402,8 @@ namespace BulkArchiveMailer.AddIn.UI var defaults = AppSettings.LoadDefaults(); txtDomain.Text = defaults.EmailDomain; - var templates = defaults.ToTemplateSet(); - txtSshSubject.Text = templates.SshAccess.Subject; - txtSshBody.Text = templates.SshAccess.BodyHtml; - txtCfgSubject.Text = templates.ConfigUpdate.Subject; - txtCfgBody.Text = templates.ConfigUpdate.BodyHtml; + templates = defaults.ToTemplateList(); + PopulateTemplateList(selectFirst: true); } private void btnSave_Click(object sender, EventArgs e) @@ -212,6 +431,21 @@ namespace BulkArchiveMailer.AddIn.UI } } + // 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 @@ -222,8 +456,7 @@ namespace BulkArchiveMailer.AddIn.UI TestModeDefault = chkTestModeDefault.Checked, FromAccountSmtpAddress = selectedAccount?.SmtpAddress ?? "", }; - data.SetTemplate(ArchiveKind.SshAccess, new MailTemplate { Subject = txtSshSubject.Text, BodyHtml = txtSshBody.Text }); - data.SetTemplate(ArchiveKind.ConfigUpdate, new MailTemplate { Subject = txtCfgSubject.Text, BodyHtml = txtCfgBody.Text }); + data.SetTemplates(templates); try { @@ -239,6 +472,36 @@ namespace BulkArchiveMailer.AddIn.UI 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; diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs index 1410682..f842cbe 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Reflection; using BulkArchiveMailer.Core; using Xunit; @@ -58,6 +59,85 @@ namespace BulkArchiveMailer.Core.Tests Assert.Equal("svc-mailer@redlab.lo", roundTripped.FromAccountSmtpAddress); } + /// + /// The riskiest part of the templates-list migration: a real + /// config.json saved by v1.0.0-v1.0.4 has "templates" as a JSON + /// OBJECT with "ssh_access"/"config_update" keys, not the array + /// AppSettingsData.Templates (List<MailTemplateData>) expects + /// now. Deserialize must transparently fall back to parsing that + /// old shape and convert it into two list entries with the right + /// FileSuffix - never throw, and never silently drop the + /// operator's saved Subject/BodyHtml text. + /// + [Fact] + public void Deserialize_LegacyObjectShapedTemplates_MigratesToTemplateList() + { + const string legacyJson = @"{ + ""archiveFolder"": ""C:\\archives"", + ""emailDomain"": ""redlab.lo"", + ""testModeDefault"": true, + ""signatureHtmlPath"": """", + ""fromAccountSmtpAddress"": """", + ""templates"": { + ""ssh_access"": { + ""subject"": ""Legacy SSH subject {Login}"", + ""bodyHtml"": ""

legacy ssh body

"" + }, + ""config_update"": { + ""subject"": ""Legacy config subject {Login}"", + ""bodyHtml"": ""

legacy config body

"" + } + } + }"; + + var migrated = InvokeDeserialize(legacyJson); + + Assert.Equal("C:\\archives", migrated.ArchiveFolder); + Assert.Equal("redlab.lo", migrated.EmailDomain); + Assert.NotNull(migrated.Templates); + Assert.Equal(2, migrated.Templates.Count); + + var ssh = migrated.Templates.Single(t => t.FileSuffix == "ssh_access"); + Assert.Equal("Legacy SSH subject {Login}", ssh.Subject); + Assert.Equal("

legacy ssh body

", ssh.BodyHtml); + Assert.False(string.IsNullOrEmpty(ssh.DisplayName)); + + var cfg = migrated.Templates.Single(t => t.FileSuffix == "config_update"); + Assert.Equal("Legacy config subject {Login}", cfg.Subject); + Assert.Equal("

legacy config body

", cfg.BodyHtml); + Assert.False(string.IsNullOrEmpty(cfg.DisplayName)); + + // And the converted result must itself be usable to build the + // Core template list AddIn code consumes (ArchiveScanner.Scan's + // "templates" parameter). + var templateList = migrated.ToTemplateList(); + Assert.Contains(templateList, t => t.FileSuffix == "ssh_access" && t.Subject == "Legacy SSH subject {Login}"); + Assert.Contains(templateList, t => t.FileSuffix == "config_update" && t.Subject == "Legacy config subject {Login}"); + } + + [Fact] + public void Deserialize_GenuinelyMalformedJson_ThrowsRatherThanSilentlyMigrating() + { + // Syntactically broken JSON (unterminated object) - not just a + // structural/shape mismatch (DataContractJsonSerializer turns + // out to be lenient about those, see the long comment on + // AppSettings.Deserialize) but text that isn't valid JSON at + // all. The legacy-shape detection must not treat this as + // legacy (IsLegacyTemplatesShape swallows the parse failure and + // returns false), and the normal parse attempt that runs next + // must still throw rather than silently producing a + // half-initialized settings object. + const string trulyMalformedJson = "{ \"archiveFolder\": \"C:\\\\archives\", \"templates\": [ { "; + + // InvokeDeserialize goes through MethodInfo.Invoke (private + // static Deserialize, same as every other test in this file), + // which wraps any exception thrown by the invoked method in a + // TargetInvocationException - unwrap it to assert on the real + // exception type Deserialize itself throws. + var thrown = Assert.Throws(() => InvokeDeserialize(trulyMalformedJson)); + Assert.NotNull(thrown.InnerException); + } + private static AppSettingsData InvokeDeserialize(string json) { var method = typeof(AppSettings).GetMethod("Deserialize", BindingFlags.NonPublic | BindingFlags.Static); diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/FilenameParserTests.cs b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/FilenameParserTests.cs index 6248769..fbda3bb 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/FilenameParserTests.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/FilenameParserTests.cs @@ -19,7 +19,20 @@ namespace BulkArchiveMailer.Core.Tests Assert.True(ok); Assert.Equal("a-makarov", parsed.Login); - Assert.Equal(ArchiveKind.SshAccess, parsed.Kind); + Assert.Equal("ssh_access", parsed.Suffix); + } + + [Fact] + public void TryParse_ArbitraryConfiguredSuffix_Succeeds() + { + // FilenameParser itself no longer knows about any fixed set of + // suffixes - matching a suffix against configured templates is + // ArchiveScanner's job now, not the parser's. + var ok = FilenameParser.TryParse("a-makarov_onboarding_extra.7z", out var parsed); + + Assert.True(ok); + Assert.Equal("a-makarov", parsed.Login); + Assert.Equal("onboarding_extra", parsed.Suffix); } } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs index b294847..c6a9b61 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.Serialization; using System.Runtime.Serialization.Json; @@ -7,9 +9,16 @@ using System.Text; namespace BulkArchiveMailer.Core { + /// One entry of AppSettingsData.Templates - the JSON shape of a MailTemplateDefinition. [DataContract] - public sealed class TemplateData + public sealed class MailTemplateData { + [DataMember(Name = "displayName")] + public string DisplayName { get; set; } = ""; + + [DataMember(Name = "fileSuffix")] + public string FileSuffix { get; set; } = ""; + [DataMember(Name = "subject")] public string Subject { get; set; } = ""; @@ -17,16 +26,6 @@ namespace BulkArchiveMailer.Core public string BodyHtml { get; set; } = ""; } - [DataContract] - public sealed class TemplateSetData - { - [DataMember(Name = "ssh_access")] - public TemplateData SshAccess { get; set; } = new TemplateData(); - - [DataMember(Name = "config_update")] - public TemplateData ConfigUpdate { get; set; } = new TemplateData(); - } - /// Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it). [DataContract] public sealed class AppSettingsData @@ -70,38 +69,57 @@ namespace BulkArchiveMailer.Core [DataMember(Name = "fromAccountSmtpAddress")] public string FromAccountSmtpAddress { get; set; } = ""; + /// + /// Arbitrary-length list of configured templates - replaces the old + /// fixed ssh_access/config_update pair. Each entry's FileSuffix is + /// expected to be non-empty and unique among the list; enforcing + /// that is SettingsForm's job at save time (see its btnSave_Click), + /// not this class's. + /// [DataMember(Name = "templates")] - public TemplateSetData Templates { get; set; } = new TemplateSetData(); + public List Templates { get; set; } = new List(); - public MailTemplateSet ToTemplateSet() + public List ToTemplateList() { - return new MailTemplateSet + var list = new List(); + if (Templates == null) { - SshAccess = new MailTemplate + return list; + } + + foreach (var t in Templates) + { + list.Add(new MailTemplateDefinition { - Subject = Templates?.SshAccess?.Subject ?? "", - BodyHtml = Templates?.SshAccess?.BodyHtml ?? "", - }, - ConfigUpdate = new MailTemplate - { - Subject = Templates?.ConfigUpdate?.Subject ?? "", - BodyHtml = Templates?.ConfigUpdate?.BodyHtml ?? "", - }, - }; + DisplayName = t?.DisplayName ?? "", + FileSuffix = t?.FileSuffix ?? "", + Subject = t?.Subject ?? "", + BodyHtml = t?.BodyHtml ?? "", + }); + } + + return list; } - public void SetTemplate(ArchiveKind kind, MailTemplate template) + /// Replaces the whole template list - used by SettingsForm's Save, which always writes back the entire in-memory list rather than one slot at a time (there are no fixed slots anymore). + public void SetTemplates(List templates) { - Templates = Templates ?? new TemplateSetData(); - var data = new TemplateData { Subject = template?.Subject ?? "", BodyHtml = template?.BodyHtml ?? "" }; - if (kind == ArchiveKind.SshAccess) + var list = new List(); + if (templates != null) { - Templates.SshAccess = data; - } - else - { - Templates.ConfigUpdate = data; + foreach (var t in templates) + { + list.Add(new MailTemplateData + { + DisplayName = t?.DisplayName ?? "", + FileSuffix = t?.FileSuffix ?? "", + Subject = t?.Subject ?? "", + BodyHtml = t?.BodyHtml ?? "", + }); + } } + + Templates = list; } } @@ -126,6 +144,57 @@ namespace BulkArchiveMailer.Core { private const string EmbeddedDefaultResourceName = "BulkArchiveMailer.Core.config.default.json"; + /// + /// Shape of a config.json written by versions v1.0.0-v1.0.4, before + /// ArchiveKind/the fixed ssh_access+config_update pair was replaced + /// by an open-ended template list - back then "templates" was a + /// JSON OBJECT with two named keys, not an array. Private/nested: + /// only ever used as a one-time fallback parse target inside + /// Load()'s migration handling below, never exposed outside this + /// file. + /// + [DataContract] + private sealed class LegacyTemplateData + { + [DataMember(Name = "subject")] + public string Subject { get; set; } = ""; + + [DataMember(Name = "bodyHtml")] + public string BodyHtml { get; set; } = ""; + } + + [DataContract] + private sealed class LegacyTemplateSetData + { + [DataMember(Name = "ssh_access")] + public LegacyTemplateData SshAccess { get; set; } = new LegacyTemplateData(); + + [DataMember(Name = "config_update")] + public LegacyTemplateData ConfigUpdate { get; set; } = new LegacyTemplateData(); + } + + [DataContract] + private sealed class LegacyAppSettingsData + { + [DataMember(Name = "archiveFolder")] + public string ArchiveFolder { get; set; } = ""; + + [DataMember(Name = "emailDomain")] + public string EmailDomain { get; set; } = "redlab.lo"; + + [DataMember(Name = "testModeDefault")] + public bool TestModeDefault { get; set; } = true; + + [DataMember(Name = "signatureHtmlPath")] + public string SignatureHtmlPath { get; set; } = ""; + + [DataMember(Name = "fromAccountSmtpAddress")] + public string FromAccountSmtpAddress { get; set; } = ""; + + [DataMember(Name = "templates")] + public LegacyTemplateSetData Templates { get; set; } = new LegacyTemplateSetData(); + } + public static string ConfigDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "BulkArchiveMailer"); @@ -158,6 +227,14 @@ namespace BulkArchiveMailer.Core /// never silently overwritten - the DataContractJsonSerializer /// exception propagates so the caller can warn the operator /// instead of quietly discarding their edits. + /// + /// If the file on disk turns out to be in the pre-v1.0.5 legacy + /// shape (object "templates" instead of a list - see + /// LegacyAppSettingsData), Deserialize below transparently + /// migrates it in memory; this method additionally re-persists the + /// migrated result immediately (Save), so every later Load() goes + /// through the fast/primary parse path instead of re-migrating on + /// every single load. ///
public static AppSettingsData Load() { @@ -168,7 +245,14 @@ namespace BulkArchiveMailer.Core } var json = File.ReadAllText(ConfigFilePath, Encoding.UTF8); - return Deserialize(json); + var data = DeserializeWithLegacyFallback(json, out var wasLegacyMigration); + + if (wasLegacyMigration) + { + Save(data); + } + + return data; } public static void Save(AppSettingsData data) @@ -182,16 +266,134 @@ namespace BulkArchiveMailer.Core File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false)); } - private static AppSettingsData Deserialize(string json) + /// + /// Pure (no disk I/O) parse of a config.json's text into + /// AppSettingsData. DataContractJsonSerializer turns out to be far + /// too lenient to detect the legacy shape by catching an exception + /// from a straight `ReadObject(typeof(AppSettingsData))` call - + /// given the old object-shaped "templates", it does NOT throw, it + /// just silently produces an EMPTY List<MailTemplateData> + /// (confirmed while writing the migration test for this method - + /// see AppSettingsTests). So detecting "is this the legacy shape" + /// has to happen explicitly, before deciding which type to parse + /// into - see IsLegacyTemplatesShape below, which inspects the + /// JSON structurally (via the same JsonReaderWriterFactory + /// DataContractJsonSerializer itself uses under the hood) rather + /// than relying on a parse exception that never comes. + /// + private static AppSettingsData Deserialize(string json) => DeserializeWithLegacyFallback(json, out _); + + private static AppSettingsData DeserializeWithLegacyFallback(string json, out bool wasLegacyMigration) + { + wasLegacyMigration = false; + + if (IsLegacyTemplatesShape(json)) + { + try + { + var legacy = DeserializeAs(json); + wasLegacyMigration = true; + return ConvertLegacyToCurrent(legacy); + } + catch + { + // Looked legacy-shaped ("templates" is a JSON object) + // but doesn't actually parse as LegacyAppSettingsData + // either - fall through to the normal parse attempt + // below and let ITS exception (if any) describe what's + // actually wrong, rather than masking it with a legacy + // parse failure that was never the real problem. + } + } + + return DeserializeAs(json); + } + + private static T DeserializeAs(string json) { var bytes = Encoding.UTF8.GetBytes(json); using (var ms = new MemoryStream(bytes)) { - var serializer = new DataContractJsonSerializer(typeof(AppSettingsData)); - return (AppSettingsData)serializer.ReadObject(ms); + var serializer = new DataContractJsonSerializer(typeof(T)); + return (T)serializer.ReadObject(ms); } } + /// + /// True if the top-level "templates" member of this JSON text is a + /// JSON OBJECT (the pre-v1.0.5 shape: {"ssh_access": {...}, + /// "config_update": {...}}) rather than a JSON ARRAY (the current + /// List<MailTemplateData> shape). Inspects the JSON via + /// JsonReaderWriterFactory/XElement - the same JSON-to-XML mapping + /// DataContractJsonSerializer itself is built on - specifically so + /// this check reflects the real wire shape rather than a hand-rolled + /// regex/text scan. Returns false (never throws) for anything that + /// isn't even parseable as JSON at all; DeserializeWithLegacyFallback's + /// normal AppSettingsData parse attempt is what surfaces a + /// genuinely malformed file's exception to the caller. + /// + private static bool IsLegacyTemplatesShape(string json) + { + try + { + var bytes = Encoding.UTF8.GetBytes(json ?? ""); + using (var reader = JsonReaderWriterFactory.CreateJsonReader(bytes, System.Xml.XmlDictionaryReaderQuotas.Max)) + { + var root = System.Xml.Linq.XElement.Load(reader); + var templatesElement = root.Element("templates"); + var typeAttr = templatesElement?.Attribute("type"); + return typeAttr != null && string.Equals(typeAttr.Value, "object", StringComparison.OrdinalIgnoreCase); + } + } + catch + { + return false; + } + } + + /// + /// Converts a pre-v1.0.5 config.json (ArchiveKind-based, exactly + /// two named templates) into the current shape: two list entries + /// with FileSuffix "ssh_access"/"config_update" (same text the old + /// JSON keys already used) carrying over the operator's saved + /// Subject/BodyHtml. DisplayName did not exist in the old shape, so + /// it is filled in from the embedded defaults instead. + /// + private static AppSettingsData ConvertLegacyToCurrent(LegacyAppSettingsData legacy) + { + var defaults = LoadDefaults(); + string DefaultDisplayName(string suffix) => + defaults.Templates?.FirstOrDefault(t => string.Equals(t?.FileSuffix, suffix, StringComparison.OrdinalIgnoreCase))?.DisplayName ?? ""; + + var migrated = new AppSettingsData + { + ArchiveFolder = legacy?.ArchiveFolder ?? "", + EmailDomain = string.IsNullOrEmpty(legacy?.EmailDomain) ? "redlab.lo" : legacy.EmailDomain, + TestModeDefault = legacy?.TestModeDefault ?? true, + SignatureHtmlPath = legacy?.SignatureHtmlPath ?? "", + FromAccountSmtpAddress = legacy?.FromAccountSmtpAddress ?? "", + Templates = new List + { + new MailTemplateData + { + DisplayName = DefaultDisplayName("ssh_access"), + FileSuffix = "ssh_access", + Subject = legacy?.Templates?.SshAccess?.Subject ?? "", + BodyHtml = legacy?.Templates?.SshAccess?.BodyHtml ?? "", + }, + new MailTemplateData + { + DisplayName = DefaultDisplayName("config_update"), + FileSuffix = "config_update", + Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "", + BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "", + }, + }, + }; + + return migrated; + } + private static string Serialize(AppSettingsData data) { using (var ms = new MemoryStream()) diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs index 3ad6a8e..c90d76a 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs @@ -26,9 +26,9 @@ namespace BulkArchiveMailer.Core { /// Folder to scan (non-recursive - ssh-access-tui writes archives flat into one output directory). /// Domain appended to the derived local part, e.g. "redlab.lo". - /// Used to render each Ready job's Subject/BodyHtml. May be null (rows are then created with empty Subject/BodyHtml, useful for pure filename-parsing tests). + /// Configured templates, matched against each parsed file's suffix (case-insensitively) to render that job's Subject/BodyHtml/KindDisplay. May be null or empty (every recognized file then goes to UnrecognizedFiles, since nothing can match), useful for pure filename-parsing tests. /// When provided, each single-archive login is checked against the log (by SHA-256 of the archive's current bytes) and marked AlreadySent if a prior Sent row matches. When null, dedup is skipped entirely and every recognized single-archive login comes back Ready. - public static ScanResult Scan(string folderPath, string emailDomain, MailTemplateSet templates, SendLogStore sendLog = null) + public static ScanResult Scan(string folderPath, string emailDomain, List templates, SendLogStore sendLog = null) { var result = new ScanResult(); @@ -41,9 +41,9 @@ namespace BulkArchiveMailer.Core return result; } - // login -> every (path, kind) found for it, case-insensitive + // login -> every (path, suffix) found for it, case-insensitive // because the file system this runs on (Windows) is too. - var byLogin = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var byLogin = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var filePath in Directory.EnumerateFiles(folderPath, "*.7z", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)) { @@ -55,18 +55,30 @@ namespace BulkArchiveMailer.Core { ArchivePath = filePath, Status = SendStatus.Skipped, - ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_ssh_access.7z\" / \"{{login}}_config_update.7z\": {fileName}", + ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_{{суффикс}}.7z\": {fileName}", + }); + continue; + } + + var matchedTemplate = templates?.FirstOrDefault(t => string.Equals(t.FileSuffix, parsed.Suffix, StringComparison.OrdinalIgnoreCase)); + if (matchedTemplate == null) + { + result.UnrecognizedFiles.Add(new SendJob + { + ArchivePath = filePath, + Status = SendStatus.Skipped, + ErrorMessage = $"Суффикс \"{parsed.Suffix}\" не соответствует ни одному настроенному шаблону: {fileName}", }); continue; } if (!byLogin.TryGetValue(parsed.Login, out var list)) { - list = new List<(string, ArchiveKind)>(); + list = new List<(string, string)>(); byLogin[parsed.Login] = list; } - list.Add((filePath, parsed.Kind)); + list.Add((filePath, parsed.Suffix)); } foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) @@ -95,19 +107,21 @@ namespace BulkArchiveMailer.Core continue; } - var (path, kind) = files[0]; - var rendered = templates != null - ? TemplateEngine.Render(templates.Get(kind), login, email) - : new MailTemplate(); + var (path, suffix) = files[0]; + // Guaranteed non-null: a suffix only ever makes it into + // byLogin after already matching a template above. + var matchedTemplate = templates.First(t => string.Equals(t.FileSuffix, suffix, StringComparison.OrdinalIgnoreCase)); + var values = TemplateEngine.BuildValues(login, email); var job = new SendJob { Login = login, Email = email, - Kind = kind, + Kind = suffix, ArchivePath = path, - Subject = rendered.Subject, - BodyHtml = rendered.BodyHtml, + Subject = TemplateEngine.Render(matchedTemplate.Subject, values), + BodyHtml = TemplateEngine.Render(matchedTemplate.BodyHtml, values), + KindDisplay = matchedTemplate.DisplayName, Status = SendStatus.Ready, }; @@ -116,7 +130,7 @@ namespace BulkArchiveMailer.Core try { job.FileSha256 = SendLogStore.ComputeSha256(path); - if (sendLog.TryFindSent(email, kind, job.FileSha256, out var priorEntry)) + if (sendLog.TryFindSent(email, suffix, job.FileSha256, out var priorEntry)) { job.Status = SendStatus.AlreadySent; job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор)."; diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj b/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj index 0d17404..d77ff86 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj @@ -48,7 +48,7 @@ - + diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/FilenameParser.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/FilenameParser.cs index 923fdc2..f8e54f0 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/FilenameParser.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/FilenameParser.cs @@ -3,51 +3,54 @@ using System.Text.RegularExpressions; namespace BulkArchiveMailer.Core { - /// - /// Which kind of archive ssh-access-tui produced. See - /// internal/keymgr/pack.go (BuildUserArchive -> "_ssh_access.7z") and - /// internal/keymgr/configonly.go (BuildConfigOnlyArchive -> - /// "_config_update.7z") in the main repo for the producing side. - /// - public enum ArchiveKind - { - SshAccess, - ConfigUpdate - } - /// Result of successfully parsing an archive file name. public sealed class ParsedFilename { - public ParsedFilename(string login, ArchiveKind kind) + public ParsedFilename(string login, string suffix) { Login = login; - Kind = kind; + Suffix = suffix; } /// Lower-cased login exactly as it appears in the file name (hyphens intact). public string Login { get; } - public ArchiveKind Kind { get; } + /// + /// Lower-cased suffix exactly as it appears in the file name (the + /// part between the login and ".7z"), e.g. "ssh_access" or + /// "config_update" for the two built-in templates, or whatever + /// FileSuffix a custom template in AppSettingsData.Templates uses. + /// Whether this suffix corresponds to any *configured* template is + /// NOT checked here - that is ArchiveScanner's job, since it is the + /// one holding the list of templates. + /// + public string Suffix { get; } } /// - /// Parses the two file name shapes ssh-access-tui ever produces: - /// "{login}_ssh_access.7z" and "{login}_config_update.7z". Anything - /// else (wrong extension, extra suffix, typo'd kind, stray file dropped - /// in the export folder by a human) simply fails to parse - the caller + /// Parses the generic "{login}_{suffix}.7z" file name shape + /// ssh-access-tui produces (see internal/keymgr/pack.go and + /// internal/keymgr/configonly.go in the main repo for the producing + /// side, and config.default.json/SettingsForm for how an operator adds + /// further suffixes via custom templates). Anything else (wrong + /// extension, no underscore-separated suffix, stray file dropped in the + /// export folder by a human) simply fails to parse - the caller /// (ArchiveScanner) is responsible for routing that into the - /// "unrecognized" bucket instead of blocking the batch. + /// "unrecognized" bucket instead of blocking the batch. Whether a + /// syntactically valid suffix actually matches a configured template is + /// also ArchiveScanner's job, not this parser's. /// public static class FilenameParser { // Matches the login part ssh-access-tui itself accepts for a Linux // username: lowercase alnum segments joined by single hyphens (no - // leading/trailing/double hyphens). Case-insensitive purely as a - // defensive measure in case a file was renamed by hand on a - // case-insensitive filesystem; the captured login is normalized to - // lower case before use. + // leading/trailing/double hyphens), followed by an underscore and + // any lowercase alnum/underscore suffix. Case-insensitive purely as + // a defensive measure in case a file was renamed by hand on a + // case-insensitive filesystem; the captured login/suffix are + // normalized to lower case before use. private static readonly Regex Pattern = new Regex( - @"^(?[a-z0-9]+(?:-[a-z0-9]+)*)_(?ssh_access|config_update)\.7z$", + @"^(?[a-z0-9]+(?:-[a-z0-9]+)*)_(?[a-z0-9_]+)\.7z$", RegexOptions.IgnoreCase | RegexOptions.Compiled); public static bool TryParse(string fileName, out ParsedFilename parsed) @@ -65,10 +68,9 @@ namespace BulkArchiveMailer.Core } var login = match.Groups["login"].Value.ToLowerInvariant(); - var kindText = match.Groups["kind"].Value.ToLowerInvariant(); - var kind = kindText == "ssh_access" ? ArchiveKind.SshAccess : ArchiveKind.ConfigUpdate; + var suffix = match.Groups["suffix"].Value.ToLowerInvariant(); - parsed = new ParsedFilename(login, kind); + parsed = new ParsedFilename(login, suffix); return true; } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplateDefinition.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplateDefinition.cs new file mode 100644 index 0000000..dfb7c41 --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplateDefinition.cs @@ -0,0 +1,34 @@ +namespace BulkArchiveMailer.Core +{ + /// + /// One configured mail template: which files it applies to (by file + /// name suffix, "{login}_{FileSuffix}.7z"), plus its Subject/BodyHtml. + /// Replaces the old fixed pair of named templates (ssh_access / + /// config_update) with an arbitrary, operator-editable list - + /// AppSettingsData.Templates now holds a List<MailTemplateDefinition> + /// instead of two hard-coded slots. + /// + /// FileSuffix is the only identifier a template needs: it must be + /// non-empty and unique (case-insensitively) among all configured + /// templates for ArchiveScanner's suffix-to-template lookup to be + /// unambiguous. Enforcing that (non-empty + unique) is SettingsForm's + /// job at save time, not this class's - a plain data holder has no + /// validation of its own. + /// + public sealed class MailTemplateDefinition + { + public string DisplayName { get; set; } = ""; + + /// + /// Suffix of the file name this template renders for: matches + /// files named "{login}_{FileSuffix}.7z" (case-insensitive; see + /// FilenameParser/ArchiveScanner). Must be non-empty and unique + /// among all configured templates - see class remarks. + /// + public string FileSuffix { get; set; } = ""; + + public string Subject { get; set; } = ""; + + public string BodyHtml { get; set; } = ""; + } +} diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplates.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplates.cs deleted file mode 100644 index dc16ea4..0000000 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplates.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace BulkArchiveMailer.Core -{ - /// One editable Subject + BodyHtml pair. - public sealed class MailTemplate - { - public string Subject { get; set; } = ""; - - public string BodyHtml { get; set; } = ""; - } - - /// The two named template keys used throughout the tool (matches ArchiveKind, and the JSON config's "templates" object). - public static class MailTemplateKeys - { - public const string SshAccess = "ssh_access"; - public const string ConfigUpdate = "config_update"; - - public static string For(ArchiveKind kind) => kind == ArchiveKind.SshAccess ? SshAccess : ConfigUpdate; - } - - /// Both named templates together, as used by ArchiveScanner to render a SendJob's Subject/BodyHtml. - public sealed class MailTemplateSet - { - public MailTemplate SshAccess { get; set; } = new MailTemplate(); - - public MailTemplate ConfigUpdate { get; set; } = new MailTemplate(); - - public MailTemplate Get(ArchiveKind kind) => kind == ArchiveKind.SshAccess ? SshAccess : ConfigUpdate; - - /// - /// The two templates as shipped in config.default.json (embedded - /// resource), independent of anything the user has saved to - /// %APPDATA%\BulkArchiveMailer\config.json. Mainly useful for a - /// "reset to defaults" action in SettingsForm. - /// - public static MailTemplateSet LoadDefaults() => AppSettings.LoadDefaults().ToTemplateSet(); - } -} diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/SendJob.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/SendJob.cs index d88a0ae..6cec815 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/SendJob.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/SendJob.cs @@ -29,8 +29,8 @@ namespace BulkArchiveMailer.Core public string Email { get; set; } = ""; - /// Null for Error rows caused by a login/kind conflict, and for Skipped (unrecognized file) rows. - public ArchiveKind? Kind { get; set; } + /// File suffix ("ssh_access", "config_update", or any other configured template's FileSuffix). Null for Error rows caused by a login/kind conflict, and for Skipped (unrecognized file) rows. + public string Kind { get; set; } /// Null for conflict Error rows (see ) - otherwise the single archive to attach. public string ArchivePath { get; set; } @@ -55,21 +55,14 @@ namespace BulkArchiveMailer.Core public string FileName => string.IsNullOrEmpty(ArchivePath) ? "" : Path.GetFileName(ArchivePath); - public string KindDisplay - { - get - { - switch (Kind) - { - case ArchiveKind.SshAccess: - return "Новый доступ (ключ + конфиг)"; - case ArchiveKind.ConfigUpdate: - return "Обновление конфига"; - default: - return "-"; - } - } - } + /// + /// Human-readable label for the matched template (its + /// DisplayName), explicitly set by ArchiveScanner when it renders + /// this job - "-" for conflict Error rows and Skipped rows, which + /// never had a matched template. No longer computed from a fixed + /// enum switch, since the set of templates is now open-ended. + /// + public string KindDisplay { get; set; } = "-"; public override string ToString() => $"{Login} <{Email}> [{Status}]"; } diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/SendLogStore.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/SendLogStore.cs index ac81936..91d9634 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/SendLogStore.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/SendLogStore.cs @@ -22,7 +22,10 @@ namespace BulkArchiveMailer.Core public DateTime TimestampUtc { get; set; } public string Login { get; set; } = ""; public string Email { get; set; } = ""; - public ArchiveKind Kind { get; set; } + + /// File suffix ("ssh_access", "config_update", or any other configured template's FileSuffix) - see the normalization note on SendLogStore.ReadAll for the legacy PascalCase values (e.g. "SshAccess") written by versions v1.0.0-v1.0.4. + public string Kind { get; set; } = ""; + public string ArchivePath { get; set; } = ""; public string FileSha256 { get; set; } = ""; public string Status { get; set; } = ""; @@ -109,8 +112,7 @@ namespace BulkArchiveMailer.Core continue; // malformed/truncated row - skip rather than throw } - ArchiveKind kind; - Enum.TryParse(fields[3], true, out kind); + var kind = NormalizeLegacyKind(fields[3]); DateTime ts; DateTime.TryParse( @@ -136,6 +138,32 @@ namespace BulkArchiveMailer.Core return result; } + /// + /// Maps the legacy PascalCase Kind values written by v1.0.0-v1.0.4 + /// (ArchiveKind.ToString(): "SshAccess"/"ConfigUpdate") to the + /// snake_case FileSuffix text used everywhere since ArchiveKind was + /// replaced by a free-form template list ("ssh_access"/ + /// "config_update" - same text as config.default.json's + /// "fileSuffix"). Anything else (already-current suffix, or a + /// custom suffix that never existed under the old enum) passes + /// through unchanged, so TryFindSent's dedup lookup keeps matching + /// against send-log.csv rows written before this migration. + /// + private static string NormalizeLegacyKind(string rawKind) + { + if (string.Equals(rawKind, "SshAccess", StringComparison.OrdinalIgnoreCase)) + { + return "ssh_access"; + } + + if (string.Equals(rawKind, "ConfigUpdate", StringComparison.OrdinalIgnoreCase)) + { + return "config_update"; + } + + return rawKind; + } + public void Append(SendLogEntry entry) { if (entry == null) @@ -162,7 +190,7 @@ namespace BulkArchiveMailer.Core Escape(entry.TimestampUtc.ToString("o", CultureInfo.InvariantCulture)), Escape(entry.Login), Escape(entry.Email), - Escape(entry.Kind.ToString()), + Escape(entry.Kind ?? ""), Escape(entry.ArchivePath), Escape(entry.FileSha256), Escape(entry.Status), @@ -176,7 +204,7 @@ namespace BulkArchiveMailer.Core /// has Status=Sent. Draft/Failed rows never count as "already /// sent" - only a completed real Send does. /// - public bool TryFindSent(string email, ArchiveKind kind, string sha256, out SendLogEntry match) + public bool TryFindSent(string email, string kind, string sha256, out SendLogEntry match) { match = null; if (string.IsNullOrEmpty(sha256) || string.IsNullOrEmpty(email)) @@ -196,7 +224,7 @@ namespace BulkArchiveMailer.Core continue; } - if (entry.Kind != kind) + if (!string.Equals(entry.Kind, kind, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/TemplateEngine.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/TemplateEngine.cs index 5a845ca..0cdbce3 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/TemplateEngine.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/TemplateEngine.cs @@ -42,21 +42,15 @@ namespace BulkArchiveMailer.Core }); } - /// Renders both Subject and BodyHtml of a template for one recipient. - public static MailTemplate Render(MailTemplate template, string login, string email) + /// Builds the {Login}/{Email}/{Date} placeholder values for one recipient, ready to pass to Render(string, IDictionary<string,string>) for a template's Subject and BodyHtml separately. + public static Dictionary BuildValues(string login, string email) { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase) + return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Login"] = login ?? "", ["Email"] = email ?? "", ["Date"] = DateTime.Now.ToString("dd.MM.yyyy"), }; - - return new MailTemplate - { - Subject = Render(template?.Subject ?? "", values), - BodyHtml = Render(template?.BodyHtml ?? "", values), - }; } } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json b/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json index 59838d5..4fdd0b3 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json @@ -4,14 +4,18 @@ "testModeDefault": true, "signatureHtmlPath": "", "fromAccountSmtpAddress": "", - "templates": { - "ssh_access": { + "templates": [ + { + "displayName": "Новый доступ (ключ + конфиг)", + "fileSuffix": "ssh_access", "subject": "Доступ к SSH jump-хосту — {Login}", "bodyHtml": "

Здравствуйте!

Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению ({Login}_ssh_access.7z).

Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.

Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.

" }, - "config_update": { + { + "displayName": "Обновление конфига", + "fileSuffix": "config_update", "subject": "Обновление конфигурации доступа — {Login}", "bodyHtml": "

Здравствуйте!

Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция ({Login}_config_update.7z). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.

Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.

" } - } + ] } diff --git a/outlook-bulk-sender/README.md b/outlook-bulk-sender/README.md index f6d61a6..fab2cd3 100644 --- a/outlook-bulk-sender/README.md +++ b/outlook-bulk-sender/README.md @@ -48,22 +48,22 @@ BulkArchiveMailer **не порождает архивы и не знает па BulkArchiveMailer.sln BulkArchiveMailer.Core/ net48, class library, БЕЗ ссылок на Outlook/COM/реестр — можно собрать и протестировать без Windows/Office. - FilenameParser.cs парсинг "{login}_(ssh_access|config_update).7z" + FilenameParser.cs парсинг "{login}_{суффикс}.7z" (суффикс - любой, сверяется с шаблонами в ArchiveScanner) EmailAddressBuilder.cs login -> "login.with.dots@domain" SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status) - ArchiveScanner.cs сканирование папки, группировка по логину, конфликт "оба архива сразу" + ArchiveScanner.cs сканирование папки, сопоставление суффикса с шаблоном, конфликт "оба архива сразу" TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон - MailTemplates.cs два именованных шаблона (ssh_access / config_update) + MailTemplateDefinition.cs один шаблон (название, суффикс файла, тема, HTML-тело) - список произвольной длины SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива) - AppSettings.cs %APPDATA%\BulkArchiveMailer\config.json, засев из config.default.json - config.default.json шаблоны писем и домен по умолчанию (встроен как Embedded Resource) + AppSettings.cs %APPDATA%\BulkArchiveMailer\config.json, засев из config.default.json, миграция старого формата шаблонов + config.default.json шаблоны писем (список) и домен по умолчанию (встроен как Embedded Resource) BulkArchiveMailer.Core.Tests/ xUnit-заготовка (один smoke-тест) — полное покрытие добавляется отдельно BulkArchiveMailer.AddIn/ VSTO-плагин, net48, Windows-only, ссылается на Core ThisAddIn.cs / .Designer.cs запуск/остановка плагина Ribbon.xml + RibbonController.cs одна кнопка ленты "Массовая рассылка" SignatureReader.cs чтение подписи Outlook из %APPDATA%\Microsoft\Signatures через реестр MailSender.cs создание/отправка Outlook.MailItem, тестовый режим (Черновики) - UI/SettingsForm.cs папка, домен, подпись, тестовый режим по умолчанию, оба шаблона + UI/SettingsForm.cs папка, домен, подпись, тестовый режим по умолчанию, список шаблонов (добавление/удаление) UI/PreviewForm.cs таблица предпросмотра, баннер про пароль, отправка, итоговый журнал docs/BUILD.md как собрать и подключить плагин в Visual Studio на Windows ``` @@ -93,8 +93,16 @@ Outlook в принципе не запустить. (`Session.Accounts`), полезно, если у оператора несколько ящиков (например, отдельный служебный/shared-ящик именно для этой рассылки). Пункт "(по умолчанию Outlook)" — прежнее поведение без явного выбора аккаунта. -- **Шаблоны писем** — тема и HTML-тело отдельно для `ssh_access` и - `config_update`, с плейсхолдерами `{Login}`, `{Email}`, `{Date}`. +- **Шаблоны писем** — вкладка "Шаблоны" со списком произвольного числа + шаблонов (кнопки "+ Добавить"/"Удалить"). Каждый шаблон — это название, + суффикс имени файла (`{login}_<суффикс>.7z`, по которому шаблон подбирается + для конкретного архива), тема и HTML-тело с плейсхолдерами `{Login}`, + `{Email}`, `{Date}`. Суффикс обязателен и должен быть уникальным среди всех + шаблонов — по нему, а не по фиксированному перечислению видов архива, + `ArchiveScanner` теперь сопоставляет файл с шаблоном. Два исходных шаблона + (`ssh_access`/`config_update`) — обычные равноправные записи в этом + списке, без особой защиты от переименования/удаления; кнопка "Сбросить + шаблоны по умолчанию" восстанавливает их, если они были случайно удалены. - **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются в Черновики, а не отправляются) до первого осознанного отключения оператором в окне предпросмотра.