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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>© 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>© 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>© 2025 ColaFlow. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user