From 8fb4aac0c100f657e605ea88812bdc29610a0677 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 16:14:35 +0200 Subject: [PATCH] Implemented Email and Hash Service --- .../Abstractions/IService.cs | 3 + .../Configuration/Hash/HasherSettings.cs | 10 + PostFundManagement.Domain/Enums.cs | 12 + .../Extensions/EmailTelemetry.cs | 12 + PostFundManagement.Domain/Extensions/Hash.cs | 23 ++ .../Models/Email/Attachment.cs | 8 + .../Models/Email/Body.cs | 26 +++ .../Models/Email/BodyProperties.cs | 8 + .../Models/Email/Message.cs | 19 ++ .../Models/Email/Party.cs | 8 + .../Models/Email/Response.cs | 20 ++ .../Services/EmailService.cs | 214 ++++++++++++++++++ .../Services/HashService.cs | 89 ++++++++ 13 files changed, 452 insertions(+) create mode 100644 PostFundManagement.Domain/Abstractions/IService.cs create mode 100644 PostFundManagement.Domain/Configuration/Hash/HasherSettings.cs create mode 100644 PostFundManagement.Domain/Extensions/EmailTelemetry.cs create mode 100644 PostFundManagement.Domain/Extensions/Hash.cs create mode 100644 PostFundManagement.Domain/Models/Email/Attachment.cs create mode 100644 PostFundManagement.Domain/Models/Email/Body.cs create mode 100644 PostFundManagement.Domain/Models/Email/BodyProperties.cs create mode 100644 PostFundManagement.Domain/Models/Email/Message.cs create mode 100644 PostFundManagement.Domain/Models/Email/Party.cs create mode 100644 PostFundManagement.Domain/Models/Email/Response.cs create mode 100644 PostFundManagement.Domain/Services/EmailService.cs create mode 100644 PostFundManagement.Domain/Services/HashService.cs diff --git a/PostFundManagement.Domain/Abstractions/IService.cs b/PostFundManagement.Domain/Abstractions/IService.cs new file mode 100644 index 0000000..4be0e2e --- /dev/null +++ b/PostFundManagement.Domain/Abstractions/IService.cs @@ -0,0 +1,3 @@ +namespace PostFundManagement.Domain.Abstractions; + +public interface IService; diff --git a/PostFundManagement.Domain/Configuration/Hash/HasherSettings.cs b/PostFundManagement.Domain/Configuration/Hash/HasherSettings.cs new file mode 100644 index 0000000..4547fde --- /dev/null +++ b/PostFundManagement.Domain/Configuration/Hash/HasherSettings.cs @@ -0,0 +1,10 @@ +namespace PostFundManagement.Domain.Configuration.Hash; + +public sealed class HasherSettings +{ + public string? Salt { get; set; } + + public int MinHashLength { get; set; } + + public string? PayfastPassphrase { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Enums.cs b/PostFundManagement.Domain/Enums.cs index d251779..7482db7 100644 --- a/PostFundManagement.Domain/Enums.cs +++ b/PostFundManagement.Domain/Enums.cs @@ -1,5 +1,17 @@ namespace PostFundManagement.Domain; +public enum EmailStatuses : int +{ + GeneralError = 0, + AuthenticationError = 1, + ProtocolError = 2, + Connected = 3, + Disconnected = 4, + TooManyConnections = 5, + ConnectionAborted = 6, + Success = 7 +} + public enum NotificationStatus : int { Pending = 1, diff --git a/PostFundManagement.Domain/Extensions/EmailTelemetry.cs b/PostFundManagement.Domain/Extensions/EmailTelemetry.cs new file mode 100644 index 0000000..218a152 --- /dev/null +++ b/PostFundManagement.Domain/Extensions/EmailTelemetry.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace PostFundManagement.Domain.Extensions; + +public static class EmailTelemetry +{ + public static readonly ActivitySource Source = new("LiteCharms.EmailService"); + public static readonly Meter Meter = new("LiteCharms.EmailService"); + public static readonly Counter EmailsSent = Meter.CreateCounter("emails_sent_total", "count", "Total successful emails sent"); + public static readonly Counter EmailsFailed = Meter.CreateCounter("emails_failed_total", "count", "Total failed email attempts"); +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Extensions/Hash.cs b/PostFundManagement.Domain/Extensions/Hash.cs new file mode 100644 index 0000000..b327472 --- /dev/null +++ b/PostFundManagement.Domain/Extensions/Hash.cs @@ -0,0 +1,23 @@ +using PostFundManagement.Domain.Configuration.Hash; +using PostFundManagement.Domain.Services; + +namespace PostFundManagement.Domain.Extensions; + +public static class Hash +{ + public const string HasherConfigSectionName = "HasherSettings"; + + public static IServiceCollection AddHashServices(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(HasherConfigSectionName)); + + var settings = configuration.GetSection(HasherConfigSectionName).Get(); + + services.AddSingleton(_ => + new Hashids(settings!.Salt, minHashLength: settings.MinHashLength)); + + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Models/Email/Attachment.cs b/PostFundManagement.Domain/Models/Email/Attachment.cs new file mode 100644 index 0000000..bf88e47 --- /dev/null +++ b/PostFundManagement.Domain/Models/Email/Attachment.cs @@ -0,0 +1,8 @@ +namespace PostFundManagement.Domain.Models.Email; + +public sealed class Attachment +{ + public string? Name { get; set; } + + public Stream? FileStream { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Models/Email/Body.cs b/PostFundManagement.Domain/Models/Email/Body.cs new file mode 100644 index 0000000..ec34619 --- /dev/null +++ b/PostFundManagement.Domain/Models/Email/Body.cs @@ -0,0 +1,26 @@ +namespace PostFundManagement.Domain.Models.Email; + +public sealed class Body : IDisposable +{ + public string? Message { get; set; } + + public ReadOnlyCollection? Attachments { get; set; } + + public BodyProperties Properties { get; set; } = new(); + + public void Dispose() + { + if (Attachments is null) return; + + foreach (var attachment in Attachments!) + { + if (attachment is not null) + { + attachment.FileStream!.Close(); + attachment.FileStream!.Dispose(); + } + } + + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Models/Email/BodyProperties.cs b/PostFundManagement.Domain/Models/Email/BodyProperties.cs new file mode 100644 index 0000000..e500930 --- /dev/null +++ b/PostFundManagement.Domain/Models/Email/BodyProperties.cs @@ -0,0 +1,8 @@ +namespace PostFundManagement.Domain.Models.Email; + +public sealed class BodyProperties +{ + public bool IsHtml { get; set; } + + public bool HasAttachments { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Models/Email/Message.cs b/PostFundManagement.Domain/Models/Email/Message.cs new file mode 100644 index 0000000..0d43f0d --- /dev/null +++ b/PostFundManagement.Domain/Models/Email/Message.cs @@ -0,0 +1,19 @@ +namespace PostFundManagement.Domain.Models.Email; + +public sealed class Message : IDisposable +{ + public Party? Sender { get; set; } + + public Party? Recipient { get; set; } + + public string? Subject { get; set; } + + public Body? Body { get; set; } + + public void Dispose() + { + Body?.Dispose(); + + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Models/Email/Party.cs b/PostFundManagement.Domain/Models/Email/Party.cs new file mode 100644 index 0000000..d59f570 --- /dev/null +++ b/PostFundManagement.Domain/Models/Email/Party.cs @@ -0,0 +1,8 @@ +namespace PostFundManagement.Domain.Models.Email; + +public sealed class Party +{ + public string? Name { get; set; } + + public string? Address { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Models/Email/Response.cs b/PostFundManagement.Domain/Models/Email/Response.cs new file mode 100644 index 0000000..b0873fe --- /dev/null +++ b/PostFundManagement.Domain/Models/Email/Response.cs @@ -0,0 +1,20 @@ +namespace PostFundManagement.Domain.Models.Email; + +public sealed class Response +{ + public int Code { get; set; } + + public string? Error { get; set; } + + public EmailStatuses Status { get; set; } + + private Response(EmailStatuses status, int code = 0, string? error = null) + { + Status = status; + Code = code; + Error = error; + } + + public static Response Create(EmailStatuses status, int code = 0, string? error = null) => + new(status, code, error); +} diff --git a/PostFundManagement.Domain/Services/EmailService.cs b/PostFundManagement.Domain/Services/EmailService.cs new file mode 100644 index 0000000..0a7610c --- /dev/null +++ b/PostFundManagement.Domain/Services/EmailService.cs @@ -0,0 +1,214 @@ +using System.Diagnostics; +using PostFundManagement.Domain.Configuration.Email; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models.Email; +using static PostFundManagement.Domain.Extensions.EmailTelemetry; + +namespace PostFundManagement.Domain.Services; + +public sealed class EmailService(IOptions options) : IDisposable +{ + private readonly SmtpSettings settings = options.Value; + + private readonly SmtpClient client = new(); + + private readonly int sendMaxCount = 10; + + private int sendCount = 0; + + public EmailStatuses Status { get; private set; } = EmailStatuses.Disconnected; + + public async ValueTask> SendEmailAsync(Message message, CancellationToken cancellationToken = default) + { + using var activity = EmailTelemetry.Source.StartActivity("Email Send"); + + activity?.SetTag("email.recipient", message.Recipient?.Address); + + try + { + if (Status != EmailStatuses.Connected) + { + activity?.SetStatus(ActivityStatusCode.Error, "Disconnected"); + + return Result.Fail("Smtp service is disconnected."); + } + + var email = ConstructEmail(message, cancellationToken); + + var response = await client.SendAsync(email, cancellationToken); + + bool emailSent = response.Contains("OK", StringComparison.InvariantCultureIgnoreCase); + + message.Dispose(); + + Interlocked.Increment(ref sendCount); + + if (sendCount % sendMaxCount == 0) + { + using var delayActivity = EmailTelemetry.Source.StartActivity("Rate Limit Pause"); + + sendCount = 0; + + await Task.Delay(1000, cancellationToken); + } + + if (emailSent) + { + EmailTelemetry.EmailsSent.Add(1, new TagList { { "host", settings.Host } }); + + return Result.Ok(Response.Create(EmailStatuses.Success)); + } + + await DisconnectAsync(cancellationToken); + + var failCheckResult = HandleNegativeResponse(response); + + if (failCheckResult.IsFailed) return failCheckResult; + + Status = EmailStatuses.Disconnected; + + return Result.Fail("General error, disconnected"); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + + EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } }); + + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + private static MimeMessage ConstructEmail(Message message, CancellationToken cancellationToken) + { + var email = new MimeMessage(); + email.From.Add(new MailboxAddress(message.Sender!.Name, message.Sender.Address!)); + email.To.Add(new MailboxAddress(message.Recipient!.Name, message.Recipient!.Address!)); + email.Subject = message.Subject!; + + var bodyBuilder = new BodyBuilder(); + + if (message.Body!.Properties.HasAttachments) + foreach (var attachment in message.Body?.Attachments!) + bodyBuilder.Attachments.Add(attachment.Name!, attachment.FileStream!, cancellationToken); + + if (!message.Body.Properties.IsHtml) bodyBuilder.TextBody = message.Body.Message; + if (message.Body.Properties.IsHtml) bodyBuilder.HtmlBody = message.Body.Message; + + email.Body = bodyBuilder.ToMessageBody(); + + return email; + } + + private Result HandleNegativeResponse(string response) + { + if (response.Contains("421", StringComparison.Ordinal)) + { + Status = EmailStatuses.TooManyConnections; + + return Result.Fail(response); + } + + if (response.Contains("451", StringComparison.Ordinal)) + { + Status = EmailStatuses.ConnectionAborted; + + return Result.Fail(response); + } + + EmailTelemetry.EmailsFailed.Add(1, new TagList { { "error_message", response } }); + + return Result.Fail(response); + } + + public async ValueTask> ConnectAsync(CancellationToken cancellationToken = default) + { + using var activity = EmailTelemetry.Source.StartActivity("Email Connect"); + activity?.SetTag("email.smtp.connect", settings.Host); + + try + { + if (Status is EmailStatuses.Connected) return Result.Ok(Response.Create(Status)); + + await client.ConnectAsync(settings.Host!, settings.Port, settings.UseSsl, cancellationToken); + await client.AuthenticateAsync(settings.Credentials!.Username!, settings.Credentials.Password!, cancellationToken); + + Status = EmailStatuses.Connected; + + activity?.SetStatus(ActivityStatusCode.Ok, "Connected"); + + return Result.Ok(Response.Create(Status)); + } + catch (MailKit.ProtocolException ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + + EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } }); + + Status = EmailStatuses.ProtocolError; + + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + catch (Exception ex) when (ex is MailKit.Security.SslHandshakeException || ex is MailKit.Security.AuthenticationException) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + + EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } }); + + Status = EmailStatuses.AuthenticationError; + + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + + EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } }); + + Status = EmailStatuses.GeneralError; + + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask DisconnectAsync(CancellationToken cancellationToken = default) + { + using var activity = EmailTelemetry.Source.StartActivity("Email Disconnect"); + activity?.SetTag("email.smtp.disconnect", settings.Host); + + try + { + if (Status is EmailStatuses.Disconnected) return Result.Ok(); + + await client.DisconnectAsync(true, cancellationToken); + + activity?.SetStatus(ActivityStatusCode.Ok, "Disconnected"); + + Status = EmailStatuses.Disconnected; + + return Result.Ok(); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + + EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } }); + + Status = EmailStatuses.GeneralError; + + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public void Dispose() + { + client.Dispose(); + + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Services/HashService.cs b/PostFundManagement.Domain/Services/HashService.cs new file mode 100644 index 0000000..fc36262 --- /dev/null +++ b/PostFundManagement.Domain/Services/HashService.cs @@ -0,0 +1,89 @@ +using PostFundManagement.Domain.Abstractions; + +namespace PostFundManagement.Domain.Services; + +public sealed partial class HashService(IHashids hasher) : IService +{ + [GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex HexHashRegex { get; } + + [GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex Md5Regex { get; } + + [GeneratedRegex(@"\A[0-9a-fA-F]{64}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex Sha256Regex { get; } + + public static bool IsMd5Hash(string? value) => + !string.IsNullOrWhiteSpace(value) && Md5Regex.IsMatch(value); + + public static bool IsSha256Hash(string? value) => + !string.IsNullOrWhiteSpace(value) && Sha256Regex.IsMatch(value); + + public static string? StringToSha256Hash(string? input) => + string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))); + + public static string? StreamToSha256Hash(Stream stream) => + stream is null ? null : Convert.ToHexString(SHA256.HashData(stream)); + + public static string? BytesToSha256Hash(byte[] bytes) => + bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes)); + + public static Result ToMd5Hash(string input) + { + if (string.IsNullOrEmpty(input)) + return Result.Fail("Input content cannot be null or empty for MD5 processing."); + + byte[] bytes = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); + } + + public Result HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input) + ? Result.Fail("Input must be a valid hexadecimal string.") + : Result.Ok(hasher.EncodeHex(input)); + + public Result HashEncodeIntId(int id) => id < 0 + ? Result.Fail("Id cannot be negative.") + : Result.Ok(hasher.Encode(id)); + + public Result HashEncodeLongId(long id) => id < 0 + ? Result.Fail("Id cannot be negative.") + : Result.Ok(hasher.EncodeLong(id)); + + public Result DecodeIntIdHash(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) return Result.Fail("Invalid token layout."); + + int[] decoded = hasher.Decode(hash); + + return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail("Invalid or modified Int hash token."); + } + + public Result DecodeLongIdHash(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) return Result.Fail("Invalid token layout."); + + long[] decoded = hasher.DecodeLong(hash); + + return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail("Invalid or modified Long hash token."); + } + + public Result DecodeHexHash(string hex) + { + try + { + string decoded = hasher.DecodeHex(hex); + + return string.IsNullOrEmpty(decoded) + ? Result.Fail("Invalid or corrupted hex hash.") + : Result.Ok(decoded); + } + catch (FormatException fex) + { + return Result.Fail(new Error("Invalid hash structure.").CausedBy(fex)); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} \ No newline at end of file