Files
Khwezi Mngoma 902942eee6
continuous-integration/drone/pr Build is passing
Completed initial database design
Sealed qualifying public classes
Migrated database changes
2026-05-27 09:12:04 +02:00

210 lines
7.1 KiB
C#

using LiteCharms.Features.Email.Configuration;
using LiteCharms.Features.Email.Extensions;
using LiteCharms.Features.Email.Models;
namespace LiteCharms.Features.Email;
public sealed class EmailService(IOptions<SmtpSettings> 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<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 = 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<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));
}
}
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<Response> HandleNegativeResponse(string response)
{
if (response.Contains("421", StringComparison.Ordinal))
{
Status = EmailStatuses.TooManyConnections;
return Result.Fail<Response>(response);
}
if (response.Contains("451", StringComparison.Ordinal))
{
Status = EmailStatuses.ConnectionAborted;
return Result.Fail<Response>(response);
}
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "error_message", response } });
return Result.Fail<Response>(response);
}
public async ValueTask<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 ValueTask<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);
}
}