Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public override void Define(IPermissionDefinitionContext context)
NotificationsPermissions.Email.Send,
L($"Permission:{NotificationsPermissions.Email.Send}"));

notificationsPermissions.AddChild(
NotificationsPermissions.Email.SendBulk,
L($"Permission:{NotificationsPermissions.Email.SendBulk}"));

notificationsPermissions.AddChild(
NotificationsPermissions.Email.DeleteDraft,
L($"Permission:{NotificationsPermissions.Email.DeleteDraft}"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public static class Email
{
public const string Default = "Notifications.Email";
public const string Send = "Notifications.Email.Send";
public const string SendBulk = "Notifications.Email.SendBulk";
public const string DeleteDraft = "Notifications.Email.DeleteDraft";
public const string CancelScheduled = "Notifications.Email.CancelScheduled";
public const string Schedule = "Notifications.Email.Schedule";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"Permission:Notifications": "Notifications",
"Permission:Notifications.Email": "Email",
"Permission:Notifications.Email.Send": "Send Email for Individual Application",
"Permission:Notifications.Email.SendBulk": "Send Bulk Email Notification",
"Permission:Notifications.Email.DeleteDraft": "Delete Draft Email for Individual Application",
"Permission:Notifications.Email.CancelScheduled": "Cancel Scheduled Email for Individual Application",
"Permission:Notifications.Email.Schedule": "Schedule Email for Individual Application",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ public interface IEmailLogsRepository : IRepository<EmailLog, Guid>
{
Task<EmailLog?> GetByIdAsync(Guid id, bool includeDetails = false);
Task<List<EmailLog>> GetByApplicationIdAsync(Guid applicationId);
Task<List<EmailLog>> GetByApplicationIdsAndStatusAsync(List<Guid> applicationIds, string status);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@ public class EmailLogsRepository : EfCoreRepository<NotificationsDbContext, Emai
{
public EmailLogsRepository(IDbContextProvider<NotificationsDbContext> dbContextProvider) : base(dbContextProvider)
{
}
}

public async Task<EmailLog?> GetByIdAsync(Guid id, bool includeDetails = false)
{
var dbSet = await GetDbSetAsync();
return await dbSet.FirstOrDefaultAsync(s => s.Id == id);
}

public async Task<List<EmailLog>> GetByApplicationIdAsync(Guid applicationId)
{
var dbSet = await GetDbSetAsync();
return await dbSet.Where(x => x.ApplicationId == applicationId).ToListAsync();
}

public async Task<List<EmailLog>> GetByApplicationIdAsync(Guid applicationId)
{
var dbSet = await GetDbSetAsync();
return await dbSet.Where(x => x.ApplicationId == applicationId).ToListAsync();
}

public async Task<List<EmailLog>> GetByApplicationIdsAndStatusAsync(List<Guid> applicationIds, string status)
{
var dbSet = await GetDbSetAsync();
return await dbSet.Where(x => applicationIds.Contains(x.ApplicationId) && x.Status == status).ToListAsync();
}

public override async Task<IQueryable<EmailLog>> WithDetailsAsync()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;

namespace Unity.GrantManager.GrantApplications
{
public class BulkEmailNotificationDto
{
public BulkEmailNotificationDto()
{
ValidationMessages = [];
ReferenceNo = string.Empty;
ApplicantName = string.Empty;
FormName = string.Empty;
ApplicationStatus = string.Empty;
}

public List<string> ValidationMessages { get; set; }
public bool IsValid { get; set; }

public Guid ApplicationId { get; set; }
public Guid? EmailId { get; set; }
public string? EmailSubject { get; set; }
public string ReferenceNo { get; set; }
public string ApplicantName { get; set; }
public string FormName { get; set; }
public string ApplicationStatus { get; set; }
public decimal RequestedAmount { get; set; }
public decimal RecommendedAmount { get; set; }
public decimal ApprovedAmount { get; set; }
public DateTime? DecisionDate { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Newtonsoft.Json;
using System.Collections.Generic;

namespace Unity.GrantManager.GrantApplications
{
public class BulkEmailNotificationResultDto
{
public List<string> Successes { get; set; } = [];

[JsonProperty("failures")]
public List<KeyValuePair<string, string>> Failures { get; set; } = [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Unity.GrantManager.GrantApplications
{
public interface IBulkEmailNotificationAppService
{
Task<BulkEmailNotificationResultDto> SendBulkEmailNotifications(List<BulkEmailNotificationDto> batchApplicationsToEmail);
Task<List<BulkEmailNotificationDto>> GetApplicationsForBulkEmail(Guid[] applicationGuids);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Unity.GrantManager.Applications;
using Unity.GrantManager.Notifications.Email;
using Unity.Modules.Shared.Utils;
using Unity.Notifications.Emails;
using Unity.Notifications.Permissions;
using Volo.Abp;

namespace Unity.GrantManager.GrantApplications
{
[Authorize(NotificationsPermissions.Email.SendBulk)]
public class BulkEmailNotificationAppService(
IApplicationRepository applicationRepository,
IEmailLogsRepository emailLogsRepository,
IEmailAppService emailAppService) : GrantManagerAppService, IBulkEmailNotificationAppService
{
/// <summary>
/// Get applications for bulk email with added draft validation information
/// </summary>
/// <param name="applicationGuids"></param>
/// <returns></returns>
public async Task<List<BulkEmailNotificationDto>> GetApplicationsForBulkEmail(Guid[] applicationGuids)
{
var applications = await applicationRepository.GetListByIdsAsync(applicationGuids);
var draftEmails = await emailLogsRepository.GetByApplicationIdsAndStatusAsync([.. applicationGuids], EmailStatus.Draft);
var draftsByApplication = draftEmails.GroupBy(e => e.ApplicationId).ToDictionary(g => g.Key, g => g.ToList());

var applicationsForEmail = new List<BulkEmailNotificationDto>();
foreach (var application in applications)
{
draftsByApplication.TryGetValue(application.Id, out var drafts);
applicationsForEmail.Add(MapBulkEmailNotification(application, drafts ?? []));
}

return applicationsForEmail;
}

/// <summary>
/// Send bulk email notifications for the given batch of draft emails
/// </summary>
/// <param name="batchApplicationsToEmail"></param>
/// <returns></returns>
public async Task<BulkEmailNotificationResultDto> SendBulkEmailNotifications(List<BulkEmailNotificationDto> batchApplicationsToEmail)
{
var bulkEmailResult = new BulkEmailNotificationResultDto();

// Fail the whole batch up front if notifications are disabled, rather than reporting false successes
// for emails that SendAsync would silently drop (it always returns true after publishing the event).
if (!await FeatureChecker.IsEnabledAsync("Unity.Notifications"))
{
foreach (var applicationToEmail in batchApplicationsToEmail)
{
bulkEmailResult.Failures.Add(new KeyValuePair<string, string>(applicationToEmail.ReferenceNo, "Email notifications are currently disabled."));
}
return bulkEmailResult;
}

// We send individually here so that a failure on one application does not block the rest of the batch
foreach (var applicationToEmail in batchApplicationsToEmail)
{
try
{
if (!applicationToEmail.EmailId.HasValue)
{
throw new UserFriendlyException("No draft email was found for this application.");
}

// Re-fetch the draft fresh (defense-in-depth: it may have changed since the modal opened)
var draft = await emailLogsRepository.GetAsync(applicationToEmail.EmailId.Value);
if (draft.Status != EmailStatus.Draft)
{
throw new UserFriendlyException("This email is no longer a draft.");
}

// The posted ApplicationId is client-controlled (hidden form field) — never trust it for
// authorization-relevant writes. Confirm it still matches the draft's real owning application
// and use the draft's own ApplicationId, not the posted one, when sending.
if (draft.ApplicationId != applicationToEmail.ApplicationId)
{
throw new UserFriendlyException("This draft no longer matches the selected application.");
}

// Re-check the "exactly one draft" invariant fresh at send time: this endpoint can be reached
// directly (bypassing the modal's GetApplicationsForBulkEmail check), and another draft may
// have been created for this application after the modal was loaded.
var currentDrafts = await emailLogsRepository.GetByApplicationIdsAndStatusAsync([draft.ApplicationId], EmailStatus.Draft);
if (currentDrafts.Count != 1)
{
throw new UserFriendlyException("Multiple draft emails found for this application. Please retain only one draft before proceeding.");
}

// A non-blank ToAddress can still parse to zero recipients (e.g. ";" or ",") — the same check
// the send pipeline itself uses. Catch that here instead of reporting a false success: SendAsync
// always returns true, but the handler silently drops emails with no parseable recipients.
if (draft.ToAddress.ParseEmailList() is not { Count: > 0 })
{
throw new UserFriendlyException("Draft email is missing a To address. Please update the draft before proceeding.");
}

await emailAppService.SendAsync(new CreateEmailDto
{
EmailId = draft.Id,
ApplicationId = draft.ApplicationId,
EmailTo = draft.ToAddress,
EmailFrom = draft.FromAddress,
EmailSubject = draft.Subject,
EmailBody = draft.Body,
EmailCC = draft.CC,
EmailBCC = draft.BCC,
EmailTemplateName = draft.TemplateName,
SendOnDateTime = draft.SendOnDateTime
});

bulkEmailResult.Successes.Add(applicationToEmail.ReferenceNo);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error sending bulk email notification for application with ID: {ApplicationId} and ReferenceNo: {ReferenceNo}",
applicationToEmail.ApplicationId,
applicationToEmail.ReferenceNo);

bulkEmailResult.Failures.Add(new KeyValuePair<string, string>(applicationToEmail.ReferenceNo, ex.Message));
}
}

return bulkEmailResult;
}

/// <summary>
/// Map the application to a BulkEmailNotificationDto with validation messages based on its draft emails
/// </summary>
/// <param name="application"></param>
/// <param name="drafts"></param>
/// <returns></returns>
private static BulkEmailNotificationDto MapBulkEmailNotification(Application application, List<EmailLog> drafts)
{
var validationMessages = new List<string>();
Guid? emailId = null;
string? emailSubject = null;

if (drafts.Count == 0)
{
validationMessages.Add("NO_DRAFT_FOUND");
}
else if (drafts.Count > 1)
{
validationMessages.Add("MULTIPLE_DRAFTS_FOUND");
}
else
{
var draft = drafts[0];
emailId = draft.Id;
emailSubject = draft.Subject;

if (string.IsNullOrWhiteSpace(draft.Subject))
{
validationMessages.Add("MISSING_SUBJECT");
}
if (draft.ToAddress.ParseEmailList() is not { Count: > 0 })
{
validationMessages.Add("MISSING_TO_ADDRESS");
}
if (string.IsNullOrWhiteSpace(draft.FromAddress))
{
validationMessages.Add("MISSING_FROM_ADDRESS");
}
if (string.IsNullOrWhiteSpace(draft.Body))
{
validationMessages.Add("MISSING_BODY");
}
}

return new BulkEmailNotificationDto()
{
ApplicationId = application.Id,
EmailId = emailId,
EmailSubject = emailSubject,
ReferenceNo = application.ReferenceNo,
ApplicantName = application.Applicant?.ApplicantName ?? string.Empty,
ApplicationStatus = application.ApplicationStatus.InternalStatus,
FormName = application.ApplicationForm?.ApplicationFormName ?? string.Empty,
RequestedAmount = application.RequestedAmount,
RecommendedAmount = application.RecommendedAmount,
ApprovedAmount = application.ApprovedAmount,
DecisionDate = application.FinalDecisionDate,
ValidationMessages = validationMessages,
IsValid = validationMessages.Count == 0
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"ApplicationList:StartAssessmentButton": "Start Assessment",
"ApplicationList:CompleteAssessmentButton": "Complete Assessment",
"ApplicationList:TagButton": "Tags",
"ApplicationList:SendEmailButton": "Send Email",
"ApplicationList:ResyncSubmissionAttachmentsButton": "Resync",
"ApplicationList:ManageTagButton": "Manage Tags",

Expand Down Expand Up @@ -533,6 +534,17 @@
"ApplicationBatchApprovalRequest:InvalidApprovedAmount": "Invalid Approved Amount, it must be greater than 0.00",
"ApplicationBatchApprovalRequest:InvalidRecommendedAmount": "Invalid Recommended Amount, it must be greater than 0.00",

"SendEmailNotificationRequest:Title": "Send Email Notification",
"SendEmailNotificationRequest:SubmitButtonText": "Send",
"SendEmailNotificationRequest:CancelButtonText": "Cancel",
"SendEmailNotificationRequest:MaxCountExceeded": "You have exceeded the maximum number of items for bulk email. Please reduce the number to {0} or fewer",
"SendEmailNotificationRequest:NoDraftFound": "No draft email found for this application. Please create a draft email before proceeding.",
"SendEmailNotificationRequest:MultipleDraftsFound": "Multiple draft emails found for this application. Please retain only one draft before proceeding.",
"SendEmailNotificationRequest:MissingSubject": "Draft email is missing a Subject. Please update the draft before proceeding.",
"SendEmailNotificationRequest:MissingToAddress": "Draft email is missing a To address. Please update the draft before proceeding.",
"SendEmailNotificationRequest:MissingFromAddress": "Draft email is missing a From address. Please update the draft before proceeding.",
"SendEmailNotificationRequest:MissingBody": "Draft email is missing a Body. Please update the draft before proceeding.",

"ApplicationBatchPublishRequest:MaxCountExceeded": "You have exceeded the maximum number of items for bulk status publishing. Please reduce the number to {0} or fewer",
"ApplicationBatchPublishRequest:MinCountExceeded": "You have no items selected for bulk status publishing. Please close this prompt to continue",
"ApplicationBatchPublishRequest:ConfirmationNote": "By confirming, the selected application statuses will be published and made visible to applicants in the portal.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder)
public readonly List<string> Notifications_CommonPermissions = [
NotificationsPermissions.Email.Default,
NotificationsPermissions.Email.Send,
NotificationsPermissions.Email.SendBulk,
NotificationsPermissions.Email.DeleteDraft
];

Expand Down
Loading
Loading