diff --git a/Wave/Data/SmtpConfiguration.cs b/Wave/Data/SmtpConfiguration.cs index f4359a5..c72c45d 100644 --- a/Wave/Data/SmtpConfiguration.cs +++ b/Wave/Data/SmtpConfiguration.cs @@ -7,5 +7,6 @@ public class SmtpConfiguration { public required string Password { get; init; } public required string SenderEmail { get; init; } public required string SenderName { get; init; } + public required string ServiceEmail { get; init; } public bool Ssl { get; init; } = true; } \ No newline at end of file diff --git a/Wave/Program.cs b/Wave/Program.cs index 85974a0..e64bc2a 100644 --- a/Wave/Program.cs +++ b/Wave/Program.cs @@ -124,6 +124,7 @@ var smtpConfig = builder.Configuration.GetSection("Email:Smtp"); if (smtpConfig.Exists()) { builder.Services.Configure(smtpConfig); + builder.Services.AddKeyedScoped("live"); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped, SmtpEmailSender>(); @@ -132,6 +133,8 @@ logMessages.Add("No Email provider configured."); } +builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Wave/Services/EmailFactory.cs b/Wave/Services/EmailFactory.cs new file mode 100644 index 0000000..182bcf6 --- /dev/null +++ b/Wave/Services/EmailFactory.cs @@ -0,0 +1,67 @@ +using System.Collections.Frozen; +using System.Net; +using System.Text; +using Microsoft.Extensions.Options; +using MimeKit; +using Wave.Data; +using Wave.Utilities; + +namespace Wave.Services; + +public class EmailFactory(IOptions customizations, EmailTemplateService templateService) { + private Customization Customizations { get; } = customizations.Value; + private EmailTemplateService TemplateService { get; } = templateService; + + public async ValueTask CreateDefaultEmail(string receiverMail, string? receiverName, string subject, string title, string bodyHtml) { + (string host, string logo) = GetStaticData(); + string body = await TemplateService.DefaultAsync(host, logo, title, bodyHtml); + + return new StaticEmail(receiverMail, receiverName, subject, title, body, FrozenDictionary.Empty); + } + + public async ValueTask CreateSubscribedEmail(EmailSubscriber subscriber, string browserLink, string subject, string title, string bodyHtml, string role = "unknown") { + (string host, string logo) = GetStaticData(); + + string unsubscribeLink = await GetUnsubscribeLink(host, subscriber.Id, role); + string body = await TemplateService.NewsletterAsync(host, browserLink, logo, title, bodyHtml, unsubscribeLink); + + return new StaticEmail(subscriber.Email, subscriber.Name, subject, title, body, new Dictionary{ + {HeaderId.ListUnsubscribe.ToHeaderName(), $"<{unsubscribeLink}>"}, + {HeaderId.ListUnsubscribePost.ToHeaderName(), "One-Click"} + }.ToFrozenDictionary()); + } + + public async ValueTask CreateWelcomeEmail(EmailSubscriber subscriber, IEnumerable articles, string subject, string title, string bodyHtml) { + (string host, string logo) = GetStaticData(); + + string articlePartial = await TemplateService.GetPartialAsync("email-article"); + var articlesHtml = new StringBuilder(""); + foreach (var n in articles) { + string articleLink = ArticleUtilities.GenerateArticleLink(n.Article, new Uri(Customizations.AppUrl, UriKind.Absolute)); + articlesHtml.AppendFormat( + articlePartial, + n.Article.Title, n.Article.Author.Name, n.Article.Body[..Math.Min(250, n.Article.Body.Length)], articleLink); + } + + string unsubscribeLink = await GetUnsubscribeLink(host, subscriber.Id, "welcome"); + string body = TemplateService.Welcome(host, logo, title, bodyHtml, unsubscribeLink, articlesHtml.ToString()); + + return new StaticEmail(subscriber.Email, subscriber.Name, subject, title, body, new Dictionary{ + {HeaderId.ListUnsubscribe.ToHeaderName(), $"<{unsubscribeLink}>"}, + {HeaderId.ListUnsubscribePost.ToHeaderName(), "One-Click"} + }.ToFrozenDictionary()); + } + + private (string host, string logo) GetStaticData() { + var host = new Uri(string.IsNullOrWhiteSpace(Customizations.AppUrl) ? "" : Customizations.AppUrl); // TODO get link + string logo = !string.IsNullOrWhiteSpace(Customizations.LogoLink) + ? Customizations.LogoLink + : new Uri(host, "/img/logo.png").AbsoluteUri; + return (host.AbsoluteUri, logo); + } + + private async ValueTask GetUnsubscribeLink(string host, Guid subscriberId, string role) { + (string user, string token) = await TemplateService.CreateConfirmTokensAsync(subscriberId, "unsubscribe-"+role, TimeSpan.FromDays(30)); + return new Uri(new Uri(host), $"/Email/Unsubscribe?newsletter={role}&user={WebUtility.UrlEncode(user)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri; + } +} \ No newline at end of file diff --git a/Wave/Services/EmailNotSendException.cs b/Wave/Services/EmailNotSendException.cs new file mode 100644 index 0000000..b19fc59 --- /dev/null +++ b/Wave/Services/EmailNotSendException.cs @@ -0,0 +1,3 @@ +namespace Wave.Services; + +public class EmailNotSendException(string message, Exception exception) : ApplicationException(message, exception); \ No newline at end of file diff --git a/Wave/Services/EmailTemplateService.cs b/Wave/Services/EmailTemplateService.cs index 9fced40..54aa254 100644 --- a/Wave/Services/EmailTemplateService.cs +++ b/Wave/Services/EmailTemplateService.cs @@ -51,6 +51,15 @@ public enum Constants { }); } + public ValueTask DefaultAsync(string home, string logoLink, string title, string body) { + return ProcessAsync("default", new Dictionary { + {Constants.HomeLink, home}, + {Constants.ContentLogo, logoLink}, + {Constants.ContentTitle, title}, + {Constants.ContentBody, body} + }); + } + public string Newsletter(string home, string browserUrl, string logoLink, string title, string body, string unsubscribe) { return Process("newsletter", new Dictionary { { Constants.HomeLink, home }, @@ -61,6 +70,16 @@ public enum Constants { { Constants.EmailUnsubscribeLink, unsubscribe } }); } + public ValueTask NewsletterAsync(string home, string browserUrl, string logoLink, string title, string body, string unsubscribe) { + return ProcessAsync("newsletter", new Dictionary { + { Constants.HomeLink, home }, + { Constants.BrowserLink, browserUrl }, + { Constants.ContentLogo, logoLink }, + { Constants.ContentTitle, title }, + { Constants.ContentBody, body }, + { Constants.EmailUnsubscribeLink, unsubscribe } + }); + } public string Welcome(string home, string logoLink, string title, string body, string unsubscribe, string articles) { return Process("welcome", new Dictionary { @@ -77,15 +96,7 @@ public enum Constants { FileSystem.GetEmailTemplate("default", DefaultTemplates["default"]); FileSystem.GetEmailTemplate("newsletter", DefaultTemplates["newsletter"]); FileSystem.GetEmailTemplate("welcome", DefaultTemplates["welcome"]); - FileSystem.GetPartialTemplateAsync("email-article", - """ -
-

{0}

- {1} -

{2}...

- Link -
- """); + FileSystem.GetPartialTemplate("email-article", DefaultPartials["email-article"]); } public string ApplyTokens(string template, Func replacer) { @@ -97,6 +108,17 @@ public enum Constants { DefaultTemplates.TryGetValue(templateName, out string? s) ? s : null) ?? throw new ApplicationException("Failed to retrieve mail template " + templateName + "."); } + public async ValueTask GetPartialAsync(string partialName) { + return await FileSystem.GetPartialTemplateAsync(partialName, + DefaultTemplates.TryGetValue(partialName, out string? s) ? s : null) + ?? throw new ApplicationException("Failed to retrieve mail template " + partialName + "."); + } + + public async ValueTask GetTemplateAsync(string templateName) { + return await FileSystem.GetEmailTemplateAsync(templateName, + DefaultTemplates.TryGetValue(templateName, out string? s) ? s : null) + ?? throw new ApplicationException("Failed to retrieve mail template " + templateName + "."); + } public string CompileTemplate(string template, string templateName = "unknown") { var options = new MjmlOptions { Beautify = false }; @@ -116,6 +138,12 @@ public enum Constants { data.TryGetValue(Enum.Parse(token, true), out object? v) ? v?.ToString() : null); return CompileTemplate(template, templateName); } + + public async ValueTask ProcessAsync(string templateName, Dictionary data) { + string template = ApplyTokens(await GetTemplateAsync(templateName), token => + data.TryGetValue(Enum.Parse(token, true), out object? v) ? v?.ToString() : null); + return CompileTemplate(template, templateName); + } [GeneratedRegex(@"(\[\[.*?\]\])", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant)] @@ -266,4 +294,19 @@ public enum Constants { """ } }; + + private Dictionary DefaultPartials { get; } = new() + { + { + "email-article", + """ +
+

{0}

+ {1} +

{2}...

+ Link +
+ """ + } + }; } \ No newline at end of file diff --git a/Wave/Services/FileSystemService.cs b/Wave/Services/FileSystemService.cs index 745a03e..e1720c2 100644 --- a/Wave/Services/FileSystemService.cs +++ b/Wave/Services/FileSystemService.cs @@ -7,7 +7,7 @@ public class FileSystemService(ILogger logger) { private ILogger Logger { get; } = logger; - public Task GetEmailTemplateAsync(string name, string? defaultTemplate = null) { + public ValueTask GetEmailTemplateAsync(string name, string? defaultTemplate = null) { string path = Path.Combine(ConfigurationDirectory, "templates", "email", name + ".mjml"); return GetFileContentAsync(path, defaultTemplate); } @@ -17,7 +17,7 @@ public class FileSystemService(ILogger logger) { return GetFileContent(path, defaultTemplate); } - public Task GetPartialTemplateAsync(string name, string? defaultTemplate = null) { + public ValueTask GetPartialTemplateAsync(string name, string? defaultTemplate = null) { string path = Path.Combine(ConfigurationDirectory, "templates", "partials", name + ".html"); return GetFileContentAsync(path, defaultTemplate); } @@ -47,7 +47,7 @@ public class FileSystemService(ILogger logger) { return defaultContent; } } - private async Task GetFileContentAsync(string path, string? defaultContent = null) { + private async ValueTask GetFileContentAsync(string path, string? defaultContent = null) { if (!File.Exists(path)) { try { Directory.CreateDirectory(Path.GetDirectoryName(path)!); diff --git a/Wave/Services/IAdvancedEmailSender.cs b/Wave/Services/IAdvancedEmailSender.cs index 354e051..afee6a2 100644 --- a/Wave/Services/IAdvancedEmailSender.cs +++ b/Wave/Services/IAdvancedEmailSender.cs @@ -3,6 +3,7 @@ namespace Wave.Services; +[Obsolete] public interface IAdvancedEmailSender : IEmailSender { Task SendEmailAsync(string email, string? name, string subject, string htmlMessage); Task SendDefaultMailAsync(string receiverMail, string? receiverName, string subject, string title, string bodyHtml); diff --git a/Wave/Services/IEmail.cs b/Wave/Services/IEmail.cs new file mode 100644 index 0000000..dd790a8 --- /dev/null +++ b/Wave/Services/IEmail.cs @@ -0,0 +1,12 @@ +using System.Collections.Frozen; + +namespace Wave.Services; + +public interface IEmail { + string ReceiverEmail { get; } + string? ReceiverName { get; } + string Subject { get; } + string Title { get; } + string ContentHtml { get; } + FrozenDictionary Headers { get; } +} \ No newline at end of file diff --git a/Wave/Services/IEmailService.cs b/Wave/Services/IEmailService.cs new file mode 100644 index 0000000..cd4aceb --- /dev/null +++ b/Wave/Services/IEmailService.cs @@ -0,0 +1,8 @@ +namespace Wave.Services; + +public interface IEmailService : IAsyncDisposable { + ValueTask Connect(CancellationToken cancellation); + ValueTask Disconnect(CancellationToken cancellation); + + ValueTask SendEmailAsync(IEmail email); +} \ No newline at end of file diff --git a/Wave/Services/LiveEmailService.cs b/Wave/Services/LiveEmailService.cs new file mode 100644 index 0000000..581a5a9 --- /dev/null +++ b/Wave/Services/LiveEmailService.cs @@ -0,0 +1,74 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; +using Wave.Data; + +namespace Wave.Services; + +public class LiveEmailService(ILogger logger, IOptions configuration) : IEmailService { + private ILogger Logger { get; } = logger; + private SmtpConfiguration Configuration { get; } = configuration.Value; + + private SmtpClient? Client { get; set; } + + public async ValueTask DisposeAsync() { + GC.SuppressFinalize(this); + await Disconnect(CancellationToken.None); + } + + public async ValueTask Connect(CancellationToken cancellation) { + if (Client is not null) return; + + try { + Client = new SmtpClient(); + await Client.ConnectAsync(Configuration.Host, Configuration.Port, + Configuration.Ssl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.None, cancellation); + + if (!string.IsNullOrWhiteSpace(Configuration.Username)) { + await Client.AuthenticateAsync(Configuration.Username, Configuration.Password, cancellation); + } + } catch (Exception ex) { + Logger.LogError(ex, "Error connecting to SMTP Client."); + Client?.Dispose(); + throw; + } + } + + public async ValueTask Disconnect(CancellationToken cancellation) { + if (Client is null) return; + await Client.DisconnectAsync(true, cancellation); + Client.Dispose(); + Client = null; + } + + public async ValueTask SendEmailAsync(IEmail email) { + try { + if (Client is null) throw new ApplicationException("Not connected."); + + var message = new MimeMessage { + From = { new MailboxAddress(Configuration.SenderName, Configuration.SenderEmail) }, + To = { new MailboxAddress(email.ReceiverName, email.ReceiverEmail) }, + Subject = email.Subject + }; + var builder = new BodyBuilder { HtmlBody = email.ContentHtml }; + message.Body = builder.ToMessageBody(); + foreach ((string id, string value) in email.Headers) { + if (id == HeaderId.ListUnsubscribe.ToHeaderName()) { + message.Headers.Add(HeaderId.ListId, $""); + } + message.Headers.Add(id, value); + } + + try { + await Client.SendAsync(message); + Logger.LogInformation("Successfully send mail to {email} (subject: {subject}).", + email.ReceiverEmail, email.Subject); + } catch (Exception ex) { + throw new EmailNotSendException("Failed Email send.", ex); + } + } catch (Exception ex) { + Logger.LogError(ex, "Error sending E-Mail"); + } + } +} \ No newline at end of file diff --git a/Wave/Services/SmtpEmailSender.cs b/Wave/Services/SmtpEmailSender.cs index a9903cd..83b9efa 100644 --- a/Wave/Services/SmtpEmailSender.cs +++ b/Wave/Services/SmtpEmailSender.cs @@ -1,138 +1,65 @@ -using System.Net; -using System.Text; -using MailKit.Net.Smtp; -using MailKit.Security; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; -using MimeKit; +using Microsoft.AspNetCore.Identity; using Wave.Data; -using Wave.Utilities; -using Uri = System.Uri; namespace Wave.Services; -public class SmtpEmailSender(ILogger logger, IOptions config, IOptions customizations, EmailTemplateService templateService, FileSystemService fileSystemService) : IEmailSender, IAdvancedEmailSender { - private ILogger Logger { get; } = logger; - private SmtpConfiguration Configuration { get; } = config.Value; - private Customization Customizations { get; } = customizations.Value; - private FileSystemService FileSystemService { get; } = fileSystemService; - private EmailTemplateService TemplateService { get; } = templateService; +public class SmtpEmailSender(EmailFactory email, [FromKeyedServices("live")]IEmailService emailService) : IEmailSender, IAdvancedEmailSender, IAsyncDisposable { + private EmailFactory Email { get; } = email; + private IEmailService EmailService { get; } = emailService; + #region IEmailSenderAsync + public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => - SendEmailAsync(email, "Confirm your email", + SendEmailAsync(email, user.FullName, "Confirm your email", $"Please confirm your account by clicking here."); public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => - SendEmailAsync(email, "Reset your password", + SendEmailAsync(email, user.FullName, "Reset your password", $"Please reset your password by clicking here."); public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => - SendEmailAsync(email, "Reset your password", + SendEmailAsync(email, user.FullName, "Reset your password", $"Please reset your password using the following code: {resetCode}"); + #endregion + #region IEmailSender + public Task SendEmailAsync(string email, string subject, string htmlMessage) { return SendEmailAsync(email, null, subject, htmlMessage); } - public Task SendEmailAsync(string email, string? name, string subject, string htmlMessage) - => SendEmailAsync(email, name, subject, htmlMessage, []); - public async Task SendEmailAsync(string email, string? name, string subject, string htmlMessage, params Header[] header) { - try { - var message = new MimeMessage { - From = {new MailboxAddress(Configuration.SenderName, Configuration.SenderEmail)}, - To = { new MailboxAddress(name, email) }, - Subject = subject - }; + #endregion - var builder = new BodyBuilder { - HtmlBody = htmlMessage - }; - message.Body = builder.ToMessageBody(); - foreach (var h in header) { - message.Headers.Add(h); - } - - using var client = new SmtpClient(); - await client.ConnectAsync(Configuration.Host, Configuration.Port, - Configuration.Ssl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.None); - if (!string.IsNullOrWhiteSpace(Configuration.Username)) { - await client.AuthenticateAsync(Configuration.Username, Configuration.Password); - } - - try { - await client.SendAsync(message); - } catch (Exception ex) { - throw new EmailNotSendException("Failed Email send.", ex); - } - await client.DisconnectAsync(true); - Logger.LogInformation("Successfully send mail to {email} (subject: {subject}).", email, subject); - } catch (Exception ex) { - Logger.LogError(ex, "Error sending E-Mail"); - throw; - } + public async Task SendEmailAsync(string email, string? name, string subject, string htmlMessage) { + await EmailService.Connect(CancellationToken.None); + await EmailService.SendEmailAsync(await Email.CreateDefaultEmail(email, name, subject, subject, htmlMessage)); + await EmailService.Disconnect(CancellationToken.None); } - - public Task SendDefaultMailAsync(string receiverMail, string? receiverName, string subject, string title, string bodyHtml) { - var host = new Uri(string.IsNullOrWhiteSpace(Customizations.AppUrl) ? "" : Customizations.AppUrl); // TODO get link - string logo = !string.IsNullOrWhiteSpace(Customizations.LogoLink) - ? Customizations.LogoLink - : new Uri(host, "/img/logo.png").AbsoluteUri; - string body = TemplateService.Default(host.AbsoluteUri, logo, title, bodyHtml); - return SendEmailAsync(receiverMail, receiverName, subject, body); + + public async Task SendDefaultMailAsync(string receiverMail, string? receiverName, string subject, string title, string bodyHtml) { + await EmailService.Connect(CancellationToken.None); + var email = await Email.CreateDefaultEmail(receiverMail, receiverName, subject, title, bodyHtml); + await EmailService.SendEmailAsync(email); + await EmailService.Disconnect(CancellationToken.None); } public async Task SendSubscribedMailAsync(EmailSubscriber subscriber, string subject, string title, string bodyHtml, string browserUrl = "", string subscribedRole = "-1") { - (string user, string token) = await TemplateService - .CreateConfirmTokensAsync(subscriber.Id, "unsubscribe-"+subscribedRole, TimeSpan.FromDays(30)); - var host = new Uri(string.IsNullOrWhiteSpace(Customizations.AppUrl) ? "" : Customizations.AppUrl); // TODO get link - browserUrl = string.IsNullOrWhiteSpace(browserUrl) ? host.AbsoluteUri : browserUrl; // TODO find better solution - - string logo = !string.IsNullOrWhiteSpace(Customizations.LogoLink) - ? Customizations.LogoLink - : new Uri(host, "/img/logo.png").AbsoluteUri; - string unsubscribeLink = new Uri(host, - $"/Email/Unsubscribe?newsletter={subscribedRole}&user={WebUtility.UrlEncode(user)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri; - string body = TemplateService.Newsletter(host.AbsoluteUri, browserUrl, logo, title, bodyHtml, unsubscribeLink); - await SendEmailAsync(subscriber.Email, subscriber.Name, subject, body, - new Header(HeaderId.ListUnsubscribe, $"<{unsubscribeLink}>"), - new Header(HeaderId.ListUnsubscribePost, "One-Click")); + var email = await Email.CreateSubscribedEmail(subscriber, browserUrl, subject, title, bodyHtml, subscribedRole); + await EmailService.Connect(CancellationToken.None); + await EmailService.SendEmailAsync(email); // TODO use bulk service } public async Task SendWelcomeMailAsync(EmailSubscriber subscriber, string subject, string title, string bodyHtml, IEnumerable articles) { - (string user, string token) = await TemplateService - .CreateConfirmTokensAsync(subscriber.Id, "unsubscribe-welcome", TimeSpan.FromDays(30)); - var host = new Uri(string.IsNullOrWhiteSpace(Customizations.AppUrl) ? "" : Customizations.AppUrl); // TODO get link - - string articlePartial = (await FileSystemService.GetPartialTemplateAsync("email-article", """ -
-

{0}

- {1} -

{2}...

- Link -
- """))!; - var articlesHtml = new StringBuilder(""); - foreach (var n in articles) { - string articleLink = ArticleUtilities.GenerateArticleLink(n.Article, new Uri(Customizations.AppUrl, UriKind.Absolute)); - articlesHtml.AppendFormat( - articlePartial, - n.Article.Title, n.Article.Author.Name, n.Article.Body[..Math.Min(250, n.Article.Body.Length)], articleLink); - } - - string logo = !string.IsNullOrWhiteSpace(Customizations.LogoLink) - ? Customizations.LogoLink - : new Uri(host, "/img/logo.png").AbsoluteUri; - string unsubscribeLink = new Uri(host, - $"/Email/Unsubscribe?newsletter=welcome&user={WebUtility.UrlEncode(user)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri; - string body = TemplateService.Welcome(host.AbsoluteUri, logo, title, bodyHtml, unsubscribeLink, articlesHtml.ToString()); - await SendEmailAsync(subscriber.Email, subscriber.Name, subject, body, - new Header(HeaderId.ListUnsubscribe, $"<{unsubscribeLink}>"), - new Header(HeaderId.ListUnsubscribePost, "One-Click")); + var email = await Email.CreateWelcomeEmail(subscriber, articles, subject, title, bodyHtml); + await EmailService.Connect(CancellationToken.None); + await EmailService.SendEmailAsync(email); // TODO use bulk service } -} -public class EmailNotSendException(string message, Exception exception) : ApplicationException(message, exception); \ No newline at end of file + public async ValueTask DisposeAsync() { + await EmailService.DisposeAsync(); + } +} \ No newline at end of file diff --git a/Wave/Services/StaticEmail.cs b/Wave/Services/StaticEmail.cs new file mode 100644 index 0000000..0830285 --- /dev/null +++ b/Wave/Services/StaticEmail.cs @@ -0,0 +1,8 @@ +using System.Collections.Frozen; +using Microsoft.Extensions.Hosting; +using Mjml.Net.Helpers; + +namespace Wave.Services; + +public record StaticEmail(string ReceiverEmail, string? ReceiverName, string Subject, string Title, string ContentHtml, + FrozenDictionary Headers) : IEmail; \ No newline at end of file