Added new E-Mail Api

This commit is contained in:
Mia Rose Winter 2024-02-18 13:06:09 +01:00
parent 42137402e2
commit 37ffb49ca1
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
12 changed files with 265 additions and 118 deletions

View file

@ -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;
}

View file

@ -124,6 +124,7 @@
var smtpConfig = builder.Configuration.GetSection("Email:Smtp");
if (smtpConfig.Exists()) {
builder.Services.Configure<SmtpConfiguration>(smtpConfig);
builder.Services.AddKeyedScoped<IEmailService, LiveEmailService>("live");
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IAdvancedEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IEmailSender<ApplicationUser>, SmtpEmailSender>();
@ -132,6 +133,8 @@
logMessages.Add("No Email provider configured.");
}
builder.Services.AddScoped<EmailFactory>();
builder.Services.AddSingleton<IMessageDisplay, MessageService>();
builder.Services.AddSingleton<FileSystemService>();
builder.Services.AddSingleton<EmailTemplateService>();

View file

@ -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<Customization> customizations, EmailTemplateService templateService) {
private Customization Customizations { get; } = customizations.Value;
private EmailTemplateService TemplateService { get; } = templateService;
public async ValueTask<IEmail> 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<string, string>.Empty);
}
public async ValueTask<IEmail> 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<string, string>{
{HeaderId.ListUnsubscribe.ToHeaderName(), $"<{unsubscribeLink}>"},
{HeaderId.ListUnsubscribePost.ToHeaderName(), "One-Click"}
}.ToFrozenDictionary());
}
public async ValueTask<IEmail> CreateWelcomeEmail(EmailSubscriber subscriber, IEnumerable<EmailNewsletter> 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<string, string>{
{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<string> 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;
}
}

View file

@ -0,0 +1,3 @@
namespace Wave.Services;
public class EmailNotSendException(string message, Exception exception) : ApplicationException(message, exception);

View file

@ -51,6 +51,15 @@ public enum Constants {
});
}
public ValueTask<string> DefaultAsync(string home, string logoLink, string title, string body) {
return ProcessAsync("default", new Dictionary<Constants, object?> {
{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, object?> {
{ Constants.HomeLink, home },
@ -61,6 +70,16 @@ public enum Constants {
{ Constants.EmailUnsubscribeLink, unsubscribe }
});
}
public ValueTask<string> NewsletterAsync(string home, string browserUrl, string logoLink, string title, string body, string unsubscribe) {
return ProcessAsync("newsletter", new Dictionary<Constants, object?> {
{ 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<Constants, object?> {
@ -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",
"""
<div style="padding: 10px; background: #9f9f9f; color: #fff; margin-bottom: 10px; border-radius: 2px">
<h3 style="margin-top: 0;">{0}</h3>
<small>{1}</small>
<p>{2}...</p>
<a href="{3}">Link</a>
</div>
""");
FileSystem.GetPartialTemplate("email-article", DefaultPartials["email-article"]);
}
public string ApplyTokens(string template, Func<string, string?> 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<string> 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<string> 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 };
@ -117,6 +139,12 @@ public enum Constants {
return CompileTemplate(template, templateName);
}
public async ValueTask<string> ProcessAsync(string templateName, Dictionary<Constants, object?> data) {
string template = ApplyTokens(await GetTemplateAsync(templateName), token =>
data.TryGetValue(Enum.Parse<Constants>(token, true), out object? v) ? v?.ToString() : null);
return CompileTemplate(template, templateName);
}
[GeneratedRegex(@"(\[\[.*?\]\])",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant)]
private static partial Regex MyRegex();
@ -266,4 +294,19 @@ public enum Constants {
"""
}
};
private Dictionary<string, string> DefaultPartials { get; } = new()
{
{
"email-article",
"""
<div style="padding: 10px; background: #9f9f9f; color: #fff; margin-bottom: 10px; border-radius: 2px">
<h3 style="margin-top: 0;">{0}</h3>
<small>{1}</small>
<p>{2}...</p>
<a href="{3}">Link</a>
</div>
"""
}
};
}

View file

@ -7,7 +7,7 @@ public class FileSystemService(ILogger<FileSystemService> logger) {
private ILogger<FileSystemService> Logger { get; } = logger;
public Task<string?> GetEmailTemplateAsync(string name, string? defaultTemplate = null) {
public ValueTask<string?> 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<FileSystemService> logger) {
return GetFileContent(path, defaultTemplate);
}
public Task<string?> GetPartialTemplateAsync(string name, string? defaultTemplate = null) {
public ValueTask<string?> 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<FileSystemService> logger) {
return defaultContent;
}
}
private async Task<string?> GetFileContentAsync(string path, string? defaultContent = null) {
private async ValueTask<string?> GetFileContentAsync(string path, string? defaultContent = null) {
if (!File.Exists(path)) {
try {
Directory.CreateDirectory(Path.GetDirectoryName(path)!);

View file

@ -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);

12
Wave/Services/IEmail.cs Normal file
View file

@ -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<string, string> Headers { get; }
}

View file

@ -0,0 +1,8 @@
namespace Wave.Services;
public interface IEmailService : IAsyncDisposable {
ValueTask Connect(CancellationToken cancellation);
ValueTask Disconnect(CancellationToken cancellation);
ValueTask SendEmailAsync(IEmail email);
}

View file

@ -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<LiveEmailService> logger, IOptions<SmtpConfiguration> configuration) : IEmailService {
private ILogger<LiveEmailService> 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, $"<mailto:{Configuration.ServiceEmail ?? Configuration.SenderEmail}>");
}
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");
}
}
}

View file

@ -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<SmtpEmailSender> logger, IOptions<SmtpConfiguration> config, IOptions<Customization> customizations, EmailTemplateService templateService, FileSystemService fileSystemService) : IEmailSender<ApplicationUser>, IAdvancedEmailSender {
private ILogger<SmtpEmailSender> 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<ApplicationUser>, IAdvancedEmailSender, IAsyncDisposable {
private EmailFactory Email { get; } = email;
private IEmailService EmailService { get; } = emailService;
#region IEmailSenderAsync<ApplicationUser>
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 <a href='{confirmationLink}'>clicking here</a>.");
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 <a href='{resetLink}'>clicking here</a>.");
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);
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);
}
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 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<EmailNewsletter> 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", """
<div style="padding: 10px; background: #9f9f9f; color: #fff; margin-bottom: 10px; border-radius: 2px">
<h3 style="margin-top: 0;">{0}</h3>
<small>{1}</small>
<p>{2}...</p>
<a href="{3}">Link</a>
</div>
"""))!;
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);
var email = await Email.CreateWelcomeEmail(subscriber, articles, subject, title, bodyHtml);
await EmailService.Connect(CancellationToken.None);
await EmailService.SendEmailAsync(email); // TODO use bulk service
}
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"));
public async ValueTask DisposeAsync() {
await EmailService.DisposeAsync();
}
}
public class EmailNotSendException(string message, Exception exception) : ApplicationException(message, exception);

View file

@ -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<string, string> Headers) : IEmail;