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
8 changes: 8 additions & 0 deletions .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ jobs:
with:
dotnet-version: '${{ env.DOTNET_VERSION }}'

# API.csproj builds the React-Email templates during dotnet build/test.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: API/SmtpTemplates/pnpm-lock.yaml
Comment on lines +52 to +58

- name: Run tests
run: |
set -euo pipefail
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

# API.csproj builds the React-Email templates during dotnet publish.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: API/SmtpTemplates/pnpm-lock.yaml
Comment on lines +44 to +50

- name: Build .NET
shell: bash
run: |
Expand Down
79 changes: 73 additions & 6 deletions API/API.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- Keep the SmtpTemplates React-Email source tree out of the SDK's default
Compile/Content/None globs so package.json, tsconfig.json, *.tsx, etc.
don't get swept into the build or published. -->
<PropertyGroup>
<DefaultItemExcludes>$(DefaultItemExcludes);SmtpTemplates\**</DefaultItemExcludes>
<SmtpTemplatesDir>$(MSBuildProjectDirectory)\SmtpTemplates</SmtpTemplatesDir>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
Expand All @@ -8,7 +16,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 +28,74 @@

<!-- 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>

<!-- Show the React-Email sources in the IDE without copying them anywhere. -->
<ItemGroup>
<None Include="SmtpTemplates\emails\**\*.tsx" />
<None Include="SmtpTemplates\scripts\**\*.ts" />
<None Include="SmtpTemplates\package.json" />
<None Include="SmtpTemplates\pnpm-lock.yaml" />
<None Include="SmtpTemplates\tsconfig.json" />
<None Include="SmtpTemplates\README.md" />
<None Include="SmtpTemplates\.gitignore" />
</ItemGroup>

<!-- React-Email template build pipeline -->

<ItemGroup>
<SmtpTemplateSources Include="$(SmtpTemplatesDir)\emails\**\*.tsx" />
<SmtpTemplateSources Include="$(SmtpTemplatesDir)\emails\**\*.ts" />
<SmtpTemplateSources Include="$(SmtpTemplatesDir)\scripts\**\*.ts" />
<SmtpTemplateSources Include="$(SmtpTemplatesDir)\package.json" />
<SmtpTemplateSources Include="$(SmtpTemplatesDir)\pnpm-lock.yaml" />
<SmtpTemplateSources Include="$(SmtpTemplatesDir)\tsconfig.json" />
</ItemGroup>

<Target Name="InstallEmailTemplateDeps"
Inputs="$(SmtpTemplatesDir)\package.json;$(SmtpTemplatesDir)\pnpm-lock.yaml"
Outputs="$(SmtpTemplatesDir)\node_modules\.package-lock.json">
<Exec WorkingDirectory="$(SmtpTemplatesDir)" Command="pnpm install --frozen-lockfile" />
</Target>

<Target Name="BuildEmailTemplates"
DependsOnTargets="InstallEmailTemplateDeps"
Inputs="@(SmtpTemplateSources)"
Outputs="$(SmtpTemplatesDir)\dist\.stamp">
<Exec WorkingDirectory="$(SmtpTemplatesDir)" Command="pnpm export" />
<Touch Files="$(SmtpTemplatesDir)\dist\.stamp" AlwaysCreate="true" />
</Target>

<!-- Glob the generated .liquid files into Content items so the SDK copies
them to the output dir and propagates them transitively via
GetCopyToOutputDirectoryItems to ProjectReference dependents. Done in a
target (rather than a top-level ItemGroup) so the glob is evaluated
AFTER BuildEmailTemplates has materialised dist/. Safe because
DefaultItemExcludes above stops the SDK from globbing SmtpTemplates\**
on its own. -->
<Target Name="IncludeEmailTemplates"
DependsOnTargets="BuildEmailTemplates"
BeforeTargets="AssignTargetPaths;GetCopyToOutputDirectoryItems">
<ItemGroup>
<!-- Stage in a private item type first so the %(Filename) batching is
qualified to these items only — an unqualified %(Filename) inside a
<Content> ItemGroup batches across ALL existing Content items
(including appsettings*.json), producing a cartesian-product mess. -->
<_EmailTemplateLiquid Include="$(SmtpTemplatesDir)\dist\*.liquid" />
<Content Include="@(_EmailTemplateLiquid)">
<Link>SmtpTemplates\%(_EmailTemplateLiquid.Filename)%(_EmailTemplateLiquid.Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Target>

<Target Name="CleanEmailTemplates" BeforeTargets="Clean">
<RemoveDir Directories="$(SmtpTemplatesDir)\dist" />
</Target>

<!-- 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.

Loading
Loading