288 lines
9.0 KiB
C#
288 lines
9.0 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; } = "";
|
|
public ArchiveKind 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.
|
|
/// "<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.
|
|
/// </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
|
|
}
|
|
|
|
ArchiveKind kind;
|
|
Enum.TryParse(fields[3], true, out kind);
|
|
|
|
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;
|
|
}
|
|
|
|
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.ToString()),
|
|
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, ArchiveKind 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 (entry.Kind != kind)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|