diff --git a/.env.example b/.env.example index 8be2885..80880ae 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,19 @@ DB_PASSWORD=YourStrong!Passw0rd # IMPORTANT: Generate a secure secret (minimum 32 characters) # Command: openssl rand -base64 32 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 diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index f764ee0..0fd96a4 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -98,6 +98,19 @@ services: volumes: - 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: sqlserverdata-dev: driver: local diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj index dbaf5e6..938fed6 100644 --- a/src/Core/API/API.Core/API.Core.csproj +++ b/src/Core/API/API.Core/API.Core.csproj @@ -20,7 +20,12 @@ - + + + diff --git a/src/Core/API/API.Core/Dockerfile b/src/Core/API/API.Core/Dockerfile index abfa65a..593fb67 100644 --- a/src/Core/API/API.Core/Dockerfile +++ b/src/Core/API/API.Core/Dockerfile @@ -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.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] 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.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"] RUN dotnet restore "API/API.Core/API.Core.csproj" diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index 6c93d98..68866b5 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -12,6 +12,9 @@ using Microsoft.AspNetCore.Mvc.Filters; using Service.Auth.Auth; using Service.UserManagement.User; using API.Core.Contracts.Common; +using Infrastructure.Email; +using Infrastructure.Email.Templates; +using Infrastructure.Email.Templates.Rendering; var builder = WebApplication.CreateBuilder(args); @@ -53,6 +56,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register the exception filter builder.Services.AddScoped(); diff --git a/src/Core/API/API.Specs/API.Specs.csproj b/src/Core/API/API.Specs/API.Specs.csproj index 45ce21a..1a89003 100644 --- a/src/Core/API/API.Specs/API.Specs.csproj +++ b/src/Core/API/API.Specs/API.Specs.csproj @@ -17,7 +17,8 @@ - + @@ -34,5 +35,7 @@ + diff --git a/src/Core/API/API.Specs/Dockerfile b/src/Core/API/API.Specs/Dockerfile index a22ad28..887e7d7 100644 --- a/src/Core/API/API.Specs/Dockerfile +++ b/src/Core/API/API.Specs/Dockerfile @@ -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.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] 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.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"] RUN dotnet restore "API/API.Specs/API.Specs.csproj" diff --git a/src/Core/API/API.Specs/Mocks/MockEmailService.cs b/src/Core/API/API.Specs/Mocks/MockEmailService.cs new file mode 100644 index 0000000..e864a0a --- /dev/null +++ b/src/Core/API/API.Specs/Mocks/MockEmailService.cs @@ -0,0 +1,54 @@ +using Infrastructure.Email; + +namespace API.Specs.Mocks; + +/// +/// Mock email service for testing that doesn't actually send emails. +/// Tracks sent emails for verification in tests if needed. +/// +public class MockEmailProvider : IEmailProvider +{ + public List 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 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 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; } + } +} diff --git a/src/Core/API/API.Specs/TestApiFactory.cs b/src/Core/API/API.Specs/TestApiFactory.cs index 15da2c8..ad63065 100644 --- a/src/Core/API/API.Specs/TestApiFactory.cs +++ b/src/Core/API/API.Specs/TestApiFactory.cs @@ -1,7 +1,10 @@ using System.Collections.Generic; +using API.Specs.Mocks; +using Infrastructure.Email; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace API.Specs { @@ -10,6 +13,20 @@ namespace API.Specs protected override void ConfigureWebHost(IWebHostBuilder builder) { 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(); + }); } } } diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx index da5c14a..19d646f 100644 --- a/src/Core/Core.slnx +++ b/src/Core/Core.slnx @@ -1,24 +1,26 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Components/Footer.razor b/src/Core/Infrastructure/Infrastructure.Email.Templates/Components/Footer.razor new file mode 100644 index 0000000..52aa354 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Components/Footer.razor @@ -0,0 +1,18 @@ + + + @if (!string.IsNullOrEmpty(FooterText)) + { +

+ @FooterText +

+ } +

+ This is an automated message. Please do not reply as this inbox is unmonitored. +

+ + + +@code { + [Parameter] + public string? FooterText { get; set; } +} diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Components/Header.razor b/src/Core/Infrastructure/Infrastructure.Email.Templates/Components/Header.razor new file mode 100644 index 0000000..3e82013 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Components/Header.razor @@ -0,0 +1,36 @@ + + + + + + + +
+
🍺
+

+ The Biergarten App +

+
+ Discover Your Perfect Brew +
+
+ + + + + + + + + diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj b/src/Core/Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj new file mode 100644 index 0000000..4dee74a --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj @@ -0,0 +1,26 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Mail/UserRegistration.razor b/src/Core/Infrastructure/Infrastructure.Email.Templates/Mail/UserRegistration.razor new file mode 100644 index 0000000..a96d5b0 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Mail/UserRegistration.razor @@ -0,0 +1,118 @@ +@using Infrastructure.Email.Templates.Components + + + + + + + + + + Welcome to The Biergarten App! + + + + + + + + + + + + +
+ + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+

+ Welcome Aboard! +

+
+

+ Hi @Username, we're excited to have you join our + community of beer enthusiasts! +

+
+ + + + +
+ + + + Confirm Your Email + + +
+
+

+ This confirmation link expires in 24 hours. +

+
+ +
+ + + +@code { + [Parameter] + public string Username { get; set; } = string.Empty; + + [Parameter] + public string ConfirmationLink { get; set; } = string.Empty; +} diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs new file mode 100644 index 0000000..a7e1743 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs @@ -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; + +/// +/// Service for rendering Razor email templates to HTML using HtmlRenderer. +/// +public class EmailTemplateProvider( + IServiceProvider serviceProvider, + ILoggerFactory loggerFactory +) : IEmailTemplateProvider +{ + /// + /// Renders the UserRegisteredEmail template with the specified parameters. + /// + public async Task RenderUserRegisteredEmailAsync( + string username, + string confirmationLink + ) + { + var parameters = new Dictionary + { + { nameof(UserRegistration.Username), username }, + { nameof(UserRegistration.ConfirmationLink), confirmationLink }, + }; + + return await RenderComponentAsync(parameters); + } + + /// + /// Generic method to render any Razor component to HTML. + /// + private async Task RenderComponentAsync( + Dictionary 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( + parameterView + ); + + return output.ToHtmlString(); + }); + + return html; + } +} diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs new file mode 100644 index 0000000..bc67c28 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs @@ -0,0 +1,18 @@ +namespace Infrastructure.Email.Templates.Rendering; + +/// +/// Service for rendering Razor email templates to HTML. +/// +public interface IEmailTemplateProvider +{ + /// + /// Renders the UserRegisteredEmail template with the specified parameters. + /// + /// The username to include in the email + /// The email confirmation link + /// The rendered HTML string + Task RenderUserRegisteredEmailAsync( + string username, + string confirmationLink + ); +} diff --git a/src/Core/Infrastructure/Infrastructure.Email/IEmailProvider.cs b/src/Core/Infrastructure/Infrastructure.Email/IEmailProvider.cs new file mode 100644 index 0000000..0a17b5d --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email/IEmailProvider.cs @@ -0,0 +1,30 @@ +namespace Infrastructure.Email; + +/// +/// Service for sending emails via SMTP. +/// +public interface IEmailProvider +{ + /// + /// Sends an email to a single recipient. + /// + /// Recipient email address + /// Email subject line + /// Email body (HTML or plain text) + /// Whether the body is HTML (default: true) + Task SendAsync(string to, string subject, string body, bool isHtml = true); + + /// + /// Sends an email to multiple recipients. + /// + /// List of recipient email addresses + /// Email subject line + /// Email body (HTML or plain text) + /// Whether the body is HTML (default: true) + Task SendAsync( + IEnumerable to, + string subject, + string body, + bool isHtml = true + ); +} diff --git a/src/Core/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj b/src/Core/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj new file mode 100644 index 0000000..b02eae8 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + Infrastructure.Email + + + + + + diff --git a/src/Core/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs b/src/Core/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs new file mode 100644 index 0000000..45c7103 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs @@ -0,0 +1,126 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; + +namespace Infrastructure.Email; + +/// +/// SMTP email service implementation using MailKit. +/// Configured via environment variables. +/// +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 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 + ); + } + } +} diff --git a/src/Core/Service/Service.Auth/Auth/RegisterService.cs b/src/Core/Service/Service.Auth/Auth/RegisterService.cs index c136c16..643876b 100644 --- a/src/Core/Service/Service.Auth/Auth/RegisterService.cs +++ b/src/Core/Service/Service.Auth/Auth/RegisterService.cs @@ -1,6 +1,9 @@ using System.Threading.Tasks; using Domain.Entities; using Domain.Exceptions; +using Infrastructure.Email; +using Infrastructure.Email.Templates; +using Infrastructure.Email.Templates.Rendering; using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; @@ -8,7 +11,9 @@ namespace Service.Auth.Auth; public class RegisterService( IAuthRepository authRepo, - IPasswordInfrastructure passwordInfrastructure + IPasswordInfrastructure passwordInfrastructure, + IEmailProvider emailProvider, + IEmailTemplateProvider emailTemplateProvider ) : IRegisterService { public async Task RegisterAsync(UserAccount userAccount, string password) @@ -23,19 +28,36 @@ public class RegisterService( } - // password hashing var hashed = passwordInfrastructure.Hash(password); - // Register user with hashed password - return await authRepo.RegisterUserAsync( + // Register user with hashed password and get the created user with generated ID + var createdUser = await authRepo.RegisterUserAsync( userAccount.Username, userAccount.FirstName, userAccount.LastName, userAccount.Email, userAccount.DateOfBirth, 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; } - - } diff --git a/src/Core/Service/Service.Auth/Service.Auth.csproj b/src/Core/Service/Service.Auth/Service.Auth.csproj index 6d9133a..b7efbea 100644 --- a/src/Core/Service/Service.Auth/Service.Auth.csproj +++ b/src/Core/Service/Service.Auth/Service.Auth.csproj @@ -12,6 +12,10 @@ + +