Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c67669fd | ||
|
|
6e823954a3 | ||
|
|
804b9610ca | ||
|
|
cb3b273741 |
No files matched your search
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Win32;
|
||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||
|
||||
@@ -38,7 +40,7 @@ namespace BulkArchiveMailer.AddIn
|
||||
}
|
||||
|
||||
var path = ResolveSignaturePath(signatureName);
|
||||
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||
return File.Exists(path) ? ReadSignatureHtml(path) : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -119,5 +121,141 @@ namespace BulkArchiveMailer.AddIn
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a signature .htm file with the encoding it was actually
|
||||
/// written in, instead of assuming UTF-8 the way
|
||||
/// <see cref="File.ReadAllText(string)"/> does for BOM-less files.
|
||||
///
|
||||
/// Outlook writes signature .htm files in the system ANSI code
|
||||
/// page (windows-1251 on a Russian Windows) without a BOM and
|
||||
/// usually without a reliable <meta charset> tag, so blindly
|
||||
/// reading as UTF-8 mangles Cyrillic into replacement characters.
|
||||
///
|
||||
/// Detection order:
|
||||
/// 1. BOM (UTF-8 / UTF-16 LE / UTF-16 BE / UTF-32 LE / UTF-32 BE) - trusted if present.
|
||||
/// 2. <meta charset=...> or <meta http-equiv="Content-Type" content="...charset=...">
|
||||
/// found in the first ~1KB, if the named encoding is recognized.
|
||||
/// 3. Encoding.Default (the system ANSI code page) - matches
|
||||
/// Outlook's legacy signature file format.
|
||||
///
|
||||
/// Never throws: any failure along the way falls back to the
|
||||
/// previous behavior, <see cref="File.ReadAllText(string)"/>.
|
||||
/// </summary>
|
||||
internal static string ReadSignatureHtml(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
|
||||
var bomEncoding = DetectBomEncoding(bytes, out var preambleLength);
|
||||
if (bomEncoding != null)
|
||||
{
|
||||
return bomEncoding.GetString(bytes, preambleLength, bytes.Length - preambleLength);
|
||||
}
|
||||
|
||||
var metaEncoding = TryDetectMetaCharsetEncoding(bytes);
|
||||
if (metaEncoding != null)
|
||||
{
|
||||
return metaEncoding.GetString(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
return Encoding.Default.GetString(bytes, 0, bytes.Length);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Encoding DetectBomEncoding(byte[] bytes, out int preambleLength)
|
||||
{
|
||||
// UTF-32 BOMs must be checked before UTF-16 ones: the UTF-32 LE
|
||||
// BOM (FF FE 00 00) starts with the same two bytes as the
|
||||
// UTF-16 LE BOM (FF FE).
|
||||
if (bytes.Length >= 4 && bytes[0] == 0x00 && bytes[1] == 0x00 && bytes[2] == 0xFE && bytes[3] == 0xFF)
|
||||
{
|
||||
preambleLength = 4;
|
||||
return new UTF32Encoding(bigEndian: true, byteOrderMark: true);
|
||||
}
|
||||
|
||||
if (bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0x00 && bytes[3] == 0x00)
|
||||
{
|
||||
preambleLength = 4;
|
||||
return new UTF32Encoding(bigEndian: false, byteOrderMark: true);
|
||||
}
|
||||
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
|
||||
{
|
||||
preambleLength = 3;
|
||||
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
|
||||
}
|
||||
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
|
||||
{
|
||||
preambleLength = 2;
|
||||
return new UnicodeEncoding(bigEndian: true, byteOrderMark: true);
|
||||
}
|
||||
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
|
||||
{
|
||||
preambleLength = 2;
|
||||
return new UnicodeEncoding(bigEndian: false, byteOrderMark: true);
|
||||
}
|
||||
|
||||
preambleLength = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly Regex MetaCharsetRegex = new Regex(
|
||||
@"<meta\b[^>]*charset\s*=\s*[""']?\s*([A-Za-z0-9_\-]+)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static Encoding TryDetectMetaCharsetEncoding(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
// <meta charset=...> tags are always plain ASCII, so
|
||||
// Latin1 (single byte, 1:1 with the raw bytes) is a safe,
|
||||
// lossless way to look for them regardless of the file's
|
||||
// real encoding.
|
||||
var headLength = Math.Min(bytes.Length, 1024);
|
||||
var head = Encoding.GetEncoding("ISO-8859-1").GetString(bytes, 0, headLength);
|
||||
|
||||
var match = MetaCharsetRegex.Match(head);
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var charsetName = match.Groups[1].Value.Trim();
|
||||
if (string.IsNullOrEmpty(charsetName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Encoding.GetEncoding(charsetName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Unrecognized charset name - let the caller fall back
|
||||
// to Encoding.Default instead.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(settings.SignatureHtmlPath))
|
||||
{
|
||||
return File.Exists(settings.SignatureHtmlPath) ? File.ReadAllText(settings.SignatureHtmlPath) : null;
|
||||
return File.Exists(settings.SignatureHtmlPath) ? SignatureReader.ReadSignatureHtml(settings.SignatureHtmlPath) : null;
|
||||
}
|
||||
|
||||
return SignatureReader.TryAutoDetectSignatureHtml(outlookApplication);
|
||||
@@ -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<PreviewRow>(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,
|
||||
|
||||
+225
-124
@@ -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,25 @@ 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.Panel pnlTemplateList;
|
||||
private System.Windows.Forms.Panel pnlTemplateButtons;
|
||||
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 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;
|
||||
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 +63,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 +82,25 @@ 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.pnlTemplateList = new System.Windows.Forms.Panel();
|
||||
this.pnlTemplateButtons = new System.Windows.Forms.Panel();
|
||||
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.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();
|
||||
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,22 +108,27 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
|
||||
this.tabControl.SuspendLayout();
|
||||
this.tabGeneral.SuspendLayout();
|
||||
this.tabSshAccess.SuspendLayout();
|
||||
this.tabConfigUpdate.SuspendLayout();
|
||||
this.tabTemplates.SuspendLayout();
|
||||
this.pnlTemplateList.SuspendLayout();
|
||||
this.pnlTemplateButtons.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
|
||||
//
|
||||
// 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)));
|
||||
this.tabControl.TabPages.AddRange(new System.Windows.Forms.TabPage[] {
|
||||
this.tabGeneral,
|
||||
this.tabSshAccess,
|
||||
this.tabConfigUpdate});
|
||||
this.tabTemplates});
|
||||
|
||||
//
|
||||
// tabGeneral
|
||||
@@ -147,150 +166,232 @@ 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;
|
||||
|
||||
//
|
||||
// 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.pnlTemplateList);
|
||||
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);
|
||||
|
||||
// lblSshSubject
|
||||
this.lblSshSubject.Location = new System.Drawing.Point(15, 20);
|
||||
this.lblSshSubject.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblSshSubject.Text = "Тема письма:";
|
||||
// pnlTemplateList
|
||||
//
|
||||
// Left-column container for the template picker (buttons +
|
||||
// list). Its own outer Location/Size/Anchor still use fixed
|
||||
// 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)));
|
||||
// Dock order matters here: pnlTemplateButtons (Dock=Top) is
|
||||
// added FIRST, lstTemplates (Dock=Fill) SECOND, so the Fill
|
||||
// control's layout is computed against whatever client area
|
||||
// remains under the already-docked top strip.
|
||||
this.pnlTemplateList.Controls.Add(this.pnlTemplateButtons);
|
||||
this.pnlTemplateList.Controls.Add(this.lstTemplates);
|
||||
|
||||
// txtSshSubject
|
||||
this.txtSshSubject.Location = new System.Drawing.Point(15, 43);
|
||||
this.txtSshSubject.Size = new System.Drawing.Size(610, 23);
|
||||
// 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);
|
||||
|
||||
// lblSshBody
|
||||
this.lblSshBody.Location = new System.Drawing.Point(15, 78);
|
||||
this.lblSshBody.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblSshBody.Text = "Тело письма (HTML):";
|
||||
// btnAddTemplate
|
||||
this.btnAddTemplate.Location = new System.Drawing.Point(0, 0);
|
||||
this.btnAddTemplate.Size = new System.Drawing.Size(90, 27);
|
||||
this.btnAddTemplate.Text = "+ Добавить";
|
||||
this.btnAddTemplate.UseVisualStyleBackColor = true;
|
||||
this.btnAddTemplate.Click += new System.EventHandler(this.btnAddTemplate_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)
|
||||
// btnRemoveTemplate
|
||||
this.btnRemoveTemplate.Location = new System.Drawing.Point(95, 0);
|
||||
this.btnRemoveTemplate.Size = new System.Drawing.Size(90, 27);
|
||||
this.btnRemoveTemplate.Text = "Удалить";
|
||||
this.btnRemoveTemplate.UseVisualStyleBackColor = true;
|
||||
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
|
||||
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;
|
||||
|
||||
// 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, 254);
|
||||
this.lblTplSubject.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblTplSubject.Text = "Тема письма:";
|
||||
|
||||
// txtTplSubject
|
||||
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, 312);
|
||||
this.lblTplBody.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblTplBody.Text = "Тело письма (HTML):";
|
||||
|
||||
// txtTplBody
|
||||
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;
|
||||
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, 467);
|
||||
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.Location = new System.Drawing.Point(12, 574);
|
||||
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);
|
||||
|
||||
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;
|
||||
@@ -298,7 +399,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;
|
||||
@@ -310,8 +411,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);
|
||||
@@ -326,10 +427,10 @@ 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.pnlTemplateButtons.ResumeLayout(false);
|
||||
this.pnlTemplateList.ResumeLayout(false);
|
||||
this.tabTemplates.ResumeLayout(false);
|
||||
this.tabTemplates.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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;
|
||||
@@ -7,13 +10,31 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public partial class SettingsForm : Form
|
||||
{
|
||||
private readonly Outlook.Application outlookApplication;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory working copy of the template list, edited freely
|
||||
/// (add/remove/rename/reorder-by-selection) until btnSave_Click
|
||||
/// persists it via AppSettings.Save - nothing here touches disk
|
||||
/// until then.
|
||||
/// </summary>
|
||||
private List<MailTemplateDefinition> templates = new List<MailTemplateDefinition>();
|
||||
|
||||
/// <summary>
|
||||
/// Guards against SyncEditorToSelectedTemplate/LoadSelectedTemplateIntoEditor
|
||||
/// re-entrancy while lstTemplates.SelectedIndex is being changed
|
||||
/// programmatically (as opposed to by the operator clicking in the
|
||||
/// list) - the standard pattern for a WinForms master-detail list.
|
||||
/// </summary>
|
||||
private bool suppressSelectionSync;
|
||||
|
||||
public SettingsForm(Outlook.Application outlookApplication)
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -61,11 +82,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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -111,6 +129,216 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
cboFromAccount.SelectedIndex = selectedIndex;
|
||||
}
|
||||
|
||||
// --- Template list master-detail -----------------------------------
|
||||
|
||||
/// <summary>Rebuilds lstTemplates' items from the in-memory `templates` list, preserving (or resetting to the first item) the current selection.</summary>
|
||||
private void PopulateTemplateList(bool selectFirst)
|
||||
{
|
||||
suppressSelectionSync = true;
|
||||
try
|
||||
{
|
||||
lstTemplates.Items.Clear();
|
||||
foreach (var t in templates)
|
||||
{
|
||||
lstTemplates.Items.Add(TemplateListLabel(t));
|
||||
}
|
||||
|
||||
if (lstTemplates.Items.Count > 0)
|
||||
{
|
||||
lstTemplates.SelectedIndex = selectFirst ? 0 : Math.Min(lstTemplates.Items.Count - 1, lstTemplates.SelectedIndex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
suppressSelectionSync = false;
|
||||
}
|
||||
|
||||
LoadSelectedTemplateIntoEditor();
|
||||
}
|
||||
|
||||
private static string TemplateListLabel(MailTemplateDefinition t) =>
|
||||
string.IsNullOrWhiteSpace(t.DisplayName) ? "(без названия)" : t.DisplayName;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites lstTemplates' visible label for the currently-edited
|
||||
/// index from the in-memory list (call right after
|
||||
/// SyncEditorToSelectedTemplate so the list reflects a renamed
|
||||
/// template without a full PopulateTemplateList rebuild, which
|
||||
/// would also disturb the current selection/scroll position).
|
||||
/// </summary>
|
||||
private void RefreshEditedTemplateLabel()
|
||||
{
|
||||
if (!(lstTemplates.Tag is int editedIndex) || editedIndex < 0 || editedIndex >= templates.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
suppressSelectionSync = true;
|
||||
try
|
||||
{
|
||||
lstTemplates.Items[editedIndex] = TemplateListLabel(templates[editedIndex]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
suppressSelectionSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes the editor fields' current values back into the in-memory list entry that was selected when editing started (tracked via lstTemplates.Tag, see LoadSelectedTemplateIntoEditor). Safe to call with nothing selected/no prior selection.</summary>
|
||||
private void SyncEditorToSelectedTemplate()
|
||||
{
|
||||
if (!(lstTemplates.Tag is int editedIndex) || editedIndex < 0 || editedIndex >= templates.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var t = templates[editedIndex];
|
||||
t.DisplayName = txtTplDisplayName.Text;
|
||||
t.FileSuffix = txtTplFileSuffix.Text.Trim();
|
||||
t.LoginPattern = txtTplLoginPattern.Text.Trim();
|
||||
t.Subject = txtTplSubject.Text;
|
||||
t.BodyHtml = txtTplBody.Text;
|
||||
}
|
||||
|
||||
/// <summary>Loads the newly selected list item's values into the editor fields, and records which index the editor now belongs to (lstTemplates.Tag) so a later SyncEditorToSelectedTemplate knows where to write back.</summary>
|
||||
private void LoadSelectedTemplateIntoEditor()
|
||||
{
|
||||
var index = lstTemplates.SelectedIndex;
|
||||
var hasSelection = index >= 0 && index < templates.Count;
|
||||
|
||||
txtTplDisplayName.Enabled = hasSelection;
|
||||
txtTplFileSuffix.Enabled = hasSelection;
|
||||
txtTplLoginPattern.Enabled = hasSelection;
|
||||
txtTplSubject.Enabled = hasSelection;
|
||||
txtTplBody.Enabled = hasSelection;
|
||||
|
||||
if (!hasSelection)
|
||||
{
|
||||
lstTemplates.Tag = -1;
|
||||
txtTplDisplayName.Text = "";
|
||||
txtTplFileSuffix.Text = "";
|
||||
txtTplLoginPattern.Text = "";
|
||||
txtTplSubject.Text = "";
|
||||
txtTplBody.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var t = templates[index];
|
||||
lstTemplates.Tag = index;
|
||||
txtTplDisplayName.Text = t.DisplayName;
|
||||
txtTplFileSuffix.Text = t.FileSuffix;
|
||||
txtTplLoginPattern.Text = t.LoginPattern;
|
||||
txtTplSubject.Text = t.Subject;
|
||||
txtTplBody.Text = t.BodyHtml;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps lstTemplates' visible label in sync as soon as the
|
||||
/// operator finishes editing the name (focus leaves the field),
|
||||
/// without waiting for a selection change or Save. Leave (not
|
||||
/// TextChanged) deliberately: TextChanged would fire re-entrantly
|
||||
/// while LoadSelectedTemplateIntoEditor is itself still in the
|
||||
/// middle of setting the other fields for a newly selected
|
||||
/// template, which could write stale values back.
|
||||
/// </summary>
|
||||
private void txtTplDisplayName_Leave(object sender, EventArgs e)
|
||||
{
|
||||
SyncEditorToSelectedTemplate();
|
||||
RefreshEditedTemplateLabel();
|
||||
}
|
||||
|
||||
private void lstTemplates_SelectedIndexChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (suppressSelectionSync)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Save whatever is currently in the editor back to the
|
||||
// template it belonged to BEFORE loading the newly selected
|
||||
// one - otherwise unsaved edits to the previous selection
|
||||
// would be lost the moment the operator clicks another item.
|
||||
SyncEditorToSelectedTemplate();
|
||||
LoadSelectedTemplateIntoEditor();
|
||||
}
|
||||
|
||||
private void btnAddTemplate_Click(object sender, EventArgs e)
|
||||
{
|
||||
SyncEditorToSelectedTemplate();
|
||||
|
||||
var newTemplate = new MailTemplateDefinition
|
||||
{
|
||||
DisplayName = "Новый шаблон",
|
||||
FileSuffix = "",
|
||||
LoginPattern = "",
|
||||
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 +396,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
"Заменить домен и оба шаблона писем значениями по умолчанию? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.",
|
||||
"Заменить домен и весь список шаблонов писем значениями по умолчанию (два исходных шаблона)? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.",
|
||||
"Подтверждение",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question);
|
||||
@@ -180,11 +408,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 +437,30 @@ 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 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
|
||||
@@ -222,8 +471,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 +487,71 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every template's FileSuffix must be non-empty, and unique
|
||||
/// case-insensitively among all configured templates (it is
|
||||
/// ArchiveScanner's only way to match a file to a template).
|
||||
/// Returns null if valid, otherwise a message describing exactly
|
||||
/// which template/duplicate is the problem.
|
||||
/// </summary>
|
||||
private string ValidateTemplateSuffixes()
|
||||
{
|
||||
for (var i = 0; i < templates.Count; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templates[i].FileSuffix))
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(templates[i].DisplayName) ? $"#{i + 1}" : templates[i].DisplayName;
|
||||
return $"У шаблона \"{name}\" не заполнен суффикс файла — заполните поле \"Суффикс файла\" (например, \"ssh_access\").";
|
||||
}
|
||||
}
|
||||
|
||||
var duplicateGroup = templates
|
||||
.GroupBy(t => t.FileSuffix.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(g => g.Count() > 1);
|
||||
if (duplicateGroup != null)
|
||||
{
|
||||
var names = string.Join(", ", duplicateGroup.Select(t => string.IsNullOrWhiteSpace(t.DisplayName) ? "(без названия)" : t.DisplayName));
|
||||
return $"Суффикс файла \"{duplicateGroup.Key}\" используется в нескольких шаблонах ({names}) — суффикс должен быть уникальным.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
DialogResult = DialogResult.Cancel;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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"": ""<p>legacy ssh body</p>""
|
||||
},
|
||||
""config_update"": {
|
||||
""subject"": ""Legacy config subject {Login}"",
|
||||
""bodyHtml"": ""<p>legacy config body</p>""
|
||||
}
|
||||
}
|
||||
}";
|
||||
|
||||
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("<p>legacy ssh body</p>", 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("<p>legacy config body</p>", 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<TargetInvocationException>(() => InvokeDeserialize(trulyMalformedJson));
|
||||
Assert.NotNull(thrown.InnerException);
|
||||
}
|
||||
|
||||
private static AppSettingsData InvokeDeserialize(string json)
|
||||
{
|
||||
var method = typeof(AppSettings).GetMethod("Deserialize", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,24 +9,25 @@ using System.Text;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>One entry of AppSettingsData.Templates - the JSON shape of a MailTemplateDefinition.</summary>
|
||||
[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; } = "";
|
||||
|
||||
[DataMember(Name = "bodyHtml")]
|
||||
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();
|
||||
/// <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>
|
||||
@@ -70,38 +73,59 @@ namespace BulkArchiveMailer.Core
|
||||
[DataMember(Name = "fromAccountSmtpAddress")]
|
||||
public string FromAccountSmtpAddress { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[DataMember(Name = "templates")]
|
||||
public TemplateSetData Templates { get; set; } = new TemplateSetData();
|
||||
public List<MailTemplateData> Templates { get; set; } = new List<MailTemplateData>();
|
||||
|
||||
public MailTemplateSet ToTemplateSet()
|
||||
public List<MailTemplateDefinition> ToTemplateList()
|
||||
{
|
||||
return new MailTemplateSet
|
||||
var list = new List<MailTemplateDefinition>();
|
||||
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 ?? "",
|
||||
LoginPattern = t?.LoginPattern ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public void SetTemplate(ArchiveKind kind, MailTemplate template)
|
||||
/// <summary>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).</summary>
|
||||
public void SetTemplates(List<MailTemplateDefinition> templates)
|
||||
{
|
||||
Templates = Templates ?? new TemplateSetData();
|
||||
var data = new TemplateData { Subject = template?.Subject ?? "", BodyHtml = template?.BodyHtml ?? "" };
|
||||
if (kind == ArchiveKind.SshAccess)
|
||||
var list = new List<MailTemplateData>();
|
||||
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 ?? "",
|
||||
LoginPattern = t?.LoginPattern ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Templates = list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +150,57 @@ namespace BulkArchiveMailer.Core
|
||||
{
|
||||
private const string EmbeddedDefaultResourceName = "BulkArchiveMailer.Core.config.default.json";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 +233,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.
|
||||
/// </summary>
|
||||
public static AppSettingsData Load()
|
||||
{
|
||||
@@ -168,7 +251,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 +272,140 @@ namespace BulkArchiveMailer.Core
|
||||
File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static AppSettingsData Deserialize(string json)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<LegacyAppSettingsData>(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<AppSettingsData>(json);
|
||||
}
|
||||
|
||||
private static T DeserializeAs<T>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<MailTemplateData>
|
||||
{
|
||||
new MailTemplateData
|
||||
{
|
||||
DisplayName = DefaultDisplayName("ssh_access"),
|
||||
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
|
||||
{
|
||||
DisplayName = DefaultDisplayName("config_update"),
|
||||
FileSuffix = "config_update",
|
||||
Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "",
|
||||
BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "",
|
||||
LoginPattern = "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
private static string Serialize(AppSettingsData data)
|
||||
{
|
||||
using (var ms = new MemoryStream())
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
@@ -26,9 +27,9 @@ namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <param name="folderPath">Folder to scan (non-recursive - ssh-access-tui writes archives flat into one output directory).</param>
|
||||
/// <param name="emailDomain">Domain appended to the derived local part, e.g. "redlab.lo".</param>
|
||||
/// <param name="templates">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).</param>
|
||||
/// <param name="templates">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.</param>
|
||||
/// <param name="sendLog">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.</param>
|
||||
public static ScanResult Scan(string folderPath, string emailDomain, MailTemplateSet templates, SendLogStore sendLog = null)
|
||||
public static ScanResult Scan(string folderPath, string emailDomain, List<MailTemplateDefinition> templates, SendLogStore sendLog = null)
|
||||
{
|
||||
var result = new ScanResult();
|
||||
|
||||
@@ -41,9 +42,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<string, List<(string Path, ArchiveKind Kind)>>(StringComparer.OrdinalIgnoreCase);
|
||||
var byLogin = new Dictionary<string, List<(string Path, string Suffix)>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var filePath in Directory.EnumerateFiles(folderPath, "*.7z", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -55,18 +56,49 @@ 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 (!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, 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 +127,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 +150,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 (тот же файл, без изменений с тех пор).";
|
||||
@@ -137,5 +171,25 @@ namespace BulkArchiveMailer.Core
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
<Compile Include="ArchiveScanner.cs" />
|
||||
<Compile Include="EmailAddressBuilder.cs" />
|
||||
<Compile Include="FilenameParser.cs" />
|
||||
<Compile Include="MailTemplates.cs" />
|
||||
<Compile Include="MailTemplateDefinition.cs" />
|
||||
<Compile Include="SendJob.cs" />
|
||||
<Compile Include="SendLogStore.cs" />
|
||||
<Compile Include="TemplateEngine.cs" />
|
||||
|
||||
@@ -3,51 +3,54 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum ArchiveKind
|
||||
{
|
||||
SshAccess,
|
||||
ConfigUpdate
|
||||
}
|
||||
|
||||
/// <summary>Result of successfully parsing an archive file name.</summary>
|
||||
public sealed class ParsedFilename
|
||||
{
|
||||
public ParsedFilename(string login, ArchiveKind kind)
|
||||
public ParsedFilename(string login, string suffix)
|
||||
{
|
||||
Login = login;
|
||||
Kind = kind;
|
||||
Suffix = suffix;
|
||||
}
|
||||
|
||||
/// <summary>Lower-cased login exactly as it appears in the file name (hyphens intact).</summary>
|
||||
public string Login { get; }
|
||||
|
||||
public ArchiveKind Kind { get; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string Suffix { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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(
|
||||
@"^(?<login>[a-z0-9]+(?:-[a-z0-9]+)*)_(?<kind>ssh_access|config_update)\.7z$",
|
||||
@"^(?<login>[a-z0-9]+(?:-[a-z0-9]+)*)_(?<suffix>[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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class MailTemplateDefinition
|
||||
{
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string FileSuffix { get; set; } = "";
|
||||
|
||||
public string Subject { 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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>One editable Subject + BodyHtml pair.</summary>
|
||||
public sealed class MailTemplate
|
||||
{
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>The two named template keys used throughout the tool (matches ArchiveKind, and the JSON config's "templates" object).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Both named templates together, as used by ArchiveScanner to render a SendJob's Subject/BodyHtml.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static MailTemplateSet LoadDefaults() => AppSettings.LoadDefaults().ToTemplateSet();
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ namespace BulkArchiveMailer.Core
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
/// <summary>Null for Error rows caused by a login/kind conflict, and for Skipped (unrecognized file) rows.</summary>
|
||||
public ArchiveKind? Kind { get; set; }
|
||||
/// <summary>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.</summary>
|
||||
public string Kind { get; set; }
|
||||
|
||||
/// <summary>Null for conflict Error rows (see <see cref="ConflictingArchivePaths"/>) - otherwise the single archive to attach.</summary>
|
||||
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 "-";
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string KindDisplay { get; set; } = "-";
|
||||
|
||||
public override string ToString() => $"{Login} <{Email}> [{Status}]";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -42,21 +42,15 @@ namespace BulkArchiveMailer.Core
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Renders both Subject and BodyHtml of a template for one recipient.</summary>
|
||||
public static MailTemplate Render(MailTemplate template, string login, string email)
|
||||
/// <summary>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.</summary>
|
||||
public static Dictionary<string, string> BuildValues(string login, string email)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
return new Dictionary<string, string>(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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,20 @@
|
||||
"testModeDefault": true,
|
||||
"signatureHtmlPath": "",
|
||||
"fromAccountSmtpAddress": "",
|
||||
"templates": {
|
||||
"ssh_access": {
|
||||
"templates": [
|
||||
{
|
||||
"displayName": "Новый доступ (ключ + конфиг)",
|
||||
"fileSuffix": "ssh_access",
|
||||
"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": ""
|
||||
},
|
||||
"config_update": {
|
||||
{
|
||||
"displayName": "Обновление конфига",
|
||||
"fileSuffix": "config_update",
|
||||
"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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 сканирование папки, сопоставление суффикса (и необязательного regex-фильтра логина) с шаблоном, конфликт "оба архива сразу"
|
||||
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,28 @@ Outlook в принципе не запустить.
|
||||
(`Session.Accounts`), полезно, если у оператора несколько ящиков (например,
|
||||
отдельный служебный/shared-ящик именно для этой рассылки). Пункт
|
||||
"(по умолчанию Outlook)" — прежнее поведение без явного выбора аккаунта.
|
||||
- **Шаблоны писем** — тема и HTML-тело отдельно для `ssh_access` и
|
||||
`config_update`, с плейсхолдерами `{Login}`, `{Email}`, `{Date}`.
|
||||
- **Шаблоны писем** — вкладка "Шаблоны" со списком произвольного числа
|
||||
шаблонов (кнопки "+ Добавить"/"Удалить"). Каждый шаблон — это название,
|
||||
суффикс имени файла (`{login}_<суффикс>.7z`, по которому шаблон подбирается
|
||||
для конкретного архива), необязательный фильтр логина (regex) и тема с
|
||||
HTML-телом с плейсхолдерами `{Login}`, `{Email}`, `{Date}`. Суффикс
|
||||
обязателен и должен быть уникальным среди всех шаблонов — по нему, а не по
|
||||
фиксированному перечислению видов архива, `ArchiveScanner` сопоставляет
|
||||
файл с шаблоном. Два исходных шаблона (`ssh_access`/`config_update`) —
|
||||
обычные равноправные записи в этом списке, без особой защиты от
|
||||
переименования/удаления; кнопка "Сбросить шаблоны по умолчанию"
|
||||
восстанавливает их, если они были случайно удалены.
|
||||
- **Фильтр логина** — необязательное регулярное выражение (.NET regex,
|
||||
без учёта регистра). Пустое значение (по умолчанию, в том числе у
|
||||
шаблонов, мигрированных из конфига старого формата) означает "любой
|
||||
логин" — прежнее поведение, когда только суффикс решает, подходит ли
|
||||
файл под шаблон. Если поле заполнено, а логин из имени файла ему не
|
||||
соответствует, файл не считается подходящим под этот шаблон: строка в
|
||||
предпросмотре получает статус "Ошибка" с пояснением, какому шаблону и
|
||||
какому паттерну логин не соответствует, — автоматический подбор другого
|
||||
шаблона не выполняется. Некорректный regex (не компилируется) не даёт
|
||||
сохранить настройки — окно "Настройки" укажет, в каком именно шаблоне
|
||||
ошибка.
|
||||
- **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются
|
||||
в Черновики, а не отправляются) до первого осознанного отключения
|
||||
оператором в окне предпросмотра.
|
||||
|
||||
Reference in new issue
Block a user