Retructured solution

This commit is contained in:
Khwezi Mngoma
2026-05-13 20:06:24 +02:00
parent 26075cd9a7
commit a42c51d7b2
231 changed files with 1618 additions and 1408 deletions
@@ -1,61 +0,0 @@
using LiteCharms.Features.Email.Commands;
using LiteCharms.Models.Configuraton.Email;
namespace LiteCharms.Features.Email.Commands.Handlers;
public class SendEmailCommandHandler(IOptions<SmtpSettings> smtpOptions) : IRequestHandler<SendEmailCommand, Result>
{
public async ValueTask<Result> Handle(SendEmailCommand request, CancellationToken cancellationToken)
{
try
{
var settings = smtpOptions.Value;
if(settings == null)
return Result.Fail(new Error("SMTP settings are not configured."));
if(settings.Credentials == null)
return Result.Fail(new Error("SMTP credentials are not configured."));
if(string.IsNullOrWhiteSpace(settings?.Credentials.Username) || string.IsNullOrWhiteSpace(settings.Credentials.Password))
return Result.Fail(new Error("SMTP credentials are incomplete."));
if(string.IsNullOrWhiteSpace(settings.Host) || settings.Port == 0)
return Result.Fail(new Error("SMTP host and port must be configured."));
var message = new MimeMessage();
message.From.Add(new MailboxAddress(request.SenderName, request.From!));
message.To.Add(new MailboxAddress(request.RecipientName, request.To!));
message.Subject = request.Subject!;
var bodyBuilder = new BodyBuilder();
if(request.Attachment?.Length > 0 && !string.IsNullOrEmpty(request.AttachmentFileName))
bodyBuilder.Attachments.Add(request.AttachmentFileName!, request.Attachment!, cancellationToken);
if (!request.IsHtml) bodyBuilder.TextBody = request.Message;
if (request.IsHtml) bodyBuilder.HtmlBody = request.Message;
message.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(settings.Host!, settings.Port, settings.UseSsl, cancellationToken);
await client.AuthenticateAsync(settings.Credentials!.Username!, settings.Credentials.Password!, cancellationToken);
var response = await client.SendAsync(message, cancellationToken);
bool emailSent = response.Contains("OK", StringComparison.InvariantCultureIgnoreCase);
await client.DisconnectAsync(true, cancellationToken);
return emailSent
? Result.Ok()
: Result.Fail(new Error("Failed to send email. SMTP response: " + response));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,79 +0,0 @@
namespace LiteCharms.Features.Email.Commands;
public class SendEmailCommand : IRequest<Result>
{
public string? From { get; set; }
public string? SenderName { get; set; }
public string? To { get; set; }
public string? RecipientName { get; set; }
public string? Subject { get; set; }
public string? Message { get; set; }
public bool IsHtml { get; set; }
public Stream? Attachment { get; set; }
public string? AttachmentFileName { get; set; }
private SendEmailCommand(string from, string senderName, string to, string recipientName, string subject, string message, bool isHtml = false, Stream? attachment = null, string? attachmentFileName = null)
{
From = from;
To = to;
Subject = subject;
Message = message;
IsHtml = isHtml;
Attachment = attachment;
AttachmentFileName = attachmentFileName;
SenderName = senderName;
RecipientName = recipientName;
}
public static SendEmailCommand Create(string from, string senderName, string to, string recipientName, string subject, string message, bool isHtml = false, Stream? attachment = null, string? attachmentFileName = null)
{
if (string.IsNullOrWhiteSpace(from))
throw new ArgumentException("From address is required.");
if (string.IsNullOrWhiteSpace(senderName))
throw new ArgumentException("Sender name is required.");
if (!string.IsNullOrWhiteSpace(senderName) && senderName?.Length > 255)
throw new ArgumentException("Sender name cannot exceed 255 characters.");
if (string.IsNullOrWhiteSpace(to))
throw new ArgumentException("To address is required.");
if (string.IsNullOrWhiteSpace(recipientName))
throw new ArgumentException("Recipient name is required.");
if (!string.IsNullOrWhiteSpace(recipientName) && recipientName?.Length > 255)
throw new ArgumentException("Recipient name cannot exceed 255 characters.");
if (string.IsNullOrWhiteSpace(subject))
throw new ArgumentException("Subject is required.");
if (!string.IsNullOrWhiteSpace(subject) && subject?.Length > 2048)
throw new ArgumentException("Subject cannot exceed 2048 characters.");
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Message is required.");
if (message.Length > 10485760)
throw new ArgumentException("Message cannot exceed 10 MB.");
if (attachment != null && string.IsNullOrWhiteSpace(attachmentFileName))
throw new ArgumentException("Attachment file name must be provided when an attachment is included.");
if (attachment is not null && attachment.Length > 10485760)
throw new ArgumentException("Attachment cannot exceed 10 MB.");
if (!string.IsNullOrWhiteSpace(attachmentFileName) && attachmentFileName.Length > 255)
throw new ArgumentException("Attachment file name cannot exceed 255 characters.");
return new(from, senderName!, to, recipientName!, subject!, message, isHtml, attachment, attachmentFileName);
}
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Configuration;
public class Account
{
public string? Username { get; set; }
public string? Password { get; set; }
}
@@ -0,0 +1,12 @@
namespace LiteCharms.Features.Email.Configuration;
public class SmtpSettings
{
public Account? Credentials { get; set; }
public int Port { get; set; }
public string? Host { get; set; }
public bool UseSsl { get; set; }
}
+192
View File
@@ -0,0 +1,192 @@
using LiteCharms.Features.Email.Configuration;
using LiteCharms.Features.Email.Extensions;
using LiteCharms.Features.Email.Models;
using LiteCharms.Features.Shop;
namespace LiteCharms.Features.Email;
public class EmailService(IOptions<SmtpSettings> options) : IEmailService
{
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 Task<Result<Response>> 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<Response>("Smtp service is disconnected.");
}
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();
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();
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);
if (response.Contains("421"))
{
Status = EmailStatuses.TooManyConnections;
return Result.Fail<Response>(response);
}
if (response.Contains("451"))
{
Status = EmailStatuses.ConnectionAborted;
return Result.Fail<Response>(response);
}
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "error_message", response } });
Status = EmailStatuses.Disconnected;
return Result.Fail<Response>("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));
}
}
public async Task<Result<Response>> 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<Response>(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<Response>(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<Response>(new Error(ex.Message).CausedBy(ex));
}
}
public async Task<Result> 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);
}
}
@@ -1,18 +1,22 @@
using LiteCharms.Features.Notifications.Commands;
using static LiteCharms.Abstractions.Constants;
using LiteCharms.Features.Shop;
using static LiteCharms.Features.Email.Extensions.Constants;
namespace LiteCharms.Features.Email.Events.Handlers;
// TODO: Inject the INotificationService
public class SendShopEmailEnquiryEventHandler(ISender mediator) :
INotificationHandler<SendShopEmailEnquiryEvent>
{
public async ValueTask Handle(SendShopEmailEnquiryEvent notification, CancellationToken cancellationToken)
{
var command = CreateNotificationCommand.Create(Models.NotificationDirection.Outgoing, notification.SenderName!,
notification.SenderAddress!, notification.Subject!, notification.Message!, Models.NotificationPlatforms.Email,
// TODO: Refactor this to use the NotificationService
var command = CreateNotification.Create(NotificationDirection.Outgoing, notification.SenderName!,
notification.SenderAddress!, notification.Subject!, notification.Message!, NotificationPlatforms.Email,
notification.Priority, ShopEmailFromName, ShopEmailFromAddress, Guid.CreateVersion7().ToString(),
Models.CorrelationIdTypes.None, isInternal: true, isHtml: false);
CorrelationIdTypes.None, isInternal: true, isHtml: false);
// TODO: Remove, deprecated
await mediator.Send(command, cancellationToken);
}
}
@@ -1,5 +1,5 @@
using LiteCharms.Abstractions;
using LiteCharms.Models;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Shop;
namespace LiteCharms.Features.Email.Events;
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Extensions;
public static class Constants
{
public const string ShopSchedulerName = "shop";
public const string ShopEmailFromName = "Khongisa Shop";
public const string ShopEmailFromAddress = "shop@litecharms.co.za";
}
@@ -0,0 +1,9 @@
namespace LiteCharms.Features.Email.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<long> EmailsSent = Meter.CreateCounter<long>("emails_sent_total", "count", "Total successful emails sent");
public static readonly Counter<long> EmailsFailed = Meter.CreateCounter<long>("emails_failed_total", "count", "Total failed email attempts");
}
@@ -0,0 +1,19 @@
using LiteCharms.Features.Email.Configuration;
namespace LiteCharms.Features.Email.Extensions;
public static class Setup
{
public static IServiceCollection AddEmailServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
services.AddSingleton<IEmailService, EmailService>();
services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("LiteCharms.EmailService"))
.WithMetrics(metrics => metrics.AddMeter("LiteCharms.EmailService"));
return services;
}
}
@@ -0,0 +1,15 @@
using LiteCharms.Features.Email.Models;
using LiteCharms.Features.Shop;
namespace LiteCharms.Features.Email;
public interface IEmailService : IDisposable
{
EmailStatuses Status { get; }
Task<Result<Response>> SendEmailAsync(Message message, CancellationToken cancellationToken = default);
Task<Result<Response>> ConnectAsync(CancellationToken cancellationToken = default);
Task<Result> DisconnectAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Models;
public class Attachment
{
public string? Name { get; set; }
public Stream? FileStream { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
namespace LiteCharms.Features.Email.Models;
public class Body : IDisposable
{
public string? Message { get; set; }
public ReadOnlyCollection<Attachment>? 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);
}
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Models;
public class BodyProperties
{
public bool IsHtml { get; set; }
public bool HasAttachments { get; set; }
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace LiteCharms.Features.Email.Models;
public sealed class EmailEnquiry
{
[Required]
[MinLength(2)]
[MaxLength(255)]
[Display(Name = "Full Name")]
public string? FullName { get; set; }
[Required]
[EmailAddress]
[MinLength(5)]
[MaxLength(255)]
[Display(Name = "Email Address")]
public string? EmailAddress { get; set; }
[Required]
[MinLength(2)]
[MaxLength(255)]
[Display(Name = "Subject")]
public string? EmailSubject { get; set; }
[Required]
[MinLength(2)]
[MaxLength(2000)]
[Display(Name = "Message")]
public string? Message { get; set; }
}
@@ -0,0 +1,19 @@
namespace LiteCharms.Features.Email.Models;
public 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);
}
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Models;
public class Party
{
public string? Name { get; set; }
public string? Address { get; set; }
}
@@ -0,0 +1,22 @@
using LiteCharms.Features.Shop;
namespace LiteCharms.Features.Email.Models;
public 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);
}