Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
804b9610ca | ||
|
|
cb3b273741 | ||
|
|
f04de700d9 |
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
private System.Windows.Forms.Button btnCheckAllReady;
|
||||
private System.Windows.Forms.Button btnUncheckAll;
|
||||
private System.Windows.Forms.Button btnRescan;
|
||||
private System.Windows.Forms.Button btnSettings;
|
||||
|
||||
private System.Windows.Forms.ProgressBar progressSend;
|
||||
private System.Windows.Forms.Label lblProgressStatus;
|
||||
@@ -56,6 +57,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
this.btnCheckAllReady = new System.Windows.Forms.Button();
|
||||
this.btnUncheckAll = new System.Windows.Forms.Button();
|
||||
this.btnRescan = new System.Windows.Forms.Button();
|
||||
this.btnSettings = new System.Windows.Forms.Button();
|
||||
|
||||
this.progressSend = new System.Windows.Forms.ProgressBar();
|
||||
this.lblProgressStatus = new System.Windows.Forms.Label();
|
||||
@@ -70,7 +72,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
// lblPasswordReminder
|
||||
//
|
||||
this.lblPasswordReminder.Location = new System.Drawing.Point(12, 10);
|
||||
this.lblPasswordReminder.Size = new System.Drawing.Size(900, 20);
|
||||
this.lblPasswordReminder.Size = new System.Drawing.Size(1018, 20);
|
||||
this.lblPasswordReminder.Text = "Напоминание: пароль от архивов сообщается получателям отдельно (вручную, не через это письмо и не через этот инструмент).";
|
||||
this.lblPasswordReminder.ForeColor = System.Drawing.Color.DarkRed;
|
||||
this.lblPasswordReminder.Font = new System.Drawing.Font(this.lblPasswordReminder.Font, System.Drawing.FontStyle.Bold);
|
||||
@@ -80,7 +82,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
// lblSignatureWarning
|
||||
//
|
||||
this.lblSignatureWarning.Location = new System.Drawing.Point(12, 32);
|
||||
this.lblSignatureWarning.Size = new System.Drawing.Size(900, 20);
|
||||
this.lblSignatureWarning.Size = new System.Drawing.Size(1018, 20);
|
||||
this.lblSignatureWarning.Text = "Подпись не найдена — письма будут отправлены без подписи. Настройте .htm-файл вручную в Настройках.";
|
||||
this.lblSignatureWarning.ForeColor = System.Drawing.Color.DarkOrange;
|
||||
this.lblSignatureWarning.Visible = false;
|
||||
@@ -90,7 +92,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
// grid
|
||||
//
|
||||
this.grid.Location = new System.Drawing.Point(12, 58);
|
||||
this.grid.Size = new System.Drawing.Size(900, 420);
|
||||
this.grid.Size = new System.Drawing.Size(1018, 420);
|
||||
this.grid.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)));
|
||||
@@ -215,24 +217,34 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
this.btnUncheckAll.UseVisualStyleBackColor = true;
|
||||
this.btnUncheckAll.Click += new System.EventHandler(this.btnUncheckAll_Click);
|
||||
|
||||
//
|
||||
// btnSettings
|
||||
//
|
||||
this.btnSettings.Location = new System.Drawing.Point(416, 540);
|
||||
this.btnSettings.Size = new System.Drawing.Size(110, 27);
|
||||
this.btnSettings.Text = "Настройки...";
|
||||
this.btnSettings.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnSettings.UseVisualStyleBackColor = true;
|
||||
this.btnSettings.Click += new System.EventHandler(this.btnSettings_Click);
|
||||
|
||||
//
|
||||
// progressSend
|
||||
//
|
||||
this.progressSend.Location = new System.Drawing.Point(430, 543);
|
||||
this.progressSend.Location = new System.Drawing.Point(548, 543);
|
||||
this.progressSend.Size = new System.Drawing.Size(300, 20);
|
||||
this.progressSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// lblProgressStatus
|
||||
//
|
||||
this.lblProgressStatus.Location = new System.Drawing.Point(430, 565);
|
||||
this.lblProgressStatus.Location = new System.Drawing.Point(548, 565);
|
||||
this.lblProgressStatus.Size = new System.Drawing.Size(482, 20);
|
||||
this.lblProgressStatus.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// btnClose
|
||||
//
|
||||
this.btnClose.Location = new System.Drawing.Point(720, 540);
|
||||
this.btnClose.Location = new System.Drawing.Point(838, 540);
|
||||
this.btnClose.Size = new System.Drawing.Size(90, 30);
|
||||
this.btnClose.Text = "Закрыть";
|
||||
this.btnClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
@@ -243,7 +255,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
//
|
||||
// btnSend
|
||||
//
|
||||
this.btnSend.Location = new System.Drawing.Point(816, 540);
|
||||
this.btnSend.Location = new System.Drawing.Point(934, 540);
|
||||
this.btnSend.Size = new System.Drawing.Size(96, 30);
|
||||
this.btnSend.Text = "Отправить";
|
||||
this.btnSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
@@ -256,8 +268,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(924, 582);
|
||||
this.MinimumSize = new System.Drawing.Size(760, 420);
|
||||
this.ClientSize = new System.Drawing.Size(1042, 582);
|
||||
this.MinimumSize = new System.Drawing.Size(878, 420);
|
||||
this.Controls.Add(this.lblPasswordReminder);
|
||||
this.Controls.Add(this.lblSignatureWarning);
|
||||
this.Controls.Add(this.grid);
|
||||
@@ -266,6 +278,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
this.Controls.Add(this.btnRescan);
|
||||
this.Controls.Add(this.btnCheckAllReady);
|
||||
this.Controls.Add(this.btnUncheckAll);
|
||||
this.Controls.Add(this.btnSettings);
|
||||
this.Controls.Add(this.progressSend);
|
||||
this.Controls.Add(this.lblProgressStatus);
|
||||
this.Controls.Add(this.btnClose);
|
||||
|
||||
@@ -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());
|
||||
@@ -172,6 +172,34 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
RunScan();
|
||||
}
|
||||
|
||||
private void btnSettings_Click(object sender, EventArgs e)
|
||||
{
|
||||
using (var settingsForm = new SettingsForm(outlookApplication))
|
||||
{
|
||||
settingsForm.ShowDialog(this);
|
||||
}
|
||||
|
||||
// Regardless of DialogResult (OK or Cancel), reload settings and
|
||||
// rescan so a changed archive folder, domain, or templates are
|
||||
// reflected immediately without a separate "Пересканировать"
|
||||
// click. A closed-without-saving dialog just reloads the same
|
||||
// settings again, which is harmless.
|
||||
try
|
||||
{
|
||||
settings = AppSettings.Load();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
signatureHtml = ResolveSignatureHtml();
|
||||
lblSignatureWarning.Visible = string.IsNullOrEmpty(signatureHtml);
|
||||
|
||||
RunScan();
|
||||
}
|
||||
|
||||
private void btnCheckAllReady_Click(object sender, EventArgs e)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
@@ -249,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,
|
||||
|
||||
+127
-109
@@ -16,8 +16,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
|
||||
private System.Windows.Forms.TabControl tabControl;
|
||||
private System.Windows.Forms.TabPage tabGeneral;
|
||||
private System.Windows.Forms.TabPage tabSshAccess;
|
||||
private System.Windows.Forms.TabPage tabConfigUpdate;
|
||||
private System.Windows.Forms.TabPage tabTemplates;
|
||||
|
||||
private System.Windows.Forms.Label lblArchiveFolder;
|
||||
private System.Windows.Forms.TextBox txtArchiveFolder;
|
||||
@@ -36,17 +35,20 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
private System.Windows.Forms.Label lblFromAccount;
|
||||
private System.Windows.Forms.ComboBox cboFromAccount;
|
||||
|
||||
private System.Windows.Forms.Label lblSshSubject;
|
||||
private System.Windows.Forms.TextBox txtSshSubject;
|
||||
private System.Windows.Forms.Label lblSshBody;
|
||||
private System.Windows.Forms.TextBox txtSshBody;
|
||||
private System.Windows.Forms.Label lblSshHint;
|
||||
private System.Windows.Forms.ListBox lstTemplates;
|
||||
private System.Windows.Forms.Button btnAddTemplate;
|
||||
private System.Windows.Forms.Button btnRemoveTemplate;
|
||||
|
||||
private System.Windows.Forms.Label lblCfgSubject;
|
||||
private System.Windows.Forms.TextBox txtCfgSubject;
|
||||
private System.Windows.Forms.Label lblCfgBody;
|
||||
private System.Windows.Forms.TextBox txtCfgBody;
|
||||
private System.Windows.Forms.Label lblCfgHint;
|
||||
private System.Windows.Forms.Label lblTplDisplayName;
|
||||
private System.Windows.Forms.TextBox txtTplDisplayName;
|
||||
private System.Windows.Forms.Label lblTplFileSuffix;
|
||||
private System.Windows.Forms.TextBox txtTplFileSuffix;
|
||||
private System.Windows.Forms.Label lblTplFileSuffixHint;
|
||||
private System.Windows.Forms.Label lblTplSubject;
|
||||
private System.Windows.Forms.TextBox txtTplSubject;
|
||||
private System.Windows.Forms.Label lblTplBody;
|
||||
private System.Windows.Forms.TextBox txtTplBody;
|
||||
private System.Windows.Forms.Label lblTplHint;
|
||||
|
||||
private System.Windows.Forms.Button btnSave;
|
||||
private System.Windows.Forms.Button btnCancel;
|
||||
@@ -56,8 +58,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
this.tabControl = new System.Windows.Forms.TabControl();
|
||||
this.tabGeneral = new System.Windows.Forms.TabPage();
|
||||
this.tabSshAccess = new System.Windows.Forms.TabPage();
|
||||
this.tabConfigUpdate = new System.Windows.Forms.TabPage();
|
||||
this.tabTemplates = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.lblArchiveFolder = new System.Windows.Forms.Label();
|
||||
this.txtArchiveFolder = new System.Windows.Forms.TextBox();
|
||||
@@ -76,17 +77,20 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
this.lblFromAccount = new System.Windows.Forms.Label();
|
||||
this.cboFromAccount = new System.Windows.Forms.ComboBox();
|
||||
|
||||
this.lblSshSubject = new System.Windows.Forms.Label();
|
||||
this.txtSshSubject = new System.Windows.Forms.TextBox();
|
||||
this.lblSshBody = new System.Windows.Forms.Label();
|
||||
this.txtSshBody = new System.Windows.Forms.TextBox();
|
||||
this.lblSshHint = new System.Windows.Forms.Label();
|
||||
this.lstTemplates = new System.Windows.Forms.ListBox();
|
||||
this.btnAddTemplate = new System.Windows.Forms.Button();
|
||||
this.btnRemoveTemplate = new System.Windows.Forms.Button();
|
||||
|
||||
this.lblCfgSubject = new System.Windows.Forms.Label();
|
||||
this.txtCfgSubject = new System.Windows.Forms.TextBox();
|
||||
this.lblCfgBody = new System.Windows.Forms.Label();
|
||||
this.txtCfgBody = new System.Windows.Forms.TextBox();
|
||||
this.lblCfgHint = new System.Windows.Forms.Label();
|
||||
this.lblTplDisplayName = new System.Windows.Forms.Label();
|
||||
this.txtTplDisplayName = new System.Windows.Forms.TextBox();
|
||||
this.lblTplFileSuffix = new System.Windows.Forms.Label();
|
||||
this.txtTplFileSuffix = new System.Windows.Forms.TextBox();
|
||||
this.lblTplFileSuffixHint = new System.Windows.Forms.Label();
|
||||
this.lblTplSubject = new System.Windows.Forms.Label();
|
||||
this.txtTplSubject = new System.Windows.Forms.TextBox();
|
||||
this.lblTplBody = new System.Windows.Forms.Label();
|
||||
this.txtTplBody = new System.Windows.Forms.TextBox();
|
||||
this.lblTplHint = new System.Windows.Forms.Label();
|
||||
|
||||
this.btnSave = new System.Windows.Forms.Button();
|
||||
this.btnCancel = new System.Windows.Forms.Button();
|
||||
@@ -94,8 +98,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
|
||||
this.tabControl.SuspendLayout();
|
||||
this.tabGeneral.SuspendLayout();
|
||||
this.tabSshAccess.SuspendLayout();
|
||||
this.tabConfigUpdate.SuspendLayout();
|
||||
this.tabTemplates.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
|
||||
//
|
||||
@@ -108,8 +111,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.tabControl.TabPages.AddRange(new System.Windows.Forms.TabPage[] {
|
||||
this.tabGeneral,
|
||||
this.tabSshAccess,
|
||||
this.tabConfigUpdate});
|
||||
this.tabTemplates});
|
||||
|
||||
//
|
||||
// tabGeneral
|
||||
@@ -195,97 +197,115 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
|
||||
//
|
||||
// tabSshAccess
|
||||
// tabTemplates
|
||||
//
|
||||
this.tabSshAccess.Text = "Шаблон: новый доступ";
|
||||
this.tabSshAccess.Padding = new System.Windows.Forms.Padding(12);
|
||||
this.tabSshAccess.UseVisualStyleBackColor = true;
|
||||
this.tabSshAccess.Controls.Add(this.lblSshSubject);
|
||||
this.tabSshAccess.Controls.Add(this.txtSshSubject);
|
||||
this.tabSshAccess.Controls.Add(this.lblSshBody);
|
||||
this.tabSshAccess.Controls.Add(this.txtSshBody);
|
||||
this.tabSshAccess.Controls.Add(this.lblSshHint);
|
||||
this.tabTemplates.Text = "Шаблоны";
|
||||
this.tabTemplates.Padding = new System.Windows.Forms.Padding(12);
|
||||
this.tabTemplates.UseVisualStyleBackColor = true;
|
||||
this.tabTemplates.Controls.Add(this.lstTemplates);
|
||||
this.tabTemplates.Controls.Add(this.btnAddTemplate);
|
||||
this.tabTemplates.Controls.Add(this.btnRemoveTemplate);
|
||||
this.tabTemplates.Controls.Add(this.lblTplDisplayName);
|
||||
this.tabTemplates.Controls.Add(this.txtTplDisplayName);
|
||||
this.tabTemplates.Controls.Add(this.lblTplFileSuffix);
|
||||
this.tabTemplates.Controls.Add(this.txtTplFileSuffix);
|
||||
this.tabTemplates.Controls.Add(this.lblTplFileSuffixHint);
|
||||
this.tabTemplates.Controls.Add(this.lblTplSubject);
|
||||
this.tabTemplates.Controls.Add(this.txtTplSubject);
|
||||
this.tabTemplates.Controls.Add(this.lblTplBody);
|
||||
this.tabTemplates.Controls.Add(this.txtTplBody);
|
||||
this.tabTemplates.Controls.Add(this.lblTplHint);
|
||||
|
||||
// lblSshSubject
|
||||
this.lblSshSubject.Location = new System.Drawing.Point(15, 20);
|
||||
this.lblSshSubject.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblSshSubject.Text = "Тема письма:";
|
||||
// lstTemplates
|
||||
this.lstTemplates.Location = new System.Drawing.Point(15, 20);
|
||||
this.lstTemplates.Size = new System.Drawing.Size(190, 329);
|
||||
this.lstTemplates.IntegralHeight = false;
|
||||
this.lstTemplates.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.lstTemplates.SelectedIndexChanged += new System.EventHandler(this.lstTemplates_SelectedIndexChanged);
|
||||
|
||||
// txtSshSubject
|
||||
this.txtSshSubject.Location = new System.Drawing.Point(15, 43);
|
||||
this.txtSshSubject.Size = new System.Drawing.Size(610, 23);
|
||||
// btnAddTemplate
|
||||
this.btnAddTemplate.Location = new System.Drawing.Point(15, 355);
|
||||
this.btnAddTemplate.Size = new System.Drawing.Size(90, 27);
|
||||
this.btnAddTemplate.Text = "+ Добавить";
|
||||
this.btnAddTemplate.UseVisualStyleBackColor = true;
|
||||
this.btnAddTemplate.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnAddTemplate.Click += new System.EventHandler(this.btnAddTemplate_Click);
|
||||
|
||||
// lblSshBody
|
||||
this.lblSshBody.Location = new System.Drawing.Point(15, 78);
|
||||
this.lblSshBody.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblSshBody.Text = "Тело письма (HTML):";
|
||||
// btnRemoveTemplate
|
||||
this.btnRemoveTemplate.Location = new System.Drawing.Point(115, 355);
|
||||
this.btnRemoveTemplate.Size = new System.Drawing.Size(90, 27);
|
||||
this.btnRemoveTemplate.Text = "Удалить";
|
||||
this.btnRemoveTemplate.UseVisualStyleBackColor = true;
|
||||
this.btnRemoveTemplate.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnRemoveTemplate.Click += new System.EventHandler(this.btnRemoveTemplate_Click);
|
||||
|
||||
// txtSshBody
|
||||
this.txtSshBody.Location = new System.Drawing.Point(15, 101);
|
||||
this.txtSshBody.Size = new System.Drawing.Size(610, 300);
|
||||
this.txtSshBody.Multiline = true;
|
||||
this.txtSshBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
|
||||
this.txtSshBody.AcceptsReturn = true;
|
||||
this.txtSshBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
// lblTplDisplayName
|
||||
this.lblTplDisplayName.Location = new System.Drawing.Point(220, 20);
|
||||
this.lblTplDisplayName.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblTplDisplayName.Text = "Название:";
|
||||
|
||||
// txtTplDisplayName
|
||||
this.txtTplDisplayName.Location = new System.Drawing.Point(220, 43);
|
||||
this.txtTplDisplayName.Size = new System.Drawing.Size(410, 23);
|
||||
this.txtTplDisplayName.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right));
|
||||
this.txtTplDisplayName.Leave += new System.EventHandler(this.txtTplDisplayName_Leave);
|
||||
|
||||
// lblTplFileSuffix
|
||||
this.lblTplFileSuffix.Location = new System.Drawing.Point(220, 78);
|
||||
this.lblTplFileSuffix.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblTplFileSuffix.Text = "Суффикс файла:";
|
||||
|
||||
// txtTplFileSuffix
|
||||
this.txtTplFileSuffix.Location = new System.Drawing.Point(220, 101);
|
||||
this.txtTplFileSuffix.Size = new System.Drawing.Size(200, 23);
|
||||
|
||||
// lblTplFileSuffixHint
|
||||
this.lblTplFileSuffixHint.Location = new System.Drawing.Point(220, 127);
|
||||
this.lblTplFileSuffixHint.Size = new System.Drawing.Size(410, 20);
|
||||
this.lblTplFileSuffixHint.Text = "Файл: {login}_<суффикс>.7z";
|
||||
this.lblTplFileSuffixHint.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
|
||||
// lblTplSubject
|
||||
this.lblTplSubject.Location = new System.Drawing.Point(220, 160);
|
||||
this.lblTplSubject.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblTplSubject.Text = "Тема письма:";
|
||||
|
||||
// txtTplSubject
|
||||
this.txtTplSubject.Location = new System.Drawing.Point(220, 183);
|
||||
this.txtTplSubject.Size = new System.Drawing.Size(410, 23);
|
||||
this.txtTplSubject.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right));
|
||||
|
||||
// lblTplBody
|
||||
this.lblTplBody.Location = new System.Drawing.Point(220, 218);
|
||||
this.lblTplBody.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblTplBody.Text = "Тело письма (HTML):";
|
||||
|
||||
// txtTplBody
|
||||
this.txtTplBody.Location = new System.Drawing.Point(220, 241);
|
||||
this.txtTplBody.Size = new System.Drawing.Size(410, 160);
|
||||
this.txtTplBody.Multiline = true;
|
||||
this.txtTplBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
|
||||
this.txtTplBody.AcceptsReturn = true;
|
||||
this.txtTplBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
// lblSshHint
|
||||
this.lblSshHint.Location = new System.Drawing.Point(15, 408);
|
||||
this.lblSshHint.Size = new System.Drawing.Size(610, 20);
|
||||
this.lblSshHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}";
|
||||
this.lblSshHint.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
this.lblSshHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// tabConfigUpdate
|
||||
//
|
||||
this.tabConfigUpdate.Text = "Шаблон: обновление конфига";
|
||||
this.tabConfigUpdate.Padding = new System.Windows.Forms.Padding(12);
|
||||
this.tabConfigUpdate.UseVisualStyleBackColor = true;
|
||||
this.tabConfigUpdate.Controls.Add(this.lblCfgSubject);
|
||||
this.tabConfigUpdate.Controls.Add(this.txtCfgSubject);
|
||||
this.tabConfigUpdate.Controls.Add(this.lblCfgBody);
|
||||
this.tabConfigUpdate.Controls.Add(this.txtCfgBody);
|
||||
this.tabConfigUpdate.Controls.Add(this.lblCfgHint);
|
||||
|
||||
// lblCfgSubject
|
||||
this.lblCfgSubject.Location = new System.Drawing.Point(15, 20);
|
||||
this.lblCfgSubject.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblCfgSubject.Text = "Тема письма:";
|
||||
|
||||
// txtCfgSubject
|
||||
this.txtCfgSubject.Location = new System.Drawing.Point(15, 43);
|
||||
this.txtCfgSubject.Size = new System.Drawing.Size(610, 23);
|
||||
|
||||
// lblCfgBody
|
||||
this.lblCfgBody.Location = new System.Drawing.Point(15, 78);
|
||||
this.lblCfgBody.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblCfgBody.Text = "Тело письма (HTML):";
|
||||
|
||||
// txtCfgBody
|
||||
this.txtCfgBody.Location = new System.Drawing.Point(15, 101);
|
||||
this.txtCfgBody.Size = new System.Drawing.Size(610, 300);
|
||||
this.txtCfgBody.Multiline = true;
|
||||
this.txtCfgBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
|
||||
this.txtCfgBody.AcceptsReturn = true;
|
||||
this.txtCfgBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
// lblCfgHint
|
||||
this.lblCfgHint.Location = new System.Drawing.Point(15, 408);
|
||||
this.lblCfgHint.Size = new System.Drawing.Size(610, 20);
|
||||
this.lblCfgHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}";
|
||||
this.lblCfgHint.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
this.lblCfgHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
// lblTplHint
|
||||
this.lblTplHint.Location = new System.Drawing.Point(220, 408);
|
||||
this.lblTplHint.Size = new System.Drawing.Size(410, 20);
|
||||
this.lblTplHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}";
|
||||
this.lblTplHint.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
this.lblTplHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// btnSave / btnCancel / btnResetDefaults
|
||||
//
|
||||
this.btnResetDefaults.Location = new System.Drawing.Point(12, 494);
|
||||
this.btnResetDefaults.Size = new System.Drawing.Size(220, 30);
|
||||
this.btnResetDefaults.Text = "Сбросить домен/шаблоны по умолчанию";
|
||||
this.btnResetDefaults.Text = "Сбросить шаблоны по умолчанию";
|
||||
this.btnResetDefaults.UseVisualStyleBackColor = true;
|
||||
this.btnResetDefaults.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnResetDefaults.Click += new System.EventHandler(this.btnResetDefaults_Click);
|
||||
@@ -326,10 +346,8 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
this.tabControl.ResumeLayout(false);
|
||||
this.tabGeneral.ResumeLayout(false);
|
||||
this.tabGeneral.PerformLayout();
|
||||
this.tabSshAccess.ResumeLayout(false);
|
||||
this.tabSshAccess.PerformLayout();
|
||||
this.tabConfigUpdate.ResumeLayout(false);
|
||||
this.tabConfigUpdate.PerformLayout();
|
||||
this.tabTemplates.ResumeLayout(false);
|
||||
this.tabTemplates.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using BulkArchiveMailer.Core;
|
||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||
@@ -7,13 +9,31 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
/// <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 +81,8 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
|
||||
PopulateFromAccountCombo(settings.FromAccountSmtpAddress ?? "");
|
||||
|
||||
var templates = settings.ToTemplateSet();
|
||||
txtSshSubject.Text = templates.SshAccess.Subject;
|
||||
txtSshBody.Text = templates.SshAccess.BodyHtml;
|
||||
txtCfgSubject.Text = templates.ConfigUpdate.Subject;
|
||||
txtCfgBody.Text = templates.ConfigUpdate.BodyHtml;
|
||||
templates = settings.ToTemplateList();
|
||||
PopulateTemplateList(selectFirst: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -111,6 +128,211 @@ 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.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;
|
||||
txtTplSubject.Enabled = hasSelection;
|
||||
txtTplBody.Enabled = hasSelection;
|
||||
|
||||
if (!hasSelection)
|
||||
{
|
||||
lstTemplates.Tag = -1;
|
||||
txtTplDisplayName.Text = "";
|
||||
txtTplFileSuffix.Text = "";
|
||||
txtTplSubject.Text = "";
|
||||
txtTplBody.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var t = templates[index];
|
||||
lstTemplates.Tag = index;
|
||||
txtTplDisplayName.Text = t.DisplayName;
|
||||
txtTplFileSuffix.Text = t.FileSuffix;
|
||||
txtTplSubject.Text = t.Subject;
|
||||
txtTplBody.Text = t.BodyHtml;
|
||||
}
|
||||
|
||||
/// <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 = "",
|
||||
Subject = "",
|
||||
BodyHtml = "",
|
||||
};
|
||||
templates.Add(newTemplate);
|
||||
|
||||
PopulateTemplateList(selectFirst: false);
|
||||
suppressSelectionSync = true;
|
||||
try
|
||||
{
|
||||
lstTemplates.SelectedIndex = templates.Count - 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
suppressSelectionSync = false;
|
||||
}
|
||||
|
||||
LoadSelectedTemplateIntoEditor();
|
||||
txtTplDisplayName.Focus();
|
||||
txtTplDisplayName.SelectAll();
|
||||
}
|
||||
|
||||
private void btnRemoveTemplate_Click(object sender, EventArgs e)
|
||||
{
|
||||
var index = lstTemplates.SelectedIndex;
|
||||
if (index < 0 || index >= templates.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var label = TemplateListLabel(templates[index]);
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
"Удалить шаблон \"" + label + "\"? Несохранённые правки в этом окне будут потеряны только после нажатия \"Сохранить\".",
|
||||
"Подтверждение",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question);
|
||||
if (confirm != DialogResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
templates.RemoveAt(index);
|
||||
|
||||
// The editor currently holds this now-removed template's stale
|
||||
// values - clear its "which index does the editor belong to"
|
||||
// marker so a later SyncEditorToSelectedTemplate (e.g. from the
|
||||
// SelectedIndexChanged this RemoveAt is about to trigger) does
|
||||
// not write those stale values into whatever template happens
|
||||
// to occupy this index next.
|
||||
lstTemplates.Tag = -1;
|
||||
|
||||
var nextIndex = templates.Count == 0 ? -1 : Math.Min(index, templates.Count - 1);
|
||||
suppressSelectionSync = true;
|
||||
try
|
||||
{
|
||||
lstTemplates.Items.RemoveAt(index);
|
||||
lstTemplates.SelectedIndex = nextIndex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
suppressSelectionSync = false;
|
||||
}
|
||||
|
||||
LoadSelectedTemplateIntoEditor();
|
||||
}
|
||||
|
||||
// --- General tab handlers --------------------------------------
|
||||
|
||||
private void btnBrowseFolder_Click(object sender, EventArgs e)
|
||||
{
|
||||
using (var dialog = new FolderBrowserDialog())
|
||||
@@ -168,7 +390,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
"Заменить домен и оба шаблона писем значениями по умолчанию? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.",
|
||||
"Заменить домен и весь список шаблонов писем значениями по умолчанию (два исходных шаблона)? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.",
|
||||
"Подтверждение",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question);
|
||||
@@ -180,11 +402,8 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
var defaults = AppSettings.LoadDefaults();
|
||||
txtDomain.Text = defaults.EmailDomain;
|
||||
|
||||
var templates = defaults.ToTemplateSet();
|
||||
txtSshSubject.Text = templates.SshAccess.Subject;
|
||||
txtSshBody.Text = templates.SshAccess.BodyHtml;
|
||||
txtCfgSubject.Text = templates.ConfigUpdate.Subject;
|
||||
txtCfgBody.Text = templates.ConfigUpdate.BodyHtml;
|
||||
templates = defaults.ToTemplateList();
|
||||
PopulateTemplateList(selectFirst: true);
|
||||
}
|
||||
|
||||
private void btnSave_Click(object sender, EventArgs e)
|
||||
@@ -212,6 +431,21 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure whatever is currently in the editor (possibly never
|
||||
// committed by a selection change, e.g. the operator edited one
|
||||
// template and clicked Save directly) is captured before
|
||||
// validating/saving the list.
|
||||
SyncEditorToSelectedTemplate();
|
||||
|
||||
var suffixError = ValidateTemplateSuffixes();
|
||||
if (suffixError != null)
|
||||
{
|
||||
MessageBox.Show(this, suffixError, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
tabControl.SelectedTab = tabTemplates;
|
||||
lstTemplates.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedAccount = cboFromAccount.SelectedItem as FromAccountItem;
|
||||
|
||||
var data = new AppSettingsData
|
||||
@@ -222,8 +456,7 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
TestModeDefault = chkTestModeDefault.Checked,
|
||||
FromAccountSmtpAddress = selectedAccount?.SmtpAddress ?? "",
|
||||
};
|
||||
data.SetTemplate(ArchiveKind.SshAccess, new MailTemplate { Subject = txtSshSubject.Text, BodyHtml = txtSshBody.Text });
|
||||
data.SetTemplate(ArchiveKind.ConfigUpdate, new MailTemplate { Subject = txtCfgSubject.Text, BodyHtml = txtCfgBody.Text });
|
||||
data.SetTemplates(templates);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -239,6 +472,36 @@ namespace BulkArchiveMailer.AddIn.UI
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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,9 +9,16 @@ 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; } = "";
|
||||
|
||||
@@ -17,16 +26,6 @@ namespace BulkArchiveMailer.Core
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class TemplateSetData
|
||||
{
|
||||
[DataMember(Name = "ssh_access")]
|
||||
public TemplateData SshAccess { get; set; } = new TemplateData();
|
||||
|
||||
[DataMember(Name = "config_update")]
|
||||
public TemplateData ConfigUpdate { get; set; } = new TemplateData();
|
||||
}
|
||||
|
||||
/// <summary>Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it).</summary>
|
||||
[DataContract]
|
||||
public sealed class AppSettingsData
|
||||
@@ -70,38 +69,57 @@ 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 ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
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 ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Templates = list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +144,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 +227,14 @@ namespace BulkArchiveMailer.Core
|
||||
/// never silently overwritten - the DataContractJsonSerializer
|
||||
/// exception propagates so the caller can warn the operator
|
||||
/// instead of quietly discarding their edits.
|
||||
///
|
||||
/// If the file on disk turns out to be in the pre-v1.0.5 legacy
|
||||
/// shape (object "templates" instead of a list - see
|
||||
/// LegacyAppSettingsData), Deserialize below transparently
|
||||
/// migrates it in memory; this method additionally re-persists the
|
||||
/// migrated result immediately (Save), so every later Load() goes
|
||||
/// through the fast/primary parse path instead of re-migrating on
|
||||
/// every single load.
|
||||
/// </summary>
|
||||
public static AppSettingsData Load()
|
||||
{
|
||||
@@ -168,7 +245,14 @@ namespace BulkArchiveMailer.Core
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(ConfigFilePath, Encoding.UTF8);
|
||||
return Deserialize(json);
|
||||
var data = DeserializeWithLegacyFallback(json, out var wasLegacyMigration);
|
||||
|
||||
if (wasLegacyMigration)
|
||||
{
|
||||
Save(data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public static void Save(AppSettingsData data)
|
||||
@@ -182,16 +266,134 @@ namespace BulkArchiveMailer.Core
|
||||
File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static AppSettingsData Deserialize(string json)
|
||||
/// <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 ?? "",
|
||||
},
|
||||
new MailTemplateData
|
||||
{
|
||||
DisplayName = DefaultDisplayName("config_update"),
|
||||
FileSuffix = "config_update",
|
||||
Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "",
|
||||
BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
private static string Serialize(AppSettingsData data)
|
||||
{
|
||||
using (var ms = new MemoryStream())
|
||||
|
||||
@@ -26,9 +26,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 +41,9 @@ namespace BulkArchiveMailer.Core
|
||||
return result;
|
||||
}
|
||||
|
||||
// login -> every (path, kind) found for it, case-insensitive
|
||||
// login -> every (path, suffix) found for it, case-insensitive
|
||||
// because the file system this runs on (Windows) is too.
|
||||
var byLogin = new Dictionary<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 +55,30 @@ namespace BulkArchiveMailer.Core
|
||||
{
|
||||
ArchivePath = filePath,
|
||||
Status = SendStatus.Skipped,
|
||||
ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_ssh_access.7z\" / \"{{login}}_config_update.7z\": {fileName}",
|
||||
ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_{{суффикс}}.7z\": {fileName}",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var matchedTemplate = templates?.FirstOrDefault(t => string.Equals(t.FileSuffix, parsed.Suffix, StringComparison.OrdinalIgnoreCase));
|
||||
if (matchedTemplate == null)
|
||||
{
|
||||
result.UnrecognizedFiles.Add(new SendJob
|
||||
{
|
||||
ArchivePath = filePath,
|
||||
Status = SendStatus.Skipped,
|
||||
ErrorMessage = $"Суффикс \"{parsed.Suffix}\" не соответствует ни одному настроенному шаблону: {fileName}",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byLogin.TryGetValue(parsed.Login, out var list))
|
||||
{
|
||||
list = new List<(string, ArchiveKind)>();
|
||||
list = new List<(string, string)>();
|
||||
byLogin[parsed.Login] = list;
|
||||
}
|
||||
|
||||
list.Add((filePath, parsed.Kind));
|
||||
list.Add((filePath, parsed.Suffix));
|
||||
}
|
||||
|
||||
foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
@@ -95,19 +107,21 @@ namespace BulkArchiveMailer.Core
|
||||
continue;
|
||||
}
|
||||
|
||||
var (path, kind) = files[0];
|
||||
var rendered = templates != null
|
||||
? TemplateEngine.Render(templates.Get(kind), login, email)
|
||||
: new MailTemplate();
|
||||
var (path, suffix) = files[0];
|
||||
// Guaranteed non-null: a suffix only ever makes it into
|
||||
// byLogin after already matching a template above.
|
||||
var matchedTemplate = templates.First(t => string.Equals(t.FileSuffix, suffix, StringComparison.OrdinalIgnoreCase));
|
||||
var values = TemplateEngine.BuildValues(login, email);
|
||||
|
||||
var job = new SendJob
|
||||
{
|
||||
Login = login,
|
||||
Email = email,
|
||||
Kind = kind,
|
||||
Kind = suffix,
|
||||
ArchivePath = path,
|
||||
Subject = rendered.Subject,
|
||||
BodyHtml = rendered.BodyHtml,
|
||||
Subject = TemplateEngine.Render(matchedTemplate.Subject, values),
|
||||
BodyHtml = TemplateEngine.Render(matchedTemplate.BodyHtml, values),
|
||||
KindDisplay = matchedTemplate.DisplayName,
|
||||
Status = SendStatus.Ready,
|
||||
};
|
||||
|
||||
@@ -116,7 +130,7 @@ namespace BulkArchiveMailer.Core
|
||||
try
|
||||
{
|
||||
job.FileSha256 = SendLogStore.ComputeSha256(path);
|
||||
if (sendLog.TryFindSent(email, kind, job.FileSha256, out var priorEntry))
|
||||
if (sendLog.TryFindSent(email, suffix, job.FileSha256, out var priorEntry))
|
||||
{
|
||||
job.Status = SendStatus.AlreadySent;
|
||||
job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор).";
|
||||
|
||||
@@ -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,34 @@
|
||||
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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@
|
||||
"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>"
|
||||
},
|
||||
"config_update": {
|
||||
{
|
||||
"displayName": "Обновление конфига",
|
||||
"fileSuffix": "config_update",
|
||||
"subject": "Обновление конфигурации доступа — {Login}",
|
||||
"bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,22 +48,22 @@ BulkArchiveMailer **не порождает архивы и не знает па
|
||||
BulkArchiveMailer.sln
|
||||
BulkArchiveMailer.Core/ net48, class library, БЕЗ ссылок на Outlook/COM/реестр — можно
|
||||
собрать и протестировать без Windows/Office.
|
||||
FilenameParser.cs парсинг "{login}_(ssh_access|config_update).7z"
|
||||
FilenameParser.cs парсинг "{login}_{суффикс}.7z" (суффикс - любой, сверяется с шаблонами в ArchiveScanner)
|
||||
EmailAddressBuilder.cs login -> "login.with.dots@domain"
|
||||
SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status)
|
||||
ArchiveScanner.cs сканирование папки, группировка по логину, конфликт "оба архива сразу"
|
||||
ArchiveScanner.cs сканирование папки, сопоставление суффикса с шаблоном, конфликт "оба архива сразу"
|
||||
TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон
|
||||
MailTemplates.cs два именованных шаблона (ssh_access / config_update)
|
||||
MailTemplateDefinition.cs один шаблон (название, суффикс файла, тема, HTML-тело) - список произвольной длины
|
||||
SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива)
|
||||
AppSettings.cs %APPDATA%\BulkArchiveMailer\config.json, засев из config.default.json
|
||||
config.default.json шаблоны писем и домен по умолчанию (встроен как Embedded Resource)
|
||||
AppSettings.cs %APPDATA%\BulkArchiveMailer\config.json, засев из config.default.json, миграция старого формата шаблонов
|
||||
config.default.json шаблоны писем (список) и домен по умолчанию (встроен как Embedded Resource)
|
||||
BulkArchiveMailer.Core.Tests/ xUnit-заготовка (один smoke-тест) — полное покрытие добавляется отдельно
|
||||
BulkArchiveMailer.AddIn/ VSTO-плагин, net48, Windows-only, ссылается на Core
|
||||
ThisAddIn.cs / .Designer.cs запуск/остановка плагина
|
||||
Ribbon.xml + RibbonController.cs одна кнопка ленты "Массовая рассылка"
|
||||
SignatureReader.cs чтение подписи Outlook из %APPDATA%\Microsoft\Signatures через реестр
|
||||
MailSender.cs создание/отправка Outlook.MailItem, тестовый режим (Черновики)
|
||||
UI/SettingsForm.cs папка, домен, подпись, тестовый режим по умолчанию, оба шаблона
|
||||
UI/SettingsForm.cs папка, домен, подпись, тестовый режим по умолчанию, список шаблонов (добавление/удаление)
|
||||
UI/PreviewForm.cs таблица предпросмотра, баннер про пароль, отправка, итоговый журнал
|
||||
docs/BUILD.md как собрать и подключить плагин в Visual Studio на Windows
|
||||
```
|
||||
@@ -93,8 +93,16 @@ Outlook в принципе не запустить.
|
||||
(`Session.Accounts`), полезно, если у оператора несколько ящиков (например,
|
||||
отдельный служебный/shared-ящик именно для этой рассылки). Пункт
|
||||
"(по умолчанию Outlook)" — прежнее поведение без явного выбора аккаунта.
|
||||
- **Шаблоны писем** — тема и HTML-тело отдельно для `ssh_access` и
|
||||
`config_update`, с плейсхолдерами `{Login}`, `{Email}`, `{Date}`.
|
||||
- **Шаблоны писем** — вкладка "Шаблоны" со списком произвольного числа
|
||||
шаблонов (кнопки "+ Добавить"/"Удалить"). Каждый шаблон — это название,
|
||||
суффикс имени файла (`{login}_<суффикс>.7z`, по которому шаблон подбирается
|
||||
для конкретного архива), тема и HTML-тело с плейсхолдерами `{Login}`,
|
||||
`{Email}`, `{Date}`. Суффикс обязателен и должен быть уникальным среди всех
|
||||
шаблонов — по нему, а не по фиксированному перечислению видов архива,
|
||||
`ArchiveScanner` теперь сопоставляет файл с шаблоном. Два исходных шаблона
|
||||
(`ssh_access`/`config_update`) — обычные равноправные записи в этом
|
||||
списке, без особой защиты от переименования/удаления; кнопка "Сбросить
|
||||
шаблоны по умолчанию" восстанавливает их, если они были случайно удалены.
|
||||
- **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются
|
||||
в Черновики, а не отправляются) до первого осознанного отключения
|
||||
оператором в окне предпросмотра.
|
||||
|
||||
Reference in new issue
Block a user