diff --git a/.dockerignore b/.dockerignore index 48b4bf27..e06e0181 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ **/bin/ **/obj/ **/out/ +**/node_modules/ +API/SmtpTemplates/dist/ # files **/appsettings.Development.json diff --git a/API/API.csproj b/API/API.csproj index 3d15c1f5..c61e25fb 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -8,7 +8,7 @@ - + @@ -20,15 +20,19 @@ - - - Always - - + + + + PreserveNewest + PreserveNewest + + + diff --git a/API/Options/MailJetOptions.cs b/API/Options/MailJetOptions.cs index 10074f4d..44691434 100644 --- a/API/Options/MailJetOptions.cs +++ b/API/Options/MailJetOptions.cs @@ -12,31 +12,6 @@ public sealed class MailJetOptions [Required(AllowEmptyStrings = false)] public required string Secret { get; init; } - - [Required] - [ValidateObjectMembers] - public required MailjetTemplateOptions Template { get; init; } - - public sealed class MailjetTemplateOptions - { - [Required] - public ulong ActivateAccount { get; set; } - - [Required] - public required ulong PasswordReset { get; init; } - - [Required] - public required ulong PasswordResetComplete { get; init; } - - [Required] - public required ulong VerifyEmail { get; init; } - - [Required] - public required ulong VerifyEmailComplete { get; init; } - - [Required] - public required ulong EmailChangeNotice { get; init; } - } } [OptionsValidator] diff --git a/API/Services/Email/EmailServiceExtension.cs b/API/Services/Email/EmailServiceExtension.cs index f45913bd..40c0cc98 100644 --- a/API/Services/Email/EmailServiceExtension.cs +++ b/API/Services/Email/EmailServiceExtension.cs @@ -10,7 +10,7 @@ public static WebApplicationBuilder AddEmailService(this WebApplicationBuilder b { var mailOptions = builder.Configuration.GetRequiredSection(MailOptions.SectionName).Get() ?? throw new NullReferenceException(); - if(mailOptions.Type == MailOptions.MailType.None) + if (mailOptions.Type == MailOptions.MailType.None) { builder.Services.AddSingleton(); // Add a dummy email service return builder; @@ -18,6 +18,7 @@ public static WebApplicationBuilder AddEmailService(this WebApplicationBuilder b // Add sender contact configuration builder.AddSenderContactConfiguration(); + builder.AddEmailServiceTemplates(); switch (mailOptions.Type) { @@ -39,4 +40,16 @@ private static WebApplicationBuilder AddSenderContactConfiguration(this WebAppli builder.Services.AddSingleton(builder.Configuration.GetRequiredSection(MailOptions.SenderSectionName).Get() ?? throw new NullReferenceException()); return builder; } + + private static WebApplicationBuilder AddEmailServiceTemplates(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(new EmailServiceTemplates + { + AccountActivation = EmailTemplate.ParseFromFileThrow("SmtpTemplates/AccountActivation.liquid").Result, + PasswordReset = EmailTemplate.ParseFromFileThrow("SmtpTemplates/PasswordReset.liquid").Result, + EmailVerification = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailVerification.liquid").Result, + EmailChangeNotice = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailChangeNotice.liquid").Result, + }); + return builder; + } } diff --git a/API/Services/Email/EmailServiceTemplates.cs b/API/Services/Email/EmailServiceTemplates.cs new file mode 100644 index 00000000..80b2287b --- /dev/null +++ b/API/Services/Email/EmailServiceTemplates.cs @@ -0,0 +1,9 @@ +namespace OpenShock.API.Services.Email; + +public sealed class EmailServiceTemplates +{ + public required EmailTemplate AccountActivation { get; init; } + public required EmailTemplate PasswordReset { get; init; } + public required EmailTemplate EmailVerification { get; init; } + public required EmailTemplate EmailChangeNotice { get; init; } +} diff --git a/API/Services/Email/EmailTemplate.cs b/API/Services/Email/EmailTemplate.cs new file mode 100644 index 00000000..957b1508 --- /dev/null +++ b/API/Services/Email/EmailTemplate.cs @@ -0,0 +1,56 @@ +using System.Text.Encodings.Web; +using Fluid; +using OpenShock.API.Services.Email.Mailjet.Mail; + +namespace OpenShock.API.Services.Email; + +public sealed class EmailTemplate +{ + private static readonly FluidParser Parser = new(); + private static readonly TemplateOptions Options = CreateOptions(); + + private static TemplateOptions CreateOptions() + { + var options = new TemplateOptions(); + options.MemberAccessStrategy.Register(); + return options; + } + + public required IFluidTemplate Subject { get; init; } + public required IFluidTemplate Body { get; init; } + + public async Task<(string Subject, string HtmlBody)> RenderAsync(T data) + { + var context = new TemplateContext(data, Options); + var subject = await Subject.RenderAsync(context); + var htmlBody = await Body.RenderAsync(context, HtmlEncoder.Default); + return (subject, htmlBody); + } + + public static async Task ParseFromFileThrow(string filePath) + { + var result = await ParseFromFile(filePath); + if (result.IsT0) return result.AsT0; + throw new InvalidDataException(result.AsT1); + } + + public static Task> ParseFromFile(string filePath) => + ParseFromFile(File.OpenRead(filePath)); + + public static async Task> ParseFromFile(FileStream fileStream) + { + using var streamReader = new StreamReader(fileStream); + var subject = await streamReader.ReadLineAsync(); + if (subject is null) throw new InvalidDataException("Subject is null"); + + if (!Parser.TryParse(subject, out var subjectTemplate, out var errorSubject)) return errorSubject; + var body = await streamReader.ReadToEndAsync(); + if (!Parser.TryParse(body, out var bodyTemplate, out var errorBody)) return errorBody; + + return new EmailTemplate + { + Subject = subjectTemplate, + Body = bodyTemplate + }; + } +} diff --git a/API/Services/Email/Mailjet/Mail/MailBase.cs b/API/Services/Email/Mailjet/Mail/MailBase.cs index ef927eab..8d185c81 100644 --- a/API/Services/Email/Mailjet/Mail/MailBase.cs +++ b/API/Services/Email/Mailjet/Mail/MailBase.cs @@ -1,13 +1,9 @@ -using System.Text.Json.Serialization; -using OpenShock.API.Utils; +namespace OpenShock.API.Services.Email.Mailjet.Mail; -namespace OpenShock.API.Services.Email.Mailjet.Mail; - -[JsonConverter(typeof(OneWayPolymorphicJsonConverter))] -public abstract class MailBase +public sealed class DirectMail { - public required Contact From { get; set; } + public required Contact From { get; set; } public required Contact[] To { get; set; } public required string Subject { get; set; } - public Dictionary? Variables { get; set; } + public string? HTMLPart { get; set; } } \ No newline at end of file diff --git a/API/Services/Email/Mailjet/Mail/MailsWrap.cs b/API/Services/Email/Mailjet/Mail/MailsWrap.cs index 23f646d0..3d6296e2 100644 --- a/API/Services/Email/Mailjet/Mail/MailsWrap.cs +++ b/API/Services/Email/Mailjet/Mail/MailsWrap.cs @@ -2,5 +2,5 @@ public sealed class MailsWrap { - public required MailBase[] Messages { get; set; } -} \ No newline at end of file + public required DirectMail[] Messages { get; set; } +} diff --git a/API/Services/Email/Mailjet/Mail/TemplateMail.cs b/API/Services/Email/Mailjet/Mail/TemplateMail.cs deleted file mode 100644 index 4059c5aa..00000000 --- a/API/Services/Email/Mailjet/Mail/TemplateMail.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenShock.API.Services.Email.Mailjet.Mail; - -public sealed class TemplateMail : MailBase -{ - public bool TemplateLanguage { get; set; } = true; - public required ulong TemplateId { get; set; } - public new required Dictionary Variables { get; set; } = new(); -} \ No newline at end of file diff --git a/API/Services/Email/Mailjet/MailjetEmailService.cs b/API/Services/Email/Mailjet/MailjetEmailService.cs index e64c3368..90063fe0 100644 --- a/API/Services/Email/Mailjet/MailjetEmailService.cs +++ b/API/Services/Email/Mailjet/MailjetEmailService.cs @@ -10,26 +10,19 @@ namespace OpenShock.API.Services.Email.Mailjet; public sealed class MailjetEmailService : IEmailService, IDisposable { private readonly HttpClient _httpClient; - private readonly MailJetOptions _options; + private readonly EmailServiceTemplates _templates; private readonly MailOptions.MailSenderContact _sender; private readonly ILogger _logger; - /// - /// DI Constructor - /// - /// - /// - /// - /// public MailjetEmailService( HttpClient httpClient, - MailJetOptions options, + EmailServiceTemplates templates, MailOptions.MailSenderContact sender, ILogger logger ) { _httpClient = httpClient; - _options = options; + _templates = templates; _sender = sender; _logger = logger; } @@ -38,79 +31,41 @@ ILogger logger public async Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default) { - await SendMail(new TemplateMail - { - From = _sender, - Subject = "Activate your account", - To = [to], - TemplateId = _options.Template.ActivateAccount, - Variables = new Dictionary - { - {"link", activationLink.ToString() }, - } - }, cancellationToken); + var (subject, htmlBody) = await _templates.AccountActivation.RenderAsync(new { To = to, ActivationLink = activationLink }); + await SendMail(to, subject, htmlBody, cancellationToken); } /// public async Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default) { - await SendMail(new TemplateMail - { - From = _sender, - Subject = "Password reset request", - To = [to], - TemplateId = _options.Template.PasswordReset, - Variables = new Dictionary - { - {"link", resetLink.ToString() }, - } - }, cancellationToken); + var (subject, htmlBody) = await _templates.PasswordReset.RenderAsync(new { To = to, ResetLink = resetLink }); + await SendMail(to, subject, htmlBody, cancellationToken); } /// public async Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default) { - await SendMail(new TemplateMail - { - From = _sender, - Subject = "Verify your Email Address", - To = [to], - TemplateId = _options.Template.VerifyEmail, - Variables = new Dictionary - { - {"link", verificationLink.ToString() }, - } - }, cancellationToken); + var (subject, htmlBody) = await _templates.EmailVerification.RenderAsync(new { To = to, VerifyLink = verificationLink }); + await SendMail(to, subject, htmlBody, cancellationToken); } /// public async Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) { - await SendMail(new TemplateMail - { - From = _sender, - Subject = "Your OpenShock email is being changed", - To = [to], - TemplateId = _options.Template.EmailChangeNotice, - Variables = new Dictionary - { - { "newEmail", newEmail }, - } - }, cancellationToken); + var (subject, htmlBody) = await _templates.EmailChangeNotice.RenderAsync(new { To = to, NewEmail = newEmail }); + await SendMail(to, subject, htmlBody, cancellationToken); } #endregion - private Task SendMail(MailBase templateMail, CancellationToken cancellationToken = default) => SendMails([templateMail], cancellationToken); + private Task SendMail(Contact to, string subject, string htmlBody, CancellationToken cancellationToken = default) + => SendMails([new DirectMail { From = _sender, To = [to], Subject = subject, HTMLPart = htmlBody }], cancellationToken); - private async Task SendMails(MailBase[] mails, CancellationToken cancellationToken = default) + private async Task SendMails(DirectMail[] mails, CancellationToken cancellationToken = default) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Sending mails {@Mails}", mails); - var json = JsonSerializer.Serialize(new MailsWrap - { - Messages = mails - }, JsonOptions.Default); + var json = JsonSerializer.Serialize(new MailsWrap { Messages = mails }, JsonOptions.Default); var response = await _httpClient.PostAsync("send", new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json), cancellationToken); @@ -126,4 +81,4 @@ public void Dispose() { _httpClient.Dispose(); } -} \ No newline at end of file +} diff --git a/API/Services/Email/Smtp/SmtpEmailService.cs b/API/Services/Email/Smtp/SmtpEmailService.cs index ee2ab771..bcce832b 100644 --- a/API/Services/Email/Smtp/SmtpEmailService.cs +++ b/API/Services/Email/Smtp/SmtpEmailService.cs @@ -1,6 +1,4 @@ -using System.Text.Encodings.Web; -using Fluid; -using MailKit.Net.Smtp; +using MailKit.Net.Smtp; using MimeKit; using MimeKit.Text; using OpenShock.API.Options; @@ -10,23 +8,13 @@ namespace OpenShock.API.Services.Email.Smtp; public sealed class SmtpEmailService : IEmailService { - private readonly SmtpServiceTemplates _templates; + private readonly EmailServiceTemplates _templates; private readonly SmtpOptions _options; private readonly MailboxAddress _sender; private readonly ILogger _logger; - private readonly TemplateOptions _templateOptions; - - - /// - /// DI Constructor - /// - /// - /// - /// - /// public SmtpEmailService( - SmtpServiceTemplates templates, + EmailServiceTemplates templates, SmtpOptions options, MailOptions.MailSenderContact sender, ILogger logger @@ -36,10 +24,6 @@ ILogger logger _options = options; _sender = sender.ToMailAddress(); _logger = logger; - - // This class is will be registered as a singleton, static members are not needed - _templateOptions = new TemplateOptions(); - _templateOptions.MemberAccessStrategy.Register(); } public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default) @@ -57,19 +41,10 @@ public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken canc public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) => SendMail(to, _templates.EmailChangeNotice, new { To = to, NewEmail = newEmail }, cancellationToken); - - private async Task SendMail(Contact to, SmtpTemplate template, T data, - CancellationToken cancellationToken = default) + private async Task SendMail(Contact to, EmailTemplate template, T data, CancellationToken cancellationToken = default) { _logger.LogDebug("Sending email"); - var context = new TemplateContext(data, _templateOptions); - var subject = await template.Subject.RenderAsync(context); - - await using var buffer = new MemoryStream(); - await using (var textStreamWriter = new StreamWriter(buffer, leaveOpen: true)) - await template.Body.RenderAsync(textStreamWriter, HtmlEncoder.Default, context); - - buffer.Position = 0; + var (subject, htmlBody) = await template.RenderAsync(data); var message = new MimeMessage { @@ -77,10 +52,7 @@ private async Task SendMail(Contact to, SmtpTemplate template, T data, Sender = _sender, To = { to.ToMailAddress() }, Subject = subject, - Body = new TextPart(TextFormat.Html) - { - Content = new MimeContent(buffer) - } + Body = new TextPart(TextFormat.Html) { Text = htmlBody } }; _logger.LogTrace("Creating smtp client and connecting..."); diff --git a/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs b/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs index 21fa8a1a..a4be55f4 100644 --- a/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs +++ b/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs @@ -14,14 +14,6 @@ public static WebApplicationBuilder AddSmtpEmailService(this WebApplicationBuild builder.Services.AddSingleton, SmtpOptionsValidator>(); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); - builder.Services.AddSingleton(new SmtpServiceTemplates - { - AccountActivation = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/AccountActivation.liquid").Result, - PasswordReset = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/PasswordReset.liquid").Result, - EmailVerification = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/EmailVerification.liquid").Result, - EmailChangeNotice = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/EmailChangeNotice.liquid").Result - }); - builder.Services.AddSingleton(); return builder; diff --git a/API/Services/Email/Smtp/SmtpServiceTemplates.cs b/API/Services/Email/Smtp/SmtpServiceTemplates.cs deleted file mode 100644 index 675b8006..00000000 --- a/API/Services/Email/Smtp/SmtpServiceTemplates.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenShock.API.Services.Email.Smtp; - -public sealed class SmtpServiceTemplates -{ - public required SmtpTemplate AccountActivation { get; set; } - public required SmtpTemplate PasswordReset { get; set; } - public required SmtpTemplate EmailVerification { get; set; } - public required SmtpTemplate EmailChangeNotice { get; set; } -} \ No newline at end of file diff --git a/API/Services/Email/Smtp/SmtpTemplate.cs b/API/Services/Email/Smtp/SmtpTemplate.cs deleted file mode 100644 index e8e7dc32..00000000 --- a/API/Services/Email/Smtp/SmtpTemplate.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Fluid; - -namespace OpenShock.API.Services.Email.Smtp; - -public sealed class SmtpTemplate -{ - private static readonly FluidParser FluidParser = new FluidParser(); - - public required IFluidTemplate Subject { get; init; } - public required IFluidTemplate Body { get; init; } - - public static async Task ParseFromFileThrow(string filePath) - { - var result = await ParseFromFile(filePath); - if(result.IsT0) return result.AsT0; - throw new InvalidDataException(result.AsT1); - } - - public static Task> ParseFromFile(string filePath) => - ParseFromFile(File.OpenRead(filePath)); - - public static async Task> ParseFromFile(FileStream fileStream) - { - using var streamReader = new StreamReader(fileStream); - var subject = await streamReader.ReadLineAsync(); - if (subject is null) throw new InvalidDataException("Subject is null"); - - if (!FluidParser.TryParse(subject, out var subjectTemplate, out var errorSubject)) return errorSubject; - var body = await streamReader.ReadToEndAsync(); - if (!FluidParser.TryParse(body, out var bodyTemplate, out var errorBody)) return errorBody; - - return new SmtpTemplate - { - Subject = subjectTemplate, - Body = bodyTemplate - }; - } -} \ No newline at end of file diff --git a/API/SmtpTemplates/AccountActivation.liquid b/API/SmtpTemplates/AccountActivation.liquid index 9dda4044..99322b53 100644 --- a/API/SmtpTemplates/AccountActivation.liquid +++ b/API/SmtpTemplates/AccountActivation.liquid @@ -1,55 +1,179 @@ -Hi! Activate your account - - - - - - Password Reset - - - -
-

Active your account!

-

Hello {{ To.Name }},

-

Thanks for signing up! Please verify your email address by clicking on the link below.

- Activate Account -

If you did not sign up, you can safely ignore this email.

-

Thank you,
OpenShock Team

-
- - \ No newline at end of file +Activate your OpenShock account + + + + + + + + +
+ One last step: activate your new OpenShock account. +
+  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ +
+
+ + + + + + + +
+ + + + + + +
+ + + + + + +
+

+ OpenShock +

+
+ + + + + + +
+

+ Activate your account +

+

+ Hello + {{ To.Name }}, +

+

+ Thanks for signing up! Confirm this is your email + address to finish setting up your OpenShock + account. +

+ + + + + + +
+ Activate account +
+

+ If the button above doesn't work, copy and + paste this link into your browser: +

+ {{ ActivationLink }} +

+ If you did not sign up for OpenShock, you can + safely ignore this email. No account will be + created without confirmation. +

+

+ Thank you,
The OpenShock Team +

+
+ + + + + + +
+

+ OpenShock will never ask for your password, API + token, or any other credentials by email. If a + message appears to come from us and asks for + these, do not respond. +

+

+ Need help? Visit + openshock.app. +

+

+ © OpenShock. This is an automated message, please + do not reply. +

+
+
+
+ + + diff --git a/API/SmtpTemplates/EmailChangeNotice.liquid b/API/SmtpTemplates/EmailChangeNotice.liquid index ac096b64..b88948d5 100644 --- a/API/SmtpTemplates/EmailChangeNotice.liquid +++ b/API/SmtpTemplates/EmailChangeNotice.liquid @@ -1,51 +1,161 @@ Your OpenShock email is being changed - - - - - - Email change requested - - - -
-

Email change requested

-

Hello {{ To.Name }},

-

Someone requested that the email address on your OpenShock account be changed to {{ NewEmail }}.

-

The change is not yet applied. It will only take effect once the new address is verified via the link sent to it.

-

If this was you, no action is needed.

-

If this was not you, sign in immediately and change your password — your account may be compromised.

-

Thank you,
OpenShock Team

-
- + + + + + + + + +
+ A change to your OpenShock account email was requested. +
+  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ +
+
+ + + + + + + +
+ + + + + + +
+ + + + + + +
+

+ OpenShock +

+
+ + + + + + +
+

+ Email change requested +

+

+ Hello + {{ To.Name }}, +

+

+ Someone requested that the email address on your + OpenShock account be changed to + {{ NewEmail }}. +

+

+ The change is not yet applied. It + will only take effect once the new address is + verified via the link sent to it. +

+

+ If this was you, no further action is needed. +

+

+ If this was not you, sign in + immediately and change your password. Your account + may be compromised. You should also review active + sessions and API tokens. +

+

+ Thank you,
The OpenShock Team +

+
+ + + + + + +
+

+ OpenShock will never ask for your password, API + token, or any other credentials by email. If a + message appears to come from us and asks for + these, do not respond. +

+

+ Need help? Visit + openshock.app. +

+

+ © OpenShock. This is an automated message, please + do not reply. +

+
+
+
+ + diff --git a/API/SmtpTemplates/EmailVerification.liquid b/API/SmtpTemplates/EmailVerification.liquid index 4b8e0595..b5228aa1 100644 --- a/API/SmtpTemplates/EmailVerification.liquid +++ b/API/SmtpTemplates/EmailVerification.liquid @@ -1,55 +1,179 @@ -Hi! Verify your Email! - - - - - - Password Reset - - - -
-

Email verification

-

Hello {{ To.Name }},

-

Thanks for signing up! Please verify your email address by clicking on the link below.

- Verify Email -

If you did not sign up, you can safely ignore this email.

-

Thank you,
OpenShock Team

-
- - \ No newline at end of file +Verify your new OpenShock email address + + + + + + + + +
+ Confirm this email address to apply the change on your OpenShock account. +
+  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ +
+
+ + + + + + + +
+ + + + + + +
+ + + + + + +
+

+ OpenShock +

+
+ + + + + + +
+

+ Verify your email +

+

+ Hello + {{ To.Name }}, +

+

+ Please confirm this is your email address by + clicking the button below. The change will only + take effect once this address has been verified. +

+ + + + + + +
+ Verify email +
+

+ If the button above doesn't work, copy and + paste this link into your browser: +

+ {{ VerifyLink }} +

+ If you did not request this change, you can safely + ignore this email. Your account email will not be + updated. +

+

+ Thank you,
The OpenShock Team +

+
+ + + + + + +
+

+ OpenShock will never ask for your password, API + token, or any other credentials by email. If a + message appears to come from us and asks for + these, do not respond. +

+

+ Need help? Visit + openshock.app. +

+

+ © OpenShock. This is an automated message, please + do not reply. +

+
+
+
+ + + diff --git a/API/SmtpTemplates/PasswordReset.liquid b/API/SmtpTemplates/PasswordReset.liquid index 121ba441..f118c242 100644 --- a/API/SmtpTemplates/PasswordReset.liquid +++ b/API/SmtpTemplates/PasswordReset.liquid @@ -1,55 +1,182 @@ -Password reset request - - - - - - Password Reset - - - -
-

Password Reset

-

Hello {{ To.Name }},

-

We have received a request to reset the password for your account. Click the button below to reset your password:

- Reset Password -

If you did not request this change, you can safely ignore this email.

-

Thank you,
OpenShock Team

+Reset your OpenShock password + + + + + + + + +
+ Reset the password for your OpenShock account. +
+  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ +
- + + + + + + + +
+ + + + + + +
+ + + + + + +
+

+ OpenShock +

+
+ + + + + + +
+

+ Password reset +

+

+ Hello + {{ To.Name }}, +

+

+ We received a request to reset the password for + your OpenShock account. Click the button below to + choose a new one. +

+ + + + + + +
+ Reset password +
+

+ If the button above doesn't work, copy and + paste this link into your browser: +

+ {{ ResetLink }} +

+ This link is single-use and will expire shortly. + If you did not request a password reset, you can + safely ignore this email. Your password will + remain unchanged. If you receive these messages + repeatedly, sign in and review your account's + recent activity. +

+

+ Thank you,
The OpenShock Team +

+
+ + + + + + +
+

+ OpenShock will never ask for your password, API + token, or any other credentials by email. If a + message appears to come from us and asks for + these, do not respond. +

+

+ Need help? Visit + openshock.app. +

+

+ © OpenShock. This is an automated message, please + do not reply. +

+
+
+
+ + diff --git a/API/Utils/OneWayPolymorphicJsonConverter.cs b/API/Utils/OneWayPolymorphicJsonConverter.cs deleted file mode 100644 index a2de0e31..00000000 --- a/API/Utils/OneWayPolymorphicJsonConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace OpenShock.API.Utils; - -public sealed class OneWayPolymorphicJsonConverter : JsonConverter -{ - public override bool CanConvert(Type typeToConvert) - { - return typeof(T) == typeToConvert; //.IsAssignableFrom(typeToConvert); - } - - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - throw new NotSupportedException(); - - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => - JsonSerializer.Serialize(writer, value, value!.GetType()); -} \ No newline at end of file diff --git a/docker/API.Dockerfile b/docker/API.Dockerfile index 2e67a470..038bc18a 100644 --- a/docker/API.Dockerfile +++ b/docker/API.Dockerfile @@ -20,4 +20,4 @@ RUN apk update && apk add --no-cache openssl COPY --link --from=build-api /app . COPY docker/appsettings.API.json /app/appsettings.Container.json -ENTRYPOINT ["/bin/ash", "/entrypoint.sh", "OpenShock.API.dll"] \ No newline at end of file +ENTRYPOINT ["/bin/ash", "/entrypoint.sh", "OpenShock.API.dll"] diff --git a/email-templates/.gitignore b/email-templates/.gitignore new file mode 100644 index 00000000..f7786c25 --- /dev/null +++ b/email-templates/.gitignore @@ -0,0 +1,8 @@ +node_modules +.react-email +.next +out +.env +.env.local +*.log +.DS_Store diff --git a/email-templates/.prettierrc.json b/email-templates/.prettierrc.json new file mode 100644 index 00000000..544138be --- /dev/null +++ b/email-templates/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/email-templates/README.md b/email-templates/README.md new file mode 100644 index 00000000..9540f185 --- /dev/null +++ b/email-templates/README.md @@ -0,0 +1,70 @@ +# OpenShock Email Templates + +The API's transactional email templates, authored as React components via +[`react-email`](https://react.email/) and exported to Liquid (`{{ name }}` +variables) for the Fluid-based `SmtpTemplate` runtime loader. + +Rendered `.liquid` files live in `API/SmtpTemplates/` and are committed to the +repo as static files — no Node/pnpm is needed during `dotnet build` or in +CI/Docker. + +## Workflow + +When you edit a template source (`emails/*.tsx`), re-render and commit: + +```sh +pnpm install # first time only +pnpm dev # live preview at http://localhost:3000 +pnpm export # overwrites API/SmtpTemplates/*.liquid +``` + +Each exported file is a Fluid/Liquid template: line 1 is the subject, the rest +is the HTML body — the format expected by +`API/Services/Email/Smtp/SmtpTemplate.cs`. + +## Authoring a template + +Each `emails/*.tsx` file should export: + +- a **named** PascalCase React component (the parameterised template), +- a `subject` string — emitted as the first line of the exported `.liquid` file (Fluid reads line 1 as the subject template), +- a `sampleProps` object — keys define which props get placeholder-substituted on export; values are used by the live preview, +- a **default** export wrapping the component with `sampleProps` so `email dev` can render it. + +Example: + +```tsx +export interface VerifyEmailProps { + 'To.Name': string; + VerifyLink: string; +} + +export const subject = 'Hi! Verify your Email!'; + +export const sampleProps: VerifyEmailProps = { + 'To.Name': 'shockee', + VerifyLink: 'https://openshock.app/verify?token=preview', +}; + +export function EmailVerification(props: VerifyEmailProps) { + /* ... */ +} + +export default function Preview_EmailVerification() { + return ; +} +``` + +On export, prop values become `{{ To.Name }}` / `{{ VerifyLink }}` etc. Prop +names must match the variable names the API passes into Fluid at send time. + +## Layout + +- `emails/*.tsx` — templates (files starting with `_` are ignored by the exporter). +- `emails/_lib/` — shared helpers. +- `scripts/export-templates.ts` — Liquid exporter. +- `../API/SmtpTemplates/*.liquid` — pre-rendered templates (committed, re-generated by `pnpm export`). + +## License + +AGPL-3.0 diff --git a/email-templates/emails/AccountActivation.tsx b/email-templates/emails/AccountActivation.tsx new file mode 100644 index 00000000..ea2ec570 --- /dev/null +++ b/email-templates/emails/AccountActivation.tsx @@ -0,0 +1,47 @@ +import { + CtaButton, + Greeting, + Layout, + Paragraph, + RawLinkFallback, + SecurityNotice, + Signoff, +} from './_lib/components.tsx'; + +export interface AccountActivationProps { + 'To.Name': string; + ActivationLink: string; +} + +export const subject = 'Activate your OpenShock account'; + +export const sampleProps: AccountActivationProps = { + 'To.Name': 'shockee', + ActivationLink: 'https://openshock.app/activate?token=preview', +}; + +export function AccountActivation(props: AccountActivationProps) { + return ( + + + + Thanks for signing up! Confirm this is your email address to finish + setting up your OpenShock account. + + Activate account + + + If you did not sign up for OpenShock, you can safely ignore this email. + No account will be created without confirmation. + + + + ); +} + +export default function Preview_AccountActivation() { + return ; +} diff --git a/email-templates/emails/EmailChangeNotice.tsx b/email-templates/emails/EmailChangeNotice.tsx new file mode 100644 index 00000000..5617eec3 --- /dev/null +++ b/email-templates/emails/EmailChangeNotice.tsx @@ -0,0 +1,50 @@ +import { + Greeting, + InlineCode, + Layout, + Paragraph, + SecurityNotice, + Signoff, +} from './_lib/components.tsx'; + +export interface EmailChangeNoticeProps { + 'To.Name': string; + NewEmail: string; +} + +export const subject = 'Your OpenShock email is being changed'; + +export const sampleProps: EmailChangeNoticeProps = { + 'To.Name': 'shockee', + NewEmail: 'new-address@example.com', +}; + +export function EmailChangeNotice(props: EmailChangeNoticeProps) { + return ( + + + + Someone requested that the email address on your OpenShock account be + changed to {props.NewEmail}. + + + The change is not yet applied. It will only take effect + once the new address is verified via the link sent to it. + + If this was you, no further action is needed. + + If this was not you, sign in immediately and change + your password. Your account may be compromised. You should also review + active sessions and API tokens. + + + + ); +} + +export default function Preview_EmailChangeNotice() { + return ; +} diff --git a/email-templates/emails/EmailVerification.tsx b/email-templates/emails/EmailVerification.tsx new file mode 100644 index 00000000..9d490192 --- /dev/null +++ b/email-templates/emails/EmailVerification.tsx @@ -0,0 +1,47 @@ +import { + CtaButton, + Greeting, + Layout, + Paragraph, + RawLinkFallback, + SecurityNotice, + Signoff, +} from './_lib/components.tsx'; + +export interface EmailVerificationProps { + 'To.Name': string; + VerifyLink: string; +} + +export const subject = 'Verify your new OpenShock email address'; + +export const sampleProps: EmailVerificationProps = { + 'To.Name': 'shockee', + VerifyLink: 'https://openshock.app/verify?token=preview', +}; + +export function EmailVerification(props: EmailVerificationProps) { + return ( + + + + Please confirm this is your email address by clicking the button below. + The change will only take effect once this address has been verified. + + Verify email + + + If you did not request this change, you can safely ignore this email. + Your account email will not be updated. + + + + ); +} + +export default function Preview_EmailVerification() { + return ; +} diff --git a/email-templates/emails/PasswordReset.tsx b/email-templates/emails/PasswordReset.tsx new file mode 100644 index 00000000..06881045 --- /dev/null +++ b/email-templates/emails/PasswordReset.tsx @@ -0,0 +1,49 @@ +import { + CtaButton, + Greeting, + Layout, + Paragraph, + RawLinkFallback, + SecurityNotice, + Signoff, +} from './_lib/components.tsx'; + +export interface PasswordResetProps { + 'To.Name': string; + ResetLink: string; +} + +export const subject = 'Reset your OpenShock password'; + +export const sampleProps: PasswordResetProps = { + 'To.Name': 'shockee', + ResetLink: 'https://openshock.app/reset?token=preview', +}; + +export function PasswordReset(props: PasswordResetProps) { + return ( + + + + We received a request to reset the password for your OpenShock account. + Click the button below to choose a new one. + + Reset password + + + This link is single-use and will expire shortly. If you did not request + a password reset, you can safely ignore this email. Your password will + remain unchanged. If you receive these messages repeatedly, sign in and + review your account's recent activity. + + + + ); +} + +export default function Preview_PasswordReset() { + return ; +} diff --git a/email-templates/emails/_lib/components.tsx b/email-templates/emails/_lib/components.tsx new file mode 100644 index 00000000..6907f03a --- /dev/null +++ b/email-templates/emails/_lib/components.tsx @@ -0,0 +1,135 @@ +import type { ReactNode } from 'react'; +import { + Body, + Container, + Head, + Heading, + Hr, + Html, + Link, + Preview, + Section, + Text, +} from '@react-email/components'; +import { styles } from './styles.ts'; + +export function Layout({ + heading, + preview, + children, +}: { + heading: string; + preview: string; + children: ReactNode; +}) { + return ( + + + {preview} + + +
+
+ {heading} + {children} +
+