Files
ssh-access-tui/outlook-bulk-sender/BulkArchiveMailer.Core/SendLogStore.cs
T
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

316 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace BulkArchiveMailer.Core
{
/// <summary>Outcome recorded for one send attempt.</summary>
public static class SendLogStatus
{
public const string Sent = "Sent";
public const string Failed = "Failed";
/// <summary>Written in test mode (mail saved to Drafts, not actually sent) - deliberately NOT "Sent", so a later real send is not treated as a duplicate.</summary>
public const string Draft = "Draft";
}
public sealed class SendLogEntry
{
public DateTime TimestampUtc { get; set; }
public string Login { get; set; } = "";
public string Email { get; set; } = "";
/// <summary>File suffix ("ssh_access", "config_update", or any other configured template's FileSuffix) - see the normalization note on SendLogStore.ReadAll for the legacy PascalCase values (e.g. "SshAccess") written by versions v1.0.0-v1.0.4.</summary>
public string Kind { get; set; } = "";
public string ArchivePath { get; set; } = "";
public string FileSha256 { get; set; } = "";
public string Status { get; set; } = "";
public string Error { get; set; } = "";
}
/// <summary>
/// Append-only CSV log at a path chosen by the caller (AddIn typically
/// keeps it next to the archive folder, e.g.
/// "&lt;archiveFolder&gt;\send-log.csv"). Columns:
/// TimestampUtc,Login,Email,Kind,ArchivePath,FileSha256,Status,Error
///
/// Dedup key for "already sent this exact archive": (Email, Kind,
/// FileSha256) with Status=Sent. Hashing the archive's *current* bytes
/// (done by the caller via ComputeSha256, at scan time) is what lets a
/// regenerated archive - same login, same kind, new key material or
/// port list - be correctly treated as NOT already sent, while a
/// second scan of an untouched folder correctly flags it as
/// AlreadySent.
/// </summary>
public sealed class SendLogStore
{
private static readonly string[] Header =
{
"TimestampUtc", "Login", "Email", "Kind", "ArchivePath", "FileSha256", "Status", "Error",
};
public string CsvPath { get; }
public SendLogStore(string csvPath)
{
if (string.IsNullOrWhiteSpace(csvPath))
{
throw new ArgumentException("csvPath must not be empty.", nameof(csvPath));
}
CsvPath = csvPath;
}
public static string ComputeSha256(string filePath)
{
using (var sha = SHA256.Create())
using (var stream = File.OpenRead(filePath))
{
var hash = sha.ComputeHash(stream);
var sb = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
{
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
}
return sb.ToString();
}
}
public IReadOnlyList<SendLogEntry> ReadAll()
{
var result = new List<SendLogEntry>();
if (!File.Exists(CsvPath))
{
return result;
}
using (var reader = new StreamReader(CsvPath, Encoding.UTF8))
{
var first = true;
string line;
while ((line = reader.ReadLine()) != null)
{
if (first)
{
first = false;
continue; // header row
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var fields = ParseCsvLine(line);
if (fields.Count < Header.Length)
{
continue; // malformed/truncated row - skip rather than throw
}
var kind = NormalizeLegacyKind(fields[3]);
DateTime ts;
DateTime.TryParse(
fields[0],
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind | DateTimeStyles.AssumeUniversal,
out ts);
result.Add(new SendLogEntry
{
TimestampUtc = ts,
Login = fields[1],
Email = fields[2],
Kind = kind,
ArchivePath = fields[4],
FileSha256 = fields[5],
Status = fields[6],
Error = fields[7],
});
}
}
return result;
}
/// <summary>
/// Maps the legacy PascalCase Kind values written by v1.0.0-v1.0.4
/// (ArchiveKind.ToString(): "SshAccess"/"ConfigUpdate") to the
/// snake_case FileSuffix text used everywhere since ArchiveKind was
/// replaced by a free-form template list ("ssh_access"/
/// "config_update" - same text as config.default.json's
/// "fileSuffix"). Anything else (already-current suffix, or a
/// custom suffix that never existed under the old enum) passes
/// through unchanged, so TryFindSent's dedup lookup keeps matching
/// against send-log.csv rows written before this migration.
/// </summary>
private static string NormalizeLegacyKind(string rawKind)
{
if (string.Equals(rawKind, "SshAccess", StringComparison.OrdinalIgnoreCase))
{
return "ssh_access";
}
if (string.Equals(rawKind, "ConfigUpdate", StringComparison.OrdinalIgnoreCase))
{
return "config_update";
}
return rawKind;
}
public void Append(SendLogEntry entry)
{
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
var dir = Path.GetDirectoryName(CsvPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var writeHeader = !File.Exists(CsvPath);
using (var writer = new StreamWriter(CsvPath, append: true, encoding: new UTF8Encoding(false)))
{
if (writeHeader)
{
writer.WriteLine(string.Join(",", Header));
}
writer.WriteLine(string.Join(",", new[]
{
Escape(entry.TimestampUtc.ToString("o", CultureInfo.InvariantCulture)),
Escape(entry.Login),
Escape(entry.Email),
Escape(entry.Kind ?? ""),
Escape(entry.ArchivePath),
Escape(entry.FileSha256),
Escape(entry.Status),
Escape(entry.Error),
}));
}
}
/// <summary>
/// True if the most recent matching row for (email, kind, sha256)
/// has Status=Sent. Draft/Failed rows never count as "already
/// sent" - only a completed real Send does.
/// </summary>
public bool TryFindSent(string email, string kind, string sha256, out SendLogEntry match)
{
match = null;
if (string.IsNullOrEmpty(sha256) || string.IsNullOrEmpty(email))
{
return false;
}
foreach (var entry in ReadAll())
{
if (!string.Equals(entry.Status, SendLogStatus.Sent, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.Equals(entry.Kind, kind, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.Equals(entry.FileSha256, sha256, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (match == null || entry.TimestampUtc > match.TimestampUtc)
{
match = entry;
}
}
return match != null;
}
private static string Escape(string field)
{
field = field ?? "";
if (field.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0)
{
return "\"" + field.Replace("\"", "\"\"") + "\"";
}
return field;
}
private static List<string> ParseCsvLine(string line)
{
var fields = new List<string>();
var i = 0;
var n = line.Length;
while (i < n)
{
string field;
if (line[i] == '"')
{
i++;
var sb = new StringBuilder();
while (i < n)
{
if (line[i] == '"')
{
if (i + 1 < n && line[i + 1] == '"')
{
sb.Append('"');
i += 2;
}
else
{
i++;
break;
}
}
else
{
sb.Append(line[i]);
i++;
}
}
field = sb.ToString();
}
else
{
var start = i;
while (i < n && line[i] != ',')
{
i++;
}
field = line.Substring(start, i - start);
}
fields.Add(field);
if (i < n && line[i] == ',')
{
i++;
}
}
return fields;
}
}
}