From 9fb48c42bbc75465c82f2f68670389dbdb8fc155 Mon Sep 17 00:00:00 2001 From: seongho park <30375118+psh0078@users.noreply.github.com> Date: Wed, 19 Jun 2024 19:18:18 +0700 Subject: [PATCH] Send email notifications for approved project request and getting added to project (#889) * create project approval email service * add translation to email * fixed translation embedding * implemented project approval email * Introduce IEmailService so EmailService is mockable * added button in ApproveProjectRequest email (still needs url) * create email template for user added to project * send email in ProjectService.cs & mock EmailService * attach url to view project button * refactor approval email code * implement sending email to users when added to project * update recipient locale * Link new project member emails to home page with filter so permissions get updated * Standardize translations. --------- Co-authored-by: Tim Haasdyk --- .../LexBoxApi/Controllers/AdminController.cs | 5 ++- .../LexBoxApi/Controllers/LoginController.cs | 3 +- .../LexBoxApi/Controllers/UserController.cs | 5 ++- .../LexBoxApi/GraphQL/GraphQlSetupKernel.cs | 3 +- backend/LexBoxApi/GraphQL/ProjectMutations.cs | 7 +-- backend/LexBoxApi/GraphQL/UserMutations.cs | 9 ++-- backend/LexBoxApi/Jobs/RetryEmailJob.cs | 4 +- backend/LexBoxApi/LexBoxKernel.cs | 3 +- .../Services/Email/EmailTemplates.cs | 8 +++- .../LexBoxApi/Services/Email/IEmailService.cs | 44 +++++++++++++++++++ backend/LexBoxApi/Services/EmailService.cs | 18 ++++++-- backend/LexBoxApi/Services/ProjectService.cs | 8 +++- backend/LexBoxApi/Services/UserService.cs | 3 +- .../LexCore/Services/ProjectServiceTest.cs | 3 +- .../lib/email/ApproveProjectRequest.svelte | 16 +++++++ frontend/src/lib/email/UserAdded.svelte | 15 +++++++ frontend/src/lib/i18n/locales/en.json | 12 ++++- frontend/src/routes/email/emails.ts | 19 ++++++++ 18 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 backend/LexBoxApi/Services/Email/IEmailService.cs create mode 100644 frontend/src/lib/email/ApproveProjectRequest.svelte create mode 100644 frontend/src/lib/email/UserAdded.svelte diff --git a/backend/LexBoxApi/Controllers/AdminController.cs b/backend/LexBoxApi/Controllers/AdminController.cs index ac6b1f0d4..a4e121c4f 100644 --- a/backend/LexBoxApi/Controllers/AdminController.cs +++ b/backend/LexBoxApi/Controllers/AdminController.cs @@ -2,6 +2,7 @@ using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; using LexBoxApi.Services; +using LexBoxApi.Services.Email; using LexCore; using LexData; using Microsoft.AspNetCore.Mvc; @@ -16,12 +17,12 @@ public class AdminController : ControllerBase private readonly LexBoxDbContext _lexBoxDbContext; private readonly LoggedInContext _loggedInContext; private readonly UserService _userService; - private readonly EmailService _emailService; + private readonly IEmailService _emailService; public AdminController(LexBoxDbContext lexBoxDbContext, LoggedInContext loggedInContext, UserService userService, - EmailService emailService + IEmailService emailService ) { _lexBoxDbContext = lexBoxDbContext; diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index 34466ab10..b60cb0ca3 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using System.Security.Claims; using Microsoft.AspNetCore.Authentication.Google; +using LexBoxApi.Services.Email; namespace LexBoxApi.Controllers; @@ -22,7 +23,7 @@ public class LoginController( LexAuthService lexAuthService, LexBoxDbContext lexBoxDbContext, LoggedInContext loggedInContext, - EmailService emailService, + IEmailService emailService, UserService userService, TurnstileService turnstileService) : ControllerBase diff --git a/backend/LexBoxApi/Controllers/UserController.cs b/backend/LexBoxApi/Controllers/UserController.cs index 92b6c7020..14075b167 100644 --- a/backend/LexBoxApi/Controllers/UserController.cs +++ b/backend/LexBoxApi/Controllers/UserController.cs @@ -4,6 +4,7 @@ using LexBoxApi.Models; using LexBoxApi.Otel; using LexBoxApi.Services; +using LexBoxApi.Services.Email; using LexCore; using LexCore.Auth; using LexCore.Entities; @@ -24,14 +25,14 @@ public class UserController : ControllerBase private readonly LexBoxDbContext _lexBoxDbContext; private readonly TurnstileService _turnstileService; private readonly LoggedInContext _loggedInContext; - private readonly EmailService _emailService; + private readonly IEmailService _emailService; private readonly LexAuthService _lexAuthService; public UserController( LexBoxDbContext lexBoxDbContext, TurnstileService turnstileService, LoggedInContext loggedInContext, - EmailService emailService, + IEmailService emailService, LexAuthService lexAuthService ) { diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs index 252da0584..1f55fc6ea 100644 --- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs +++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs @@ -3,6 +3,7 @@ using LexBoxApi.Auth; using LexBoxApi.GraphQL.CustomFilters; using LexBoxApi.Services; +using LexBoxApi.Services.Email; using LexCore.ServiceInterfaces; using LexData; @@ -22,7 +23,7 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm .RegisterService() .RegisterService() .RegisterService() - .RegisterService() + .RegisterService() .RegisterService() .RegisterService() .AddDataAnnotationsValidator() diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 9f4c87bd0..717f28fb5 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -14,6 +14,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using System.Net.Mail; +using LexBoxApi.Services.Email; namespace LexBoxApi.GraphQL; @@ -38,7 +39,7 @@ public record CreateProjectResponse(Guid? Id, CreateProjectResult Result); IPermissionService permissionService, CreateProjectInput input, [Service] ProjectService projectService, - [Service] EmailService emailService) + [Service] IEmailService emailService) { if (!loggedInContext.User.IsAdmin) { @@ -54,7 +55,6 @@ public record CreateProjectResponse(Guid? Id, CreateProjectResult Result); await emailService.SendCreateProjectRequestEmail(loggedInContext.User, input); return new CreateProjectResponse(draftProjectId, CreateProjectResult.Requested); } - var projectId = await projectService.CreateProject(input); return new CreateProjectResponse(projectId, CreateProjectResult.Created); } @@ -73,7 +73,7 @@ public async Task> AddProjectMember(IPermissionService permi LoggedInContext loggedInContext, AddProjectMemberInput input, LexBoxDbContext dbContext, - [Service] EmailService emailService) + [Service] IEmailService emailService) { permissionService.AssertCanManageProject(input.ProjectId); var project = await dbContext.Projects.FindAsync(input.ProjectId); @@ -106,6 +106,7 @@ public async Task> AddProjectMember(IPermissionService permi user.UpdateUpdatedDate(); project.UpdateUpdatedDate(); await dbContext.SaveChangesAsync(); + await emailService.SendUserAddedEmail(user, project.Name, project.Code); return dbContext.Projects.Where(p => p.Id == input.ProjectId); } diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs index aff7f6108..230b9021a 100644 --- a/backend/LexBoxApi/GraphQL/UserMutations.cs +++ b/backend/LexBoxApi/GraphQL/UserMutations.cs @@ -6,6 +6,7 @@ using LexBoxApi.Models.Project; using LexBoxApi.Otel; using LexBoxApi.Services; +using LexBoxApi.Services.Email; using LexCore; using LexCore.Auth; using LexCore.Entities; @@ -45,7 +46,7 @@ public async Task ChangeUserAccountBySelf( IPermissionService permissionService, ChangeUserAccountBySelfInput input, LexBoxDbContext dbContext, - EmailService emailService + IEmailService emailService ) { if (loggedInContext.User.Id != input.UserId) throw new UnauthorizedAccessException(); @@ -68,7 +69,7 @@ public Task ChangeUserAccountByAdmin( IPermissionService permissionService, ChangeUserAccountByAdminInput input, LexBoxDbContext dbContext, - EmailService emailService + IEmailService emailService ) { return UpdateUser(loggedInContext, permissionService, input, dbContext, emailService); @@ -83,7 +84,7 @@ public async Task CreateGuestUserByAdmin( LoggedInContext loggedInContext, CreateGuestUserByAdminInput input, LexBoxDbContext dbContext, - EmailService emailService + IEmailService emailService ) { using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser"); @@ -128,7 +129,7 @@ private static async Task UpdateUser( IPermissionService permissionService, ChangeUserAccountDataInput input, LexBoxDbContext dbContext, - EmailService emailService + IEmailService emailService ) { var user = await dbContext.Users.FindAsync(input.UserId); diff --git a/backend/LexBoxApi/Jobs/RetryEmailJob.cs b/backend/LexBoxApi/Jobs/RetryEmailJob.cs index f2bf4c729..864bbda48 100644 --- a/backend/LexBoxApi/Jobs/RetryEmailJob.cs +++ b/backend/LexBoxApi/Jobs/RetryEmailJob.cs @@ -1,10 +1,10 @@ -using LexBoxApi.Services; +using LexBoxApi.Services.Email; using MimeKit; using Quartz; namespace LexBoxApi.Jobs; -public class RetryEmailJob(EmailService emailService) : LexJob +public class RetryEmailJob(IEmailService emailService) : LexJob { public static async Task Queue(ISchedulerFactory schedulerFactory, MimeMessage email, diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index 47f9a946a..cc336503a 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -3,6 +3,7 @@ using LexBoxApi.GraphQL; using LexBoxApi.GraphQL.CustomTypes; using LexBoxApi.Services; +using LexBoxApi.Services.Email; using LexCore.Config; using LexCore.ServiceInterfaces; using LexSyncReverseProxy; @@ -50,7 +51,7 @@ public static void AddLexBoxApi(this IServiceCollection services, services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/LexBoxApi/Services/Email/EmailTemplates.cs b/backend/LexBoxApi/Services/Email/EmailTemplates.cs index b23db8d8f..6d7684f52 100644 --- a/backend/LexBoxApi/Services/Email/EmailTemplates.cs +++ b/backend/LexBoxApi/Services/Email/EmailTemplates.cs @@ -18,7 +18,9 @@ public enum EmailTemplate VerifyEmailAddress, PasswordChanged, CreateAccountRequest, - CreateProjectRequest + CreateProjectRequest, + ApproveProjectRequest, + UserAdded, } public record ForgotPasswordEmail(string Name, string ResetUrl, TimeSpan lifetime) : EmailTemplateBase(EmailTemplate.ForgotPassword); @@ -32,4 +34,6 @@ public record ProjectInviteEmail(string Email, string ProjectId, string ManagerN public record PasswordChangedEmail(string Name) : EmailTemplateBase(EmailTemplate.PasswordChanged); public record CreateProjectRequestUser(string Name, string Email); -public record CreateProjectRequestEmail(string Name, CreateProjectRequestUser User, CreateProjectInput Project): EmailTemplateBase(EmailTemplate.CreateProjectRequest); +public record CreateProjectRequestEmail(string Name, CreateProjectRequestUser User, CreateProjectInput Project) : EmailTemplateBase(EmailTemplate.CreateProjectRequest); +public record ApproveProjectRequestEmail(string Name, CreateProjectRequestUser User, CreateProjectInput Project) : EmailTemplateBase(EmailTemplate.ApproveProjectRequest); +public record UserAddedEmail(string Name, string Email, string ProjectName, string ProjectCode) : EmailTemplateBase(EmailTemplate.UserAdded); diff --git a/backend/LexBoxApi/Services/Email/IEmailService.cs b/backend/LexBoxApi/Services/Email/IEmailService.cs new file mode 100644 index 000000000..fce76a58e --- /dev/null +++ b/backend/LexBoxApi/Services/Email/IEmailService.cs @@ -0,0 +1,44 @@ +using LexBoxApi.Models.Project; +using LexCore.Auth; +using LexCore.Entities; +using MimeKit; + +namespace LexBoxApi.Services.Email; + +public interface IEmailService +{ + public Task SendForgotPasswordEmail(string emailAddress); + + public Task SendNewAdminEmail(IAsyncEnumerable admins, string newAdminName, string newAdminEmail); + + /// + /// Sends a verification email to the user for their email address. + /// + /// The user to verify the email address for. + /// + /// If the user is trying to change their address, this is the new email address. + /// If null, the verification email will be sent to the current email address of the user. + /// + public Task SendVerifyAddressEmail(User user, string? newEmail = null); + + /// + /// Sends a project invitation email to a new user, whose account will be created when they accept. + /// + /// The name (real name, NOT username) of user to invite. + /// The email address to send the invitation to + /// The GUID of the project the user is being invited to + /// The language in which the invitation email should be sent (default English) + public Task SendCreateAccountEmail(string emailAddress, + Guid projectId, + ProjectRole role, + string managerName, + string projectName, + string? language = null); + + public Task SendPasswordChangedEmail(User user); + + public Task SendCreateProjectRequestEmail(LexAuthUser user, CreateProjectInput projectInput); + public Task SendApproveProjectRequestEmail(User user, CreateProjectInput projectInput); + public Task SendUserAddedEmail(User user, string projectName, string projectCode); + public Task SendEmailAsync(MimeMessage message); +} diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index af8c46a93..6ba247889 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -25,7 +25,7 @@ public class EmailService( LexboxLinkGenerator linkGenerator, IHttpContextAccessor httpContextAccessor, Quartz.ISchedulerFactory schedulerFactory, - LexAuthService lexAuthService) + LexAuthService lexAuthService) : IEmailService { private readonly EmailConfig _emailConfig = emailConfig.Value; private readonly LinkGenerator _linkGenerator = linkGenerator; @@ -156,7 +156,19 @@ await RenderEmail(email, new CreateProjectRequestEmail("Admin", new CreateProjectRequestUser(user.Name, user.Email), projectInput), "en"); await SendEmailWithRetriesAsync(email); } - + public async Task SendApproveProjectRequestEmail(User user, CreateProjectInput projectInput) + { + var email = StartUserEmail(user) ?? throw new ArgumentNullException("emailAddress"); + await RenderEmail(email, + new ApproveProjectRequestEmail(user.Name, new CreateProjectRequestUser(user.Name, user.Email!), projectInput), user.LocalizationCode); + await SendEmailWithRetriesAsync(email); + } + public async Task SendUserAddedEmail(User user, string projectName, string projectCode) + { + var email = StartUserEmail(user) ?? throw new ArgumentNullException("emailAddress"); + await RenderEmail(email, new UserAddedEmail(user.Name, user.Email!, projectName, projectCode), user.LocalizationCode); + await SendEmailWithRetriesAsync(email); + } public async Task SendEmailAsync(MimeMessage message) { message.From.Add(MailboxAddress.Parse(_emailConfig.From)); @@ -181,7 +193,7 @@ public async Task SendEmailAsync(MimeMessage message) throw; } } - private async Task SendEmailWithRetriesAsync(MimeMessage message, int retryCount = 3, int retryWaitSeconds = 5 * 60) + private async Task SendEmailWithRetriesAsync(MimeMessage message, int retryCount = 3, int retryWaitSeconds = 5 * 60) { try { diff --git a/backend/LexBoxApi/Services/ProjectService.cs b/backend/LexBoxApi/Services/ProjectService.cs index 5e351fc38..826b10a03 100644 --- a/backend/LexBoxApi/Services/ProjectService.cs +++ b/backend/LexBoxApi/Services/ProjectService.cs @@ -1,5 +1,6 @@ using System.Data.Common; using LexBoxApi.Models.Project; +using LexBoxApi.Services.Email; using LexCore.Config; using LexCore.Entities; using LexCore.Exceptions; @@ -11,7 +12,7 @@ namespace LexBoxApi.Services; -public class ProjectService(LexBoxDbContext dbContext, IHgService hgService, IOptions hgConfig, IMemoryCache memoryCache) +public class ProjectService(LexBoxDbContext dbContext, IHgService hgService, IOptions hgConfig, IMemoryCache memoryCache, IEmailService emailService) { public async Task CreateProject(CreateProjectInput input) { @@ -42,7 +43,10 @@ public async Task CreateProject(CreateProjectInput input) { var manager = await dbContext.Users.FindAsync(input.ProjectManagerId.Value); manager?.UpdateCreateProjectsPermission(ProjectRole.Manager); - + if (draftProject != null && manager != null) + { + await emailService.SendApproveProjectRequestEmail(manager, input); + } } await dbContext.SaveChangesAsync(); await hgService.InitRepo(input.Code); diff --git a/backend/LexBoxApi/Services/UserService.cs b/backend/LexBoxApi/Services/UserService.cs index ed7f12c66..3fa44c10e 100644 --- a/backend/LexBoxApi/Services/UserService.cs +++ b/backend/LexBoxApi/Services/UserService.cs @@ -1,10 +1,11 @@ using LexBoxApi.Auth; +using LexBoxApi.Services.Email; using LexData; using Microsoft.EntityFrameworkCore; namespace LexBoxApi.Services; -public class UserService(LexBoxDbContext dbContext, EmailService emailService, LexAuthService lexAuthService) +public class UserService(LexBoxDbContext dbContext, IEmailService emailService, LexAuthService lexAuthService) { public async Task ForgotPassword(string email) { diff --git a/backend/Testing/LexCore/Services/ProjectServiceTest.cs b/backend/Testing/LexCore/Services/ProjectServiceTest.cs index 2151e8db8..f4b701628 100644 --- a/backend/Testing/LexCore/Services/ProjectServiceTest.cs +++ b/backend/Testing/LexCore/Services/ProjectServiceTest.cs @@ -1,7 +1,7 @@ using LexBoxApi.Services; +using LexBoxApi.Services.Email; using LexCore.Entities; using LexCore.ServiceInterfaces; -using LexData; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; @@ -22,6 +22,7 @@ public ProjectServiceTest(TestingServicesFixture testing) var serviceProvider = testing.ConfigureServices(s => { s.AddScoped(_ => Mock.Of()); + s.AddScoped(_ => Mock.Of()); s.AddSingleton(_ => Mock.Of()); s.AddScoped(); }); diff --git a/frontend/src/lib/email/ApproveProjectRequest.svelte b/frontend/src/lib/email/ApproveProjectRequest.svelte new file mode 100644 index 000000000..4fd13b643 --- /dev/null +++ b/frontend/src/lib/email/ApproveProjectRequest.svelte @@ -0,0 +1,16 @@ + + + + {$t('emails.approve_project_request_email.heading', {projectName})} + {$t('emails.approve_project_request_email.view_button')} + diff --git a/frontend/src/lib/email/UserAdded.svelte b/frontend/src/lib/email/UserAdded.svelte new file mode 100644 index 000000000..f1142d7ed --- /dev/null +++ b/frontend/src/lib/email/UserAdded.svelte @@ -0,0 +1,15 @@ + + + + {$t('emails.user_added.body', {projectName})} + {$t('emails.user_added.view_button')} + diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 8f79f2294..64ecf1390 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -569,12 +569,22 @@ If you don't see a dialog or already closed it, click the button below:", }, "create_account_request_email": { "subject": "Project invitation: {projectName}", - "body": "{managerName} has invited you to join the {projectName} language project. Click below to join.", + "body": "{managerName} has invited you to join the project: {projectName}. Click below to join.", "join_button": "Join project" }, "create_project_request_email": { "subject": "Project request: {projectName}", "heading": "User {name} ({email}) requested that a project be created for them. Details below:" + }, + "approve_project_request_email": { + "subject": "Project approved: {projectName}", + "heading": "The project you requested, {projectName}, has been approved and created.", + "view_button": "View project" + }, + "user_added": { + "subject": "You joined project: {projectName}!", + "body": "You have been added to the project: {projectName}.", + "view_button": "View project" } }, "footer": { diff --git a/frontend/src/routes/email/emails.ts b/frontend/src/routes/email/emails.ts index ba78e8940..9e2e18788 100644 --- a/frontend/src/routes/email/emails.ts +++ b/frontend/src/routes/email/emails.ts @@ -6,6 +6,9 @@ import PasswordChanged from '$lib/email/PasswordChanged.svelte'; import CreateAccountRequest from '$lib/email/CreateAccountRequest.svelte'; import CreateProjectRequest from '$lib/email/CreateProjectRequest.svelte'; import type {CreateProjectInput} from '$lib/gql/generated/graphql'; +import ApproveProjectRequest from '$lib/email/ApproveProjectRequest.svelte'; +import UserAdded from '$lib/email/UserAdded.svelte'; + export const enum EmailTemplate { NewAdmin = 'NEW_ADMIN', @@ -14,6 +17,8 @@ export const enum EmailTemplate { PasswordChanged = 'PASSWORD_CHANGED', CreateAccountRequest = 'CREATE_ACCOUNT_REQUEST', CreateProjectRequest = 'CREATE_PROJECT_REQUEST', + ApproveProjectRequest = 'APPROVE_PROJECT_REQUEST', + UserAdded = 'USER_ADDED', } export const componentMap = { @@ -23,6 +28,8 @@ export const componentMap = { [EmailTemplate.PasswordChanged]: PasswordChanged, [EmailTemplate.CreateAccountRequest]: CreateAccountRequest, [EmailTemplate.CreateProjectRequest]: CreateProjectRequest, + [EmailTemplate.ApproveProjectRequest]: ApproveProjectRequest, + [EmailTemplate.UserAdded]: UserAdded, } satisfies Record; interface EmailTemplatePropsBase { @@ -59,10 +66,22 @@ interface CreateProjectProps extends EmailTemplatePropsBase { + project: CreateProjectInput; + user: { name: string, email: string }; +} + +interface UserAddedProps extends EmailTemplatePropsBase { + projectName: string; + projectCode: string; +} + export type EmailTemplateProps = ForgotPasswordProps | NewAdminProps | VerifyEmailAddressProps | CreateAccountProps | CreateProjectProps + | ApproveProjectProps + | UserAddedProps | EmailTemplatePropsBase;