Add user registration emails + email infrastructure (#150)

* Add email functionality

* Add email template project and rendering service

* Update email template dir structure

* Add email header and footer components for user registration template

* update example env

* Refactor email templates namespace and components

* Format email dir
This commit is contained in:
Aaron Po
2026-02-13 21:46:19 -05:00
committed by GitHub
parent 82f0d26200
commit 6b66f5680f
21 changed files with 615 additions and 30 deletions

View File

@@ -0,0 +1,18 @@
<tr>
<td style="padding: 30px 40px 40px 40px; text-align: center; border-top: 1px solid #eeeeee;">
@if (!string.IsNullOrEmpty(FooterText))
{
<p style="margin: 0 0 10px 0; font-size: 13px; line-height: 20px; color: #999999;">
@FooterText
</p>
}
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #999999;">
This is an automated message. Please do not reply as this inbox is unmonitored.
</p>
</td>
</tr>
@code {
[Parameter]
public string? FooterText { get; set; }
}

View File

@@ -0,0 +1,36 @@
<tr>
<td style="padding: 0; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 8px 8px 0 0;">
<!--[if mso]>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f59e0b;">
<tr>
<td style="padding: 30px 40px;">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="padding: 30px 40px; text-align: center;">
<div style="font-size: 48px; margin-bottom: 12px; line-height: 1;">🍺</div>
<h1
style="margin: 0; font-size: 32px; color: #ffffff; font-weight: 700; letter-spacing: -0.5px; text-shadow: 0 2px 4px rgba(0,0,0,0.1);">
The Biergarten App
</h1>
<div
style="margin-top: 8px; font-size: 14px; color: rgba(255,255,255,0.9); font-weight: 500; letter-spacing: 2px; text-transform: uppercase;">
Discover Your Perfect Brew
</div>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- Divider line -->
<tr>
<td
style="padding: 0; height: 4px; background: linear-gradient(to right, #f59e0b, #d97706, #b45309, #d97706, #f59e0b);">
</td>
</tr>

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.AspNetCore.Components.Web"
Version="10.0.1"
/>
<PackageReference
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.1"
/>
<PackageReference
Include="Microsoft.Extensions.Logging.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,118 @@
@using Infrastructure.Email.Templates.Components
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<title>Welcome to The Biergarten App!</title>
<!--[if mso]>
<style>
* { font-family: Arial, sans-serif !important; }
table { border-collapse: collapse; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<style>
* {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
</style>
<!--<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4; width: 100%;">
<!-- Wrapper table for email clients -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
style="background-color: #f4f4f4;">
<tr>
<td align="center" style="padding: 40px 10px;">
<!-- Main container -->
<!--[if mso]>
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width: 600px;">
<tr>
<td>
<![endif]-->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width: 600px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Include branded header component -->
<Header />
<!-- Welcome message -->
<tr>
<td style="padding: 40px 40px 20px 40px; text-align: center;">
<h1 style="margin: 0; font-size: 28px; color: #333333; font-weight: 600;">
Welcome Aboard!
</h1>
</td>
</tr>
<!-- Body content -->
<tr>
<td style="padding: 10px 40px 30px 40px; text-align: center;">
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #666666;">
Hi <strong style="color: #333333;">@Username</strong>, we're excited to have you join our
community of beer enthusiasts!
</p>
</td>
</tr>
<!-- Confirmation button -->
<tr>
<td style="padding: 10px 40px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:250px;" arcsize="10%" stroke="f" fillcolor="#f59e0b">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:600;">Confirm Your Email</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
style="display: inline-block; padding: 16px 48px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: 600; min-width: 170px; text-align: center; box-shadow: 0 4px 6px rgba(245, 158, 11, 0.3);">
Confirm Your Email
</a>
<!--<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
<!-- Link expiry notice -->
<tr>
<td style="padding: 20px 40px 10px 40px; text-align: center;">
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #999999;">
This confirmation link expires in 24 hours.
</p>
</td>
</tr>
<!-- Footer info -->
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
[Parameter]
public string ConfirmationLink { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,58 @@
using Infrastructure.Email.Templates.Mail;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Email.Templates.Rendering;
/// <summary>
/// Service for rendering Razor email templates to HTML using HtmlRenderer.
/// </summary>
public class EmailTemplateProvider(
IServiceProvider serviceProvider,
ILoggerFactory loggerFactory
) : IEmailTemplateProvider
{
/// <summary>
/// Renders the UserRegisteredEmail template with the specified parameters.
/// </summary>
public async Task<string> RenderUserRegisteredEmailAsync(
string username,
string confirmationLink
)
{
var parameters = new Dictionary<string, object?>
{
{ nameof(UserRegistration.Username), username },
{ nameof(UserRegistration.ConfirmationLink), confirmationLink },
};
return await RenderComponentAsync<UserRegistration>(parameters);
}
/// <summary>
/// Generic method to render any Razor component to HTML.
/// </summary>
private async Task<string> RenderComponentAsync<TComponent>(
Dictionary<string, object?> parameters
)
where TComponent : IComponent
{
await using var htmlRenderer = new HtmlRenderer(
serviceProvider,
loggerFactory
);
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
var parameterView = ParameterView.FromDictionary(parameters);
var output = await htmlRenderer.RenderComponentAsync<TComponent>(
parameterView
);
return output.ToHtmlString();
});
return html;
}
}

View File

@@ -0,0 +1,18 @@
namespace Infrastructure.Email.Templates.Rendering;
/// <summary>
/// Service for rendering Razor email templates to HTML.
/// </summary>
public interface IEmailTemplateProvider
{
/// <summary>
/// Renders the UserRegisteredEmail template with the specified parameters.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The email confirmation link</param>
/// <returns>The rendered HTML string</returns>
Task<string> RenderUserRegisteredEmailAsync(
string username,
string confirmationLink
);
}

View File

@@ -0,0 +1,30 @@
namespace Infrastructure.Email;
/// <summary>
/// Service for sending emails via SMTP.
/// </summary>
public interface IEmailProvider
{
/// <summary>
/// Sends an email to a single recipient.
/// </summary>
/// <param name="to">Recipient email address</param>
/// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
Task SendAsync(string to, string subject, string body, bool isHtml = true);
/// <summary>
/// Sends an email to multiple recipients.
/// </summary>
/// <param name="to">List of recipient email addresses</param>
/// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
Task SendAsync(
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
);
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Email</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.9.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,126 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
namespace Infrastructure.Email;
/// <summary>
/// SMTP email service implementation using MailKit.
/// Configured via environment variables.
/// </summary>
public class SmtpEmailProvider : IEmailProvider
{
private readonly string _host;
private readonly int _port;
private readonly string? _username;
private readonly string? _password;
private readonly bool _useSsl;
private readonly string _fromEmail;
private readonly string _fromName;
public SmtpEmailProvider()
{
_host =
Environment.GetEnvironmentVariable("SMTP_HOST")
?? throw new InvalidOperationException(
"SMTP_HOST environment variable is not set"
);
var portString =
Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587";
if (!int.TryParse(portString, out _port))
{
throw new InvalidOperationException(
$"SMTP_PORT '{portString}' is not a valid integer"
);
}
_username = Environment.GetEnvironmentVariable("SMTP_USERNAME");
_password = Environment.GetEnvironmentVariable("SMTP_PASSWORD");
var useSslString =
Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
_useSsl = bool.Parse(useSslString);
_fromEmail =
Environment.GetEnvironmentVariable("SMTP_FROM_EMAIL")
?? throw new InvalidOperationException(
"SMTP_FROM_EMAIL environment variable is not set"
);
_fromName =
Environment.GetEnvironmentVariable("SMTP_FROM_NAME")
?? "The Biergarten";
}
public async Task SendAsync(
string to,
string subject,
string body,
bool isHtml = true
)
{
await SendAsync([to], subject, body, isHtml);
}
public async Task SendAsync(
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_fromName, _fromEmail));
foreach (var recipient in to)
{
message.To.Add(MailboxAddress.Parse(recipient));
}
message.Subject = subject;
var bodyBuilder = new BodyBuilder();
if (isHtml)
{
bodyBuilder.HtmlBody = body;
}
else
{
bodyBuilder.TextBody = body;
}
message.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
try
{
// Determine the SecureSocketOptions based on SSL setting
var secureSocketOptions = _useSsl
? SecureSocketOptions.StartTls
: SecureSocketOptions.None;
await client.ConnectAsync(_host, _port, secureSocketOptions);
// Authenticate if credentials are provided
if (
!string.IsNullOrEmpty(_username)
&& !string.IsNullOrEmpty(_password)
)
{
await client.AuthenticateAsync(_username, _password);
}
await client.SendAsync(message);
await client.DisconnectAsync(true);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to send email: {ex.Message}",
ex
);
}
}
}