Implemented customizable email templates
This commit is contained in:
parent
ff3397cd90
commit
56f067d4f5
|
@ -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.
|
#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
|
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
|
USER app
|
||||||
WORKDIR /app
|
|
||||||
RUN mkdir ./files && chown app ./files
|
|
||||||
VOLUME /app/files
|
VOLUME /app/files
|
||||||
VOLUME /configuration
|
VOLUME /configuration
|
||||||
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
|
|
@ -20,11 +20,11 @@
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Configuration
|
builder.Configuration
|
||||||
.AddJsonFile("/configuration/config.json", true, false)
|
.AddJsonFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.json"), true, false)
|
||||||
.AddYamlFile("/configuration/config.yml", true, false)
|
.AddYamlFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.yml"), true, false)
|
||||||
.AddTomlFile("/configuration/config.toml", true, false)
|
.AddTomlFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.toml"), true, false)
|
||||||
.AddIniFile( "/configuration/config.ini", true, false)
|
.AddIniFile( Path.Combine(FileSystemService.ConfigurationDirectory, "config.ini"), true, false)
|
||||||
.AddXmlFile( "/configuration/config.xml", true, false)
|
.AddXmlFile( Path.Combine(FileSystemService.ConfigurationDirectory, "config.xml"), true, false)
|
||||||
.AddEnvironmentVariables("WAVE_");
|
.AddEnvironmentVariables("WAVE_");
|
||||||
|
|
||||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||||
|
@ -128,6 +128,7 @@
|
||||||
logMessages.Add("No Email provider configured.");
|
logMessages.Add("No Email provider configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<FileSystemService>();
|
||||||
builder.Services.AddSingleton<EmailTemplateService>();
|
builder.Services.AddSingleton<EmailTemplateService>();
|
||||||
builder.Services.AddHostedService<EmailBackgroundWorker>();
|
builder.Services.AddHostedService<EmailBackgroundWorker>();
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
using Mjml.Net;
|
|
||||||
using Wave.Data;
|
using Wave.Data;
|
||||||
using Wave.Utilities;
|
using Wave.Utilities;
|
||||||
|
|
||||||
|
@ -33,6 +32,8 @@ public class EmailBackgroundWorker(ILogger<EmailBackgroundWorker> logger, IDbCon
|
||||||
waitTime, now.AddMinutes(waitTime).LocalDateTime.ToString("u"));
|
waitTime, now.AddMinutes(waitTime).LocalDateTime.ToString("u"));
|
||||||
Timer = new Timer(DoWork, null, TimeSpan.FromMinutes(waitTime), TimeSpan.FromMinutes(15));
|
Timer = new Timer(DoWork, null, TimeSpan.FromMinutes(waitTime), TimeSpan.FromMinutes(15));
|
||||||
|
|
||||||
|
TemplateService.TryCreateDefaultTemplates();
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,7 +85,10 @@ public class EmailBackgroundWorker(ILogger<EmailBackgroundWorker> logger, IDbCon
|
||||||
newsletter.Article, new Uri(Customizations.AppUrl, UriKind.Absolute));
|
newsletter.Article, new Uri(Customizations.AppUrl, UriKind.Absolute));
|
||||||
string template = TemplateService.Process("newsletter", new Dictionary<EmailTemplateService.Constants, object?>{
|
string template = TemplateService.Process("newsletter", new Dictionary<EmailTemplateService.Constants, object?>{
|
||||||
{EmailTemplateService.Constants.BrowserLink, articleLink},
|
{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.ContentTitle, newsletter.Article.Title},
|
||||||
{EmailTemplateService.Constants.ContentBody, newsletter.Article.BodyHtml},
|
{EmailTemplateService.Constants.ContentBody, newsletter.Article.BodyHtml},
|
||||||
{EmailTemplateService.Constants.EmailUnsubscribeLink, "[[<__UNSUBSCRIBE__>]]"}
|
{EmailTemplateService.Constants.EmailUnsubscribeLink, "[[<__UNSUBSCRIBE__>]]"}
|
||||||
|
|
|
@ -4,15 +4,16 @@
|
||||||
|
|
||||||
namespace Wave.Services;
|
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 {
|
public enum Constants {
|
||||||
BrowserLink, HomeLink, ContentLogo, ContentTitle, ContentBody, EmailUnsubscribeLink
|
BrowserLink, HomeLink, ContentLogo, ContentTitle, ContentBody, EmailUnsubscribeLink
|
||||||
}
|
}
|
||||||
|
|
||||||
private ILogger<EmailTemplateService> Logger { get; } = logger;
|
private ILogger<EmailTemplateService> Logger { get; } = logger;
|
||||||
private IMjmlRenderer Renderer { get; } = new MjmlRenderer();
|
private MjmlRenderer Renderer { get; } = new();
|
||||||
private IDistributedCache TokenCache { get; } = tokenCache;
|
private IDistributedCache TokenCache { get; } = tokenCache;
|
||||||
|
private FileSystemService FileSystem { get; } = fileSystem;
|
||||||
|
|
||||||
private Regex TokenMatcher { get; } = MyRegex();
|
private Regex TokenMatcher { get; } = MyRegex();
|
||||||
|
|
||||||
public async Task<(string user, string token)> CreateConfirmTokensAsync(Guid subscriberId, string role = "subscribe", TimeSpan? expiration = null) {
|
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) {
|
public string Process(string templateName, Dictionary<Constants, object?> data) {
|
||||||
var options = new MjmlOptions {
|
var options = new MjmlOptions {
|
||||||
Beautify = false
|
Beautify = false
|
||||||
};
|
};
|
||||||
|
|
||||||
string template = $"""
|
string template = FileSystem.GetEmailTemplate(templateName,
|
||||||
<mjml>
|
DefaultTemplates.TryGetValue(templateName, out string? s) ? s : null)
|
||||||
<mj-head>
|
?? throw new ApplicationException("Failed to retrieve mail template " + templateName + ".");
|
||||||
<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>
|
|
||||||
""";
|
|
||||||
|
|
||||||
template = TokenMatcher.Replace(template, t =>
|
template = TokenMatcher.Replace(template, t =>
|
||||||
data.TryGetValue(Enum.Parse<Constants>(t.Value[2..^2], true), out object? v) ?
|
data.TryGetValue(Enum.Parse<Constants>(t.Value[2..^2], true), out object? v) ?
|
||||||
v?.ToString() ?? "" :
|
v?.ToString() ?? "" :
|
||||||
|
@ -121,4 +83,96 @@ public enum Constants {
|
||||||
[GeneratedRegex(@"(\[\[.*?\]\])",
|
[GeneratedRegex(@"(\[\[.*?\]\])",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant)]
|
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant)]
|
||||||
private static partial Regex MyRegex();
|
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>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
55
Wave/Services/FileSystemService.cs
Normal file
55
Wave/Services/FileSystemService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue