using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Security.Cryptography; using System.Text; namespace BulkArchiveMailer.Core { /// Outcome recorded for one send attempt. public static class SendLogStatus { public const string Sent = "Sent"; public const string Failed = "Failed"; /// 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. public const string Draft = "Draft"; } public sealed class SendLogEntry { public DateTime TimestampUtc { get; set; } public string Login { get; set; } = ""; public string Email { get; set; } = ""; /// 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. 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; } = ""; } /// /// Append-only CSV log at a path chosen by the caller (AddIn typically /// keeps it next to the archive folder, e.g. /// "<archiveFolder>\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. /// 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 ReadAll() { var result = new List(); 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; } /// /// 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. /// 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), })); } } /// /// 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. /// 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 ParseCsvLine(string line) { var fields = new List(); 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; } } }