add ProxyJump function

This commit is contained in:
redlineo committed 2026-07-23 12:38:13 +03:00
1 parent 56e20e5464
commit 7f0e0fdb23
48 files changed
+3875 -44

No files matched your search

@@ -0,0 +1,287 @@
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.
/// "&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
}
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;
}
}
}