Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
**/bin/
**/obj/
**/out/
**/node_modules/
API/SmtpTemplates/dist/

# files
**/appsettings.Development.json
Expand Down
16 changes: 10 additions & 6 deletions API/API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<!-- Expose API internals to IntegrationTest project -->
<InternalsVisibleTo Include="$(AssemblyName).Tests.Integration" />
</ItemGroup>

<!-- NuGet packages -->
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.Discord" />
Expand All @@ -20,15 +20,19 @@

<!-- Files to copy -->
<ItemGroup>
<!-- Copy all Liquid templates (recursively) to build and publish outputs -->
<None Update="SmtpTemplates\**\*.liquid">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<!-- Dev cert included on publish -->
<None Include="devcert.pfx" CopyToPublishDirectory="Always" />
</ItemGroup>

<!-- Pre-rendered Liquid email templates (committed to repo, not built at compile time).
Re-render with `pnpm export` in email-templates/ when you change a .tsx source. -->
<ItemGroup>
<Content Include="SmtpTemplates\*.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>

<!-- Git stuff -->
<Target Name="SetHash" AfterTargets="InitializeSourceControlInformation">
<ItemGroup>
Expand Down
25 changes: 0 additions & 25 deletions API/Options/MailJetOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
15 changes: 14 additions & 1 deletion API/Services/Email/EmailServiceExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ public static WebApplicationBuilder AddEmailService(this WebApplicationBuilder b
{
var mailOptions = builder.Configuration.GetRequiredSection(MailOptions.SectionName).Get<MailOptions>() ?? throw new NullReferenceException();

if(mailOptions.Type == MailOptions.MailType.None)
if (mailOptions.Type == MailOptions.MailType.None)
{
builder.Services.AddSingleton<IEmailService, NoneEmailService>(); // Add a dummy email service
return builder;
}

// Add sender contact configuration
builder.AddSenderContactConfiguration();
builder.AddEmailServiceTemplates();

switch (mailOptions.Type)
{
Expand All @@ -39,4 +40,16 @@ private static WebApplicationBuilder AddSenderContactConfiguration(this WebAppli
builder.Services.AddSingleton(builder.Configuration.GetRequiredSection(MailOptions.SenderSectionName).Get<MailOptions.MailSenderContact>() ?? 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;
}
}
9 changes: 9 additions & 0 deletions API/Services/Email/EmailServiceTemplates.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
56 changes: 56 additions & 0 deletions API/Services/Email/EmailTemplate.cs
Original file line number Diff line number Diff line change
@@ -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<Contact>();
return options;
}

public required IFluidTemplate Subject { get; init; }
public required IFluidTemplate Body { get; init; }

public async Task<(string Subject, string HtmlBody)> RenderAsync<T>(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<EmailTemplate> ParseFromFileThrow(string filePath)
{
var result = await ParseFromFile(filePath);
if (result.IsT0) return result.AsT0;
throw new InvalidDataException(result.AsT1);
}

public static Task<OneOf.OneOf<EmailTemplate, string>> ParseFromFile(string filePath) =>
ParseFromFile(File.OpenRead(filePath));

public static async Task<OneOf.OneOf<EmailTemplate, string>> 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
};
}
}
12 changes: 4 additions & 8 deletions API/Services/Email/Mailjet/Mail/MailBase.cs
Original file line number Diff line number Diff line change
@@ -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<MailBase>))]
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<string, string>? Variables { get; set; }
public string? HTMLPart { get; set; }
}
4 changes: 2 additions & 2 deletions API/Services/Email/Mailjet/Mail/MailsWrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

public sealed class MailsWrap
{
public required MailBase[] Messages { get; set; }
}
public required DirectMail[] Messages { get; set; }
}
8 changes: 0 additions & 8 deletions API/Services/Email/Mailjet/Mail/TemplateMail.cs

This file was deleted.

77 changes: 16 additions & 61 deletions API/Services/Email/Mailjet/MailjetEmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MailjetEmailService> _logger;

/// <summary>
/// DI Constructor
/// </summary>
/// <param name="httpClient"></param>
/// <param name="options"></param>
/// <param name="sender"></param>
/// <param name="logger"></param>
public MailjetEmailService(
HttpClient httpClient,
MailJetOptions options,
EmailServiceTemplates templates,
MailOptions.MailSenderContact sender,
ILogger<MailjetEmailService> logger
)
{
_httpClient = httpClient;
_options = options;
_templates = templates;
_sender = sender;
_logger = logger;
}
Expand All @@ -38,79 +31,41 @@ ILogger<MailjetEmailService> 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<string, string>
{
{"link", activationLink.ToString() },
}
}, cancellationToken);
var (subject, htmlBody) = await _templates.AccountActivation.RenderAsync(new { To = to, ActivationLink = activationLink });
await SendMail(to, subject, htmlBody, cancellationToken);
}

/// <inheritdoc />
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<string, string>
{
{"link", resetLink.ToString() },
}
}, cancellationToken);
var (subject, htmlBody) = await _templates.PasswordReset.RenderAsync(new { To = to, ResetLink = resetLink });
await SendMail(to, subject, htmlBody, cancellationToken);
}

/// <inheritdoc />
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<string, string>
{
{"link", verificationLink.ToString() },
}
}, cancellationToken);
var (subject, htmlBody) = await _templates.EmailVerification.RenderAsync(new { To = to, VerifyLink = verificationLink });
await SendMail(to, subject, htmlBody, cancellationToken);
}

/// <inheritdoc />
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<string, string>
{
{ "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);
Expand All @@ -126,4 +81,4 @@ public void Dispose()
{
_httpClient.Dispose();
}
}
}
Loading
Loading