diff --git a/Directory.Packages.props b/Directory.Packages.props index ce949ac..a3b6e02 100755 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,6 +45,7 @@ + diff --git a/Place.sln b/Place.sln index 4a11483..f61e8e0 100755 --- a/Place.sln +++ b/Place.sln @@ -58,6 +58,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{CC68CA38-62B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Place.API", "src\Place.API\Place.API.csproj", "{C4DD7B27-138C-4DFC-BC57-69BFC70853CA}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{3F0BAB74-75A0-4901-85CC-2F4D3BA668AD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Notification", "Notification", "{9AE3E340-7217-415E-B4E2-34F22EC50D53}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Place.Notification", "src\Place.Notification\Place.Notification.csproj", "{4CAF9B33-94CC-40E7-B6F3-86BF4538FB9B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Identity.UnitTests", "tests\Core.Identity.UnitTests\Core.Identity.UnitTests.csproj", "{512A629D-3BAA-4AAF-ACF2-47B6E6653C15}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,7 +77,6 @@ Global {69B31787-CBE3-4009-B7AA-E261200F93E7} = {5B48E2F5-D7CC-48EA-94FE-A2F132650C8F} {B6BB1030-4DB5-497F-AAC9-E2F5D2252AD3} = {5B48E2F5-D7CC-48EA-94FE-A2F132650C8F} {69A29059-CFF9-4B4F-BB68-55DEE27F3910} = {25654B5F-D4E9-4019-B307-8759C38E94A8} - {31B51268-2826-4CB5-B1FC-80519BB3ED07} = {25654B5F-D4E9-4019-B307-8759C38E94A8} {5D217518-06F0-4D31-8969-2EAFF2A7D1F6} = {25654B5F-D4E9-4019-B307-8759C38E94A8} {2AF91DCD-AABF-4EF0-844F-2319A1FAA4B5} = {25654B5F-D4E9-4019-B307-8759C38E94A8} {C37B0761-0FF0-46F4-932B-B2C42C926E73} = {25654B5F-D4E9-4019-B307-8759C38E94A8} @@ -82,6 +89,11 @@ Global {5B48E2F5-D7CC-48EA-94FE-A2F132650C8F} = {4B354D87-FCA4-46F3-A64E-76376D3C68C2} {C3F80B30-C2DE-41DC-933A-A24E04827504} = {4B354D87-FCA4-46F3-A64E-76376D3C68C2} {C4DD7B27-138C-4DFC-BC57-69BFC70853CA} = {CC68CA38-62BE-4535-9BB4-AB1597AFC90A} + {3F0BAB74-75A0-4901-85CC-2F4D3BA668AD} = {25654B5F-D4E9-4019-B307-8759C38E94A8} + {31B51268-2826-4CB5-B1FC-80519BB3ED07} = {3F0BAB74-75A0-4901-85CC-2F4D3BA668AD} + {9AE3E340-7217-415E-B4E2-34F22EC50D53} = {4B354D87-FCA4-46F3-A64E-76376D3C68C2} + {4CAF9B33-94CC-40E7-B6F3-86BF4538FB9B} = {9AE3E340-7217-415E-B4E2-34F22EC50D53} + {512A629D-3BAA-4AAF-ACF2-47B6E6653C15} = {3F0BAB74-75A0-4901-85CC-2F4D3BA668AD} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1DFC8537-6B29-4BB5-8449-1910496DB479}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -146,5 +158,13 @@ Global {C4DD7B27-138C-4DFC-BC57-69BFC70853CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4DD7B27-138C-4DFC-BC57-69BFC70853CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4DD7B27-138C-4DFC-BC57-69BFC70853CA}.Release|Any CPU.Build.0 = Release|Any CPU + {4CAF9B33-94CC-40E7-B6F3-86BF4538FB9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CAF9B33-94CC-40E7-B6F3-86BF4538FB9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CAF9B33-94CC-40E7-B6F3-86BF4538FB9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CAF9B33-94CC-40E7-B6F3-86BF4538FB9B}.Release|Any CPU.Build.0 = Release|Any CPU + {512A629D-3BAA-4AAF-ACF2-47B6E6653C15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {512A629D-3BAA-4AAF-ACF2-47B6E6653C15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {512A629D-3BAA-4AAF-ACF2-47B6E6653C15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {512A629D-3BAA-4AAF-ACF2-47B6E6653C15}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/Common/Core.Identity/Core.Identity.csproj b/src/Common/Core.Identity/Core.Identity.csproj index 5b1bbc2..4940a21 100755 --- a/src/Common/Core.Identity/Core.Identity.csproj +++ b/src/Common/Core.Identity/Core.Identity.csproj @@ -18,9 +18,11 @@ + + diff --git a/src/Common/Core.Identity/EmailSender.cs b/src/Common/Core.Identity/EmailSender.cs deleted file mode 100755 index 4a34ebd..0000000 --- a/src/Common/Core.Identity/EmailSender.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Identity.UI.Services; -using Microsoft.Extensions.Logging; - -namespace Core.Identity; - -/// -public class EmailSender(ILogger logger) : IEmailSender -{ - /// - public async Task SendEmailAsync(string email, string subject, string htmlMessage) - { - logger.LogInformation( - "Send email to {Email}; Subject {Subject}; Message {htmlMessage}", - email, - subject, - htmlMessage - ); - - await Task.CompletedTask; - } -} diff --git a/src/Common/Core.Identity/IdentityEmailSender.cs b/src/Common/Core.Identity/IdentityEmailSender.cs new file mode 100755 index 0000000..b0a0c4a --- /dev/null +++ b/src/Common/Core.Identity/IdentityEmailSender.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Place.Notification; +using Place.Notification.Email; +using Place.Notification.Email.SMTP; + +namespace Core.Identity; + +/// +public class IdentityEmailSender : IEmailSender + where TUser : class +{ + private readonly ILogger> _logger; + private readonly IEmailService _emailService; + + /// + public IdentityEmailSender( + ILogger> logger, + IEmailService emailService + ) + { + _logger = logger; + _emailService = emailService; + } + + /// + public async Task SendConfirmationLinkAsync(TUser user, string email, string confirmationLink) + { + string subject = "Confirmez votre adresse email"; + string htmlContent = GetConfirmationEmailTemplate(confirmationLink); + + await SendEmailAsync(email, subject, htmlContent); + } + + public async Task SendPasswordResetLinkAsync(TUser user, string email, string resetLink) + { + string subject = "Réinitialisation de votre mot de passe"; + string htmlContent = GetPasswordResetTemplate(resetLink); + + await SendEmailAsync(email, subject, htmlContent); + } + + public async Task SendPasswordResetCodeAsync(TUser user, string email, string resetCode) + { + string subject = "Votre code de réinitialisation de mot de passe"; + string htmlContent = GetPasswordResetTemplate(resetCode); + + await SendEmailAsync(email, subject, htmlContent); + } + + protected virtual async Task SendEmailAsync(string to, string subject, string htmlContent) + { + EmailMessage email = new(to, subject, htmlContent); + + try + { + await _emailService.SendAsync(email); + + _logger.LogInformation("Email sent successfully to {Email}", email.To); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending email to {Email}", email.To); + throw; + } + } + + private string GetConfirmationEmailTemplate(string confirmationLink) + { + return $@" + + +

Confirmez votre adresse email

+

Pour confirmer votre compte, veuillez cliquer sur le lien ci-dessous :

+

Confirmer mon compte

+

Si le lien ne fonctionne pas, copiez et collez cette URL dans votre navigateur :

+

{confirmationLink}

+ + "; + } + + private string GetPasswordResetTemplate(string resetLink) + { + return $@" + + +

Réinitialisation de votre mot de passe

+

Pour réinitialiser votre mot de passe, cliquez sur le lien ci-dessous :

+

Réinitialiser mon mot de passe

+

Si le lien ne fonctionne pas, copiez et collez cette URL dans votre navigateur :

+

{resetLink}

+ + "; + } + + private string GetPasswordResetCodeTemplate(string resetCode) + { + return $@" + + +

Code de réinitialisation de mot de passe

+

Voici votre code de réinitialisation de mot de passe :

+

{resetCode}

+

Ce code est valable pendant une durée limitée.

+ + "; + } +} diff --git a/src/Common/Core.Identity/ServiceCollectionExtions.cs b/src/Common/Core.Identity/ServiceCollectionExtions.cs index 8f4123e..e9f2692 100755 --- a/src/Common/Core.Identity/ServiceCollectionExtions.cs +++ b/src/Common/Core.Identity/ServiceCollectionExtions.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -81,7 +80,10 @@ IConfiguration configuration public static IServiceCollection AddEmailSender(this IServiceCollection services) { - services.AddTransient(); + services.AddTransient< + IEmailSender, + IdentityEmailSender + >(); return services; } diff --git a/src/Place.API/Place.API.csproj b/src/Place.API/Place.API.csproj index c8d1128..67cda42 100644 --- a/src/Place.API/Place.API.csproj +++ b/src/Place.API/Place.API.csproj @@ -10,6 +10,7 @@ + @@ -17,6 +18,7 @@ + diff --git a/src/Place.API/Program.cs b/src/Place.API/Program.cs index d91a41c..c11ebfd 100644 --- a/src/Place.API/Program.cs +++ b/src/Place.API/Program.cs @@ -1,6 +1,5 @@ using Account; using Core.Framework; -using Core.Identity; using Core.MediatR; using Identity; using Microsoft.AspNetCore.Builder; @@ -9,7 +8,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Place.API; +using Place.Notification; +using Place.Notification.Email; using Scalar.AspNetCore; +using SendGrid; +using SendGrid.Helpers.Mail; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); IConfiguration configuration = builder.Configuration; @@ -21,6 +24,8 @@ builder.Services.AddAccountModule(configuration); +builder.Services.AddEmailService(configuration); + builder.Services.AddCoreMediatR(typeof(IIdentityRoot).Assembly); builder.Services.AddCoreMediatR(typeof(IAccountRoot).Assembly); diff --git a/src/Place.API/appsettings.Development.json b/src/Place.API/appsettings.Development.json index 0c208ae..bb58892 100644 --- a/src/Place.API/appsettings.Development.json +++ b/src/Place.API/appsettings.Development.json @@ -4,5 +4,80 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "app": { + "name": "Place Profile Api" + }, + "Serilog": { + "applicationName": "identity-service", + "excludePaths": ["/ping", "/metrics"], + "level": "information", + "console": { + "enabled": true + }, + "file": { + "enabled": true, + "path": "logs/logs.txt", + "interval": "day" + }, + "seq": { + "enabled": true, + "url": "http://localhost:5341", + "token": "secret" + } + }, + "Swagger": { + "Title": "Place Profile Api", + "Description": "Place Profile Api documentation", + "Version": "v1", + "EnableBearerAuth": true, + "SecuritySchemaName": "Bearer", + "SecurityScheme": "JWT", + "SecurityDescription": "Utiliser le format: Bearer {votre_token}", + "EnableVersioning": true, + "RoutePrefix": "swagger" + }, + "ApiVersioning": { + "DefaultApiVersionMajor": 1, + "DefaultApiVersionMinor": 0, + "AssumeDefaultVersionWhenUnspecified": true, + "ReportApiVersions": true, + "ApiVersionReaderType": "Combine", + "ReaderOptions": { + "HeaderName": "x-api-version", + "QueryStringParam": "api-version", + "MediaTypeParam": "v" + }, + "GroupNameFormat": "'v'VVV", + "SubstituteApiVersionInUrl": true, + "DeprecatedVersionOptions": { + "DeprecationMessage": "Cette version de l'API est obsolète. Veuillez migrer vers la version la plus récente.", + "SunsetDate": "2025-12-31", + "DocumentationUrl": "https://api.monsite.com/deprecation-policy" + }, + "ApiExplorerOptions": { + "GroupNameFormat": "'v'VVV", + "SubstituteApiVersionInUrl": true, + "UrlFormat": "v{version:apiVersion}", + "AddApiVersionParametersWhenVersionNeutral": true + } + }, + "ConnectionStrings": { + "PlaceDb": "Server=localhost;Port=5499;Database=place_db;User Id=postgres;Password=postgres;Include Error Detail=true" + }, + "Email": { + "Provider": "SendGrid", + "From": "support@place.cm", + "FromName": "Support Place", + "Smtp": { + "Host": "smtp.sendgrid.net", + "Port": 587, + "Username": "username@gmail.com", + "Password": "Pass0rd123!" + }, + "SendGrid": { + "ApiKey": null + } } + } diff --git a/src/Place.API/appsettings.json b/src/Place.API/appsettings.json index 683182a..c88437e 100644 --- a/src/Place.API/appsettings.json +++ b/src/Place.API/appsettings.json @@ -5,40 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "apiVersioning": { - "enabled": true, - "defaultVersion": "1.0", - "assumeDefaultVersionWhenUnspecified": true, - "reportApiVersions": true, - "addVersionParamToNeutralEndpoints": false, - "versionReaderType": "UrlSegment", - "headerName": "X-Api-Version", - "queryStringParam": "api-version", - "mediaTypeParam": "v" - }, - "swagger": { - "enabled": true, - "title": "Place API", - "versions": [ - "1.0", - "2.0" - ], - "useAuthentication": true, - "enableDownload": true, - "useSwaggerUI": true, - "useReDoc": true, - "swaggerUIRoute": "swagger", - "reDocRoute": "api-docs", - "uiOptions": { - "docExpansion": "List", - "defaultModelsExpandDepth": 1 - } - }, "app": { - "name": "Place API v1.0" - }, - "ConnectionStrings": { - "PlaceDb": "Server=localhost;Port=5499;Database=place_db;User Id=postgres;Password=postgres;Include Error Detail=true" + "name": "Place Profile Api" }, "Serilog": { "applicationName": "identity-service", @@ -53,9 +21,48 @@ "interval": "day" }, "seq": { - "enabled": false, + "enabled": true, "url": "http://localhost:5341", "token": "secret" } + }, + "Swagger": { + "Title": "Place Profile Api", + "Description": "Place Profile Api documentation", + "Version": "v1", + "EnableBearerAuth": true, + "SecuritySchemaName": "Bearer", + "SecurityScheme": "JWT", + "SecurityDescription": "Utiliser le format: Bearer {votre_token}", + "EnableVersioning": true, + "RoutePrefix": "swagger" + }, + "ApiVersioning": { + "DefaultApiVersionMajor": 1, + "DefaultApiVersionMinor": 0, + "AssumeDefaultVersionWhenUnspecified": true, + "ReportApiVersions": true, + "ApiVersionReaderType": "Combine", + "ReaderOptions": { + "HeaderName": "x-api-version", + "QueryStringParam": "api-version", + "MediaTypeParam": "v" + }, + "GroupNameFormat": "'v'VVV", + "SubstituteApiVersionInUrl": true, + "DeprecatedVersionOptions": { + "DeprecationMessage": "Cette version de l'API est obsolète. Veuillez migrer vers la version la plus récente.", + "SunsetDate": "2025-12-31", + "DocumentationUrl": "https://api.monsite.com/deprecation-policy" + }, + "ApiExplorerOptions": { + "GroupNameFormat": "'v'VVV", + "SubstituteApiVersionInUrl": true, + "UrlFormat": "v{version:apiVersion}", + "AddApiVersionParametersWhenVersionNeutral": true + } + }, + "ConnectionStrings": { + "PlaceDb": "Server=localhost;Port=5499;Database=place_db;User Id=postgres;Password=postgres;Include Error Detail=true" } } diff --git a/src/Place.Notification/Email/EmailMessage.cs b/src/Place.Notification/Email/EmailMessage.cs new file mode 100644 index 0000000..5c17062 --- /dev/null +++ b/src/Place.Notification/Email/EmailMessage.cs @@ -0,0 +1,30 @@ +using System; + +namespace Place.Notification.Email; + +public class EmailMessage +{ + public string To { get; private set; } + public string Subject { get; private set; } + public string Body { get; private set; } + + public EmailMessage(string to, string subject, string body) + { + if (string.IsNullOrWhiteSpace(to)) + { + throw new ArgumentNullException(nameof(to)); + } + if (string.IsNullOrWhiteSpace(subject)) + { + throw new ArgumentNullException(nameof(subject)); + } + if (string.IsNullOrWhiteSpace(body)) + { + throw new ArgumentNullException(nameof(body)); + } + + To = to; + Subject = subject; + Body = body; + } +} diff --git a/src/Place.Notification/Email/EmailOptions.cs b/src/Place.Notification/Email/EmailOptions.cs new file mode 100644 index 0000000..34343b9 --- /dev/null +++ b/src/Place.Notification/Email/EmailOptions.cs @@ -0,0 +1,14 @@ +using Place.Notification.Email.SendGrid; +using Place.Notification.Email.SMTP; + +namespace Place.Notification.Email; + +public class EmailOptions +{ + public static string SectionName = "Email"; + public EmailProvider? Provider { get; set; } = EmailProvider.SendGrid; + public SmtpOptions? Smtp { get; set; } + public SendGridOptions? SendGrid { get; set; } + public string? FromAddress { get; set; } + public string? FromName { get; set; } +} diff --git a/src/Place.Notification/Email/EmailProvider.cs b/src/Place.Notification/Email/EmailProvider.cs new file mode 100644 index 0000000..0bd554a --- /dev/null +++ b/src/Place.Notification/Email/EmailProvider.cs @@ -0,0 +1,7 @@ +namespace Place.Notification.Email; + +public enum EmailProvider +{ + Smtp, + SendGrid, +} diff --git a/src/Place.Notification/Email/EmailServiceFactory.cs b/src/Place.Notification/Email/EmailServiceFactory.cs new file mode 100644 index 0000000..8f214ab --- /dev/null +++ b/src/Place.Notification/Email/EmailServiceFactory.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Place.Notification.Email.SendGrid; +using Place.Notification.Email.SMTP; + +namespace Place.Notification.Email; + +public class EmailServiceFactory(IServiceProvider serviceProvider, IOptions options) + : IEmailServiceFactory +{ + private readonly EmailOptions _options = options.Value; + + public IEmailService Create() + { + return _options.Provider switch + { + EmailProvider.Smtp => serviceProvider.GetRequiredService(), + EmailProvider.SendGrid => serviceProvider.GetRequiredService(), + _ => throw new ArgumentException("Invalid email provider"), + }; + } +} + +public interface IEmailServiceFactory +{ + public IEmailService Create(); +} diff --git a/src/Place.Notification/Email/SMTP/ISmtpClient.cs b/src/Place.Notification/Email/SMTP/ISmtpClient.cs new file mode 100644 index 0000000..69807f5 --- /dev/null +++ b/src/Place.Notification/Email/SMTP/ISmtpClient.cs @@ -0,0 +1,10 @@ +using System; +using System.Net.Mail; +using System.Threading.Tasks; + +namespace Place.Notification.Email.SMTP; + +public interface ISmtpClient : IDisposable +{ + Task SendMailAsync(MailMessage message); +} diff --git a/src/Place.Notification/Email/SMTP/ISmtpClientFactory.cs b/src/Place.Notification/Email/SMTP/ISmtpClientFactory.cs new file mode 100644 index 0000000..0ffb55b --- /dev/null +++ b/src/Place.Notification/Email/SMTP/ISmtpClientFactory.cs @@ -0,0 +1,6 @@ +namespace Place.Notification.Email.SMTP; + +public interface ISmtpClientFactory +{ + ISmtpClient CreateClient(); +} diff --git a/src/Place.Notification/Email/SMTP/SmtpClientFactory.cs b/src/Place.Notification/Email/SMTP/SmtpClientFactory.cs new file mode 100644 index 0000000..914a550 --- /dev/null +++ b/src/Place.Notification/Email/SMTP/SmtpClientFactory.cs @@ -0,0 +1,22 @@ +using System.Net; +using System.Net.Mail; +using Microsoft.Extensions.Options; + +namespace Place.Notification.Email.SMTP; + +public class SmtpClientFactory(IOptions settings) : ISmtpClientFactory +{ + private readonly EmailOptions _options = settings.Value; + + public ISmtpClient CreateClient() + { + SmtpClient client = + new(_options.Smtp?.Host, _options!.Smtp!.Port) + { + Credentials = new NetworkCredential(_options.Smtp.Username, _options.Smtp.Password), + EnableSsl = true, + }; + + return new SmtpClientWrapper(client); + } +} diff --git a/src/Place.Notification/Email/SMTP/SmtpClientWrapper.cs b/src/Place.Notification/Email/SMTP/SmtpClientWrapper.cs new file mode 100644 index 0000000..73b19be --- /dev/null +++ b/src/Place.Notification/Email/SMTP/SmtpClientWrapper.cs @@ -0,0 +1,17 @@ +using System.Net.Mail; +using System.Threading.Tasks; + +namespace Place.Notification.Email.SMTP; + +public class SmtpClientWrapper(SmtpClient smtpClient) : ISmtpClient +{ + public async Task SendMailAsync(MailMessage message) + { + await smtpClient.SendMailAsync(message); + } + + public void Dispose() + { + smtpClient?.Dispose(); + } +} diff --git a/src/Place.Notification/Email/SMTP/SmtpEmailService.cs b/src/Place.Notification/Email/SMTP/SmtpEmailService.cs new file mode 100644 index 0000000..18bf835 --- /dev/null +++ b/src/Place.Notification/Email/SMTP/SmtpEmailService.cs @@ -0,0 +1,41 @@ +using System; +using System.Net.Mail; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Place.Notification.Email.SMTP; + +public class SmtpEmailService( + IOptions settings, + ISmtpClientFactory smtpClientFactory, + ILogger logger +) : IEmailService +{ + private readonly EmailOptions _settings = settings.Value; + + public async Task SendAsync(EmailMessage email) + { + using ISmtpClient client = smtpClientFactory.CreateClient(); + using MailMessage mailMessage = + new() + { + From = new MailAddress(_settings!.FromAddress!), + Subject = email.Subject, + Body = email.Body, + IsBodyHtml = true, + }; + mailMessage.To.Add(email.To); + + try + { + await client.SendMailAsync(mailMessage); + logger.LogInformation("Email sent successfully to {Email}", email.To); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending email to {Email}", email.To); + throw; + } + } +} diff --git a/src/Place.Notification/Email/SMTP/SmtpOptions.cs b/src/Place.Notification/Email/SMTP/SmtpOptions.cs new file mode 100644 index 0000000..ff72684 --- /dev/null +++ b/src/Place.Notification/Email/SMTP/SmtpOptions.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; + +namespace Place.Notification.Email.SMTP; + +public class SmtpOptions +{ + public string? Host { get; set; } + public int Port { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } +} + +public interface IEmailService +{ + Task SendAsync(EmailMessage message); +} diff --git a/src/Place.Notification/Email/SendGrid/SendGridEmailService.cs b/src/Place.Notification/Email/SendGrid/SendGridEmailService.cs new file mode 100644 index 0000000..cb066ee --- /dev/null +++ b/src/Place.Notification/Email/SendGrid/SendGridEmailService.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Place.Notification.Email.SMTP; +using SendGrid; +using SendGrid.Helpers.Mail; + +namespace Place.Notification.Email.SendGrid; + +public class SendGridEmailService( + IOptions options, + ILogger logger +) : IEmailService +{ + private readonly SendGridClient _client = new(options.Value.SendGrid!.ApiKey); + private readonly string _fromAddress = options.Value.FromAddress!; + private readonly string _fromName = options.Value.FromName!; + + public async Task SendAsync(EmailMessage email) + { + try + { + SendGridMessage msg = + new() + { + From = new EmailAddress(_fromAddress, _fromName), + Subject = email.Subject, + PlainTextContent = email.Body, + HtmlContent = email.Body, + }; + msg.AddTo(new EmailAddress(email.To)); + + Response? response = await _client.SendEmailAsync(msg); + + if (response.IsSuccessStatusCode) + { + logger.LogInformation($"Email sent successfully via SendGrid to {email.To}"); + } + else + { + throw new Exception($"SendGrid returned status code: {response.StatusCode}"); + } + } + catch (Exception ex) + { + logger.LogError( + "Error sending email via SendGrid to {email.To} with error {Error}", + email.To, + ex + ); + throw; + } + } +} diff --git a/src/Place.Notification/Email/SendGrid/SendGridOptions.cs b/src/Place.Notification/Email/SendGrid/SendGridOptions.cs new file mode 100644 index 0000000..27eea60 --- /dev/null +++ b/src/Place.Notification/Email/SendGrid/SendGridOptions.cs @@ -0,0 +1,6 @@ +namespace Place.Notification.Email.SendGrid; + +public class SendGridOptions +{ + public string? ApiKey { get; set; } +} diff --git a/src/Place.Notification/Email/ServiceCollectionExtensions.cs b/src/Place.Notification/Email/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..01bb4db --- /dev/null +++ b/src/Place.Notification/Email/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Place.Notification.Email.SendGrid; +using Place.Notification.Email.SMTP; + +namespace Place.Notification.Email; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEmailService( + this IServiceCollection services, + IConfiguration configuration + ) + { + IConfigurationSection section = configuration.GetSection(EmailOptions.SectionName); + + section.BindOptions(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(sp => + { + IEmailServiceFactory factory = sp.GetRequiredService(); + return factory.Create(); + }); + + return services; + } +} diff --git a/src/Place.Notification/Place.Notification.csproj b/src/Place.Notification/Place.Notification.csproj new file mode 100644 index 0000000..b3e0bf0 --- /dev/null +++ b/src/Place.Notification/Place.Notification.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/Core.Identity.UnitTests/Core.Identity.UnitTests.csproj b/tests/Core.Identity.UnitTests/Core.Identity.UnitTests.csproj new file mode 100644 index 0000000..20e5dcc --- /dev/null +++ b/tests/Core.Identity.UnitTests/Core.Identity.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Core.Identity.UnitTests/IdentityEmailSenderTests.cs b/tests/Core.Identity.UnitTests/IdentityEmailSenderTests.cs new file mode 100644 index 0000000..fa130b6 --- /dev/null +++ b/tests/Core.Identity.UnitTests/IdentityEmailSenderTests.cs @@ -0,0 +1,122 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Place.Notification; +using Place.Notification.Email; +using Place.Notification.Email.SMTP; + +namespace Core.Identity.UnitTests; + +[Trait("Category", "Unit")] +[Trait("Category", "IdentitySendEmail")] +public class IdentityEmailSenderTests +{ + private readonly ILogger> _logger = Substitute.For< + ILogger> + >(); + private readonly IEmailService _emailService = Substitute.For(); + private readonly TestUser _testUser = new() { Email = TestEmail }; + private const string TestEmail = "test@example.com"; + + [Fact] + public async Task SendConfirmationLinkAsync_Should_SendEmailWithCorrectContent() + { + // Arrange + IdentityEmailSender sender = new(_logger, _emailService); + const string confirmationLink = "https://example.com/confirm"; + EmailMessage? capturedEmail = null; + + _emailService + .SendAsync(Arg.Do(email => capturedEmail = email)) + .Returns(Task.CompletedTask); + + // Act + await sender.SendConfirmationLinkAsync(_testUser, TestEmail, confirmationLink); + + // Assert + await _emailService.Received(1).SendAsync(Arg.Any()); + + capturedEmail.Should().NotBeNull(); + capturedEmail?.To.Should().Be(TestEmail); + capturedEmail?.Subject.Should().Be("Confirmez votre adresse email"); + capturedEmail?.Body.Should().Contain(confirmationLink); + } + + [Fact] + public async Task SendPasswordResetLinkAsync_Should_SendEmailWithCorrectContent() + { + // Arrange + IdentityEmailSender sender = new(_logger, _emailService); + const string resetLink = "https://example.com/reset"; + EmailMessage? capturedEmail = null; + + _emailService + .SendAsync(Arg.Do(email => capturedEmail = email)) + .Returns(Task.CompletedTask); + + // Act + await sender.SendPasswordResetLinkAsync(_testUser, TestEmail, resetLink); + + // Assert + await _emailService.Received(1).SendAsync(Arg.Any()); + + capturedEmail.Should().NotBeNull(); + capturedEmail?.To.Should().Be(TestEmail); + capturedEmail?.Subject.Should().Be("Réinitialisation de votre mot de passe"); + capturedEmail?.Body.Should().Contain(resetLink); + } + + [Fact] + public async Task SendPasswordResetCodeAsync_Should_SendEmailWithCorrectContent() + { + // Arrange + IdentityEmailSender sender = new(_logger, _emailService); + const string resetCode = "123456"; + EmailMessage? capturedEmail = null; + + _emailService + .SendAsync(Arg.Do(email => capturedEmail = email)) + .Returns(Task.CompletedTask); + + // Act + await sender.SendPasswordResetCodeAsync(_testUser, TestEmail, resetCode); + + // Assert + await _emailService.Received(1).SendAsync(Arg.Any()); + + capturedEmail.Should().NotBeNull(); + capturedEmail?.To.Should().Be(TestEmail); + capturedEmail?.Subject.Should().Be("Votre code de réinitialisation de mot de passe"); + capturedEmail?.Body.Should().Contain(resetCode); + } + + [Theory] + [InlineData(EmailProvider.Smtp)] + [InlineData(EmailProvider.SendGrid)] + public async Task SendEmail_Should_Work_WithDifferentProviders(EmailProvider provider) + { + // Arrange + + IdentityEmailSender sender = new(_logger, _emailService); + + // Act + await sender.SendConfirmationLinkAsync(_testUser, TestEmail, "https://example.com"); + + // Assert + await _emailService + .Received(1) + .SendAsync( + Arg.Is(email => + email.To == TestEmail + && email.Subject == "Confirmez votre adresse email" + && email.Body.Contains("https://example.com") + ) + ); + } +} + +public class TestUser +{ + public string Email { get; set; } = null!; +}