Author SHA1 Message Date
redlineoandClaude Sonnet 5 b42815aa2a Fix add/remove template buttons overlapping the list (Dock add-order bug)
WinForms docks child controls in the REVERSE of their Controls.Add()
order - the last control added gets docked first (claims its edge
space first), and earlier-added controls lay out against whatever
remains. pnlTemplateButtons (Dock=Top) was added before lstTemplates
(Dock=Fill), so Fill was processed first against the full, unclaimed
panel area and expanded to cover the whole thing - overlapping the
button strip instead of leaving room for it. Swapped the add order
(Fill first, Top second) so the button strip actually carves out its
band before Fill claims what's left.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 13:04:48 +03:00
redlineoandClaude Sonnet 5 f1c67669fd Fix template list becoming unclickable with many entries via Dock layout
lstTemplates was a direct child of tabTemplates with an explicit pixel
Size, computed once against this dev machine's tab-strip height under
its own DPI/font scale. On the operator's real machine, adding enough
templates pushed rows past the control's actual bottom edge -
maximizing the window didn't help, confirming it wasn't a screen-space
problem but a stale size assumption baked into the designer file (the
same root cause as the earlier add/remove-button visibility bug).

Moved the button strip + list into their own panel (pnlTemplateList),
with the buttons Dock=Top and lstTemplates Dock=Fill - the list's
bounds are now computed by the layout engine against the real
container at runtime, with no absolute size to be wrong about,
regardless of DPI/font scale/window size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 12:39:49 +03:00
redlineoandClaude Sonnet 5 6e823954a3 Fix hidden add/remove template buttons, add per-template login filter
The add/remove template buttons sat directly below the fixed-height
list with only 6px of margin to the tab's bottom edge - enough on this
dev machine but not on the operator's real one (different DPI/font
scale), so the buttons rendered off the visible area. Moved them above
the list instead (visibility no longer depends on how much space is
left at the bottom) and gave the whole "Шаблоны" tab substantially
more vertical room throughout.

Also added an optional per-template login regex filter
(MailTemplateDefinition.LoginPattern): once a file's suffix matches a
template, its login must also match that template's pattern (if any)
or the row surfaces as a clear Error in the preview instead of
silently going through - addresses "непонятно что будет работать".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 12:23:54 +03:00
8 changed files with 421 additions and 44 deletions

No files matched your search

@@ -35,6 +35,8 @@ namespace BulkArchiveMailer.AddIn.UI
private System.Windows.Forms.Label lblFromAccount; private System.Windows.Forms.Label lblFromAccount;
private System.Windows.Forms.ComboBox cboFromAccount; private System.Windows.Forms.ComboBox cboFromAccount;
private System.Windows.Forms.Panel pnlTemplateList;
private System.Windows.Forms.Panel pnlTemplateButtons;
private System.Windows.Forms.ListBox lstTemplates; private System.Windows.Forms.ListBox lstTemplates;
private System.Windows.Forms.Button btnAddTemplate; private System.Windows.Forms.Button btnAddTemplate;
private System.Windows.Forms.Button btnRemoveTemplate; private System.Windows.Forms.Button btnRemoveTemplate;
@@ -44,6 +46,9 @@ namespace BulkArchiveMailer.AddIn.UI
private System.Windows.Forms.Label lblTplFileSuffix; private System.Windows.Forms.Label lblTplFileSuffix;
private System.Windows.Forms.TextBox txtTplFileSuffix; private System.Windows.Forms.TextBox txtTplFileSuffix;
private System.Windows.Forms.Label lblTplFileSuffixHint; 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.Label lblTplSubject;
private System.Windows.Forms.TextBox txtTplSubject; private System.Windows.Forms.TextBox txtTplSubject;
private System.Windows.Forms.Label lblTplBody; private System.Windows.Forms.Label lblTplBody;
@@ -77,6 +82,8 @@ namespace BulkArchiveMailer.AddIn.UI
this.lblFromAccount = new System.Windows.Forms.Label(); this.lblFromAccount = new System.Windows.Forms.Label();
this.cboFromAccount = new System.Windows.Forms.ComboBox(); this.cboFromAccount = new System.Windows.Forms.ComboBox();
this.pnlTemplateList = new System.Windows.Forms.Panel();
this.pnlTemplateButtons = new System.Windows.Forms.Panel();
this.lstTemplates = new System.Windows.Forms.ListBox(); this.lstTemplates = new System.Windows.Forms.ListBox();
this.btnAddTemplate = new System.Windows.Forms.Button(); this.btnAddTemplate = new System.Windows.Forms.Button();
this.btnRemoveTemplate = new System.Windows.Forms.Button(); this.btnRemoveTemplate = new System.Windows.Forms.Button();
@@ -86,6 +93,9 @@ namespace BulkArchiveMailer.AddIn.UI
this.lblTplFileSuffix = new System.Windows.Forms.Label(); this.lblTplFileSuffix = new System.Windows.Forms.Label();
this.txtTplFileSuffix = new System.Windows.Forms.TextBox(); this.txtTplFileSuffix = new System.Windows.Forms.TextBox();
this.lblTplFileSuffixHint = new System.Windows.Forms.Label(); 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.lblTplSubject = new System.Windows.Forms.Label();
this.txtTplSubject = new System.Windows.Forms.TextBox(); this.txtTplSubject = new System.Windows.Forms.TextBox();
this.lblTplBody = new System.Windows.Forms.Label(); this.lblTplBody = new System.Windows.Forms.Label();
@@ -99,13 +109,20 @@ namespace BulkArchiveMailer.AddIn.UI
this.tabControl.SuspendLayout(); this.tabControl.SuspendLayout();
this.tabGeneral.SuspendLayout(); this.tabGeneral.SuspendLayout();
this.tabTemplates.SuspendLayout(); this.tabTemplates.SuspendLayout();
this.pnlTemplateList.SuspendLayout();
this.pnlTemplateButtons.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
// tabControl // tabControl
// //
this.tabControl.Location = new System.Drawing.Point(12, 12); 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) 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.Left)
| System.Windows.Forms.AnchorStyles.Right))); | System.Windows.Forms.AnchorStyles.Right)));
@@ -149,50 +166,50 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnBrowseFolder.Click += new System.EventHandler(this.btnBrowseFolder_Click); this.btnBrowseFolder.Click += new System.EventHandler(this.btnBrowseFolder_Click);
// lblDomain // 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.Size = new System.Drawing.Size(400, 20);
this.lblDomain.Text = "Домен электронной почты (login → login@домен):"; this.lblDomain.Text = "Домен электронной почты (login → login@домен):";
// txtDomain // 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); this.txtDomain.Size = new System.Drawing.Size(300, 23);
// lblSignaturePath // 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.Size = new System.Drawing.Size(610, 20);
this.lblSignaturePath.Text = "Файл подписи (.htm). Пусто = определять автоматически по умолчанию Outlook при каждой отправке:"; this.lblSignaturePath.Text = "Файл подписи (.htm). Пусто = определять автоматически по умолчанию Outlook при каждой отправке:";
// txtSignaturePath // 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); this.txtSignaturePath.Size = new System.Drawing.Size(500, 23);
// btnBrowseSignature // 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.Size = new System.Drawing.Size(100, 25);
this.btnBrowseSignature.Text = "Обзор..."; this.btnBrowseSignature.Text = "Обзор...";
this.btnBrowseSignature.UseVisualStyleBackColor = true; this.btnBrowseSignature.UseVisualStyleBackColor = true;
this.btnBrowseSignature.Click += new System.EventHandler(this.btnBrowseSignature_Click); this.btnBrowseSignature.Click += new System.EventHandler(this.btnBrowseSignature_Click);
// btnAutoDetectSignature // 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.Size = new System.Drawing.Size(280, 25);
this.btnAutoDetectSignature.Text = "Определить автоматически сейчас"; this.btnAutoDetectSignature.Text = "Определить автоматически сейчас";
this.btnAutoDetectSignature.UseVisualStyleBackColor = true; this.btnAutoDetectSignature.UseVisualStyleBackColor = true;
this.btnAutoDetectSignature.Click += new System.EventHandler(this.btnAutoDetectSignature_Click); this.btnAutoDetectSignature.Click += new System.EventHandler(this.btnAutoDetectSignature_Click);
// chkTestModeDefault // 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.Size = new System.Drawing.Size(610, 24);
this.chkTestModeDefault.Text = "По умолчанию открывать предпросмотр в тестовом режиме (сохранять в Черновики)"; this.chkTestModeDefault.Text = "По умолчанию открывать предпросмотр в тестовом режиме (сохранять в Черновики)";
this.chkTestModeDefault.UseVisualStyleBackColor = true; this.chkTestModeDefault.UseVisualStyleBackColor = true;
// lblFromAccount // 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.Size = new System.Drawing.Size(400, 20);
this.lblFromAccount.Text = "Отправлять от (аккаунт Outlook текущей сессии):"; this.lblFromAccount.Text = "Отправлять от (аккаунт Outlook текущей сессии):";
// cboFromAccount // 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.Size = new System.Drawing.Size(500, 23);
this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
@@ -202,44 +219,103 @@ namespace BulkArchiveMailer.AddIn.UI
this.tabTemplates.Text = "Шаблоны"; this.tabTemplates.Text = "Шаблоны";
this.tabTemplates.Padding = new System.Windows.Forms.Padding(12); this.tabTemplates.Padding = new System.Windows.Forms.Padding(12);
this.tabTemplates.UseVisualStyleBackColor = true; this.tabTemplates.UseVisualStyleBackColor = true;
this.tabTemplates.Controls.Add(this.lstTemplates); this.tabTemplates.Controls.Add(this.pnlTemplateList);
this.tabTemplates.Controls.Add(this.btnAddTemplate);
this.tabTemplates.Controls.Add(this.btnRemoveTemplate);
this.tabTemplates.Controls.Add(this.lblTplDisplayName); this.tabTemplates.Controls.Add(this.lblTplDisplayName);
this.tabTemplates.Controls.Add(this.txtTplDisplayName); this.tabTemplates.Controls.Add(this.txtTplDisplayName);
this.tabTemplates.Controls.Add(this.lblTplFileSuffix); this.tabTemplates.Controls.Add(this.lblTplFileSuffix);
this.tabTemplates.Controls.Add(this.txtTplFileSuffix); this.tabTemplates.Controls.Add(this.txtTplFileSuffix);
this.tabTemplates.Controls.Add(this.lblTplFileSuffixHint); 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.lblTplSubject);
this.tabTemplates.Controls.Add(this.txtTplSubject); this.tabTemplates.Controls.Add(this.txtTplSubject);
this.tabTemplates.Controls.Add(this.lblTplBody); this.tabTemplates.Controls.Add(this.lblTplBody);
this.tabTemplates.Controls.Add(this.txtTplBody); this.tabTemplates.Controls.Add(this.txtTplBody);
this.tabTemplates.Controls.Add(this.lblTplHint); this.tabTemplates.Controls.Add(this.lblTplHint);
// lstTemplates // pnlTemplateList
this.lstTemplates.Location = new System.Drawing.Point(15, 20); //
this.lstTemplates.Size = new System.Drawing.Size(190, 329); // Left-column container for the template picker (buttons +
this.lstTemplates.IntegralHeight = false; // list). Its own outer Location/Size/Anchor still use fixed
this.lstTemplates.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) // pixel coordinates relative to the tab page, exactly like
// before - that outer placement was never the problem. What
// is Dock-based from here down is everything INSIDE this
// panel, which is the part that actually broke on a real
// machine: lstTemplates used to be a direct child of
// tabTemplates with an explicit Size, so its bottom edge (and
// therefore its scrollability/clickability once many
// templates were added) depended on a hand-computed pixel
// height that was only ever verified on the dev machine's own
// DPI/font scale. Docking removes that computation entirely -
// see pnlTemplateButtons/lstTemplates below.
this.pnlTemplateList.Location = new System.Drawing.Point(15, 20);
this.pnlTemplateList.Size = new System.Drawing.Size(190, 467);
this.pnlTemplateList.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left))); | System.Windows.Forms.AnchorStyles.Left)));
this.lstTemplates.SelectedIndexChanged += new System.EventHandler(this.lstTemplates_SelectedIndexChanged); // Dock order matters here, and it is the OPPOSITE of what looks
// intuitive: WinForms lays out docked children in the REVERSE
// of their Controls.Add() order - the LAST control added is
// docked FIRST (closest to the parent's edge), and each
// earlier-added control is then laid out against whatever
// client area is left over. So lstTemplates (Dock=Fill) must be
// added FIRST, and pnlTemplateButtons (Dock=Top) must be added
// SECOND/last, so that the Top strip is actually the one that
// gets docked last (i.e. first in effect) and carves its 35px
// band out of the top; Fill then only gets whatever remains
// below it. Adding them in the other order (as this used to be)
// makes Fill get laid out first, against the FULL panel client
// area (nothing has carved out the top strip yet), so it
// expands to cover the entire panel and visually overlaps
// pnlTemplateButtons - exactly the "buttons overlap the list"
// bug reported on the real machine.
this.pnlTemplateList.Controls.Add(this.lstTemplates);
this.pnlTemplateList.Controls.Add(this.pnlTemplateButtons);
// pnlTemplateButtons
//
// Fixed-height strip pinned to the top of pnlTemplateList.
// btnAddTemplate/btnRemoveTemplate live inside it at a fixed
// offset from ITS top-left corner (0,0)/(95,0) - a small,
// never-resized container, so plain Location placement here
// carries none of the earlier risk (that risk only existed
// when the same fixed coordinates were relative to the whole,
// variable-height tab page).
this.pnlTemplateButtons.Dock = System.Windows.Forms.DockStyle.Top;
this.pnlTemplateButtons.Size = new System.Drawing.Size(190, 35);
this.pnlTemplateButtons.Controls.Add(this.btnAddTemplate);
this.pnlTemplateButtons.Controls.Add(this.btnRemoveTemplate);
// btnAddTemplate // btnAddTemplate
this.btnAddTemplate.Location = new System.Drawing.Point(15, 355); this.btnAddTemplate.Location = new System.Drawing.Point(0, 0);
this.btnAddTemplate.Size = new System.Drawing.Size(90, 27); this.btnAddTemplate.Size = new System.Drawing.Size(90, 27);
this.btnAddTemplate.Text = "+ Добавить"; this.btnAddTemplate.Text = "+ Добавить";
this.btnAddTemplate.UseVisualStyleBackColor = true; 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); this.btnAddTemplate.Click += new System.EventHandler(this.btnAddTemplate_Click);
// btnRemoveTemplate // btnRemoveTemplate
this.btnRemoveTemplate.Location = new System.Drawing.Point(115, 355); this.btnRemoveTemplate.Location = new System.Drawing.Point(95, 0);
this.btnRemoveTemplate.Size = new System.Drawing.Size(90, 27); this.btnRemoveTemplate.Size = new System.Drawing.Size(90, 27);
this.btnRemoveTemplate.Text = "Удалить"; this.btnRemoveTemplate.Text = "Удалить";
this.btnRemoveTemplate.UseVisualStyleBackColor = true; 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); this.btnRemoveTemplate.Click += new System.EventHandler(this.btnRemoveTemplate_Click);
// lstTemplates
//
// Dock=Fill: always occupies exactly whatever area of
// pnlTemplateList is left below pnlTemplateButtons, computed
// by the layout engine at every resize/DPI - not a hand-picked
// pixel height that can end up too short (and therefore
// effectively unscrollable/unclickable for items past the cut
// -off) on a machine with different DPI/font scaling than the
// one this was last tested on. IntegralHeight = false just
// means the list doesn't force its height to a whole multiple
// of the item height - irrelevant to whether Fill can "escape"
// the panel, which it structurally cannot.
this.lstTemplates.Dock = System.Windows.Forms.DockStyle.Fill;
this.lstTemplates.IntegralHeight = false;
this.lstTemplates.SelectedIndexChanged += new System.EventHandler(this.lstTemplates_SelectedIndexChanged);
// lblTplDisplayName // lblTplDisplayName
this.lblTplDisplayName.Location = new System.Drawing.Point(220, 20); this.lblTplDisplayName.Location = new System.Drawing.Point(220, 20);
this.lblTplDisplayName.Size = new System.Drawing.Size(200, 20); this.lblTplDisplayName.Size = new System.Drawing.Size(200, 20);
@@ -267,25 +343,42 @@ namespace BulkArchiveMailer.AddIn.UI
this.lblTplFileSuffixHint.Text = "Файл: {login}_<суффикс>.7z"; this.lblTplFileSuffixHint.Text = "Файл: {login}_<суффикс>.7z";
this.lblTplFileSuffixHint.ForeColor = System.Drawing.SystemColors.GrayText; 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 // 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.Size = new System.Drawing.Size(200, 20);
this.lblTplSubject.Text = "Тема письма:"; this.lblTplSubject.Text = "Тема письма:";
// txtTplSubject // 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.Size = new System.Drawing.Size(410, 23);
this.txtTplSubject.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) this.txtTplSubject.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)); | System.Windows.Forms.AnchorStyles.Right));
// lblTplBody // 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.Size = new System.Drawing.Size(200, 20);
this.lblTplBody.Text = "Тело письма (HTML):"; this.lblTplBody.Text = "Тело письма (HTML):";
// txtTplBody // txtTplBody
this.txtTplBody.Location = new System.Drawing.Point(220, 241); this.txtTplBody.Location = new System.Drawing.Point(220, 335);
this.txtTplBody.Size = new System.Drawing.Size(410, 160); this.txtTplBody.Size = new System.Drawing.Size(410, 125);
this.txtTplBody.Multiline = true; this.txtTplBody.Multiline = true;
this.txtTplBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; this.txtTplBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
this.txtTplBody.AcceptsReturn = true; this.txtTplBody.AcceptsReturn = true;
@@ -294,7 +387,7 @@ namespace BulkArchiveMailer.AddIn.UI
| System.Windows.Forms.AnchorStyles.Right))); | System.Windows.Forms.AnchorStyles.Right)));
// lblTplHint // 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.Size = new System.Drawing.Size(410, 20);
this.lblTplHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}"; this.lblTplHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}";
this.lblTplHint.ForeColor = System.Drawing.SystemColors.GrayText; this.lblTplHint.ForeColor = System.Drawing.SystemColors.GrayText;
@@ -303,14 +396,14 @@ namespace BulkArchiveMailer.AddIn.UI
// //
// btnSave / btnCancel / btnResetDefaults // 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.Size = new System.Drawing.Size(220, 30);
this.btnResetDefaults.Text = "Сбросить шаблоны по умолчанию"; this.btnResetDefaults.Text = "Сбросить шаблоны по умолчанию";
this.btnResetDefaults.UseVisualStyleBackColor = true; this.btnResetDefaults.UseVisualStyleBackColor = true;
this.btnResetDefaults.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); 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.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.Size = new System.Drawing.Size(90, 30);
this.btnCancel.Text = "Отмена"; this.btnCancel.Text = "Отмена";
this.btnCancel.UseVisualStyleBackColor = true; this.btnCancel.UseVisualStyleBackColor = true;
@@ -318,7 +411,7 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); 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.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.Size = new System.Drawing.Size(97, 30);
this.btnSave.Text = "Сохранить"; this.btnSave.Text = "Сохранить";
this.btnSave.UseVisualStyleBackColor = true; this.btnSave.UseVisualStyleBackColor = true;
@@ -330,8 +423,8 @@ namespace BulkArchiveMailer.AddIn.UI
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(684, 536); this.ClientSize = new System.Drawing.Size(684, 616);
this.MinimumSize = new System.Drawing.Size(560, 420); this.MinimumSize = new System.Drawing.Size(560, 500);
this.Controls.Add(this.tabControl); this.Controls.Add(this.tabControl);
this.Controls.Add(this.btnResetDefaults); this.Controls.Add(this.btnResetDefaults);
this.Controls.Add(this.btnCancel); this.Controls.Add(this.btnCancel);
@@ -346,6 +439,8 @@ namespace BulkArchiveMailer.AddIn.UI
this.tabControl.ResumeLayout(false); this.tabControl.ResumeLayout(false);
this.tabGeneral.ResumeLayout(false); this.tabGeneral.ResumeLayout(false);
this.tabGeneral.PerformLayout(); this.tabGeneral.PerformLayout();
this.pnlTemplateButtons.ResumeLayout(false);
this.pnlTemplateList.ResumeLayout(false);
this.tabTemplates.ResumeLayout(false); this.tabTemplates.ResumeLayout(false);
this.tabTemplates.PerformLayout(); this.tabTemplates.PerformLayout();
this.ResumeLayout(false); this.ResumeLayout(false);
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Forms; using System.Windows.Forms;
using BulkArchiveMailer.Core; using BulkArchiveMailer.Core;
using Outlook = Microsoft.Office.Interop.Outlook; using Outlook = Microsoft.Office.Interop.Outlook;
@@ -194,6 +195,7 @@ namespace BulkArchiveMailer.AddIn.UI
var t = templates[editedIndex]; var t = templates[editedIndex];
t.DisplayName = txtTplDisplayName.Text; t.DisplayName = txtTplDisplayName.Text;
t.FileSuffix = txtTplFileSuffix.Text.Trim(); t.FileSuffix = txtTplFileSuffix.Text.Trim();
t.LoginPattern = txtTplLoginPattern.Text.Trim();
t.Subject = txtTplSubject.Text; t.Subject = txtTplSubject.Text;
t.BodyHtml = txtTplBody.Text; t.BodyHtml = txtTplBody.Text;
} }
@@ -206,6 +208,7 @@ namespace BulkArchiveMailer.AddIn.UI
txtTplDisplayName.Enabled = hasSelection; txtTplDisplayName.Enabled = hasSelection;
txtTplFileSuffix.Enabled = hasSelection; txtTplFileSuffix.Enabled = hasSelection;
txtTplLoginPattern.Enabled = hasSelection;
txtTplSubject.Enabled = hasSelection; txtTplSubject.Enabled = hasSelection;
txtTplBody.Enabled = hasSelection; txtTplBody.Enabled = hasSelection;
@@ -214,6 +217,7 @@ namespace BulkArchiveMailer.AddIn.UI
lstTemplates.Tag = -1; lstTemplates.Tag = -1;
txtTplDisplayName.Text = ""; txtTplDisplayName.Text = "";
txtTplFileSuffix.Text = ""; txtTplFileSuffix.Text = "";
txtTplLoginPattern.Text = "";
txtTplSubject.Text = ""; txtTplSubject.Text = "";
txtTplBody.Text = ""; txtTplBody.Text = "";
return; return;
@@ -223,6 +227,7 @@ namespace BulkArchiveMailer.AddIn.UI
lstTemplates.Tag = index; lstTemplates.Tag = index;
txtTplDisplayName.Text = t.DisplayName; txtTplDisplayName.Text = t.DisplayName;
txtTplFileSuffix.Text = t.FileSuffix; txtTplFileSuffix.Text = t.FileSuffix;
txtTplLoginPattern.Text = t.LoginPattern;
txtTplSubject.Text = t.Subject; txtTplSubject.Text = t.Subject;
txtTplBody.Text = t.BodyHtml; txtTplBody.Text = t.BodyHtml;
} }
@@ -265,6 +270,7 @@ namespace BulkArchiveMailer.AddIn.UI
{ {
DisplayName = "Новый шаблон", DisplayName = "Новый шаблон",
FileSuffix = "", FileSuffix = "",
LoginPattern = "",
Subject = "", Subject = "",
BodyHtml = "", BodyHtml = "",
}; };
@@ -446,6 +452,15 @@ namespace BulkArchiveMailer.AddIn.UI
return; 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 selectedAccount = cboFromAccount.SelectedItem as FromAccountItem;
var data = new AppSettingsData var data = new AppSettingsData
@@ -502,6 +517,41 @@ namespace BulkArchiveMailer.AddIn.UI
return null; return null;
} }
/// <summary>
/// Every template's LoginPattern (if non-empty) must compile as a
/// .NET regular expression - ArchiveScanner treats an uncompilable
/// pattern defensively as "never matches" rather than throwing, but
/// that would silently turn a typo'd filter into "this template
/// never applies to anything", which is worth catching here instead
/// while the operator still has the exact field in front of them.
/// Returns null if every configured pattern is either empty or
/// compiles, otherwise a message naming the offending template and
/// the underlying regex error.
/// </summary>
private string ValidateTemplateLoginPatterns()
{
for (var i = 0; i < templates.Count; i++)
{
var pattern = templates[i].LoginPattern;
if (string.IsNullOrEmpty(pattern))
{
continue;
}
try
{
_ = new Regex(pattern);
}
catch (ArgumentException ex)
{
var name = string.IsNullOrWhiteSpace(templates[i].DisplayName) ? $"#{i + 1}" : templates[i].DisplayName;
return $"У шаблона \"{name}\" некорректное регулярное выражение в поле \"Фильтр логина\" (\"{pattern}\"): {ex.Message}";
}
}
return null;
}
private void btnCancel_Click(object sender, EventArgs e) private void btnCancel_Click(object sender, EventArgs e)
{ {
DialogResult = DialogResult.Cancel; DialogResult = DialogResult.Cancel;
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<MailTemplateDefinition>
{
new MailTemplateDefinition
{
DisplayName = "SSH-доступ (только a-*)",
FileSuffix = "ssh_access",
Subject = "Доступ — {Login}",
BodyHtml = "<p>{Login}</p>",
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<MailTemplateDefinition>
{
new MailTemplateDefinition
{
DisplayName = "SSH-доступ (только a-*)",
FileSuffix = "ssh_access",
Subject = "Доступ — {Login}",
BodyHtml = "<p>{Login}</p>",
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<MailTemplateDefinition>
{
new MailTemplateDefinition
{
DisplayName = "SSH-доступ",
FileSuffix = "ssh_access",
Subject = "Доступ — {Login}",
BodyHtml = "<p>{Login}</p>",
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<MailTemplateDefinition>
{
new MailTemplateDefinition
{
DisplayName = "SSH-доступ (битый regex)",
FileSuffix = "ssh_access",
Subject = "Доступ — {Login}",
BodyHtml = "<p>{Login}</p>",
// 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);
}
}
}
@@ -24,6 +24,10 @@ namespace BulkArchiveMailer.Core
[DataMember(Name = "bodyHtml")] [DataMember(Name = "bodyHtml")]
public string BodyHtml { get; set; } = ""; public string BodyHtml { get; set; } = "";
/// <summary>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 `?? ""`.</summary>
[DataMember(Name = "loginPattern")]
public string LoginPattern { get; set; } = "";
} }
/// <summary>Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it).</summary> /// <summary>Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it).</summary>
@@ -95,6 +99,7 @@ namespace BulkArchiveMailer.Core
FileSuffix = t?.FileSuffix ?? "", FileSuffix = t?.FileSuffix ?? "",
Subject = t?.Subject ?? "", Subject = t?.Subject ?? "",
BodyHtml = t?.BodyHtml ?? "", BodyHtml = t?.BodyHtml ?? "",
LoginPattern = t?.LoginPattern ?? "",
}); });
} }
@@ -115,6 +120,7 @@ namespace BulkArchiveMailer.Core
FileSuffix = t?.FileSuffix ?? "", FileSuffix = t?.FileSuffix ?? "",
Subject = t?.Subject ?? "", Subject = t?.Subject ?? "",
BodyHtml = t?.BodyHtml ?? "", BodyHtml = t?.BodyHtml ?? "",
LoginPattern = t?.LoginPattern ?? "",
}); });
} }
} }
@@ -380,6 +386,11 @@ namespace BulkArchiveMailer.Core
FileSuffix = "ssh_access", FileSuffix = "ssh_access",
Subject = legacy?.Templates?.SshAccess?.Subject ?? "", Subject = legacy?.Templates?.SshAccess?.Subject ?? "",
BodyHtml = legacy?.Templates?.SshAccess?.BodyHtml ?? "", 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 new MailTemplateData
{ {
@@ -387,6 +398,7 @@ namespace BulkArchiveMailer.Core
FileSuffix = "config_update", FileSuffix = "config_update",
Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "", Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "",
BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "", BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "",
LoginPattern = "",
}, },
}, },
}; };
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
namespace BulkArchiveMailer.Core namespace BulkArchiveMailer.Core
{ {
@@ -72,6 +73,25 @@ namespace BulkArchiveMailer.Core
continue; 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)) if (!byLogin.TryGetValue(parsed.Login, out var list))
{ {
list = new List<(string, string)>(); list = new List<(string, string)>();
@@ -151,5 +171,25 @@ namespace BulkArchiveMailer.Core
return result; return result;
} }
/// <summary>
/// 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.
/// </summary>
private static bool LoginMatchesPattern(string login, string pattern)
{
try
{
return Regex.IsMatch(login, pattern, RegexOptions.IgnoreCase);
}
catch (ArgumentException)
{
return false;
}
}
} }
} }
@@ -30,5 +30,19 @@ namespace BulkArchiveMailer.Core
public string Subject { get; set; } = ""; public string Subject { get; set; } = "";
public string BodyHtml { get; set; } = ""; public string BodyHtml { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
public string LoginPattern { get; set; } = "";
} }
} }
@@ -9,13 +9,15 @@
"displayName": "Новый доступ (ключ + конфиг)", "displayName": "Новый доступ (ключ + конфиг)",
"fileSuffix": "ssh_access", "fileSuffix": "ssh_access",
"subject": "Доступ к SSH jump-хосту — {Login}", "subject": "Доступ к SSH jump-хосту — {Login}",
"bodyHtml": "<p>Здравствуйте!</p><p>Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению (<b>{Login}_ssh_access.7z</b>).</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p><p>Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.</p>" "bodyHtml": "<p>Здравствуйте!</p><p>Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению (<b>{Login}_ssh_access.7z</b>).</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p><p>Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.</p>",
"loginPattern": ""
}, },
{ {
"displayName": "Обновление конфига", "displayName": "Обновление конфига",
"fileSuffix": "config_update", "fileSuffix": "config_update",
"subject": "Обновление конфигурации доступа — {Login}", "subject": "Обновление конфигурации доступа — {Login}",
"bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>" "bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>",
"loginPattern": ""
} }
] ]
} }
+20 -8
View File
@@ -51,7 +51,7 @@ BulkArchiveMailer.Core/ net48, class library, БЕЗ ссылок н
FilenameParser.cs парсинг "{login}_{суффикс}.7z" (суффикс - любой, сверяется с шаблонами в ArchiveScanner) FilenameParser.cs парсинг "{login}_{суффикс}.7z" (суффикс - любой, сверяется с шаблонами в ArchiveScanner)
EmailAddressBuilder.cs login -> "login.with.dots@domain" EmailAddressBuilder.cs login -> "login.with.dots@domain"
SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status) SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status)
ArchiveScanner.cs сканирование папки, сопоставление суффикса с шаблоном, конфликт "оба архива сразу" ArchiveScanner.cs сканирование папки, сопоставление суффикса (и необязательного regex-фильтра логина) с шаблоном, конфликт "оба архива сразу"
TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон
MailTemplateDefinition.cs один шаблон (название, суффикс файла, тема, HTML-тело) - список произвольной длины MailTemplateDefinition.cs один шаблон (название, суффикс файла, тема, HTML-тело) - список произвольной длины
SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива) SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива)
@@ -96,13 +96,25 @@ Outlook в принципе не запустить.
- **Шаблоны писем** — вкладка "Шаблоны" со списком произвольного числа - **Шаблоны писем** — вкладка "Шаблоны" со списком произвольного числа
шаблонов (кнопки "+ Добавить"/"Удалить"). Каждый шаблон — это название, шаблонов (кнопки "+ Добавить"/"Удалить"). Каждый шаблон — это название,
суффикс имени файла (`{login}_<суффикс>.7z`, по которому шаблон подбирается суффикс имени файла (`{login}_<суффикс>.7z`, по которому шаблон подбирается
для конкретного архива), тема и HTML-тело с плейсхолдерами `{Login}`, для конкретного архива), необязательный фильтр логина (regex) и тема с
`{Email}`, `{Date}`. Суффикс обязателен и должен быть уникальным среди всех HTML-телом с плейсхолдерами `{Login}`, `{Email}`, `{Date}`. Суффикс
шаблонов — по нему, а не по фиксированному перечислению видов архива, обязателен и должен быть уникальным среди всех шаблонов — по нему, а не по
`ArchiveScanner` теперь сопоставляет файл с шаблоном. Два исходных шаблона фиксированному перечислению видов архива, `ArchiveScanner` сопоставляет
(`ssh_access`/`config_update`) — обычные равноправные записи в этом файл с шаблоном. Два исходных шаблона (`ssh_access`/`config_update`) —
списке, без особой защиты от переименования/удаления; кнопка "Сбросить обычные равноправные записи в этом списке, без особой защиты от
шаблоны по умолчанию" восстанавливает их, если они были случайно удалены. переименования/удаления; кнопка "Сбросить шаблоны по умолчанию"
восстанавливает их, если они были случайно удалены.
- **Фильтр логина** — необязательное регулярное выражение (.NET regex,
без учёта регистра). Пустое значение (по умолчанию, в том числе у
шаблонов, мигрированных из конфига старого формата) означает "любой
логин" — прежнее поведение, когда только суффикс решает, подходит ли
файл под шаблон. Если поле заполнено, а логин из имени файла ему не
соответствует, файл не считается подходящим под этот шаблон: строка в
предпросмотре получает статус "Ошибка" с пояснением, какому шаблону и
какому паттерну логин не соответствует, — автоматический подбор другого
шаблона не выполняется. Некорректный regex (не компилируется) не даёт
сохранить настройки — окно "Настройки" укажет, в каком именно шаблоне
ошибка.
- **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются - **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются
в Черновики, а не отправляются) до первого осознанного отключения в Черновики, а не отправляются) до первого осознанного отключения
оператором в окне предпросмотра. оператором в окне предпросмотра.