feat(backend): Implement email service infrastructure for Day 7

Add complete email service infrastructure with Mock and SMTP implementations.

Changes:
- Created EmailMessage domain model for email data
- Added IEmailService interface for email sending
- Implemented MockEmailService for development/testing (logs emails)
- Implemented SmtpEmailService for production SMTP sending
- Added IEmailTemplateService interface for email templates
- Implemented EmailTemplateService with HTML templates for verification, password reset, and invitation emails
- Registered email services in DependencyInjection with provider selection
- Added email configuration to appsettings.Development.json (Mock provider by default)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 21:16:11 +01:00
parent a220e5d5d7
commit 921990a043
8 changed files with 362 additions and 0 deletions

View File

@@ -10,6 +10,18 @@
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password",
"DefaultConnection": "Host=localhost;Port=5432;Database=colaflow_identity;Username=colaflow;Password=colaflow_dev_password"
},
"Email": {
"Provider": "Mock",
"From": "noreply@colaflow.local",
"FromName": "ColaFlow",
"Smtp": {
"Host": "smtp.example.com",
"Port": "587",
"Username": "",
"Password": "",
"EnableSsl": "true"
}
},
"MediatR": {
"LicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxNzkzNTc3NjAwIiwiaWF0IjoiMTc2MjEyNTU2MiIsImFjY291bnRfaWQiOiIwMTlhNDZkZGZiZjk3YTk4Yjg1ZTVmOTllNWRhZjIwNyIsImN1c3RvbWVyX2lkIjoiY3RtXzAxazkzZHdnOG0weDByanp3Mm5rM2dxeDExIiwic3ViX2lkIjoiLSIsImVkaXRpb24iOiIwIiwidHlwZSI6IjIifQ.V45vUlze27pQG3Vs9dvagyUTSp-a74ymB6I0TIGD_NwFt1mMMPsuVXOKH1qK7A7V5qDQBvYyryzJy8xRE1rRKq2MJKgyfYjvzuGkpBbKbM6JRQPYknb5tjF-Rf3LAeWp73FiqbPZOPt5saCsoKqUHej-4zcKg5GA4y-PpGaGAONKyqwK9G2rvc1BUHfEnHKRMr0pprA5W1Yx-Lry85KOckUsI043HGOdfbubnGdAZs74FKvrV2qVir6K6VsZjWwX8IFnl1CzxjICa5MxyHOAVpXRnRtMt6fpsA1fMstFuRjq_2sbqGfsTv6LyCzLPnXdmU5DnWZHUcjy0xlAT_f0aw"
},

View File

@@ -0,0 +1,17 @@
using ColaFlow.Modules.Identity.Domain.Services;
namespace ColaFlow.Modules.Identity.Application.Services;
/// <summary>
/// Service for sending emails
/// </summary>
public interface IEmailService
{
/// <summary>
/// Sends an email message
/// </summary>
/// <param name="message">The email message to send</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if email was sent successfully, false otherwise</returns>
Task<bool> SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,37 @@
namespace ColaFlow.Modules.Identity.Application.Services;
/// <summary>
/// Service for rendering email templates
/// </summary>
public interface IEmailTemplateService
{
/// <summary>
/// Renders a verification email template
/// </summary>
/// <param name="recipientName">Name of the recipient</param>
/// <param name="verificationUrl">URL for email verification</param>
/// <returns>HTML email body</returns>
string RenderVerificationEmail(string recipientName, string verificationUrl);
/// <summary>
/// Renders a password reset email template
/// </summary>
/// <param name="recipientName">Name of the recipient</param>
/// <param name="resetUrl">URL for password reset</param>
/// <returns>HTML email body</returns>
string RenderPasswordResetEmail(string recipientName, string resetUrl);
/// <summary>
/// Renders a tenant invitation email template
/// </summary>
/// <param name="recipientName">Name of the recipient</param>
/// <param name="tenantName">Name of the tenant</param>
/// <param name="inviterName">Name of the person who sent the invitation</param>
/// <param name="invitationUrl">URL for accepting the invitation</param>
/// <returns>HTML email body</returns>
string RenderInvitationEmail(
string recipientName,
string tenantName,
string inviterName,
string invitationUrl);
}

View File

@@ -0,0 +1,13 @@
namespace ColaFlow.Modules.Identity.Domain.Services;
/// <summary>
/// Represents an email message to be sent
/// </summary>
public sealed record EmailMessage(
string To,
string Subject,
string HtmlBody,
string? PlainTextBody = null,
string? FromEmail = null,
string? FromName = null
);

View File

@@ -43,6 +43,18 @@ public static class DependencyInjection
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
// Email Services
var emailProvider = configuration["Email:Provider"] ?? "Mock";
if (emailProvider.Equals("Mock", StringComparison.OrdinalIgnoreCase))
{
services.AddSingleton<IEmailService, MockEmailService>();
}
else
{
services.AddScoped<IEmailService, SmtpEmailService>();
}
services.AddScoped<IEmailTemplateService, EmailTemplateService>();
return services;
}
}

View File

@@ -0,0 +1,150 @@
using ColaFlow.Modules.Identity.Application.Services;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// <summary>
/// Service for rendering HTML email templates
/// </summary>
public sealed class EmailTemplateService : IEmailTemplateService
{
public string RenderVerificationEmail(string recipientName, string verificationUrl)
{
return $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Verify Your Email</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; background-color: #4F46E5; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class=""container"">
<div class=""header"">
<h1>ColaFlow</h1>
</div>
<div class=""content"">
<h2>Welcome to ColaFlow, {recipientName}!</h2>
<p>Thank you for registering. Please verify your email address by clicking the button below:</p>
<div style=""text-align: center;"">
<a href=""{verificationUrl}"" class=""button"">Verify Email Address</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style=""word-break: break-all; color: #4F46E5;"">{verificationUrl}</p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
</div>
<div class=""footer"">
<p>&copy; 2025 ColaFlow. All rights reserved.</p>
</div>
</div>
</body>
</html>";
}
public string RenderPasswordResetEmail(string recipientName, string resetUrl)
{
return $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Reset Your Password</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #DC2626; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; background-color: #DC2626; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #666; }}
.warning {{ background-color: #FEF3C7; border-left: 4px solid #F59E0B; padding: 12px; margin: 20px 0; }}
</style>
</head>
<body>
<div class=""container"">
<div class=""header"">
<h1>ColaFlow</h1>
</div>
<div class=""content"">
<h2>Password Reset Request</h2>
<p>Hi {recipientName},</p>
<p>We received a request to reset your password. Click the button below to create a new password:</p>
<div style=""text-align: center;"">
<a href=""{resetUrl}"" class=""button"">Reset Password</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style=""word-break: break-all; color: #DC2626;"">{resetUrl}</p>
<div class=""warning"">
<strong>Important:</strong> This link will expire in 1 hour for security reasons.
</div>
<p>If you didn't request a password reset, please ignore this email or contact support if you have concerns.</p>
</div>
<div class=""footer"">
<p>&copy; 2025 ColaFlow. All rights reserved.</p>
</div>
</div>
</body>
</html>";
}
public string RenderInvitationEmail(
string recipientName,
string tenantName,
string inviterName,
string invitationUrl)
{
return $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>You've Been Invited</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #10B981; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; background-color: #10B981; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #666; }}
.info-box {{ background-color: #E0F2FE; border-left: 4px solid #0EA5E9; padding: 12px; margin: 20px 0; }}
</style>
</head>
<body>
<div class=""container"">
<div class=""header"">
<h1>ColaFlow</h1>
</div>
<div class=""content"">
<h2>You've Been Invited!</h2>
<p>Hi {recipientName},</p>
<p><strong>{inviterName}</strong> has invited you to join the <strong>{tenantName}</strong> workspace on ColaFlow.</p>
<div class=""info-box"">
<strong>Workspace:</strong> {tenantName}<br>
<strong>Invited by:</strong> {inviterName}
</div>
<p>Click the button below to accept the invitation and get started:</p>
<div style=""text-align: center;"">
<a href=""{invitationUrl}"" class=""button"">Accept Invitation</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style=""word-break: break-all; color: #10B981;"">{invitationUrl}</p>
<p>This invitation will expire in 7 days.</p>
<p>If you don't know {inviterName} or weren't expecting this invitation, you can safely ignore this email.</p>
</div>
<div class=""footer"">
<p>&copy; 2025 ColaFlow. All rights reserved.</p>
</div>
</div>
</body>
</html>";
}
}

View File

@@ -0,0 +1,34 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Services;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// <summary>
/// Mock email service for development/testing that logs emails instead of sending them
/// </summary>
public sealed class MockEmailService : IEmailService
{
private readonly ILogger<MockEmailService> _logger;
public MockEmailService(ILogger<MockEmailService> logger)
{
_logger = logger;
}
public Task<bool> SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"[MOCK EMAIL] To: {To}, Subject: {Subject}, From: {From}",
message.To,
message.Subject,
message.FromEmail ?? "default");
_logger.LogDebug(
"[MOCK EMAIL] HTML Body: {HtmlBody}",
message.HtmlBody);
// Simulate successful send
return Task.FromResult(true);
}
}

View File

@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Mail;
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// <summary>
/// SMTP-based email service for production use
/// </summary>
public sealed class SmtpEmailService : IEmailService
{
private readonly ILogger<SmtpEmailService> _logger;
private readonly IConfiguration _configuration;
public SmtpEmailService(
ILogger<SmtpEmailService> logger,
IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
}
public async Task<bool> SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default)
{
try
{
var smtpHost = _configuration["Email:Smtp:Host"];
var smtpPort = int.Parse(_configuration["Email:Smtp:Port"] ?? "587");
var smtpUsername = _configuration["Email:Smtp:Username"];
var smtpPassword = _configuration["Email:Smtp:Password"];
var enableSsl = bool.Parse(_configuration["Email:Smtp:EnableSsl"] ?? "true");
var defaultFromEmail = _configuration["Email:From"] ?? "noreply@colaflow.local";
var defaultFromName = _configuration["Email:FromName"] ?? "ColaFlow";
using var smtpClient = new SmtpClient(smtpHost, smtpPort)
{
Credentials = new NetworkCredential(smtpUsername, smtpPassword),
EnableSsl = enableSsl
};
using var mailMessage = new MailMessage
{
From = new MailAddress(
message.FromEmail ?? defaultFromEmail,
message.FromName ?? defaultFromName),
Subject = message.Subject,
Body = message.HtmlBody,
IsBodyHtml = true
};
mailMessage.To.Add(message.To);
// Add plain text alternative if provided
if (!string.IsNullOrEmpty(message.PlainTextBody))
{
var plainView = AlternateView.CreateAlternateViewFromString(
message.PlainTextBody,
null,
"text/plain");
mailMessage.AlternateViews.Add(plainView);
}
await smtpClient.SendMailAsync(mailMessage, cancellationToken);
_logger.LogInformation(
"Email sent successfully to {To} with subject: {Subject}",
message.To,
message.Subject);
return true;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to send email to {To} with subject: {Subject}",
message.To,
message.Subject);
return false;
}
}
}