Implemented customizable email templates

This commit is contained in:
Mia Rose Winter 2024-02-13 17:26:35 +01:00
parent ff3397cd90
commit 56f067d4f5
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
5 changed files with 175 additions and 60 deletions

View file

@ -1,11 +1,12 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
RUN mkdir -p /app/files && chown app /app/files
RUN mkdir -p /configuration && chown app /configuration
USER app
WORKDIR /app
RUN mkdir ./files && chown app ./files
VOLUME /app/files
VOLUME /configuration
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build

View file

@ -20,11 +20,11 @@
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("/configuration/config.json", true, false)
.AddYamlFile("/configuration/config.yml", true, false)
.AddTomlFile("/configuration/config.toml", true, false)
.AddIniFile( "/configuration/config.ini", true, false)
.AddXmlFile( "/configuration/config.xml", true, false)
.AddJsonFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.json"), true, false)
.AddYamlFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.yml"), true, false)
.AddTomlFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.toml"), true, false)
.AddIniFile( Path.Combine(FileSystemService.ConfigurationDirectory, "config.ini"), true, false)
.AddXmlFile( Path.Combine(FileSystemService.ConfigurationDirectory, "config.xml"), true, false)
.AddEnvironmentVariables("WAVE_");
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
@ -128,6 +128,7 @@
logMessages.Add("No Email provider configured.");
}
builder.Services.AddSingleton<FileSystemService>();
builder.Services.AddSingleton<EmailTemplateService>();
builder.Services.AddHostedService<EmailBackgroundWorker>();

View file

@ -4,7 +4,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MimeKit;
using Mjml.Net;
using Wave.Data;
using Wave.Utilities;
@ -33,6 +32,8 @@ public class EmailBackgroundWorker(ILogger<EmailBackgroundWorker> logger, IDbCon
waitTime, now.AddMinutes(waitTime).LocalDateTime.ToString("u"));
Timer = new Timer(DoWork, null, TimeSpan.FromMinutes(waitTime), TimeSpan.FromMinutes(15));
TemplateService.TryCreateDefaultTemplates();
return Task.CompletedTask;
}
@ -84,7 +85,10 @@ public class EmailBackgroundWorker(ILogger<EmailBackgroundWorker> logger, IDbCon
newsletter.Article, new Uri(Customizations.AppUrl, UriKind.Absolute));
string template = TemplateService.Process("newsletter", new Dictionary<EmailTemplateService.Constants, object?>{
{EmailTemplateService.Constants.BrowserLink, articleLink},
{EmailTemplateService.Constants.ContentLogo, "https://blog.winter-software.com/img/logo.png"},
{EmailTemplateService.Constants.ContentLogo, (!string.IsNullOrWhiteSpace(Customizations.LogoLink) ?
new Uri(Customizations.LogoLink) :
new Uri(host, "/img/logo.png"))
.AbsoluteUri},
{EmailTemplateService.Constants.ContentTitle, newsletter.Article.Title},
{EmailTemplateService.Constants.ContentBody, newsletter.Article.BodyHtml},
{EmailTemplateService.Constants.EmailUnsubscribeLink, "[[<__UNSUBSCRIBE__>]]"}

View file

@ -4,15 +4,16 @@
namespace Wave.Services;
public partial class EmailTemplateService(ILogger<EmailTemplateService> logger, IDistributedCache tokenCache) {
public partial class EmailTemplateService(ILogger<EmailTemplateService> logger, IDistributedCache tokenCache, FileSystemService fileSystem) {
public enum Constants {
BrowserLink, HomeLink, ContentLogo, ContentTitle, ContentBody, EmailUnsubscribeLink
}
private ILogger<EmailTemplateService> Logger { get; } = logger;
private IMjmlRenderer Renderer { get; } = new MjmlRenderer();
private MjmlRenderer Renderer { get; } = new();
private IDistributedCache TokenCache { get; } = tokenCache;
private FileSystemService FileSystem { get; } = fileSystem;
private Regex TokenMatcher { get; } = MyRegex();
public async Task<(string user, string token)> CreateConfirmTokensAsync(Guid subscriberId, string role = "subscribe", TimeSpan? expiration = null) {
@ -50,59 +51,20 @@ public enum Constants {
});
}
public void TryCreateDefaultTemplates() {
FileSystem.GetEmailTemplate("default", DefaultTemplates["default"]);
FileSystem.GetEmailTemplate("newsletter", DefaultTemplates["newsletter"]);
}
public string Process(string templateName, Dictionary<Constants, object?> data) {
var options = new MjmlOptions {
Beautify = false
};
string template = $"""
<mjml>
<mj-head>
<mj-preview/>
</mj-head>
<mj-body>
<mj-section>
<mj-column>
<mj-text align="center" font-size="13px" font-family="Ubuntu,Verdana">
<a href="[[{Constants.BrowserLink}]]">Read in Browser</a></mj-text>
</mj-column>
</mj-section>
<mj-section direction="rtl" padding-bottom="15px" padding-left="0px" padding-right="0px" padding-top="15px" padding="15px 0px 15px 0px">
<mj-column vertical-align="middle" width="33%">
<mj-image align="center" alt="" border-radius="0" border="none" container-background-color="transparent" height="auto" padding-bottom="5px" padding-left="5px" padding-right="5px" padding-top="5px" padding="5px 5px 5px 5px" href="[[{Constants.HomeLink}]]" src="[[{Constants.ContentLogo}]]"></mj-image>
</mj-column>
<mj-column vertical-align="middle" width="67%">
<mj-text font-size="13px" font-family="Ubuntu,Verdana">
<h1>[[{Constants.ContentTitle}]]</h1>
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-divider border-width="1px"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#55575d" font-size="13px" font-family="Ubuntu,Verdana">[[{Constants.ContentBody}]]</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-divider border-width="1px"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text align="center" font-size="13px" font-family="Ubuntu,Verdana">
<a href="[[{Constants.EmailUnsubscribeLink}]]">Unsubscribe</a>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
""";
string template = FileSystem.GetEmailTemplate(templateName,
DefaultTemplates.TryGetValue(templateName, out string? s) ? s : null)
?? throw new ApplicationException("Failed to retrieve mail template " + templateName + ".");
template = TokenMatcher.Replace(template, t =>
data.TryGetValue(Enum.Parse<Constants>(t.Value[2..^2], true), out object? v) ?
v?.ToString() ?? "" :
@ -121,4 +83,96 @@ public enum Constants {
[GeneratedRegex(@"(\[\[.*?\]\])",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant)]
private static partial Regex MyRegex();
private Dictionary<string, string> DefaultTemplates { get; } = new() {
{
"default",
$"""
<mjml>
<mj-head>
<mj-preview />
</mj-head>
<mj-body>
<mj-section direction="rtl" padding-bottom="5px" padding-left="0px" padding-right="0px" padding-top="15px" padding="15px 0px 5px 0px">
<mj-column vertical-align="middle" width="33%">
<mj-image align="center" alt="" border-radius="0" border="none" container-background-color="transparent" height="auto" padding-bottom="5px" padding-left="5px" padding-right="5px" padding-top="5px" padding="5px 5px 5px 5px" href="[[{Constants.HomeLink}]]" src="[[{Constants.ContentLogo}]]"></mj-image>
</mj-column>
<mj-column vertical-align="middle" width="67%">
<mj-text font-size="13px" font-family="Ubuntu,Verdana">
<h1>[[{Constants.ContentTitle}]]</h1>
</mj-text>
</mj-column>
</mj-section>
<mj-section padding-top="5px" padding-bottom="5px" padding="5px 0 5px 0">
<mj-column>
<mj-divider border-color="#9f9f9f" border-width="1px"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#55575d" font-size="13px" font-family="Ubuntu,Verdana">[[{Constants.ContentBody}]]</mj-text>
</mj-column>
</mj-section>
<mj-section padding-top="5px" padding-bottom="5px" padding="5px 0 5px 0">
<mj-column>
<mj-divider border-color="#9f9f9f" border-width="1px"></mj-divider>
</mj-column>
</mj-section>
</mj-body>
</mjml>
"""
},
{
"newsletter",
$"""
<mjml>
<mj-head>
<mj-preview />
</mj-head>
<mj-body>
<mj-section>
<mj-column>
<mj-text align="center" font-size="13px" font-family="Ubuntu,Verdana">
<a href="[[{Constants.BrowserLink}]]">Read in Browser</a>
</mj-text>
</mj-column>
</mj-section>
<mj-section direction="rtl" padding-bottom="5px" padding-left="0px" padding-right="0px" padding-top="15px" padding="15px 0px 5px 0px">
<mj-column vertical-align="middle" width="33%">
<mj-image align="center" alt="" border-radius="0" border="none" container-background-color="transparent" height="auto" padding-bottom="5px" padding-left="5px" padding-right="5px" padding-top="5px" padding="5px 5px 5px 5px" href="[[{Constants.HomeLink}]]" src="[[{Constants.ContentLogo}]]"></mj-image>
</mj-column>
<mj-column vertical-align="middle" width="67%">
<mj-text font-size="13px" font-family="Ubuntu,Verdana">
<h1>[[{Constants.ContentTitle}]]</h1>
</mj-text>
</mj-column>
</mj-section>
<mj-section padding-top="5px" padding-bottom="5px" padding="5px 0 5px 0">
<mj-column>
<mj-divider border-color="#9f9f9f" border-width="1px"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#55575d" font-size="13px" font-family="Ubuntu,Verdana">[[{Constants.ContentBody}]]</mj-text>
</mj-column>
</mj-section>
<mj-section padding-top="5px" padding-bottom="5px" padding="5px 0 5px 0">
<mj-column>
<mj-divider border-color="#9f9f9f" border-width="1px"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text align="center" font-size="13px" font-family="Ubuntu,Verdana">
<a href="[[{Constants.EmailUnsubscribeLink}]]">Unsubscribe</a>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
"""
}
};
}

View file

@ -0,0 +1,55 @@
using System.Text;
namespace Wave.Services;
public class FileSystemService(ILogger<FileSystemService> logger) {
public const string ConfigurationDirectory = "/configuration";
private ILogger<FileSystemService> Logger { get; } = logger;
public async Task<string?> GetEmailTemplateAsync(string name, string? defaultTemplate = null) {
string path = Path.Combine(ConfigurationDirectory, "templates", "email", name + ".mjml");
if (!File.Exists(path)) {
try {
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (!string.IsNullOrWhiteSpace(defaultTemplate)) {
await File.WriteAllTextAsync(path, defaultTemplate, Encoding.UTF8);
}
} catch (Exception ex) {
Logger.LogError(ex, "File system access failed trying write default E-Mail template '{template}'", name);
return defaultTemplate;
}
}
try {
return await File.ReadAllTextAsync(path, Encoding.UTF8);
} catch (Exception ex) {
Logger.LogError(ex, "File system access failed trying to retrieve E-Mail template '{template}'", name);
return defaultTemplate;
}
}
public string? GetEmailTemplate(string name, string? defaultTemplate = null) {
string path = Path.Combine(ConfigurationDirectory, "templates", "email", name + ".mjml");
if (!File.Exists(path)) {
try {
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (!string.IsNullOrWhiteSpace(defaultTemplate)) {
File.WriteAllText(path, defaultTemplate, Encoding.UTF8);
}
} catch (Exception ex) {
Logger.LogError(ex, "File system access failed trying write default E-Mail template '{template}'", name);
return defaultTemplate;
}
}
try {
return File.ReadAllText(path, Encoding.UTF8);
} catch (Exception ex) {
Logger.LogError(ex, "File system access failed trying to retrieve E-Mail template '{template}'", name);
return defaultTemplate;
}
}
}