Author SHA1 Message Date
redlineoandClaude Sonnet 5 f1c67669fd Fix template list becoming unclickable with many entries via Dock layout
lstTemplates was a direct child of tabTemplates with an explicit pixel
Size, computed once against this dev machine's tab-strip height under
its own DPI/font scale. On the operator's real machine, adding enough
templates pushed rows past the control's actual bottom edge -
maximizing the window didn't help, confirming it wasn't a screen-space
problem but a stale size assumption baked into the designer file (the
same root cause as the earlier add/remove-button visibility bug).

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 12:23:54 +03:00
redlineoandClaude Sonnet 5 804b9610ca Generalize mail templates from a fixed pair to an operator-managed list
ArchiveKind was a hardcoded two-value enum (ssh_access/config_update),
wired through FilenameParser's regex, MailTemplateSet's two fixed
slots, and SettingsForm's two fixed tabs. Operators wanted arbitrary
custom templates (own file-name suffix, subject, body) without a code
change per new archive kind ssh-access-tui might start producing.

Replaced with MailTemplateDefinition (DisplayName/FileSuffix/Subject/
BodyHtml) in an open-ended list: FilenameParser now accepts any
"{login}_{suffix}.7z" and ArchiveScanner matches the suffix against
the configured list; SendJob.Kind/SendLogEntry.Kind became plain
strings; SettingsForm gained a template list (+/- buttons, editor
panel) replacing the two static tabs. ssh_access/config_update are now
just the two default entries in that list, not special-cased.

Handles the two backward-compat edges that matter for a tool already
in real use: old config.json's object-shaped "templates" migrates to
the new list shape transparently on first load (DataContractJsonSerializer
turned out to silently produce an empty list rather than throw on the
old shape, so detection is structural, not exception-based), and old
send-log.csv rows with PascalCase Kind values (SshAccess/ConfigUpdate)
still dedupe correctly against the new snake_case suffixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 11:26:31 +03:00
redlineoandClaude Sonnet 5 cb3b273741 Fix mojibake in signature: detect real encoding instead of assuming UTF-8
Outlook writes signature .htm files in the system ANSI code page
(windows-1251 on Russian Windows) without a BOM, but File.ReadAllText
defaults to UTF-8 for BOM-less files - mangling Cyrillic into
replacement characters. ReadSignatureHtml now checks BOM, then a
<meta charset> tag, then falls back to Encoding.Default (the actual
system code page) before ever assuming UTF-8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:45:37 +03:00
redlineoandClaude Sonnet 5 f04de700d9 Add Settings button to the bulk sender window
The ribbon button only opened SettingsForm while the archive folder
was unconfigured - once saved once, there was no way back into
settings (domain, templates, sender account, signature path) short of
breaking the saved folder path. Added a "Настройки..." button to
PreviewForm that reopens SettingsForm and rescans with the reloaded
settings afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:35:56 +03:00
redlineoandClaude Sonnet 5 e40d971de0 Fix crash opening bulk sender window (rows null during InitializeComponent)
Real stack trace from the operator's machine pinned it down: WinForms
fires DataGridView.CellValueChanged as a side effect of
DataGridViewColumn.HeaderText assignments inside InitializeComponent(),
i.e. before PreviewForm's constructor has had a chance to run RunScan()
and populate the `rows` field. UpdateSummaryLabel() then hit
rows.Count(...) on a null source. Nothing to do with Outlook/COM at
all - a pure WinForms initialization-order gotcha.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:26:29 +03:00
redlineoandClaude Sonnet 5 f50624023a Show full exception detail in error dialogs instead of just Message
An operator hit "Значение не может быть неопределенным. Имя параметра:
source" (ArgumentNullException, ParamName=source) opening the bulk
sender window on a real Outlook install. A full read-through of every
LINQ call reachable from that path found nothing that could receive a
null source, and this dev machine has no Outlook to reproduce against
- so the next occurrence needs a real stack trace to diagnose instead
of guessing from ex.Message alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:19:09 +03:00
20 changed files with 1490 additions and 319 deletions

No files matched your search

@@ -60,7 +60,7 @@ namespace BulkArchiveMailer.AddIn
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show( MessageBox.Show(
"Не удалось открыть окно массовой рассылки: " + ex.Message, "Не удалось открыть окно массовой рассылки: " + ex.ToString(),
"BulkArchiveMailer", "BulkArchiveMailer",
MessageBoxButtons.OK, MessageBoxButtons.OK,
MessageBoxIcon.Error); MessageBoxIcon.Error);
@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Win32; using Microsoft.Win32;
using Outlook = Microsoft.Office.Interop.Outlook; using Outlook = Microsoft.Office.Interop.Outlook;
@@ -38,7 +40,7 @@ namespace BulkArchiveMailer.AddIn
} }
var path = ResolveSignaturePath(signatureName); var path = ResolveSignaturePath(signatureName);
return File.Exists(path) ? File.ReadAllText(path) : null; return File.Exists(path) ? ReadSignatureHtml(path) : null;
} }
catch catch
{ {
@@ -119,5 +121,141 @@ namespace BulkArchiveMailer.AddIn
return null; 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 &lt;meta charset&gt; 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. &lt;meta charset=...&gt; or &lt;meta http-equiv="Content-Type" content="...charset=..."&gt;
/// 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 btnCheckAllReady;
private System.Windows.Forms.Button btnUncheckAll; private System.Windows.Forms.Button btnUncheckAll;
private System.Windows.Forms.Button btnRescan; private System.Windows.Forms.Button btnRescan;
private System.Windows.Forms.Button btnSettings;
private System.Windows.Forms.ProgressBar progressSend; private System.Windows.Forms.ProgressBar progressSend;
private System.Windows.Forms.Label lblProgressStatus; private System.Windows.Forms.Label lblProgressStatus;
@@ -56,6 +57,7 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnCheckAllReady = new System.Windows.Forms.Button(); this.btnCheckAllReady = new System.Windows.Forms.Button();
this.btnUncheckAll = new System.Windows.Forms.Button(); this.btnUncheckAll = new System.Windows.Forms.Button();
this.btnRescan = 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.progressSend = new System.Windows.Forms.ProgressBar();
this.lblProgressStatus = new System.Windows.Forms.Label(); this.lblProgressStatus = new System.Windows.Forms.Label();
@@ -70,7 +72,7 @@ namespace BulkArchiveMailer.AddIn.UI
// lblPasswordReminder // lblPasswordReminder
// //
this.lblPasswordReminder.Location = new System.Drawing.Point(12, 10); 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.Text = "Напоминание: пароль от архивов сообщается получателям отдельно (вручную, не через это письмо и не через этот инструмент).";
this.lblPasswordReminder.ForeColor = System.Drawing.Color.DarkRed; this.lblPasswordReminder.ForeColor = System.Drawing.Color.DarkRed;
this.lblPasswordReminder.Font = new System.Drawing.Font(this.lblPasswordReminder.Font, System.Drawing.FontStyle.Bold); this.lblPasswordReminder.Font = new System.Drawing.Font(this.lblPasswordReminder.Font, System.Drawing.FontStyle.Bold);
@@ -80,7 +82,7 @@ namespace BulkArchiveMailer.AddIn.UI
// lblSignatureWarning // lblSignatureWarning
// //
this.lblSignatureWarning.Location = new System.Drawing.Point(12, 32); 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.Text = "Подпись не найдена — письма будут отправлены без подписи. Настройте .htm-файл вручную в Настройках.";
this.lblSignatureWarning.ForeColor = System.Drawing.Color.DarkOrange; this.lblSignatureWarning.ForeColor = System.Drawing.Color.DarkOrange;
this.lblSignatureWarning.Visible = false; this.lblSignatureWarning.Visible = false;
@@ -90,7 +92,7 @@ namespace BulkArchiveMailer.AddIn.UI
// grid // grid
// //
this.grid.Location = new System.Drawing.Point(12, 58); 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) 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.Left)
| System.Windows.Forms.AnchorStyles.Right))); | System.Windows.Forms.AnchorStyles.Right)));
@@ -215,24 +217,34 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnUncheckAll.UseVisualStyleBackColor = true; this.btnUncheckAll.UseVisualStyleBackColor = true;
this.btnUncheckAll.Click += new System.EventHandler(this.btnUncheckAll_Click); 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 // 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.Size = new System.Drawing.Size(300, 20);
this.progressSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); this.progressSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
// //
// lblProgressStatus // 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.Size = new System.Drawing.Size(482, 20);
this.lblProgressStatus.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); this.lblProgressStatus.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
// //
// btnClose // 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.Size = new System.Drawing.Size(90, 30);
this.btnClose.Text = "Закрыть"; this.btnClose.Text = "Закрыть";
this.btnClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); 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 // 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.Size = new System.Drawing.Size(96, 30);
this.btnSend.Text = "Отправить"; this.btnSend.Text = "Отправить";
this.btnSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); 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.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(924, 582); this.ClientSize = new System.Drawing.Size(1042, 582);
this.MinimumSize = new System.Drawing.Size(760, 420); this.MinimumSize = new System.Drawing.Size(878, 420);
this.Controls.Add(this.lblPasswordReminder); this.Controls.Add(this.lblPasswordReminder);
this.Controls.Add(this.lblSignatureWarning); this.Controls.Add(this.lblSignatureWarning);
this.Controls.Add(this.grid); this.Controls.Add(this.grid);
@@ -266,6 +278,7 @@ namespace BulkArchiveMailer.AddIn.UI
this.Controls.Add(this.btnRescan); this.Controls.Add(this.btnRescan);
this.Controls.Add(this.btnCheckAllReady); this.Controls.Add(this.btnCheckAllReady);
this.Controls.Add(this.btnUncheckAll); this.Controls.Add(this.btnUncheckAll);
this.Controls.Add(this.btnSettings);
this.Controls.Add(this.progressSend); this.Controls.Add(this.progressSend);
this.Controls.Add(this.lblProgressStatus); this.Controls.Add(this.lblProgressStatus);
this.Controls.Add(this.btnClose); this.Controls.Add(this.btnClose);
@@ -39,7 +39,7 @@ namespace BulkArchiveMailer.AddIn.UI
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.Message, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error); MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
Close(); Close();
return; return;
} }
@@ -58,7 +58,7 @@ namespace BulkArchiveMailer.AddIn.UI
{ {
if (!string.IsNullOrWhiteSpace(settings.SignatureHtmlPath)) 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); return SignatureReader.TryAutoDetectSignatureHtml(outlookApplication);
@@ -86,7 +86,7 @@ namespace BulkArchiveMailer.AddIn.UI
var logPath = Path.Combine(settings.ArchiveFolder, "send-log.csv"); var logPath = Path.Combine(settings.ArchiveFolder, "send-log.csv");
sendLog = new SendLogStore(logPath); sendLog = new SendLogStore(logPath);
var templates = settings.ToTemplateSet(); var templates = settings.ToTemplateList();
var scan = ArchiveScanner.Scan(settings.ArchiveFolder, settings.EmailDomain, templates, sendLog); var scan = ArchiveScanner.Scan(settings.ArchiveFolder, settings.EmailDomain, templates, sendLog);
rows = new BindingList<PreviewRow>(scan.AllRows.Select(job => new PreviewRow(job)).ToList()); rows = new BindingList<PreviewRow>(scan.AllRows.Select(job => new PreviewRow(job)).ToList());
@@ -128,6 +128,17 @@ namespace BulkArchiveMailer.AddIn.UI
private void UpdateSummaryLabel() private void UpdateSummaryLabel()
{ {
if (rows == null)
{
// Can be invoked before RunScan() ever ran - most notably,
// WinForms fires DataGridView.CellValueChanged as a side
// effect of DataGridViewColumn.HeaderText assignments in
// InitializeComponent(), i.e. before the constructor (and
// therefore RunScan()) has had a chance to populate `rows`.
// Nothing to summarize yet in that case.
return;
}
var ready = rows.Count(r => r.Job.Status == SendStatus.Ready); var ready = rows.Count(r => r.Job.Status == SendStatus.Ready);
var already = rows.Count(r => r.Job.Status == SendStatus.AlreadySent); var already = rows.Count(r => r.Job.Status == SendStatus.AlreadySent);
var errors = rows.Count(r => r.Job.Status == SendStatus.Error); var errors = rows.Count(r => r.Job.Status == SendStatus.Error);
@@ -161,6 +172,34 @@ namespace BulkArchiveMailer.AddIn.UI
RunScan(); 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) private void btnCheckAllReady_Click(object sender, EventArgs e)
{ {
foreach (var row in rows) foreach (var row in rows)
@@ -238,7 +277,7 @@ namespace BulkArchiveMailer.AddIn.UI
TimestampUtc = DateTime.UtcNow, TimestampUtc = DateTime.UtcNow,
Login = row.Job.Login, Login = row.Job.Login,
Email = row.Job.Email, Email = row.Job.Email,
Kind = row.Job.Kind ?? default, Kind = row.Job.Kind ?? "",
ArchivePath = row.Job.ArchivePath ?? "", ArchivePath = row.Job.ArchivePath ?? "",
FileSha256 = row.Job.FileSha256 ?? "", FileSha256 = row.Job.FileSha256 ?? "",
Status = outcome.Success ? (testMode ? SendLogStatus.Draft : SendLogStatus.Sent) : SendLogStatus.Failed, Status = outcome.Success ? (testMode ? SendLogStatus.Draft : SendLogStatus.Sent) : SendLogStatus.Failed,
@@ -16,8 +16,7 @@ namespace BulkArchiveMailer.AddIn.UI
private System.Windows.Forms.TabControl tabControl; private System.Windows.Forms.TabControl tabControl;
private System.Windows.Forms.TabPage tabGeneral; private System.Windows.Forms.TabPage tabGeneral;
private System.Windows.Forms.TabPage tabSshAccess; private System.Windows.Forms.TabPage tabTemplates;
private System.Windows.Forms.TabPage tabConfigUpdate;
private System.Windows.Forms.Label lblArchiveFolder; private System.Windows.Forms.Label lblArchiveFolder;
private System.Windows.Forms.TextBox txtArchiveFolder; private System.Windows.Forms.TextBox txtArchiveFolder;
@@ -36,17 +35,25 @@ namespace BulkArchiveMailer.AddIn.UI
private System.Windows.Forms.Label lblFromAccount; private System.Windows.Forms.Label lblFromAccount;
private System.Windows.Forms.ComboBox cboFromAccount; private System.Windows.Forms.ComboBox cboFromAccount;
private System.Windows.Forms.Label lblSshSubject; private System.Windows.Forms.Panel pnlTemplateList;
private System.Windows.Forms.TextBox txtSshSubject; private System.Windows.Forms.Panel pnlTemplateButtons;
private System.Windows.Forms.Label lblSshBody; private System.Windows.Forms.ListBox lstTemplates;
private System.Windows.Forms.TextBox txtSshBody; private System.Windows.Forms.Button btnAddTemplate;
private System.Windows.Forms.Label lblSshHint; private System.Windows.Forms.Button btnRemoveTemplate;
private System.Windows.Forms.Label lblCfgSubject; private System.Windows.Forms.Label lblTplDisplayName;
private System.Windows.Forms.TextBox txtCfgSubject; private System.Windows.Forms.TextBox txtTplDisplayName;
private System.Windows.Forms.Label lblCfgBody; private System.Windows.Forms.Label lblTplFileSuffix;
private System.Windows.Forms.TextBox txtCfgBody; private System.Windows.Forms.TextBox txtTplFileSuffix;
private System.Windows.Forms.Label lblCfgHint; 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 btnSave;
private System.Windows.Forms.Button btnCancel; private System.Windows.Forms.Button btnCancel;
@@ -56,8 +63,7 @@ namespace BulkArchiveMailer.AddIn.UI
{ {
this.tabControl = new System.Windows.Forms.TabControl(); this.tabControl = new System.Windows.Forms.TabControl();
this.tabGeneral = new System.Windows.Forms.TabPage(); this.tabGeneral = new System.Windows.Forms.TabPage();
this.tabSshAccess = new System.Windows.Forms.TabPage(); this.tabTemplates = new System.Windows.Forms.TabPage();
this.tabConfigUpdate = new System.Windows.Forms.TabPage();
this.lblArchiveFolder = new System.Windows.Forms.Label(); this.lblArchiveFolder = new System.Windows.Forms.Label();
this.txtArchiveFolder = new System.Windows.Forms.TextBox(); this.txtArchiveFolder = new System.Windows.Forms.TextBox();
@@ -76,17 +82,25 @@ namespace BulkArchiveMailer.AddIn.UI
this.lblFromAccount = new System.Windows.Forms.Label(); this.lblFromAccount = new System.Windows.Forms.Label();
this.cboFromAccount = new System.Windows.Forms.ComboBox(); this.cboFromAccount = new System.Windows.Forms.ComboBox();
this.lblSshSubject = new System.Windows.Forms.Label(); this.pnlTemplateList = new System.Windows.Forms.Panel();
this.txtSshSubject = new System.Windows.Forms.TextBox(); this.pnlTemplateButtons = new System.Windows.Forms.Panel();
this.lblSshBody = new System.Windows.Forms.Label(); this.lstTemplates = new System.Windows.Forms.ListBox();
this.txtSshBody = new System.Windows.Forms.TextBox(); this.btnAddTemplate = new System.Windows.Forms.Button();
this.lblSshHint = new System.Windows.Forms.Label(); this.btnRemoveTemplate = new System.Windows.Forms.Button();
this.lblCfgSubject = new System.Windows.Forms.Label(); this.lblTplDisplayName = new System.Windows.Forms.Label();
this.txtCfgSubject = new System.Windows.Forms.TextBox(); this.txtTplDisplayName = new System.Windows.Forms.TextBox();
this.lblCfgBody = new System.Windows.Forms.Label(); this.lblTplFileSuffix = new System.Windows.Forms.Label();
this.txtCfgBody = new System.Windows.Forms.TextBox(); this.txtTplFileSuffix = new System.Windows.Forms.TextBox();
this.lblCfgHint = new System.Windows.Forms.Label(); this.lblTplFileSuffixHint = new System.Windows.Forms.Label();
this.lblTplLoginPattern = new System.Windows.Forms.Label();
this.txtTplLoginPattern = new System.Windows.Forms.TextBox();
this.lblTplLoginPatternHint = new System.Windows.Forms.Label();
this.lblTplSubject = new System.Windows.Forms.Label();
this.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.btnSave = new System.Windows.Forms.Button();
this.btnCancel = new System.Windows.Forms.Button(); this.btnCancel = new System.Windows.Forms.Button();
@@ -94,22 +108,27 @@ namespace BulkArchiveMailer.AddIn.UI
this.tabControl.SuspendLayout(); this.tabControl.SuspendLayout();
this.tabGeneral.SuspendLayout(); this.tabGeneral.SuspendLayout();
this.tabSshAccess.SuspendLayout(); this.tabTemplates.SuspendLayout();
this.tabConfigUpdate.SuspendLayout(); this.pnlTemplateList.SuspendLayout();
this.pnlTemplateButtons.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
// tabControl // tabControl
// //
this.tabControl.Location = new System.Drawing.Point(12, 12); this.tabControl.Location = new System.Drawing.Point(12, 12);
this.tabControl.Size = new System.Drawing.Size(660, 470); // Height deliberately generous (+80px versus the original 470)
// rather than packed to the computed minimum - see the long
// remark on lstTemplates/btnAddTemplate below for why "just
// enough" turned out not to be enough in practice on a real
// machine (DPI/font-scaling dependent tab-strip height).
this.tabControl.Size = new System.Drawing.Size(660, 550);
this.tabControl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) this.tabControl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right))); | System.Windows.Forms.AnchorStyles.Right)));
this.tabControl.TabPages.AddRange(new System.Windows.Forms.TabPage[] { this.tabControl.TabPages.AddRange(new System.Windows.Forms.TabPage[] {
this.tabGeneral, this.tabGeneral,
this.tabSshAccess, this.tabTemplates});
this.tabConfigUpdate});
// //
// tabGeneral // tabGeneral
@@ -147,150 +166,232 @@ namespace BulkArchiveMailer.AddIn.UI
this.btnBrowseFolder.Click += new System.EventHandler(this.btnBrowseFolder_Click); this.btnBrowseFolder.Click += new System.EventHandler(this.btnBrowseFolder_Click);
// lblDomain // lblDomain
this.lblDomain.Location = new System.Drawing.Point(15, 85); this.lblDomain.Location = new System.Drawing.Point(15, 99);
this.lblDomain.Size = new System.Drawing.Size(400, 20); this.lblDomain.Size = new System.Drawing.Size(400, 20);
this.lblDomain.Text = "Домен электронной почты (login → login@домен):"; this.lblDomain.Text = "Домен электронной почты (login → login@домен):";
// txtDomain // txtDomain
this.txtDomain.Location = new System.Drawing.Point(15, 108); this.txtDomain.Location = new System.Drawing.Point(15, 122);
this.txtDomain.Size = new System.Drawing.Size(300, 23); this.txtDomain.Size = new System.Drawing.Size(300, 23);
// lblSignaturePath // lblSignaturePath
this.lblSignaturePath.Location = new System.Drawing.Point(15, 150); this.lblSignaturePath.Location = new System.Drawing.Point(15, 178);
this.lblSignaturePath.Size = new System.Drawing.Size(610, 20); this.lblSignaturePath.Size = new System.Drawing.Size(610, 20);
this.lblSignaturePath.Text = "Файл подписи (.htm). Пусто = определять автоматически по умолчанию Outlook при каждой отправке:"; this.lblSignaturePath.Text = "Файл подписи (.htm). Пусто = определять автоматически по умолчанию Outlook при каждой отправке:";
// txtSignaturePath // txtSignaturePath
this.txtSignaturePath.Location = new System.Drawing.Point(15, 173); this.txtSignaturePath.Location = new System.Drawing.Point(15, 201);
this.txtSignaturePath.Size = new System.Drawing.Size(500, 23); this.txtSignaturePath.Size = new System.Drawing.Size(500, 23);
// btnBrowseSignature // btnBrowseSignature
this.btnBrowseSignature.Location = new System.Drawing.Point(525, 171); this.btnBrowseSignature.Location = new System.Drawing.Point(525, 199);
this.btnBrowseSignature.Size = new System.Drawing.Size(100, 25); this.btnBrowseSignature.Size = new System.Drawing.Size(100, 25);
this.btnBrowseSignature.Text = "Обзор..."; this.btnBrowseSignature.Text = "Обзор...";
this.btnBrowseSignature.UseVisualStyleBackColor = true; this.btnBrowseSignature.UseVisualStyleBackColor = true;
this.btnBrowseSignature.Click += new System.EventHandler(this.btnBrowseSignature_Click); this.btnBrowseSignature.Click += new System.EventHandler(this.btnBrowseSignature_Click);
// btnAutoDetectSignature // btnAutoDetectSignature
this.btnAutoDetectSignature.Location = new System.Drawing.Point(15, 205); this.btnAutoDetectSignature.Location = new System.Drawing.Point(15, 247);
this.btnAutoDetectSignature.Size = new System.Drawing.Size(280, 25); this.btnAutoDetectSignature.Size = new System.Drawing.Size(280, 25);
this.btnAutoDetectSignature.Text = "Определить автоматически сейчас"; this.btnAutoDetectSignature.Text = "Определить автоматически сейчас";
this.btnAutoDetectSignature.UseVisualStyleBackColor = true; this.btnAutoDetectSignature.UseVisualStyleBackColor = true;
this.btnAutoDetectSignature.Click += new System.EventHandler(this.btnAutoDetectSignature_Click); this.btnAutoDetectSignature.Click += new System.EventHandler(this.btnAutoDetectSignature_Click);
// chkTestModeDefault // chkTestModeDefault
this.chkTestModeDefault.Location = new System.Drawing.Point(15, 250); this.chkTestModeDefault.Location = new System.Drawing.Point(15, 306);
this.chkTestModeDefault.Size = new System.Drawing.Size(610, 24); this.chkTestModeDefault.Size = new System.Drawing.Size(610, 24);
this.chkTestModeDefault.Text = "По умолчанию открывать предпросмотр в тестовом режиме (сохранять в Черновики)"; this.chkTestModeDefault.Text = "По умолчанию открывать предпросмотр в тестовом режиме (сохранять в Черновики)";
this.chkTestModeDefault.UseVisualStyleBackColor = true; this.chkTestModeDefault.UseVisualStyleBackColor = true;
// lblFromAccount // lblFromAccount
this.lblFromAccount.Location = new System.Drawing.Point(15, 284); this.lblFromAccount.Location = new System.Drawing.Point(15, 378);
this.lblFromAccount.Size = new System.Drawing.Size(400, 20); this.lblFromAccount.Size = new System.Drawing.Size(400, 20);
this.lblFromAccount.Text = "Отправлять от (аккаунт Outlook текущей сессии):"; this.lblFromAccount.Text = "Отправлять от (аккаунт Outlook текущей сессии):";
// cboFromAccount // cboFromAccount
this.cboFromAccount.Location = new System.Drawing.Point(15, 307); this.cboFromAccount.Location = new System.Drawing.Point(15, 401);
this.cboFromAccount.Size = new System.Drawing.Size(500, 23); this.cboFromAccount.Size = new System.Drawing.Size(500, 23);
this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
// //
// tabSshAccess // tabTemplates
// //
this.tabSshAccess.Text = "Шаблон: новый доступ"; this.tabTemplates.Text = "Шаблоны";
this.tabSshAccess.Padding = new System.Windows.Forms.Padding(12); this.tabTemplates.Padding = new System.Windows.Forms.Padding(12);
this.tabSshAccess.UseVisualStyleBackColor = true; this.tabTemplates.UseVisualStyleBackColor = true;
this.tabSshAccess.Controls.Add(this.lblSshSubject); this.tabTemplates.Controls.Add(this.pnlTemplateList);
this.tabSshAccess.Controls.Add(this.txtSshSubject); this.tabTemplates.Controls.Add(this.lblTplDisplayName);
this.tabSshAccess.Controls.Add(this.lblSshBody); this.tabTemplates.Controls.Add(this.txtTplDisplayName);
this.tabSshAccess.Controls.Add(this.txtSshBody); this.tabTemplates.Controls.Add(this.lblTplFileSuffix);
this.tabSshAccess.Controls.Add(this.lblSshHint); 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 // pnlTemplateList
this.lblSshSubject.Location = new System.Drawing.Point(15, 20); //
this.lblSshSubject.Size = new System.Drawing.Size(200, 20); // Left-column container for the template picker (buttons +
this.lblSshSubject.Text = "Тема письма:"; // 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 // pnlTemplateButtons
this.txtSshSubject.Location = new System.Drawing.Point(15, 43); //
this.txtSshSubject.Size = new System.Drawing.Size(610, 23); // 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 // btnAddTemplate
this.lblSshBody.Location = new System.Drawing.Point(15, 78); this.btnAddTemplate.Location = new System.Drawing.Point(0, 0);
this.lblSshBody.Size = new System.Drawing.Size(200, 20); this.btnAddTemplate.Size = new System.Drawing.Size(90, 27);
this.lblSshBody.Text = "Тело письма (HTML):"; this.btnAddTemplate.Text = "+ Добавить";
this.btnAddTemplate.UseVisualStyleBackColor = true;
this.btnAddTemplate.Click += new System.EventHandler(this.btnAddTemplate_Click);
// txtSshBody // btnRemoveTemplate
this.txtSshBody.Location = new System.Drawing.Point(15, 101); this.btnRemoveTemplate.Location = new System.Drawing.Point(95, 0);
this.txtSshBody.Size = new System.Drawing.Size(610, 300); this.btnRemoveTemplate.Size = new System.Drawing.Size(90, 27);
this.txtSshBody.Multiline = true; this.btnRemoveTemplate.Text = "Удалить";
this.txtSshBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; this.btnRemoveTemplate.UseVisualStyleBackColor = true;
this.txtSshBody.AcceptsReturn = true; this.btnRemoveTemplate.Click += new System.EventHandler(this.btnRemoveTemplate_Click);
this.txtSshBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
// 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.Left)
| System.Windows.Forms.AnchorStyles.Right))); | System.Windows.Forms.AnchorStyles.Right)));
// lblSshHint // lblTplHint
this.lblSshHint.Location = new System.Drawing.Point(15, 408); this.lblTplHint.Location = new System.Drawing.Point(220, 467);
this.lblSshHint.Size = new System.Drawing.Size(610, 20); this.lblTplHint.Size = new System.Drawing.Size(410, 20);
this.lblSshHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}"; this.lblTplHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}";
this.lblSshHint.ForeColor = System.Drawing.SystemColors.GrayText; this.lblTplHint.ForeColor = System.Drawing.SystemColors.GrayText;
this.lblSshHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); this.lblTplHint.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)));
// //
// btnSave / btnCancel / btnResetDefaults // btnSave / btnCancel / btnResetDefaults
// //
this.btnResetDefaults.Location = new System.Drawing.Point(12, 494); this.btnResetDefaults.Location = new System.Drawing.Point(12, 574);
this.btnResetDefaults.Size = new System.Drawing.Size(220, 30); this.btnResetDefaults.Size = new System.Drawing.Size(220, 30);
this.btnResetDefaults.Text = "Сбросить домен/шаблоны по умолчанию"; this.btnResetDefaults.Text = "Сбросить шаблоны по умолчанию";
this.btnResetDefaults.UseVisualStyleBackColor = true; this.btnResetDefaults.UseVisualStyleBackColor = true;
this.btnResetDefaults.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); this.btnResetDefaults.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.btnResetDefaults.Click += new System.EventHandler(this.btnResetDefaults_Click); this.btnResetDefaults.Click += new System.EventHandler(this.btnResetDefaults_Click);
this.btnCancel.Location = new System.Drawing.Point(479, 494); this.btnCancel.Location = new System.Drawing.Point(479, 574);
this.btnCancel.Size = new System.Drawing.Size(90, 30); this.btnCancel.Size = new System.Drawing.Size(90, 30);
this.btnCancel.Text = "Отмена"; this.btnCancel.Text = "Отмена";
this.btnCancel.UseVisualStyleBackColor = true; this.btnCancel.UseVisualStyleBackColor = true;
@@ -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.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click);
this.btnSave.Location = new System.Drawing.Point(575, 494); this.btnSave.Location = new System.Drawing.Point(575, 574);
this.btnSave.Size = new System.Drawing.Size(97, 30); this.btnSave.Size = new System.Drawing.Size(97, 30);
this.btnSave.Text = "Сохранить"; this.btnSave.Text = "Сохранить";
this.btnSave.UseVisualStyleBackColor = true; this.btnSave.UseVisualStyleBackColor = true;
@@ -310,8 +411,8 @@ namespace BulkArchiveMailer.AddIn.UI
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(684, 536); this.ClientSize = new System.Drawing.Size(684, 616);
this.MinimumSize = new System.Drawing.Size(560, 420); this.MinimumSize = new System.Drawing.Size(560, 500);
this.Controls.Add(this.tabControl); this.Controls.Add(this.tabControl);
this.Controls.Add(this.btnResetDefaults); this.Controls.Add(this.btnResetDefaults);
this.Controls.Add(this.btnCancel); this.Controls.Add(this.btnCancel);
@@ -326,10 +427,10 @@ namespace BulkArchiveMailer.AddIn.UI
this.tabControl.ResumeLayout(false); this.tabControl.ResumeLayout(false);
this.tabGeneral.ResumeLayout(false); this.tabGeneral.ResumeLayout(false);
this.tabGeneral.PerformLayout(); this.tabGeneral.PerformLayout();
this.tabSshAccess.ResumeLayout(false); this.pnlTemplateButtons.ResumeLayout(false);
this.tabSshAccess.PerformLayout(); this.pnlTemplateList.ResumeLayout(false);
this.tabConfigUpdate.ResumeLayout(false); this.tabTemplates.ResumeLayout(false);
this.tabConfigUpdate.PerformLayout(); this.tabTemplates.PerformLayout();
this.ResumeLayout(false); this.ResumeLayout(false);
} }
} }
@@ -1,4 +1,7 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Forms; using System.Windows.Forms;
using BulkArchiveMailer.Core; using BulkArchiveMailer.Core;
using Outlook = Microsoft.Office.Interop.Outlook; using Outlook = Microsoft.Office.Interop.Outlook;
@@ -7,13 +10,31 @@ namespace BulkArchiveMailer.AddIn.UI
{ {
/// <summary> /// <summary>
/// Archive folder, email domain, signature override, test-mode default, /// 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. /// %APPDATA%\BulkArchiveMailer\config.json via AppSettings.
/// </summary> /// </summary>
public partial class SettingsForm : Form public partial class SettingsForm : Form
{ {
private readonly Outlook.Application outlookApplication; 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) public SettingsForm(Outlook.Application outlookApplication)
{ {
InitializeComponent(); InitializeComponent();
@@ -31,7 +52,7 @@ namespace BulkArchiveMailer.AddIn.UI
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show( MessageBox.Show(
"Не удалось прочитать config.json (" + AppSettings.ConfigFilePath + "):\n" + ex.Message + "Не удалось прочитать config.json (" + AppSettings.ConfigFilePath + "):\n" + ex.ToString() +
"\n\nБудут показаны пустые настройки — сохранение перезапишет файл заново.", "\n\nБудут показаны пустые настройки — сохранение перезапишет файл заново.",
"BulkArchiveMailer", "BulkArchiveMailer",
MessageBoxButtons.OK, MessageBoxButtons.OK,
@@ -61,11 +82,8 @@ namespace BulkArchiveMailer.AddIn.UI
PopulateFromAccountCombo(settings.FromAccountSmtpAddress ?? ""); PopulateFromAccountCombo(settings.FromAccountSmtpAddress ?? "");
var templates = settings.ToTemplateSet(); templates = settings.ToTemplateList();
txtSshSubject.Text = templates.SshAccess.Subject; PopulateTemplateList(selectFirst: true);
txtSshBody.Text = templates.SshAccess.BodyHtml;
txtCfgSubject.Text = templates.ConfigUpdate.Subject;
txtCfgBody.Text = templates.ConfigUpdate.BodyHtml;
} }
/// <summary> /// <summary>
@@ -111,6 +129,216 @@ namespace BulkArchiveMailer.AddIn.UI
cboFromAccount.SelectedIndex = selectedIndex; 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) private void btnBrowseFolder_Click(object sender, EventArgs e)
{ {
using (var dialog = new FolderBrowserDialog()) using (var dialog = new FolderBrowserDialog())
@@ -168,7 +396,7 @@ namespace BulkArchiveMailer.AddIn.UI
{ {
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
this, this,
"Заменить домен и оба шаблона писем значениями по умолчанию? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.", "Заменить домен и весь список шаблонов писем значениями по умолчанию (два исходных шаблона)? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.",
"Подтверждение", "Подтверждение",
MessageBoxButtons.YesNo, MessageBoxButtons.YesNo,
MessageBoxIcon.Question); MessageBoxIcon.Question);
@@ -180,11 +408,8 @@ namespace BulkArchiveMailer.AddIn.UI
var defaults = AppSettings.LoadDefaults(); var defaults = AppSettings.LoadDefaults();
txtDomain.Text = defaults.EmailDomain; txtDomain.Text = defaults.EmailDomain;
var templates = defaults.ToTemplateSet(); templates = defaults.ToTemplateList();
txtSshSubject.Text = templates.SshAccess.Subject; PopulateTemplateList(selectFirst: true);
txtSshBody.Text = templates.SshAccess.BodyHtml;
txtCfgSubject.Text = templates.ConfigUpdate.Subject;
txtCfgBody.Text = templates.ConfigUpdate.BodyHtml;
} }
private void btnSave_Click(object sender, EventArgs e) 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 selectedAccount = cboFromAccount.SelectedItem as FromAccountItem;
var data = new AppSettingsData var data = new AppSettingsData
@@ -222,8 +471,7 @@ namespace BulkArchiveMailer.AddIn.UI
TestModeDefault = chkTestModeDefault.Checked, TestModeDefault = chkTestModeDefault.Checked,
FromAccountSmtpAddress = selectedAccount?.SmtpAddress ?? "", FromAccountSmtpAddress = selectedAccount?.SmtpAddress ?? "",
}; };
data.SetTemplate(ArchiveKind.SshAccess, new MailTemplate { Subject = txtSshSubject.Text, BodyHtml = txtSshBody.Text }); data.SetTemplates(templates);
data.SetTemplate(ArchiveKind.ConfigUpdate, new MailTemplate { Subject = txtCfgSubject.Text, BodyHtml = txtCfgBody.Text });
try try
{ {
@@ -231,7 +479,7 @@ namespace BulkArchiveMailer.AddIn.UI
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show(this, "Не удалось сохранить настройки: " + ex.Message, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error); MessageBox.Show(this, "Не удалось сохранить настройки: " + ex.ToString(), "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
return; return;
} }
@@ -239,6 +487,71 @@ namespace BulkArchiveMailer.AddIn.UI
Close(); 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) private void btnCancel_Click(object sender, EventArgs e)
{ {
DialogResult = DialogResult.Cancel; DialogResult = DialogResult.Cancel;
@@ -1,3 +1,4 @@
using System.Linq;
using System.Reflection; using System.Reflection;
using BulkArchiveMailer.Core; using BulkArchiveMailer.Core;
using Xunit; using Xunit;
@@ -58,6 +59,85 @@ namespace BulkArchiveMailer.Core.Tests
Assert.Equal("svc-mailer@redlab.lo", roundTripped.FromAccountSmtpAddress); 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&lt;MailTemplateData&gt;) 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) private static AppSettingsData InvokeDeserialize(string json)
{ {
var method = typeof(AppSettings).GetMethod("Deserialize", BindingFlags.NonPublic | BindingFlags.Static); 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.True(ok);
Assert.Equal("a-makarov", parsed.Login); 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;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Runtime.Serialization.Json; using System.Runtime.Serialization.Json;
@@ -7,24 +9,25 @@ using System.Text;
namespace BulkArchiveMailer.Core namespace BulkArchiveMailer.Core
{ {
/// <summary>One entry of AppSettingsData.Templates - the JSON shape of a MailTemplateDefinition.</summary>
[DataContract] [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")] [DataMember(Name = "subject")]
public string Subject { get; set; } = ""; public string Subject { get; set; } = "";
[DataMember(Name = "bodyHtml")] [DataMember(Name = "bodyHtml")]
public string BodyHtml { get; set; } = ""; public string BodyHtml { get; set; } = "";
}
[DataContract] /// <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>
public sealed class TemplateSetData [DataMember(Name = "loginPattern")]
{ public string LoginPattern { get; set; } = "";
[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> /// <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")] [DataMember(Name = "fromAccountSmtpAddress")]
public string FromAccountSmtpAddress { get; set; } = ""; 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")] [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 ?? "", DisplayName = t?.DisplayName ?? "",
BodyHtml = Templates?.SshAccess?.BodyHtml ?? "", FileSuffix = t?.FileSuffix ?? "",
}, Subject = t?.Subject ?? "",
ConfigUpdate = new MailTemplate BodyHtml = t?.BodyHtml ?? "",
{ LoginPattern = t?.LoginPattern ?? "",
Subject = Templates?.ConfigUpdate?.Subject ?? "", });
BodyHtml = Templates?.ConfigUpdate?.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 list = new List<MailTemplateData>();
var data = new TemplateData { Subject = template?.Subject ?? "", BodyHtml = template?.BodyHtml ?? "" }; if (templates != null)
if (kind == ArchiveKind.SshAccess)
{ {
Templates.SshAccess = data; foreach (var t in templates)
} {
else list.Add(new MailTemplateData
{ {
Templates.ConfigUpdate = data; 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"; 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 => public static string ConfigDirectory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "BulkArchiveMailer"); Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "BulkArchiveMailer");
@@ -158,6 +233,14 @@ namespace BulkArchiveMailer.Core
/// never silently overwritten - the DataContractJsonSerializer /// never silently overwritten - the DataContractJsonSerializer
/// exception propagates so the caller can warn the operator /// exception propagates so the caller can warn the operator
/// instead of quietly discarding their edits. /// 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> /// </summary>
public static AppSettingsData Load() public static AppSettingsData Load()
{ {
@@ -168,7 +251,14 @@ namespace BulkArchiveMailer.Core
} }
var json = File.ReadAllText(ConfigFilePath, Encoding.UTF8); 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) public static void Save(AppSettingsData data)
@@ -182,16 +272,140 @@ namespace BulkArchiveMailer.Core
File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false)); 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&lt;MailTemplateData&gt;
/// (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); var bytes = Encoding.UTF8.GetBytes(json);
using (var ms = new MemoryStream(bytes)) using (var ms = new MemoryStream(bytes))
{ {
var serializer = new DataContractJsonSerializer(typeof(AppSettingsData)); var serializer = new DataContractJsonSerializer(typeof(T));
return (AppSettingsData)serializer.ReadObject(ms); 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&lt;MailTemplateData&gt; 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) private static string Serialize(AppSettingsData data)
{ {
using (var ms = new MemoryStream()) using (var ms = new MemoryStream())
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
namespace BulkArchiveMailer.Core namespace BulkArchiveMailer.Core
{ {
@@ -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="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="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> /// <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(); var result = new ScanResult();
@@ -41,9 +42,9 @@ namespace BulkArchiveMailer.Core
return result; 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. // 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)) foreach (var filePath in Directory.EnumerateFiles(folderPath, "*.7z", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{ {
@@ -55,18 +56,49 @@ namespace BulkArchiveMailer.Core
{ {
ArchivePath = filePath, ArchivePath = filePath,
Status = SendStatus.Skipped, 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; continue;
} }
if (!byLogin.TryGetValue(parsed.Login, out var list)) if (!byLogin.TryGetValue(parsed.Login, out var list))
{ {
list = new List<(string, ArchiveKind)>(); list = new List<(string, string)>();
byLogin[parsed.Login] = list; 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)) foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
@@ -95,19 +127,21 @@ namespace BulkArchiveMailer.Core
continue; continue;
} }
var (path, kind) = files[0]; var (path, suffix) = files[0];
var rendered = templates != null // Guaranteed non-null: a suffix only ever makes it into
? TemplateEngine.Render(templates.Get(kind), login, email) // byLogin after already matching a template above.
: new MailTemplate(); var matchedTemplate = templates.First(t => string.Equals(t.FileSuffix, suffix, StringComparison.OrdinalIgnoreCase));
var values = TemplateEngine.BuildValues(login, email);
var job = new SendJob var job = new SendJob
{ {
Login = login, Login = login,
Email = email, Email = email,
Kind = kind, Kind = suffix,
ArchivePath = path, ArchivePath = path,
Subject = rendered.Subject, Subject = TemplateEngine.Render(matchedTemplate.Subject, values),
BodyHtml = rendered.BodyHtml, BodyHtml = TemplateEngine.Render(matchedTemplate.BodyHtml, values),
KindDisplay = matchedTemplate.DisplayName,
Status = SendStatus.Ready, Status = SendStatus.Ready,
}; };
@@ -116,7 +150,7 @@ namespace BulkArchiveMailer.Core
try try
{ {
job.FileSha256 = SendLogStore.ComputeSha256(path); 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.Status = SendStatus.AlreadySent;
job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор)."; job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор).";
@@ -137,5 +171,25 @@ namespace BulkArchiveMailer.Core
return result; return result;
} }
/// <summary>
/// Whether login satisfies the template's LoginPattern regex
/// (case-insensitive). An invalid pattern (e.g. hand-edited
/// config.json with broken regex syntax) is treated as "does not
/// match" rather than throwing and aborting the whole folder scan -
/// SettingsForm.btnSave_Click is where a bad pattern is meant to be
/// caught, before it ever reaches disk.
/// </summary>
private static bool LoginMatchesPattern(string login, string pattern)
{
try
{
return Regex.IsMatch(login, pattern, RegexOptions.IgnoreCase);
}
catch (ArgumentException)
{
return false;
}
}
} }
} }
@@ -48,7 +48,7 @@
<Compile Include="ArchiveScanner.cs" /> <Compile Include="ArchiveScanner.cs" />
<Compile Include="EmailAddressBuilder.cs" /> <Compile Include="EmailAddressBuilder.cs" />
<Compile Include="FilenameParser.cs" /> <Compile Include="FilenameParser.cs" />
<Compile Include="MailTemplates.cs" /> <Compile Include="MailTemplateDefinition.cs" />
<Compile Include="SendJob.cs" /> <Compile Include="SendJob.cs" />
<Compile Include="SendLogStore.cs" /> <Compile Include="SendLogStore.cs" />
<Compile Include="TemplateEngine.cs" /> <Compile Include="TemplateEngine.cs" />
@@ -3,51 +3,54 @@ using System.Text.RegularExpressions;
namespace BulkArchiveMailer.Core 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> /// <summary>Result of successfully parsing an archive file name.</summary>
public sealed class ParsedFilename public sealed class ParsedFilename
{ {
public ParsedFilename(string login, ArchiveKind kind) public ParsedFilename(string login, string suffix)
{ {
Login = login; Login = login;
Kind = kind; Suffix = suffix;
} }
/// <summary>Lower-cased login exactly as it appears in the file name (hyphens intact).</summary> /// <summary>Lower-cased login exactly as it appears in the file name (hyphens intact).</summary>
public string Login { get; } 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> /// <summary>
/// Parses the two file name shapes ssh-access-tui ever produces: /// Parses the generic "{login}_{suffix}.7z" file name shape
/// "{login}_ssh_access.7z" and "{login}_config_update.7z". Anything /// ssh-access-tui produces (see internal/keymgr/pack.go and
/// else (wrong extension, extra suffix, typo'd kind, stray file dropped /// internal/keymgr/configonly.go in the main repo for the producing
/// in the export folder by a human) simply fails to parse - the caller /// 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 /// (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> /// </summary>
public static class FilenameParser public static class FilenameParser
{ {
// Matches the login part ssh-access-tui itself accepts for a Linux // Matches the login part ssh-access-tui itself accepts for a Linux
// username: lowercase alnum segments joined by single hyphens (no // username: lowercase alnum segments joined by single hyphens (no
// leading/trailing/double hyphens). Case-insensitive purely as a // leading/trailing/double hyphens), followed by an underscore and
// defensive measure in case a file was renamed by hand on a // any lowercase alnum/underscore suffix. Case-insensitive purely as
// case-insensitive filesystem; the captured login is normalized to // a defensive measure in case a file was renamed by hand on a
// lower case before use. // case-insensitive filesystem; the captured login/suffix are
// normalized to lower case before use.
private static readonly Regex Pattern = new Regex( 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); RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static bool TryParse(string fileName, out ParsedFilename parsed) public static bool TryParse(string fileName, out ParsedFilename parsed)
@@ -65,10 +68,9 @@ namespace BulkArchiveMailer.Core
} }
var login = match.Groups["login"].Value.ToLowerInvariant(); var login = match.Groups["login"].Value.ToLowerInvariant();
var kindText = match.Groups["kind"].Value.ToLowerInvariant(); var suffix = match.Groups["suffix"].Value.ToLowerInvariant();
var kind = kindText == "ssh_access" ? ArchiveKind.SshAccess : ArchiveKind.ConfigUpdate;
parsed = new ParsedFilename(login, kind); parsed = new ParsedFilename(login, suffix);
return true; 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&lt;MailTemplateDefinition&gt;
/// 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; } = ""; public string Email { get; set; } = "";
/// <summary>Null for Error rows caused by a login/kind conflict, and for Skipped (unrecognized file) rows.</summary> /// <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 ArchiveKind? Kind { get; set; } public string Kind { get; set; }
/// <summary>Null for conflict Error rows (see <see cref="ConflictingArchivePaths"/>) - otherwise the single archive to attach.</summary> /// <summary>Null for conflict Error rows (see <see cref="ConflictingArchivePaths"/>) - otherwise the single archive to attach.</summary>
public string ArchivePath { get; set; } public string ArchivePath { get; set; }
@@ -55,21 +55,14 @@ namespace BulkArchiveMailer.Core
public string FileName => string.IsNullOrEmpty(ArchivePath) ? "" : Path.GetFileName(ArchivePath); public string FileName => string.IsNullOrEmpty(ArchivePath) ? "" : Path.GetFileName(ArchivePath);
public string KindDisplay /// <summary>
{ /// Human-readable label for the matched template (its
get /// DisplayName), explicitly set by ArchiveScanner when it renders
{ /// this job - "-" for conflict Error rows and Skipped rows, which
switch (Kind) /// never had a matched template. No longer computed from a fixed
{ /// enum switch, since the set of templates is now open-ended.
case ArchiveKind.SshAccess: /// </summary>
return "Новый доступ (ключ + конфиг)"; public string KindDisplay { get; set; } = "-";
case ArchiveKind.ConfigUpdate:
return "Обновление конфига";
default:
return "-";
}
}
}
public override string ToString() => $"{Login} <{Email}> [{Status}]"; public override string ToString() => $"{Login} <{Email}> [{Status}]";
} }
@@ -22,7 +22,10 @@ namespace BulkArchiveMailer.Core
public DateTime TimestampUtc { get; set; } public DateTime TimestampUtc { get; set; }
public string Login { get; set; } = ""; public string Login { get; set; } = "";
public string Email { 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 ArchivePath { get; set; } = "";
public string FileSha256 { get; set; } = ""; public string FileSha256 { get; set; } = "";
public string Status { get; set; } = ""; public string Status { get; set; } = "";
@@ -109,8 +112,7 @@ namespace BulkArchiveMailer.Core
continue; // malformed/truncated row - skip rather than throw continue; // malformed/truncated row - skip rather than throw
} }
ArchiveKind kind; var kind = NormalizeLegacyKind(fields[3]);
Enum.TryParse(fields[3], true, out kind);
DateTime ts; DateTime ts;
DateTime.TryParse( DateTime.TryParse(
@@ -136,6 +138,32 @@ namespace BulkArchiveMailer.Core
return result; 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) public void Append(SendLogEntry entry)
{ {
if (entry == null) if (entry == null)
@@ -162,7 +190,7 @@ namespace BulkArchiveMailer.Core
Escape(entry.TimestampUtc.ToString("o", CultureInfo.InvariantCulture)), Escape(entry.TimestampUtc.ToString("o", CultureInfo.InvariantCulture)),
Escape(entry.Login), Escape(entry.Login),
Escape(entry.Email), Escape(entry.Email),
Escape(entry.Kind.ToString()), Escape(entry.Kind ?? ""),
Escape(entry.ArchivePath), Escape(entry.ArchivePath),
Escape(entry.FileSha256), Escape(entry.FileSha256),
Escape(entry.Status), Escape(entry.Status),
@@ -176,7 +204,7 @@ namespace BulkArchiveMailer.Core
/// has Status=Sent. Draft/Failed rows never count as "already /// has Status=Sent. Draft/Failed rows never count as "already
/// sent" - only a completed real Send does. /// sent" - only a completed real Send does.
/// </summary> /// </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; match = null;
if (string.IsNullOrEmpty(sha256) || string.IsNullOrEmpty(email)) if (string.IsNullOrEmpty(sha256) || string.IsNullOrEmpty(email))
@@ -196,7 +224,7 @@ namespace BulkArchiveMailer.Core
continue; continue;
} }
if (entry.Kind != kind) if (!string.Equals(entry.Kind, kind, StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
} }
@@ -42,21 +42,15 @@ namespace BulkArchiveMailer.Core
}); });
} }
/// <summary>Renders both Subject and BodyHtml of a template for one recipient.</summary> /// <summary>Builds the {Login}/{Email}/{Date} placeholder values for one recipient, ready to pass to Render(string, IDictionary&lt;string,string&gt;) for a template's Subject and BodyHtml separately.</summary>
public static MailTemplate Render(MailTemplate template, string login, string email) 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 ?? "", ["Login"] = login ?? "",
["Email"] = email ?? "", ["Email"] = email ?? "",
["Date"] = DateTime.Now.ToString("dd.MM.yyyy"), ["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, "testModeDefault": true,
"signatureHtmlPath": "", "signatureHtmlPath": "",
"fromAccountSmtpAddress": "", "fromAccountSmtpAddress": "",
"templates": { "templates": [
"ssh_access": { {
"displayName": "Новый доступ (ключ + конфиг)",
"fileSuffix": "ssh_access",
"subject": "Доступ к SSH jump-хосту — {Login}", "subject": "Доступ к SSH jump-хосту — {Login}",
"bodyHtml": "<p>Здравствуйте!</p><p>Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению (<b>{Login}_ssh_access.7z</b>).</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p><p>Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.</p>" "bodyHtml": "<p>Здравствуйте!</p><p>Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению (<b>{Login}_ssh_access.7z</b>).</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p><p>Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.</p>",
"loginPattern": ""
}, },
"config_update": { {
"displayName": "Обновление конфига",
"fileSuffix": "config_update",
"subject": "Обновление конфигурации доступа — {Login}", "subject": "Обновление конфигурации доступа — {Login}",
"bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>" "bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>",
"loginPattern": ""
} }
} ]
} }
+28 -8
View File
@@ -48,22 +48,22 @@ BulkArchiveMailer **не порождает архивы и не знает па
BulkArchiveMailer.sln BulkArchiveMailer.sln
BulkArchiveMailer.Core/ net48, class library, БЕЗ ссылок на Outlook/COM/реестр — можно BulkArchiveMailer.Core/ net48, class library, БЕЗ ссылок на Outlook/COM/реестр — можно
собрать и протестировать без Windows/Office. собрать и протестировать без Windows/Office.
FilenameParser.cs парсинг "{login}_(ssh_access|config_update).7z" FilenameParser.cs парсинг "{login}_{суффикс}.7z" (суффикс - любой, сверяется с шаблонами в ArchiveScanner)
EmailAddressBuilder.cs login -> "login.with.dots@domain" EmailAddressBuilder.cs login -> "login.with.dots@domain"
SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status) SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status)
ArchiveScanner.cs сканирование папки, группировка по логину, конфликт "оба архива сразу" ArchiveScanner.cs сканирование папки, сопоставление суффикса (и необязательного regex-фильтра логина) с шаблоном, конфликт "оба архива сразу"
TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон
MailTemplates.cs два именованных шаблона (ssh_access / config_update) MailTemplateDefinition.cs один шаблон (название, суффикс файла, тема, HTML-тело) - список произвольной длины
SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива) SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива)
AppSettings.cs %APPDATA%\BulkArchiveMailer\config.json, засев из config.default.json AppSettings.cs %APPDATA%\BulkArchiveMailer\config.json, засев из config.default.json, миграция старого формата шаблонов
config.default.json шаблоны писем и домен по умолчанию (встроен как Embedded Resource) config.default.json шаблоны писем (список) и домен по умолчанию (встроен как Embedded Resource)
BulkArchiveMailer.Core.Tests/ xUnit-заготовка (один smoke-тест) — полное покрытие добавляется отдельно BulkArchiveMailer.Core.Tests/ xUnit-заготовка (один smoke-тест) — полное покрытие добавляется отдельно
BulkArchiveMailer.AddIn/ VSTO-плагин, net48, Windows-only, ссылается на Core BulkArchiveMailer.AddIn/ VSTO-плагин, net48, Windows-only, ссылается на Core
ThisAddIn.cs / .Designer.cs запуск/остановка плагина ThisAddIn.cs / .Designer.cs запуск/остановка плагина
Ribbon.xml + RibbonController.cs одна кнопка ленты "Массовая рассылка" Ribbon.xml + RibbonController.cs одна кнопка ленты "Массовая рассылка"
SignatureReader.cs чтение подписи Outlook из %APPDATA%\Microsoft\Signatures через реестр SignatureReader.cs чтение подписи Outlook из %APPDATA%\Microsoft\Signatures через реестр
MailSender.cs создание/отправка Outlook.MailItem, тестовый режим (Черновики) MailSender.cs создание/отправка Outlook.MailItem, тестовый режим (Черновики)
UI/SettingsForm.cs папка, домен, подпись, тестовый режим по умолчанию, оба шаблона UI/SettingsForm.cs папка, домен, подпись, тестовый режим по умолчанию, список шаблонов (добавление/удаление)
UI/PreviewForm.cs таблица предпросмотра, баннер про пароль, отправка, итоговый журнал UI/PreviewForm.cs таблица предпросмотра, баннер про пароль, отправка, итоговый журнал
docs/BUILD.md как собрать и подключить плагин в Visual Studio на Windows docs/BUILD.md как собрать и подключить плагин в Visual Studio на Windows
``` ```
@@ -93,8 +93,28 @@ Outlook в принципе не запустить.
(`Session.Accounts`), полезно, если у оператора несколько ящиков (например, (`Session.Accounts`), полезно, если у оператора несколько ящиков (например,
отдельный служебный/shared-ящик именно для этой рассылки). Пункт отдельный служебный/shared-ящик именно для этой рассылки). Пункт
"(по умолчанию Outlook)" — прежнее поведение без явного выбора аккаунта. "(по умолчанию Outlook)" — прежнее поведение без явного выбора аккаунта.
- **Шаблоны писем** — тема и HTML-тело отдельно для `ssh_access` и - **Шаблоны писем** — вкладка "Шаблоны" со списком произвольного числа
`config_update`, с плейсхолдерами `{Login}`, `{Email}`, `{Date}`. шаблонов (кнопки "+ Добавить"/"Удалить"). Каждый шаблон — это название,
суффикс имени файла (`{login}_<суффикс>.7z`, по которому шаблон подбирается
для конкретного архива), необязательный фильтр логина (regex) и тема с
HTML-телом с плейсхолдерами `{Login}`, `{Email}`, `{Date}`. Суффикс
обязателен и должен быть уникальным среди всех шаблонов — по нему, а не по
фиксированному перечислению видов архива, `ArchiveScanner` сопоставляет
файл с шаблоном. Два исходных шаблона (`ssh_access`/`config_update`) —
обычные равноправные записи в этом списке, без особой защиты от
переименования/удаления; кнопка "Сбросить шаблоны по умолчанию"
восстанавливает их, если они были случайно удалены.
- **Фильтр логина** — необязательное регулярное выражение (.NET regex,
без учёта регистра). Пустое значение (по умолчанию, в том числе у
шаблонов, мигрированных из конфига старого формата) означает "любой
логин" — прежнее поведение, когда только суффикс решает, подходит ли
файл под шаблон. Если поле заполнено, а логин из имени файла ему не
соответствует, файл не считается подходящим под этот шаблон: строка в
предпросмотре получает статус "Ошибка" с пояснением, какому шаблону и
какому паттерну логин не соответствует, — автоматический подбор другого
шаблона не выполняется. Некорректный regex (не компилируется) не даёт
сохранить настройки — окно "Настройки" укажет, в каком именно шаблоне
ошибка.
- **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются - **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются
в Черновики, а не отправляются) до первого осознанного отключения в Черновики, а не отправляются) до первого осознанного отключения
оператором в окне предпросмотра. оператором в окне предпросмотра.