Skip to content

Commit

Permalink
Send email notifications for approved project request and getting add…
Browse files Browse the repository at this point in the history
…ed 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 <tim_haasdyk@sil.org>
  • Loading branch information
psh0078 and myieye authored Jun 19, 2024
1 parent 69e07a2 commit 9fb48c4
Show file tree
Hide file tree
Showing 18 changed files with 159 additions and 26 deletions.
5 changes: 3 additions & 2 deletions backend/LexBoxApi/Controllers/AdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion backend/LexBoxApi/Controllers/LoginController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,7 +23,7 @@ public class LoginController(
LexAuthService lexAuthService,
LexBoxDbContext lexBoxDbContext,
LoggedInContext loggedInContext,
EmailService emailService,
IEmailService emailService,
UserService userService,
TurnstileService turnstileService)
: ControllerBase
Expand Down
5 changes: 3 additions & 2 deletions backend/LexBoxApi/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
)
{
Expand Down
3 changes: 2 additions & 1 deletion backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using LexBoxApi.Auth;
using LexBoxApi.GraphQL.CustomFilters;
using LexBoxApi.Services;
using LexBoxApi.Services.Email;
using LexCore.ServiceInterfaces;
using LexData;

Expand All @@ -22,7 +23,7 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm
.RegisterService<IHgService>()
.RegisterService<IIsLanguageForgeProjectDataLoader>()
.RegisterService<LoggedInContext>()
.RegisterService<EmailService>()
.RegisterService<IEmailService>()
.RegisterService<LexAuthService>()
.RegisterService<IPermissionService>()
.AddDataAnnotationsValidator()
Expand Down
7 changes: 4 additions & 3 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Net.Mail;
using LexBoxApi.Services.Email;

namespace LexBoxApi.GraphQL;

Expand All @@ -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)
{
Expand All @@ -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);
}
Expand All @@ -73,7 +73,7 @@ public async Task<IQueryable<Project>> 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);
Expand Down Expand Up @@ -106,6 +106,7 @@ public async Task<IQueryable<Project>> 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);
}

Expand Down
9 changes: 5 additions & 4 deletions backend/LexBoxApi/GraphQL/UserMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,7 +46,7 @@ public async Task<MeDto> ChangeUserAccountBySelf(
IPermissionService permissionService,
ChangeUserAccountBySelfInput input,
LexBoxDbContext dbContext,
EmailService emailService
IEmailService emailService
)
{
if (loggedInContext.User.Id != input.UserId) throw new UnauthorizedAccessException();
Expand All @@ -68,7 +69,7 @@ public Task<User> ChangeUserAccountByAdmin(
IPermissionService permissionService,
ChangeUserAccountByAdminInput input,
LexBoxDbContext dbContext,
EmailService emailService
IEmailService emailService
)
{
return UpdateUser(loggedInContext, permissionService, input, dbContext, emailService);
Expand All @@ -83,7 +84,7 @@ public async Task<LexAuthUser> CreateGuestUserByAdmin(
LoggedInContext loggedInContext,
CreateGuestUserByAdminInput input,
LexBoxDbContext dbContext,
EmailService emailService
IEmailService emailService
)
{
using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser");
Expand Down Expand Up @@ -128,7 +129,7 @@ private static async Task<User> UpdateUser(
IPermissionService permissionService,
ChangeUserAccountDataInput input,
LexBoxDbContext dbContext,
EmailService emailService
IEmailService emailService
)
{
var user = await dbContext.Users.FindAsync(input.UserId);
Expand Down
4 changes: 2 additions & 2 deletions backend/LexBoxApi/Jobs/RetryEmailJob.cs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
3 changes: 2 additions & 1 deletion backend/LexBoxApi/LexBoxKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,7 +51,7 @@ public static void AddLexBoxApi(this IServiceCollection services,
services.AddScoped<IPermissionService, PermissionService>();
services.AddScoped<ProjectService>();
services.AddScoped<UserService>();
services.AddScoped<EmailService>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<TusService>();
services.AddScoped<TurnstileService>();
services.AddScoped<IHgService, HgService>();
Expand Down
8 changes: 6 additions & 2 deletions backend/LexBoxApi/Services/Email/EmailTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
44 changes: 44 additions & 0 deletions backend/LexBoxApi/Services/Email/IEmailService.cs
Original file line number Diff line number Diff line change
@@ -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<User> admins, string newAdminName, string newAdminEmail);

/// <summary>
/// Sends a verification email to the user for their email address.
/// </summary>
/// <param name="user">The user to verify the email address for.</param>
/// <param name="newEmail">
/// 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.
/// </param>
public Task SendVerifyAddressEmail(User user, string? newEmail = null);

/// <summary>
/// Sends a project invitation email to a new user, whose account will be created when they accept.
/// </summary>
/// <param name="name">The name (real name, NOT username) of user to invite.</param>
/// <param name="emailAddress">The email address to send the invitation to</param>
/// <param name="projectId">The GUID of the project the user is being invited to</param>
/// <param name="language">The language in which the invitation email should be sent (default English)</param>
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);
}
18 changes: 15 additions & 3 deletions backend/LexBoxApi/Services/EmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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
{
Expand Down
8 changes: 6 additions & 2 deletions backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,7 +12,7 @@

namespace LexBoxApi.Services;

public class ProjectService(LexBoxDbContext dbContext, IHgService hgService, IOptions<HgConfig> hgConfig, IMemoryCache memoryCache)
public class ProjectService(LexBoxDbContext dbContext, IHgService hgService, IOptions<HgConfig> hgConfig, IMemoryCache memoryCache, IEmailService emailService)
{
public async Task<Guid> CreateProject(CreateProjectInput input)
{
Expand Down Expand Up @@ -42,7 +43,10 @@ public async Task<Guid> 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);
Expand Down
3 changes: 2 additions & 1 deletion backend/LexBoxApi/Services/UserService.cs
Original file line number Diff line number Diff line change
@@ -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)

Check warning on line 8 in backend/LexBoxApi/Services/UserService.cs

View workflow job for this annotation

GitHub Actions / Integration tests / Dotnet tests on self-hosted for Mercurial 6 on develop

Parameter 'lexAuthService' is unread.
{
public async Task ForgotPassword(string email)
{
Expand Down
3 changes: 2 additions & 1 deletion backend/Testing/LexCore/Services/ProjectServiceTest.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,6 +22,7 @@ public ProjectServiceTest(TestingServicesFixture testing)
var serviceProvider = testing.ConfigureServices(s =>
{
s.AddScoped<IHgService>(_ => Mock.Of<IHgService>());
s.AddScoped<IEmailService>(_ => Mock.Of<IEmailService>());
s.AddSingleton<IMemoryCache>(_ => Mock.Of<IMemoryCache>());
s.AddScoped<ProjectService>();
});
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/lib/email/ApproveProjectRequest.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import Email from '$lib/email/Email.svelte';
import type { CreateProjectInput } from '$lib/gql/types';
import t from '$lib/i18n';
export let name: string;
export let baseUrl: string;
export let project: CreateProjectInput;
let projectUrl = new URL(`/?projectSearch=${encodeURIComponent(project.code)}`, baseUrl);
let projectName = project.name;
</script>

<Email subject={$t('emails.approve_project_request_email.subject', {projectName})} {name}>
<mj-text>{$t('emails.approve_project_request_email.heading', {projectName})}</mj-text>
<mj-button href={projectUrl}>{$t('emails.approve_project_request_email.view_button')}</mj-button>
</Email>
15 changes: 15 additions & 0 deletions frontend/src/lib/email/UserAdded.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
import Email from '$lib/email/Email.svelte';
import t from '$lib/i18n';
export let name: string;
export let baseUrl: string;
export let projectName: string;
export let projectCode: string;
let projectUrl = new URL(`/?projectSearch=${encodeURIComponent(projectCode)}`, baseUrl);
</script>

<Email subject={$t('emails.user_added.subject', {projectName})} {name}>
<mj-text>{$t('emails.user_added.body', {projectName})}</mj-text>
<mj-button href={projectUrl}>{$t('emails.user_added.view_button')}</mj-button>
</Email>
Loading

0 comments on commit 9fb48c4

Please sign in to comment.