mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
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:
16
.env.example
16
.env.example
@@ -36,3 +36,19 @@ DB_PASSWORD=YourStrong!Passw0rd
|
|||||||
# IMPORTANT: Generate a secure secret (minimum 32 characters)
|
# IMPORTANT: Generate a secure secret (minimum 32 characters)
|
||||||
# Command: openssl rand -base64 32
|
# Command: openssl rand -base64 32
|
||||||
JWT_SECRET=128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR
|
JWT_SECRET=128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR
|
||||||
|
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# SMTP Configuration
|
||||||
|
# ======================
|
||||||
|
# SMTP settings for sending emails (e.g., password resets)
|
||||||
|
# For development, you can use a local SMTP testing tool like Mailpit or MailHog
|
||||||
|
# In production, set these to real SMTP server credentials from an email service
|
||||||
|
# provider (e.g., SendGrid, Mailgun, Amazon SES).
|
||||||
|
SMTP_HOST=mailpit
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_USE_SSL=false
|
||||||
|
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
||||||
|
SMTP_FROM_NAME=The Biergarten App
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- nuget-cache-dev:/root/.nuget/packages
|
- nuget-cache-dev:/root/.nuget/packages
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: dev-env-mailpit
|
||||||
|
ports:
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
- "1025:1025" # SMTP server
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
volumes:
|
volumes:
|
||||||
sqlserverdata-dev:
|
sqlserverdata-dev:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -20,7 +20,12 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
|||||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||||
|
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||||
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
||||||
RUN dotnet restore "API/API.Core/API.Core.csproj"
|
RUN dotnet restore "API/API.Core/API.Core.csproj"
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ using Microsoft.AspNetCore.Mvc.Filters;
|
|||||||
using Service.Auth.Auth;
|
using Service.Auth.Auth;
|
||||||
using Service.UserManagement.User;
|
using Service.UserManagement.User;
|
||||||
using API.Core.Contracts.Common;
|
using API.Core.Contracts.Common;
|
||||||
|
using Infrastructure.Email;
|
||||||
|
using Infrastructure.Email.Templates;
|
||||||
|
using Infrastructure.Email.Templates.Rendering;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -53,6 +56,8 @@ builder.Services.AddScoped<IRegisterService, RegisterService>();
|
|||||||
|
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||||
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||||
|
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||||
|
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
||||||
|
|
||||||
// Register the exception filter
|
// Register the exception filter
|
||||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
||||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
||||||
<PackageReference Include="Reqnroll.Tools.MsBuild.Generation" Version="3.3.3" PrivateAssets="all" />
|
<PackageReference Include="Reqnroll.Tools.MsBuild.Generation" Version="3.3.3"
|
||||||
|
PrivateAssets="all" />
|
||||||
|
|
||||||
<!-- ASP.NET Core integration testing -->
|
<!-- ASP.NET Core integration testing -->
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
|
||||||
@@ -34,5 +35,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
|||||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||||
|
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||||
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
||||||
RUN dotnet restore "API/API.Specs/API.Specs.csproj"
|
RUN dotnet restore "API/API.Specs/API.Specs.csproj"
|
||||||
|
|||||||
54
src/Core/API/API.Specs/Mocks/MockEmailService.cs
Normal file
54
src/Core/API/API.Specs/Mocks/MockEmailService.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using Infrastructure.Email;
|
||||||
|
|
||||||
|
namespace API.Specs.Mocks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock email service for testing that doesn't actually send emails.
|
||||||
|
/// Tracks sent emails for verification in tests if needed.
|
||||||
|
/// </summary>
|
||||||
|
public class MockEmailProvider : IEmailProvider
|
||||||
|
{
|
||||||
|
public List<SentEmail> SentEmails { get; } = new();
|
||||||
|
|
||||||
|
public Task SendAsync(string to, string subject, string body, bool isHtml = true)
|
||||||
|
{
|
||||||
|
SentEmails.Add(new SentEmail
|
||||||
|
{
|
||||||
|
To = [to],
|
||||||
|
Subject = subject,
|
||||||
|
Body = body,
|
||||||
|
IsHtml = isHtml,
|
||||||
|
SentAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
|
||||||
|
{
|
||||||
|
SentEmails.Add(new SentEmail
|
||||||
|
{
|
||||||
|
To = to.ToList(),
|
||||||
|
Subject = subject,
|
||||||
|
Body = body,
|
||||||
|
IsHtml = isHtml,
|
||||||
|
SentAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
SentEmails.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SentEmail
|
||||||
|
{
|
||||||
|
public List<string> To { get; init; } = new();
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
public string Body { get; init; } = string.Empty;
|
||||||
|
public bool IsHtml { get; init; }
|
||||||
|
public DateTime SentAt { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using API.Specs.Mocks;
|
||||||
|
using Infrastructure.Email;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace API.Specs
|
namespace API.Specs
|
||||||
{
|
{
|
||||||
@@ -10,6 +13,20 @@ namespace API.Specs
|
|||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
builder.UseEnvironment("Testing");
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Replace the real email service with mock for testing
|
||||||
|
var descriptor = services.SingleOrDefault(
|
||||||
|
d => d.ServiceType == typeof(IEmailProvider));
|
||||||
|
|
||||||
|
if (descriptor != null)
|
||||||
|
{
|
||||||
|
services.Remove(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/API/">
|
<Folder Name="/API/">
|
||||||
<Project Path="API/API.Core/API.Core.csproj" />
|
<Project Path="API/API.Core/API.Core.csproj"/>
|
||||||
<Project Path="API/API.Specs/API.Specs.csproj" />
|
<Project Path="API/API.Specs/API.Specs.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Database/">
|
<Folder Name="/Database/">
|
||||||
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
|
<Project Path="Database/Database.Migrations/Database.Migrations.csproj"/>
|
||||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
<Project Path="Database/Database.Seed/Database.Seed.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Domain/">
|
<Folder Name="/Domain/">
|
||||||
<Project Path="Domain.Entities\Domain.Entities.csproj" />
|
<Project Path="Domain.Entities\Domain.Entities.csproj"/>
|
||||||
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
|
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Infrastructure/">
|
<Folder Name="/Infrastructure/">
|
||||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/>
|
||||||
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
|
||||||
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj"/>
|
||||||
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
|
||||||
</Folder>
|
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj"/>
|
||||||
<Folder Name="/Service/">
|
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj"/>
|
||||||
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
|
</Folder>
|
||||||
<Project Path="Service\Service.Auth\Service.Auth.csproj" />
|
<Folder Name="/Service/">
|
||||||
</Folder>
|
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj"/>
|
||||||
|
<Project Path="Service\Service.Auth\Service.Auth.csproj"/>
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
|
using Infrastructure.Email;
|
||||||
|
using Infrastructure.Email.Templates;
|
||||||
|
using Infrastructure.Email.Templates.Rendering;
|
||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.PasswordHashing;
|
||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
|
|
||||||
@@ -8,7 +11,9 @@ namespace Service.Auth.Auth;
|
|||||||
|
|
||||||
public class RegisterService(
|
public class RegisterService(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
IPasswordInfrastructure passwordInfrastructure
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
|
IEmailProvider emailProvider,
|
||||||
|
IEmailTemplateProvider emailTemplateProvider
|
||||||
) : IRegisterService
|
) : IRegisterService
|
||||||
{
|
{
|
||||||
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
|
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
|
||||||
@@ -23,19 +28,36 @@ public class RegisterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// password hashing
|
// password hashing
|
||||||
var hashed = passwordInfrastructure.Hash(password);
|
var hashed = passwordInfrastructure.Hash(password);
|
||||||
|
|
||||||
// Register user with hashed password
|
// Register user with hashed password and get the created user with generated ID
|
||||||
return await authRepo.RegisterUserAsync(
|
var createdUser = await authRepo.RegisterUserAsync(
|
||||||
userAccount.Username,
|
userAccount.Username,
|
||||||
userAccount.FirstName,
|
userAccount.FirstName,
|
||||||
userAccount.LastName,
|
userAccount.LastName,
|
||||||
userAccount.Email,
|
userAccount.Email,
|
||||||
userAccount.DateOfBirth,
|
userAccount.DateOfBirth,
|
||||||
hashed);
|
hashed);
|
||||||
|
|
||||||
|
|
||||||
|
// Generate confirmation link (TODO: implement proper token-based confirmation)
|
||||||
|
var confirmationLink = $"https://thebiergarten.app/confirm?email={Uri.EscapeDataString(createdUser.Email)}";
|
||||||
|
|
||||||
|
// Render email template
|
||||||
|
var emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||||
|
createdUser.FirstName,
|
||||||
|
confirmationLink
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send welcome email with rendered template
|
||||||
|
await emailProvider.SendAsync(
|
||||||
|
createdUser.Email,
|
||||||
|
"Welcome to The Biergarten App!",
|
||||||
|
emailHtml,
|
||||||
|
isHtml: true
|
||||||
|
);
|
||||||
|
|
||||||
|
return createdUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||||
<ProjectReference
|
<ProjectReference
|
||||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
<ProjectReference
|
<ProjectReference
|
||||||
|
|||||||
Reference in New Issue
Block a user