diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs index 4a4b517..a09197a 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs @@ -44,6 +44,9 @@ namespace BulkArchiveMailer.AddIn.UI private System.Windows.Forms.Label lblTplFileSuffix; private System.Windows.Forms.TextBox txtTplFileSuffix; private System.Windows.Forms.Label lblTplFileSuffixHint; + private System.Windows.Forms.Label lblTplLoginPattern; + private System.Windows.Forms.TextBox txtTplLoginPattern; + private System.Windows.Forms.Label lblTplLoginPatternHint; private System.Windows.Forms.Label lblTplSubject; private System.Windows.Forms.TextBox txtTplSubject; private System.Windows.Forms.Label lblTplBody; @@ -86,6 +89,9 @@ namespace BulkArchiveMailer.AddIn.UI this.lblTplFileSuffix = new System.Windows.Forms.Label(); this.txtTplFileSuffix = new System.Windows.Forms.TextBox(); this.lblTplFileSuffixHint = new System.Windows.Forms.Label(); + this.lblTplLoginPattern = new System.Windows.Forms.Label(); + this.txtTplLoginPattern = new System.Windows.Forms.TextBox(); + this.lblTplLoginPatternHint = 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(); @@ -105,7 +111,12 @@ namespace BulkArchiveMailer.AddIn.UI // tabControl // this.tabControl.Location = new System.Drawing.Point(12, 12); - this.tabControl.Size = new System.Drawing.Size(660, 470); + // Height deliberately generous (+80px versus the original 470) + // rather than packed to the computed minimum - see the long + // remark on lstTemplates/btnAddTemplate below for why "just + // enough" turned out not to be enough in practice on a real + // machine (DPI/font-scaling dependent tab-strip height). + this.tabControl.Size = new System.Drawing.Size(660, 550); this.tabControl.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))); @@ -149,50 +160,50 @@ namespace BulkArchiveMailer.AddIn.UI this.btnBrowseFolder.Click += new System.EventHandler(this.btnBrowseFolder_Click); // lblDomain - this.lblDomain.Location = new System.Drawing.Point(15, 85); + this.lblDomain.Location = new System.Drawing.Point(15, 99); this.lblDomain.Size = new System.Drawing.Size(400, 20); this.lblDomain.Text = "Домен электронной почты (login → login@домен):"; // txtDomain - this.txtDomain.Location = new System.Drawing.Point(15, 108); + this.txtDomain.Location = new System.Drawing.Point(15, 122); this.txtDomain.Size = new System.Drawing.Size(300, 23); // lblSignaturePath - this.lblSignaturePath.Location = new System.Drawing.Point(15, 150); + this.lblSignaturePath.Location = new System.Drawing.Point(15, 178); this.lblSignaturePath.Size = new System.Drawing.Size(610, 20); this.lblSignaturePath.Text = "Файл подписи (.htm). Пусто = определять автоматически по умолчанию Outlook при каждой отправке:"; // txtSignaturePath - this.txtSignaturePath.Location = new System.Drawing.Point(15, 173); + this.txtSignaturePath.Location = new System.Drawing.Point(15, 201); this.txtSignaturePath.Size = new System.Drawing.Size(500, 23); // btnBrowseSignature - this.btnBrowseSignature.Location = new System.Drawing.Point(525, 171); + this.btnBrowseSignature.Location = new System.Drawing.Point(525, 199); this.btnBrowseSignature.Size = new System.Drawing.Size(100, 25); this.btnBrowseSignature.Text = "Обзор..."; this.btnBrowseSignature.UseVisualStyleBackColor = true; this.btnBrowseSignature.Click += new System.EventHandler(this.btnBrowseSignature_Click); // btnAutoDetectSignature - this.btnAutoDetectSignature.Location = new System.Drawing.Point(15, 205); + this.btnAutoDetectSignature.Location = new System.Drawing.Point(15, 247); this.btnAutoDetectSignature.Size = new System.Drawing.Size(280, 25); this.btnAutoDetectSignature.Text = "Определить автоматически сейчас"; this.btnAutoDetectSignature.UseVisualStyleBackColor = true; this.btnAutoDetectSignature.Click += new System.EventHandler(this.btnAutoDetectSignature_Click); // chkTestModeDefault - this.chkTestModeDefault.Location = new System.Drawing.Point(15, 250); + this.chkTestModeDefault.Location = new System.Drawing.Point(15, 306); this.chkTestModeDefault.Size = new System.Drawing.Size(610, 24); this.chkTestModeDefault.Text = "По умолчанию открывать предпросмотр в тестовом режиме (сохранять в Черновики)"; this.chkTestModeDefault.UseVisualStyleBackColor = true; // lblFromAccount - this.lblFromAccount.Location = new System.Drawing.Point(15, 284); + this.lblFromAccount.Location = new System.Drawing.Point(15, 378); this.lblFromAccount.Size = new System.Drawing.Size(400, 20); this.lblFromAccount.Text = "Отправлять от (аккаунт Outlook текущей сессии):"; // cboFromAccount - this.cboFromAccount.Location = new System.Drawing.Point(15, 307); + this.cboFromAccount.Location = new System.Drawing.Point(15, 401); this.cboFromAccount.Size = new System.Drawing.Size(500, 23); this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; @@ -202,44 +213,69 @@ namespace BulkArchiveMailer.AddIn.UI 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.lstTemplates); 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.lblTplLoginPattern); + this.tabTemplates.Controls.Add(this.txtTplLoginPattern); + this.tabTemplates.Controls.Add(this.lblTplLoginPatternHint); 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); + // btnAddTemplate / btnRemoveTemplate + // + // Deliberately placed ABOVE lstTemplates (not below it) so + // their visibility never depends on how much vertical room is + // left over at the bottom of the tab page after the list - + // that computation (tab-strip height under the operator's + // actual DPI/font scale, which this code cannot know at design + // time) is exactly what went wrong before: lstTemplates ran all + // the way to the tab page's real bottom edge with only ~6px to + // spare for the buttons beneath it, which was enough on some + // machines and not on others. Anchored Top|Left (fixed + // distance from the tab page's top-left corner) rather than + // Bottom, they are visible as long as the tab page itself is + // visible at all - not merely as long as it is "tall enough". + this.btnAddTemplate.Location = new System.Drawing.Point(15, 20); + 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.Top | System.Windows.Forms.AnchorStyles.Left))); + this.btnAddTemplate.Click += new System.EventHandler(this.btnAddTemplate_Click); + + // btnRemoveTemplate + this.btnRemoveTemplate.Location = new System.Drawing.Point(115, 20); + 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.Top | System.Windows.Forms.AnchorStyles.Left))); + this.btnRemoveTemplate.Click += new System.EventHandler(this.btnRemoveTemplate_Click); + // lstTemplates - this.lstTemplates.Location = new System.Drawing.Point(15, 20); - this.lstTemplates.Size = new System.Drawing.Size(190, 329); + // + // Starts below the two buttons now (y=55, was y=20) and still + // stretches down to the bottom of the tab page (Anchor + // Top|Bottom|Left) - only its OWN bottom edge is at risk of + // being visually tight against the tab page's real bottom + // edge if the assumed content height is off, which just means + // slightly less of the list is visible at once (it already has + // a scrollbar - IntegralHeight = false - so this degrades + // gracefully) rather than an entire control disappearing. + this.lstTemplates.Location = new System.Drawing.Point(15, 55); + this.lstTemplates.Size = new System.Drawing.Size(190, 432); 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); - // 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); - - // 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); - // lblTplDisplayName this.lblTplDisplayName.Location = new System.Drawing.Point(220, 20); this.lblTplDisplayName.Size = new System.Drawing.Size(200, 20); @@ -267,25 +303,42 @@ namespace BulkArchiveMailer.AddIn.UI this.lblTplFileSuffixHint.Text = "Файл: {login}_<суффикс>.7z"; this.lblTplFileSuffixHint.ForeColor = System.Drawing.SystemColors.GrayText; + // lblTplLoginPattern + this.lblTplLoginPattern.Location = new System.Drawing.Point(220, 160); + this.lblTplLoginPattern.Size = new System.Drawing.Size(410, 20); + this.lblTplLoginPattern.Text = "Фильтр логина (regex, необязательно):"; + + // txtTplLoginPattern + this.txtTplLoginPattern.Location = new System.Drawing.Point(220, 183); + this.txtTplLoginPattern.Size = new System.Drawing.Size(410, 23); + this.txtTplLoginPattern.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right)); + + // lblTplLoginPatternHint + this.lblTplLoginPatternHint.Location = new System.Drawing.Point(220, 209); + this.lblTplLoginPatternHint.Size = new System.Drawing.Size(410, 32); + this.lblTplLoginPatternHint.Text = "Пусто = любой логин. Пример: ^a-.* — только логины, начинающиеся с \"a-\"."; + this.lblTplLoginPatternHint.ForeColor = System.Drawing.SystemColors.GrayText; + // lblTplSubject - this.lblTplSubject.Location = new System.Drawing.Point(220, 160); + this.lblTplSubject.Location = new System.Drawing.Point(220, 254); this.lblTplSubject.Size = new System.Drawing.Size(200, 20); this.lblTplSubject.Text = "Тема письма:"; // txtTplSubject - this.txtTplSubject.Location = new System.Drawing.Point(220, 183); + this.txtTplSubject.Location = new System.Drawing.Point(220, 277); 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.Location = new System.Drawing.Point(220, 312); 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.Location = new System.Drawing.Point(220, 335); + this.txtTplBody.Size = new System.Drawing.Size(410, 125); this.txtTplBody.Multiline = true; this.txtTplBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; this.txtTplBody.AcceptsReturn = true; @@ -294,7 +347,7 @@ namespace BulkArchiveMailer.AddIn.UI | System.Windows.Forms.AnchorStyles.Right))); // lblTplHint - this.lblTplHint.Location = new System.Drawing.Point(220, 408); + this.lblTplHint.Location = new System.Drawing.Point(220, 467); this.lblTplHint.Size = new System.Drawing.Size(410, 20); this.lblTplHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}"; this.lblTplHint.ForeColor = System.Drawing.SystemColors.GrayText; @@ -303,14 +356,14 @@ namespace BulkArchiveMailer.AddIn.UI // // btnSave / btnCancel / btnResetDefaults // - this.btnResetDefaults.Location = new System.Drawing.Point(12, 494); + this.btnResetDefaults.Location = new System.Drawing.Point(12, 574); this.btnResetDefaults.Size = new System.Drawing.Size(220, 30); 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); - this.btnCancel.Location = new System.Drawing.Point(479, 494); + this.btnCancel.Location = new System.Drawing.Point(479, 574); this.btnCancel.Size = new System.Drawing.Size(90, 30); this.btnCancel.Text = "Отмена"; this.btnCancel.UseVisualStyleBackColor = true; @@ -318,7 +371,7 @@ namespace BulkArchiveMailer.AddIn.UI this.btnCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); - this.btnSave.Location = new System.Drawing.Point(575, 494); + this.btnSave.Location = new System.Drawing.Point(575, 574); this.btnSave.Size = new System.Drawing.Size(97, 30); this.btnSave.Text = "Сохранить"; this.btnSave.UseVisualStyleBackColor = true; @@ -330,8 +383,8 @@ namespace BulkArchiveMailer.AddIn.UI // this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(684, 536); - this.MinimumSize = new System.Drawing.Size(560, 420); + this.ClientSize = new System.Drawing.Size(684, 616); + this.MinimumSize = new System.Drawing.Size(560, 500); this.Controls.Add(this.tabControl); this.Controls.Add(this.btnResetDefaults); this.Controls.Add(this.btnCancel); diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs index 04613d5..1c40cca 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Windows.Forms; using BulkArchiveMailer.Core; using Outlook = Microsoft.Office.Interop.Outlook; @@ -194,6 +195,7 @@ namespace BulkArchiveMailer.AddIn.UI var t = templates[editedIndex]; t.DisplayName = txtTplDisplayName.Text; t.FileSuffix = txtTplFileSuffix.Text.Trim(); + t.LoginPattern = txtTplLoginPattern.Text.Trim(); t.Subject = txtTplSubject.Text; t.BodyHtml = txtTplBody.Text; } @@ -206,6 +208,7 @@ namespace BulkArchiveMailer.AddIn.UI txtTplDisplayName.Enabled = hasSelection; txtTplFileSuffix.Enabled = hasSelection; + txtTplLoginPattern.Enabled = hasSelection; txtTplSubject.Enabled = hasSelection; txtTplBody.Enabled = hasSelection; @@ -214,6 +217,7 @@ namespace BulkArchiveMailer.AddIn.UI lstTemplates.Tag = -1; txtTplDisplayName.Text = ""; txtTplFileSuffix.Text = ""; + txtTplLoginPattern.Text = ""; txtTplSubject.Text = ""; txtTplBody.Text = ""; return; @@ -223,6 +227,7 @@ namespace BulkArchiveMailer.AddIn.UI lstTemplates.Tag = index; txtTplDisplayName.Text = t.DisplayName; txtTplFileSuffix.Text = t.FileSuffix; + txtTplLoginPattern.Text = t.LoginPattern; txtTplSubject.Text = t.Subject; txtTplBody.Text = t.BodyHtml; } @@ -265,6 +270,7 @@ namespace BulkArchiveMailer.AddIn.UI { DisplayName = "Новый шаблон", FileSuffix = "", + LoginPattern = "", Subject = "", BodyHtml = "", }; @@ -446,6 +452,15 @@ namespace BulkArchiveMailer.AddIn.UI return; } + var loginPatternError = ValidateTemplateLoginPatterns(); + if (loginPatternError != null) + { + MessageBox.Show(this, loginPatternError, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning); + tabControl.SelectedTab = tabTemplates; + lstTemplates.Focus(); + return; + } + var selectedAccount = cboFromAccount.SelectedItem as FromAccountItem; var data = new AppSettingsData @@ -502,6 +517,41 @@ namespace BulkArchiveMailer.AddIn.UI return null; } + /// + /// Every template's LoginPattern (if non-empty) must compile as a + /// .NET regular expression - ArchiveScanner treats an uncompilable + /// pattern defensively as "never matches" rather than throwing, but + /// that would silently turn a typo'd filter into "this template + /// never applies to anything", which is worth catching here instead + /// while the operator still has the exact field in front of them. + /// Returns null if every configured pattern is either empty or + /// compiles, otherwise a message naming the offending template and + /// the underlying regex error. + /// + private string ValidateTemplateLoginPatterns() + { + for (var i = 0; i < templates.Count; i++) + { + var pattern = templates[i].LoginPattern; + if (string.IsNullOrEmpty(pattern)) + { + continue; + } + + try + { + _ = new Regex(pattern); + } + catch (ArgumentException ex) + { + var name = string.IsNullOrWhiteSpace(templates[i].DisplayName) ? $"#{i + 1}" : templates[i].DisplayName; + return $"У шаблона \"{name}\" некорректное регулярное выражение в поле \"Фильтр логина\" (\"{pattern}\"): {ex.Message}"; + } + } + + return null; + } + private void btnCancel_Click(object sender, EventArgs e) { DialogResult = DialogResult.Cancel; diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/ArchiveScannerTests.cs b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/ArchiveScannerTests.cs new file mode 100644 index 0000000..1eb75a8 --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/ArchiveScannerTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using BulkArchiveMailer.Core; +using Xunit; + +namespace BulkArchiveMailer.Core.Tests +{ + /// + /// ArchiveScanner.Scan touches the real file system (Directory.Exists/ + /// EnumerateFiles), so these tests set up a throwaway folder under + /// Path.GetTempPath() per test and delete it afterwards (IDisposable), + /// rather than mocking - the existing precedent in this test project + /// (SendLogStoreTests-equivalent file-based tests) already treats this + /// as an acceptable pattern for .NET file-system-touching code. + /// + public class ArchiveScannerTests : IDisposable + { + private readonly string tempDir; + + public ArchiveScannerTests() + { + tempDir = Path.Combine(Path.GetTempPath(), "BulkArchiveMailerTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + } + + public void Dispose() + { + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Best-effort cleanup only - never fail a passing test over + // a locked/leftover temp file. + } + } + + private static void WriteArchive(string path) => File.WriteAllBytes(path, new byte[] { 1, 2, 3 }); + + [Fact] + public void Scan_LoginMatchesTemplateLoginPattern_IsReady() + { + var filePath = Path.Combine(tempDir, "a-makarov_ssh_access.7z"); + WriteArchive(filePath); + + var templates = new List + { + new MailTemplateDefinition + { + DisplayName = "SSH-доступ (только a-*)", + FileSuffix = "ssh_access", + Subject = "Доступ — {Login}", + BodyHtml = "

{Login}

", + LoginPattern = "^a-.*", + }, + }; + + var result = ArchiveScanner.Scan(tempDir, "redlab.lo", templates); + + var job = Assert.Single(result.Jobs); + Assert.Equal(SendStatus.Ready, job.Status); + Assert.Equal("a-makarov", job.Login); + Assert.Equal("a.makarov@redlab.lo", job.Email); + Assert.Empty(result.UnrecognizedFiles); + } + + [Fact] + public void Scan_LoginDoesNotMatchTemplateLoginPattern_IsErrorInUnrecognized() + { + var filePath = Path.Combine(tempDir, "b-petrov_ssh_access.7z"); + WriteArchive(filePath); + + var templates = new List + { + new MailTemplateDefinition + { + DisplayName = "SSH-доступ (только a-*)", + FileSuffix = "ssh_access", + Subject = "Доступ — {Login}", + BodyHtml = "

{Login}

", + LoginPattern = "^a-.*", + }, + }; + + var result = ArchiveScanner.Scan(tempDir, "redlab.lo", templates); + + Assert.Empty(result.Jobs); + var row = Assert.Single(result.UnrecognizedFiles); + Assert.Equal(SendStatus.Error, row.Status); + Assert.Contains("b-petrov", row.ErrorMessage); + Assert.Contains("SSH-доступ (только a-*)", row.ErrorMessage); + Assert.Contains("^a-.*", row.ErrorMessage); + } + + [Fact] + public void Scan_EmptyLoginPattern_MatchesAnyLogin_UnchangedLegacyBehavior() + { + var filePath = Path.Combine(tempDir, "z-unrelated_ssh_access.7z"); + WriteArchive(filePath); + + var templates = new List + { + new MailTemplateDefinition + { + DisplayName = "SSH-доступ", + FileSuffix = "ssh_access", + Subject = "Доступ — {Login}", + BodyHtml = "

{Login}

", + LoginPattern = "", + }, + }; + + var result = ArchiveScanner.Scan(tempDir, "redlab.lo", templates); + + var job = Assert.Single(result.Jobs); + Assert.Equal(SendStatus.Ready, job.Status); + Assert.Equal("z-unrelated", job.Login); + } + + [Fact] + public void Scan_InvalidLoginPatternRegex_TreatedAsNoMatchRatherThanThrowing() + { + var filePath = Path.Combine(tempDir, "a-makarov_ssh_access.7z"); + WriteArchive(filePath); + + var templates = new List + { + new MailTemplateDefinition + { + DisplayName = "SSH-доступ (битый regex)", + FileSuffix = "ssh_access", + Subject = "Доступ — {Login}", + BodyHtml = "

{Login}

", + // Unbalanced group - not a valid regex. + LoginPattern = "(a-", + }, + }; + + var result = ArchiveScanner.Scan(tempDir, "redlab.lo", templates); + + Assert.Empty(result.Jobs); + var row = Assert.Single(result.UnrecognizedFiles); + Assert.Equal(SendStatus.Error, row.Status); + } + } +} diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs index c6a9b61..da54b72 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs @@ -24,6 +24,10 @@ namespace BulkArchiveMailer.Core [DataMember(Name = "bodyHtml")] public string BodyHtml { get; set; } = ""; + + /// See MailTemplateDefinition.LoginPattern. Absent from any config.json written before v1.2.0 - DataContractJsonSerializer leaves this null (not "") for those, same caveat as every other string member here; ToTemplateList/SetTemplates and every call site normalize via `?? ""`. + [DataMember(Name = "loginPattern")] + public string LoginPattern { get; set; } = ""; } /// Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it). @@ -95,6 +99,7 @@ namespace BulkArchiveMailer.Core FileSuffix = t?.FileSuffix ?? "", Subject = t?.Subject ?? "", BodyHtml = t?.BodyHtml ?? "", + LoginPattern = t?.LoginPattern ?? "", }); } @@ -115,6 +120,7 @@ namespace BulkArchiveMailer.Core FileSuffix = t?.FileSuffix ?? "", Subject = t?.Subject ?? "", BodyHtml = t?.BodyHtml ?? "", + LoginPattern = t?.LoginPattern ?? "", }); } } @@ -380,6 +386,11 @@ namespace BulkArchiveMailer.Core FileSuffix = "ssh_access", Subject = legacy?.Templates?.SshAccess?.Subject ?? "", BodyHtml = legacy?.Templates?.SshAccess?.BodyHtml ?? "", + // The legacy shape never had a per-template login + // filter - migrated entries keep the old + // unrestricted "suffix alone decides the match" + // behavior. + LoginPattern = "", }, new MailTemplateData { @@ -387,6 +398,7 @@ namespace BulkArchiveMailer.Core FileSuffix = "config_update", Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "", BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "", + LoginPattern = "", }, }, }; diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs index c90d76a..1512b82 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; namespace BulkArchiveMailer.Core { @@ -72,6 +73,25 @@ namespace BulkArchiveMailer.Core continue; } + if (!string.IsNullOrEmpty(matchedTemplate.LoginPattern) && !LoginMatchesPattern(parsed.Login, matchedTemplate.LoginPattern)) + { + // Suffix matched, but the operator scoped this template + // to a subset of logins (e.g. a per-team/per-suffix + // custom template) and this file's login falls outside + // it - surfaced next to the other per-file + // "recognized but does not qualify" case (suffix + // mismatch above) rather than silently picking a + // different template or falling through to + // byLogin/Ready. + result.UnrecognizedFiles.Add(new SendJob + { + ArchivePath = filePath, + Status = SendStatus.Error, + ErrorMessage = $"Логин \"{parsed.Login}\" не соответствует фильтру шаблона \"{matchedTemplate.DisplayName}\" ({matchedTemplate.LoginPattern}): {fileName}", + }); + continue; + } + if (!byLogin.TryGetValue(parsed.Login, out var list)) { list = new List<(string, string)>(); @@ -151,5 +171,25 @@ namespace BulkArchiveMailer.Core return result; } + + /// + /// Whether login satisfies the template's LoginPattern regex + /// (case-insensitive). An invalid pattern (e.g. hand-edited + /// config.json with broken regex syntax) is treated as "does not + /// match" rather than throwing and aborting the whole folder scan - + /// SettingsForm.btnSave_Click is where a bad pattern is meant to be + /// caught, before it ever reaches disk. + /// + private static bool LoginMatchesPattern(string login, string pattern) + { + try + { + return Regex.IsMatch(login, pattern, RegexOptions.IgnoreCase); + } + catch (ArgumentException) + { + return false; + } + } } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplateDefinition.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplateDefinition.cs index dfb7c41..80a7b01 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplateDefinition.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/MailTemplateDefinition.cs @@ -30,5 +30,19 @@ namespace BulkArchiveMailer.Core public string Subject { get; set; } = ""; public string BodyHtml { get; set; } = ""; + + /// + /// Optional regular expression the parsed login (from + /// "{login}_{FileSuffix}.7z") must match (case-insensitively) for + /// this template to actually apply, checked by ArchiveScanner AFTER + /// the file's suffix has already matched this template. Empty (the + /// default) means "no restriction" - the original, pre-v1.2.0 + /// behavior where suffix alone decides the match. Validated for + /// compilability by SettingsForm at save time; ArchiveScanner + /// itself treats an uncompilable pattern defensively as "does not + /// match" rather than throwing, in case an operator hand-edits + /// config.json with a broken regex. + /// + public string LoginPattern { get; set; } = ""; } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json b/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json index 4fdd0b3..cbf7d98 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json @@ -9,13 +9,15 @@ "displayName": "Новый доступ (ключ + конфиг)", "fileSuffix": "ssh_access", "subject": "Доступ к SSH jump-хосту — {Login}", - "bodyHtml": "

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

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

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

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

" + "bodyHtml": "

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

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

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

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

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

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

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

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

" + "bodyHtml": "

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

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

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

", + "loginPattern": "" } ] } diff --git a/outlook-bulk-sender/README.md b/outlook-bulk-sender/README.md index fab2cd3..3c292a0 100644 --- a/outlook-bulk-sender/README.md +++ b/outlook-bulk-sender/README.md @@ -51,7 +51,7 @@ BulkArchiveMailer.Core/ net48, class library, БЕЗ ссылок н FilenameParser.cs парсинг "{login}_{суффикс}.7z" (суффикс - любой, сверяется с шаблонами в ArchiveScanner) EmailAddressBuilder.cs login -> "login.with.dots@domain" SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status) - ArchiveScanner.cs сканирование папки, сопоставление суффикса с шаблоном, конфликт "оба архива сразу" + ArchiveScanner.cs сканирование папки, сопоставление суффикса (и необязательного regex-фильтра логина) с шаблоном, конфликт "оба архива сразу" TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон MailTemplateDefinition.cs один шаблон (название, суффикс файла, тема, HTML-тело) - список произвольной длины SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива) @@ -96,13 +96,25 @@ Outlook в принципе не запустить. - **Шаблоны писем** — вкладка "Шаблоны" со списком произвольного числа шаблонов (кнопки "+ Добавить"/"Удалить"). Каждый шаблон — это название, суффикс имени файла (`{login}_<суффикс>.7z`, по которому шаблон подбирается - для конкретного архива), тема и HTML-тело с плейсхолдерами `{Login}`, - `{Email}`, `{Date}`. Суффикс обязателен и должен быть уникальным среди всех - шаблонов — по нему, а не по фиксированному перечислению видов архива, - `ArchiveScanner` теперь сопоставляет файл с шаблоном. Два исходных шаблона - (`ssh_access`/`config_update`) — обычные равноправные записи в этом - списке, без особой защиты от переименования/удаления; кнопка "Сбросить - шаблоны по умолчанию" восстанавливает их, если они были случайно удалены. + для конкретного архива), необязательный фильтр логина (regex) и тема с + HTML-телом с плейсхолдерами `{Login}`, `{Email}`, `{Date}`. Суффикс + обязателен и должен быть уникальным среди всех шаблонов — по нему, а не по + фиксированному перечислению видов архива, `ArchiveScanner` сопоставляет + файл с шаблоном. Два исходных шаблона (`ssh_access`/`config_update`) — + обычные равноправные записи в этом списке, без особой защиты от + переименования/удаления; кнопка "Сбросить шаблоны по умолчанию" + восстанавливает их, если они были случайно удалены. + - **Фильтр логина** — необязательное регулярное выражение (.NET regex, + без учёта регистра). Пустое значение (по умолчанию, в том числе у + шаблонов, мигрированных из конфига старого формата) означает "любой + логин" — прежнее поведение, когда только суффикс решает, подходит ли + файл под шаблон. Если поле заполнено, а логин из имени файла ему не + соответствует, файл не считается подходящим под этот шаблон: строка в + предпросмотре получает статус "Ошибка" с пояснением, какому шаблону и + какому паттерну логин не соответствует, — автоматический подбор другого + шаблона не выполняется. Некорректный regex (не компилируется) не даёт + сохранить настройки — окно "Настройки" укажет, в каком именно шаблоне + ошибка. - **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются в Черновики, а не отправляются) до первого осознанного отключения оператором в окне предпросмотра.