add ProxyJump function
This commit is contained in:
1 parent
56e20e5464
commit
7f0e0fdb23
48 files changed
+3875
-44
No files matched your search
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using System.Text;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
[DataContract]
|
||||
public sealed class TemplateData
|
||||
{
|
||||
[DataMember(Name = "subject")]
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "bodyHtml")]
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class TemplateSetData
|
||||
{
|
||||
[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>
|
||||
[DataContract]
|
||||
public sealed class AppSettingsData
|
||||
{
|
||||
[DataMember(Name = "archiveFolder")]
|
||||
public string ArchiveFolder { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "emailDomain")]
|
||||
public string EmailDomain { get; set; } = "redlab.lo";
|
||||
|
||||
/// <summary>Whether "test mode" (save to Drafts instead of Send) should be pre-checked when PreviewForm opens. Defaults to true - sending live is an explicit opt-in each time the operator unchecks it.</summary>
|
||||
[DataMember(Name = "testModeDefault")]
|
||||
public bool TestModeDefault { get; set; } = true;
|
||||
|
||||
/// <summary>Manual override for the Outlook signature .htm path, used when SignatureReader's registry-based auto-detection fails or the operator wants a different signature. Empty = rely on auto-detection.</summary>
|
||||
[DataMember(Name = "signatureHtmlPath")]
|
||||
public string SignatureHtmlPath { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "templates")]
|
||||
public TemplateSetData Templates { get; set; } = new TemplateSetData();
|
||||
|
||||
public MailTemplateSet ToTemplateSet()
|
||||
{
|
||||
return new MailTemplateSet
|
||||
{
|
||||
SshAccess = new MailTemplate
|
||||
{
|
||||
Subject = Templates?.SshAccess?.Subject ?? "",
|
||||
BodyHtml = Templates?.SshAccess?.BodyHtml ?? "",
|
||||
},
|
||||
ConfigUpdate = new MailTemplate
|
||||
{
|
||||
Subject = Templates?.ConfigUpdate?.Subject ?? "",
|
||||
BodyHtml = Templates?.ConfigUpdate?.BodyHtml ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public void SetTemplate(ArchiveKind kind, MailTemplate template)
|
||||
{
|
||||
Templates = Templates ?? new TemplateSetData();
|
||||
var data = new TemplateData { Subject = template?.Subject ?? "", BodyHtml = template?.BodyHtml ?? "" };
|
||||
if (kind == ArchiveKind.SshAccess)
|
||||
{
|
||||
Templates.SshAccess = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
Templates.ConfigUpdate = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads/saves the small JSON settings file at
|
||||
/// %APPDATA%\BulkArchiveMailer\config.json. On first run (file
|
||||
/// missing) the file is seeded verbatim from the config.default.json
|
||||
/// resource embedded in this assembly, so the shipped defaults and the
|
||||
/// initial user-editable file always start out identical - there is
|
||||
/// only one place (config.default.json) where default copy/domain
|
||||
/// text is authored.
|
||||
///
|
||||
/// Uses DataContractJsonSerializer (part of the .NET Framework since
|
||||
/// 3.5, ships in System.Runtime.Serialization.dll) rather than a
|
||||
/// third-party JSON package, specifically so this project has zero
|
||||
/// NuGet dependencies to resolve - this whole solution cannot be
|
||||
/// build-tested in the environment this was authored in, so removing
|
||||
/// every avoidable source of "does the package restore correctly on
|
||||
/// first open" risk is worth the small amount of extra code below.
|
||||
/// </summary>
|
||||
public static class AppSettings
|
||||
{
|
||||
private const string EmbeddedDefaultResourceName = "BulkArchiveMailer.Core.config.default.json";
|
||||
|
||||
public static string ConfigDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "BulkArchiveMailer");
|
||||
|
||||
public static string ConfigFilePath => Path.Combine(ConfigDirectory, "config.json");
|
||||
|
||||
public static string ReadEmbeddedDefaultJson()
|
||||
{
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
using (var stream = asm.GetManifestResourceStream(EmbeddedDefaultResourceName))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Embedded resource '{EmbeddedDefaultResourceName}' not found in {asm.FullName}. " +
|
||||
"Check that config.default.json's Build Action is set to Embedded Resource in BulkArchiveMailer.Core.csproj.");
|
||||
}
|
||||
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
||||
{
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static AppSettingsData LoadDefaults() => Deserialize(ReadEmbeddedDefaultJson());
|
||||
|
||||
/// <summary>
|
||||
/// Loads settings from disk, seeding the file from the embedded
|
||||
/// defaults on first run. A malformed hand-edited config.json is
|
||||
/// never silently overwritten - the DataContractJsonSerializer
|
||||
/// exception propagates so the caller can warn the operator
|
||||
/// instead of quietly discarding their edits.
|
||||
/// </summary>
|
||||
public static AppSettingsData Load()
|
||||
{
|
||||
if (!File.Exists(ConfigFilePath))
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
File.WriteAllText(ConfigFilePath, ReadEmbeddedDefaultJson(), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(ConfigFilePath, Encoding.UTF8);
|
||||
return Deserialize(json);
|
||||
}
|
||||
|
||||
public static void Save(AppSettingsData data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static AppSettingsData Deserialize(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
using (var ms = new MemoryStream(bytes))
|
||||
{
|
||||
var serializer = new DataContractJsonSerializer(typeof(AppSettingsData));
|
||||
return (AppSettingsData)serializer.ReadObject(ms);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Serialize(AppSettingsData data)
|
||||
{
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
var serializer = new DataContractJsonSerializer(typeof(AppSettingsData));
|
||||
serializer.WriteObject(ms, data);
|
||||
var minified = Encoding.UTF8.GetString(ms.ToArray());
|
||||
return PrettyPrintJson(minified);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataContractJsonSerializer has no built-in indentation option
|
||||
/// and writes single-line JSON. This tiny formatter re-indents it
|
||||
/// so config.json stays comfortable to hand-edit (it is, after
|
||||
/// all, the officially supported way to change the mail
|
||||
/// templates). It only tracks string-literal boundaries, which is
|
||||
/// sufficient for the flat/shallow shape this file always has.
|
||||
/// </summary>
|
||||
private static string PrettyPrintJson(string json)
|
||||
{
|
||||
var sb = new StringBuilder(json.Length * 2);
|
||||
var indent = 0;
|
||||
var inString = false;
|
||||
var escapeNext = false;
|
||||
|
||||
for (var i = 0; i < json.Length; i++)
|
||||
{
|
||||
var c = json[i];
|
||||
|
||||
if (inString)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (escapeNext)
|
||||
{
|
||||
escapeNext = false;
|
||||
}
|
||||
else if (c == '\\')
|
||||
{
|
||||
escapeNext = true;
|
||||
}
|
||||
else if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '"':
|
||||
inString = true;
|
||||
sb.Append(c);
|
||||
break;
|
||||
case '{':
|
||||
case '[':
|
||||
sb.Append(c);
|
||||
if (i + 1 < json.Length && (json[i + 1] == '}' || json[i + 1] == ']'))
|
||||
{
|
||||
sb.Append(json[i + 1]);
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
indent++;
|
||||
sb.Append('\n').Append(' ', indent * 2);
|
||||
}
|
||||
|
||||
break;
|
||||
case '}':
|
||||
case ']':
|
||||
indent--;
|
||||
sb.Append('\n').Append(' ', indent * 2).Append(c);
|
||||
break;
|
||||
case ',':
|
||||
sb.Append(c).Append('\n').Append(' ', indent * 2);
|
||||
break;
|
||||
case ':':
|
||||
sb.Append(c).Append(' ');
|
||||
break;
|
||||
default:
|
||||
sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>Everything ArchiveScanner.Scan found in one folder, split by shape so the caller can render them however it likes.</summary>
|
||||
public sealed class ScanResult
|
||||
{
|
||||
/// <summary>Ready / AlreadySent / Error rows - one per login that had at least one recognized *.7z file.</summary>
|
||||
public List<SendJob> Jobs { get; } = new List<SendJob>();
|
||||
|
||||
/// <summary>One Skipped row per file that did not match the expected naming pattern.</summary>
|
||||
public List<SendJob> UnrecognizedFiles { get; } = new List<SendJob>();
|
||||
|
||||
/// <summary>Convenience: both lists concatenated, sorted the same way PreviewForm wants to bind a single grid.</summary>
|
||||
public IEnumerable<SendJob> AllRows => Jobs.Concat(UnrecognizedFiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a folder of ssh-access-tui output archives (plain System.IO -
|
||||
/// no Outlook/COM involved) and builds the list of preview rows.
|
||||
/// </summary>
|
||||
public static class ArchiveScanner
|
||||
{
|
||||
/// <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="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="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)
|
||||
{
|
||||
var result = new ScanResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath))
|
||||
{
|
||||
// Caller (SettingsForm/PreviewForm) is expected to validate
|
||||
// the configured folder up front and show its own error;
|
||||
// scanning a missing folder just yields an empty result
|
||||
// rather than throwing.
|
||||
return result;
|
||||
}
|
||||
|
||||
// login -> every (path, kind) found for it, case-insensitive
|
||||
// because the file system this runs on (Windows) is too.
|
||||
var byLogin = new Dictionary<string, List<(string Path, ArchiveKind Kind)>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var filePath in Directory.EnumerateFiles(folderPath, "*.7z", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
if (!FilenameParser.TryParse(fileName, out var parsed))
|
||||
{
|
||||
result.UnrecognizedFiles.Add(new SendJob
|
||||
{
|
||||
ArchivePath = filePath,
|
||||
Status = SendStatus.Skipped,
|
||||
ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_ssh_access.7z\" / \"{{login}}_config_update.7z\": {fileName}",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byLogin.TryGetValue(parsed.Login, out var list))
|
||||
{
|
||||
list = new List<(string, ArchiveKind)>();
|
||||
byLogin[parsed.Login] = list;
|
||||
}
|
||||
|
||||
list.Add((filePath, parsed.Kind));
|
||||
}
|
||||
|
||||
foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var login = kv.Key;
|
||||
var files = kv.Value;
|
||||
var email = EmailAddressBuilder.BuildEmail(login, emailDomain);
|
||||
|
||||
if (files.Count > 1)
|
||||
{
|
||||
// Both archive kinds present for one login (or, less
|
||||
// likely, a same-kind duplicate left behind from a
|
||||
// prior export) - this is a data error upstream. Never
|
||||
// auto-pick one; surface it as a blocking row instead.
|
||||
var names = string.Join(", ", files.Select(f => Path.GetFileName(f.Path)));
|
||||
result.Jobs.Add(new SendJob
|
||||
{
|
||||
Login = login,
|
||||
Email = email,
|
||||
Kind = null,
|
||||
ArchivePath = null,
|
||||
ConflictingArchivePaths = files.Select(f => f.Path).ToList(),
|
||||
Status = SendStatus.Error,
|
||||
ErrorMessage = $"Для логина \"{login}\" одновременно найдено {files.Count} архива(ов): {names}. Это ошибка данных (должен существовать только один архив на логин за раз) — требуется ручная проверка на jump-хосте, автоматический выбор не выполняется.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (path, kind) = files[0];
|
||||
var rendered = templates != null
|
||||
? TemplateEngine.Render(templates.Get(kind), login, email)
|
||||
: new MailTemplate();
|
||||
|
||||
var job = new SendJob
|
||||
{
|
||||
Login = login,
|
||||
Email = email,
|
||||
Kind = kind,
|
||||
ArchivePath = path,
|
||||
Subject = rendered.Subject,
|
||||
BodyHtml = rendered.BodyHtml,
|
||||
Status = SendStatus.Ready,
|
||||
};
|
||||
|
||||
if (sendLog != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
job.FileSha256 = SendLogStore.ComputeSha256(path);
|
||||
if (sendLog.TryFindSent(email, kind, job.FileSha256, out var priorEntry))
|
||||
{
|
||||
job.Status = SendStatus.AlreadySent;
|
||||
job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор).";
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
// File locked/unreadable right now - don't block
|
||||
// the whole scan over one bad file, surface it as
|
||||
// an error row instead.
|
||||
job.Status = SendStatus.Error;
|
||||
job.ErrorMessage = $"Не удалось прочитать файл архива для проверки дубликатов: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
result.Jobs.Add(job);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}</ProjectGuid>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>BulkArchiveMailer.Core</RootNamespace>
|
||||
<AssemblyName>BulkArchiveMailer.Core</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<Deterministic>true</Deterministic>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
<!--
|
||||
Deliberately NOT referencing anything Outlook/COM/registry-related
|
||||
here - this project must stay buildable and testable without
|
||||
Windows/Office installed. See BulkArchiveMailer.Core.Tests and the
|
||||
dotnet-developer agent notes for why that boundary matters.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Runtime.Serialization" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="AppSettings.cs" />
|
||||
<Compile Include="ArchiveScanner.cs" />
|
||||
<Compile Include="EmailAddressBuilder.cs" />
|
||||
<Compile Include="FilenameParser.cs" />
|
||||
<Compile Include="MailTemplates.cs" />
|
||||
<Compile Include="SendJob.cs" />
|
||||
<Compile Include="SendLogStore.cs" />
|
||||
<Compile Include="TemplateEngine.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="config.default.json" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Derives the recipient's email address from the login embedded in an
|
||||
/// archive file name. Rule (see repo README/plan, confirmed with the
|
||||
/// operator): a Linux login such as "a-makarov" maps to
|
||||
/// "a.makarov@<domain>" by replacing every hyphen with a dot -
|
||||
/// there is no separate mapping table.
|
||||
///
|
||||
/// Known accepted limitation (do NOT try to fix this with an override
|
||||
/// mechanism): if a real login contains a literal hyphen that is not
|
||||
/// an initial/surname separator, this produces the wrong local part.
|
||||
/// That is out of scope for this tool - see README.md.
|
||||
/// </summary>
|
||||
public static class EmailAddressBuilder
|
||||
{
|
||||
public static string BuildEmail(string login, string domain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
throw new ArgumentException("login must not be empty.", nameof(login));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
throw new ArgumentException("domain must not be empty.", nameof(domain));
|
||||
}
|
||||
|
||||
var localPart = login.Trim().ToLowerInvariant().Replace('-', '.');
|
||||
var cleanDomain = domain.Trim().TrimStart('@');
|
||||
return $"{localPart}@{cleanDomain}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
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>
|
||||
public sealed class ParsedFilename
|
||||
{
|
||||
public ParsedFilename(string login, ArchiveKind kind)
|
||||
{
|
||||
Login = login;
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
/// <summary>Lower-cased login exactly as it appears in the file name (hyphens intact).</summary>
|
||||
public string Login { get; }
|
||||
|
||||
public ArchiveKind Kind { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the two file name shapes ssh-access-tui ever produces:
|
||||
/// "{login}_ssh_access.7z" and "{login}_config_update.7z". Anything
|
||||
/// else (wrong extension, extra suffix, typo'd kind, stray file dropped
|
||||
/// in the export folder by a human) simply fails to parse - the caller
|
||||
/// (ArchiveScanner) is responsible for routing that into the
|
||||
/// "unrecognized" bucket instead of blocking the batch.
|
||||
/// </summary>
|
||||
public static class FilenameParser
|
||||
{
|
||||
// Matches the login part ssh-access-tui itself accepts for a Linux
|
||||
// username: lowercase alnum segments joined by single hyphens (no
|
||||
// leading/trailing/double hyphens). Case-insensitive purely as a
|
||||
// defensive measure in case a file was renamed by hand on a
|
||||
// case-insensitive filesystem; the captured login is normalized to
|
||||
// lower case before use.
|
||||
private static readonly Regex Pattern = new Regex(
|
||||
@"^(?<login>[a-z0-9]+(?:-[a-z0-9]+)*)_(?<kind>ssh_access|config_update)\.7z$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static bool TryParse(string fileName, out ParsedFilename parsed)
|
||||
{
|
||||
parsed = null;
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = Pattern.Match(fileName);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var login = match.Groups["login"].Value.ToLowerInvariant();
|
||||
var kindText = match.Groups["kind"].Value.ToLowerInvariant();
|
||||
var kind = kindText == "ssh_access" ? ArchiveKind.SshAccess : ArchiveKind.ConfigUpdate;
|
||||
|
||||
parsed = new ParsedFilename(login, kind);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("BulkArchiveMailer.Core")]
|
||||
[assembly: AssemblyDescription("Portable (no Outlook/COM) logic for BulkArchiveMailer: filename parsing, email derivation, templates, send log, settings.")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("BulkArchiveMailer.Core")]
|
||||
[assembly: AssemblyCopyright("")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
[assembly: NeutralResourcesLanguage("ru")]
|
||||
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("2f3f5a4e-6a3b-4a7c-8f2f-6f9d0b4a9d10")]
|
||||
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>Status of one preview row / send attempt.</summary>
|
||||
public enum SendStatus
|
||||
{
|
||||
/// <summary>Exactly one archive found for this login, nothing sent for it yet - safe to send.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>A prior log entry shows this exact archive (by SHA-256) was already sent to this address.</summary>
|
||||
AlreadySent,
|
||||
|
||||
/// <summary>Blocking data problem (e.g. both archive kinds present for one login) - never sendable.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>File in the folder did not match the expected naming pattern - listed for visibility, not sendable.</summary>
|
||||
Skipped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row of the send preview: everything needed to either display the
|
||||
/// row in the grid or hand it to MailSender.
|
||||
/// </summary>
|
||||
public sealed class SendJob
|
||||
{
|
||||
public string Login { 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>
|
||||
public ArchiveKind? Kind { get; set; }
|
||||
|
||||
/// <summary>Null for conflict Error rows (see <see cref="ConflictingArchivePaths"/>) - otherwise the single archive to attach.</summary>
|
||||
public string ArchivePath { get; set; }
|
||||
|
||||
/// <summary>Populated only for the "both kinds present" Error case: every archive path found for that login.</summary>
|
||||
public IReadOnlyList<string> ConflictingArchivePaths { get; set; }
|
||||
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
public string BodyHtml { get; set; } = "";
|
||||
|
||||
public SendStatus Status { get; set; } = SendStatus.Ready;
|
||||
|
||||
/// <summary>Human-readable explanation, set for Error/Skipped rows and for AlreadySent rows (when it was sent).</summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of ArchivePath's current bytes, computed when a SendLogStore is available. Used for the AlreadySent dedup key.</summary>
|
||||
public string FileSha256 { get; set; }
|
||||
|
||||
/// <summary>Whether this row is even eligible to be checked/sent in the preview grid.</summary>
|
||||
public bool IsSelectable => Status == SendStatus.Ready || Status == SendStatus.AlreadySent;
|
||||
|
||||
public string FileName => string.IsNullOrEmpty(ArchivePath) ? "" : Path.GetFileName(ArchivePath);
|
||||
|
||||
public string KindDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Kind)
|
||||
{
|
||||
case ArchiveKind.SshAccess:
|
||||
return "Новый доступ (ключ + конфиг)";
|
||||
case ArchiveKind.ConfigUpdate:
|
||||
return "Обновление конфига";
|
||||
default:
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Login} <{Email}> [{Status}]";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
/// "<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal placeholder substitution for Subject/BodyHtml templates.
|
||||
/// Deliberately simple (no conditionals/loops) - templates are short,
|
||||
/// hand-edited strings, not a general templating language.
|
||||
/// </summary>
|
||||
public static class TemplateEngine
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = new Regex(@"\{(?<name>[A-Za-z0-9_]+)\}", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every "{Name}" placeholder with the matching value from
|
||||
/// <paramref name="values"/> (case-insensitive lookup). Unknown
|
||||
/// placeholders are left untouched rather than throwing, so a typo
|
||||
/// in a hand-edited template shows up visibly in the preview
|
||||
/// instead of crashing the scan.
|
||||
/// </summary>
|
||||
public static string Render(string template, IDictionary<string, string> values)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
{
|
||||
return template ?? string.Empty;
|
||||
}
|
||||
|
||||
return PlaceholderPattern.Replace(template, match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value;
|
||||
foreach (var kvp in values)
|
||||
{
|
||||
if (string.Equals(kvp.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return kvp.Value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return match.Value; // leave "{Unknown}" as-is
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Renders both Subject and BodyHtml of a template for one recipient.</summary>
|
||||
public static MailTemplate Render(MailTemplate template, string login, string email)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Login"] = login ?? "",
|
||||
["Email"] = email ?? "",
|
||||
["Date"] = DateTime.Now.ToString("dd.MM.yyyy"),
|
||||
};
|
||||
|
||||
return new MailTemplate
|
||||
{
|
||||
Subject = Render(template?.Subject ?? "", values),
|
||||
BodyHtml = Render(template?.BodyHtml ?? "", values),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"archiveFolder": "",
|
||||
"emailDomain": "redlab.lo",
|
||||
"testModeDefault": true,
|
||||
"signatureHtmlPath": "",
|
||||
"templates": {
|
||||
"ssh_access": {
|
||||
"subject": "Доступ к SSH jump-хосту — {Login}",
|
||||
"bodyHtml": "<p>Здравствуйте!</p><p>Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению (<b>{Login}_ssh_access.7z</b>).</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p><p>Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.</p>"
|
||||
},
|
||||
"config_update": {
|
||||
"subject": "Обновление конфигурации доступа — {Login}",
|
||||
"bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user