diff --git a/applications/Unity.GrantManager/.env.example b/applications/Unity.GrantManager/.env.example index c2094e4c0..c937e8659 100644 --- a/applications/Unity.GrantManager/.env.example +++ b/applications/Unity.GrantManager/.env.example @@ -56,6 +56,8 @@ AuthServer__OidcSignoutCallback="http://localhost:44342/signout-callback-oidc" ##S3__AssessmentS3Folder="Unity/Adjudication" ##S3__DisallowedFileTypes="[ "exe" , "sh" , "ksh" , "bat" , "cmd" ]" ##S3__MaxFileSize="25" +##S3__EmailAttachmentMaxFileSize="20" +##S3__EmailAttachmentsTotalMaxFileSize="25" ## RABBIT MQ UNITY_RABBIT_MQ_HOST="rabbitmq" UNITY_RABBIT_MQ_PORT="5672" diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs index 23f2ae4f0..cdea764cb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs @@ -6,4 +6,5 @@ namespace Unity.Notifications.Emails; public interface IEmailLogAttachmentUploadService { Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType); + Task GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs index 71ca3e02b..6a0172dd0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using Unity.Notifications.Emails; using Volo.Abp.DependencyInjection; @@ -174,6 +175,12 @@ public async Task> GetAttachmentsAsync(Guid emailLogId) return await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); } + public async Task GetTotalFileSizeAsync(Guid emailLogId) + { + var attachments = await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); + return attachments.Sum(a => a.FileSize); + } + private static string BuildUserAttachmentS3Key(Guid? tenantId, Guid emailLogId, Guid attachmentId, string fileName) { var basePath = "Email/Attachments"; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs index b7e1c4d75..31a5a9b20 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs @@ -27,7 +27,7 @@ public async Task> GetListByEmailLogIdAsync(Guid ema foreach (var attachment in attachments) { - var dto = new EmailLogAttachmentDto + dtos.Add(new EmailLogAttachmentDto { Id = attachment.Id, FileName = attachment.FileName, @@ -37,8 +37,7 @@ public async Task> GetListByEmailLogIdAsync(Guid ema ContentType = attachment.ContentType, S3ObjectKey = attachment.S3ObjectKey, AttachedBy = await ResolveUserNameAsync(attachment.UserId) - }; - dtos.Add(dto); + }); } return dtos; @@ -65,6 +64,11 @@ public async Task DeleteAsync(Guid id) await emailLogAttachmentRepository.DeleteAsync(id); } + public async Task GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId) + { + return await emailAttachmentService.GetTotalFileSizeAsync(emailLogId); + } + public async Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType) { var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, tenantId, fileName, content, contentType); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs index 42f004ef3..cd74758f6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs @@ -280,6 +280,35 @@ public async Task UploadEmailAttachments(Guid emailLogId, IList f.Length * 0.000001 > maxFileSizeMB).ToList(); + if (oversizedFiles.Count > 0) + { + var sizeErrors = oversizedFiles.Select(f => + new ValidationResult($"File '{f.FileName}' exceeds the maximum allowed size of {maxFileSizeMB} MB for email attachments.", [f.FileName]) + ).ToList(); + throw new AbpValidationException("One or more files exceed the maximum allowed size for email attachments.", sizeErrors); + } + } + + var totalMaxFileSizeConfig = _configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; + if (double.TryParse(totalMaxFileSizeConfig, out double totalMaxSizeMB)) + { + long existingTotalBytes = await _emailLogAttachmentUploadService + .GetTotalFileSizeByEmailLogIdAsync(emailLogId); + long newFilesBytes = files.Sum(f => f.Length); + double combinedMB = (existingTotalBytes + newFilesBytes) * 0.000001; + + if (combinedMB > totalMaxSizeMB) + { + throw new AbpValidationException( + $"The total size of all attachments ({combinedMB:F1} MB) would exceed the maximum allowed {totalMaxSizeMB} MB for email attachments. Please remove existing attachments or select a smaller file.", + [new ValidationResult("Total attachment size exceeds the allowed limit.")]); + } + } + var results = new List(); foreach (var file in files) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 57d486624..a62fccad4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -70,6 +70,8 @@ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index aaec1bb21..092b831c6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -85,6 +85,8 @@ public class DetailsModel : AbpPageModel public string? CurrentUserName { get; set; } public string Extensions { get; set; } public string MaxFileSize { get; set; } + public string EmailAttachmentMaxFileSize { get; set; } + public string TotalEmailAttachmentMaxFileSize { get; set; } [BindProperty(SupportsGet = true)] public List CustomTabs { get; set; } = []; @@ -123,6 +125,8 @@ public DetailsModel( CurrentUserName = currentUser.SurName + ", " + currentUser.Name; Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; + EmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentMaxFileSize"] ?? "20"; + TotalEmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; IsDevPromptControlsEnabled = aiPromptToolViewOptionsProvider.IsDevPromptControlsEnabled; DefaultPromptVersion = aiPromptToolViewOptionsProvider.DefaultPromptVersion; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml index e050edc3a..3cd452a9c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml @@ -125,11 +125,25 @@ + +
+ style="display:none" /> = 1 ? mb.toFixed(2) + ' MB' : (data / 1024).toFixed(0) + ' KB'; + } + }, { title: '', data: 'id', @@ -784,11 +795,44 @@ return isDraft ? generateEmailAttachmentButtonContent(data) : ''; } } - ] + ], + drawCallback: function () { + if (isDraft) { + checkTotalAttachmentSize(); + } + } }) ); } + function checkTotalAttachmentSize() { + const totalMaxFileSize = Number.parseFloat( + decodeURIComponent($('#TotalEmailAttachmentMaxFileSize').val()) || '25' + ); + + let totalBytes = 0; + if (emailAttachmentsTable) { + emailAttachmentsTable.rows().data().each(function (row) { + totalBytes += (row.fileSize || 0); + }); + } + + const totalMB = totalBytes * 0.000001; + const isExceeded = totalMB > totalMaxFileSize; + + if (isExceeded) { + $('#email-attachment-size-error-message').text( + 'The total size of all attachments (' + totalMB.toFixed(2) + ' MB) exceeds the ' + + 'maximum allowed ' + totalMaxFileSize + ' MB. Please remove one or more attachments before sending.' + ); + $('#email-attachment-size-error').show(); + $('#btn-send').prop('disabled', true); + } else { + $('#email-attachment-size-error').hide(); + $('#btn-send').prop('disabled', false); + } + } + function reloadEmailAttachmentsTable() { if (emailAttachmentsTable) { emailAttachmentsTable.ajax.reload(); @@ -798,6 +842,110 @@ $('#email_attachment_upload_btn').on('click', function () { $('#email_attachment_upload').click(); }); + + $('#email_attachment_upload').on('change', function () { + uploadEmailFiles('email_attachment_upload'); + }); + + function uploadEmailFiles(inputId) { + const emailLogId = $('#EmailId').val(); + const input = document.getElementById(inputId); + if (!input?.files?.length) return; + + const disallowedTypes = JSON.parse(decodeURIComponent($('#Extensions').val())); + const maxFileSize = decodeURIComponent($('#EmailAttachmentMaxFileSize').val()); + + let isAllowedTypeError = false; + let isMaxFileSizeError = false; + const formData = new FormData(); + + for (let file of input.files) { + const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase(); + if (disallowedTypes.includes(ext)) { + isAllowedTypeError = true; + } + if (file.size * 0.000001 > maxFileSize) { + isMaxFileSizeError = true; + } + formData.append('files', file); + } + + if (isAllowedTypeError) { + input.value = ''; + return abp.notify.error('Error', 'File type not supported'); + } + if (isMaxFileSizeError) { + input.value = ''; + return abp.notify.error( + 'File Too Large', + 'The selected file exceeds the maximum allowed size of ' + maxFileSize + ' MB for email attachments. Please select a smaller file.' + ); + } + + const totalMaxFileSize = Number.parseFloat( + decodeURIComponent($('#TotalEmailAttachmentMaxFileSize').val()) || '25' + ); + let existingTotalBytes = 0; + if (emailAttachmentsTable) { + emailAttachmentsTable.rows().data().each(function (row) { + existingTotalBytes += (row.fileSize || 0); + }); + } + let newFilesBytes = 0; + for (let file of input.files) { + newFilesBytes += file.size; + } + const combinedMB = (existingTotalBytes + newFilesBytes) * 0.000001; + if (combinedMB > totalMaxFileSize) { + input.value = ''; + return abp.notify.error( + 'Total Size Exceeded', + 'The total size of all attachments would exceed the maximum allowed ' + totalMaxFileSize + + ' MB. Please remove existing attachments or select a smaller file.' + ); + } + + $.ajax({ + url: `/api/app/attachment/email/${emailLogId}/upload`, + type: 'POST', + data: formData, + processData: false, + contentType: false, + xhr: function () { + const xhr = new globalThis.XMLHttpRequest(); + xhr.upload.addEventListener('progress', function (e) { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + $('#attachment-upload-progress-bar') + .css('width', pct + '%') + .attr('aria-valuenow', pct) + .text(pct + '%'); + } + }); + return xhr; + }, + beforeSend: function () { + $('#email_attachment_upload_btn') + .html('Uploading...') + .prop('disabled', true); + $('#attachment-upload-progress-bar').css('width', '0%').text('0%'); + $('#attachment-upload-progress').show(); + }, + success: function () { + PubSub.publish('reload_email_attachments_table'); + }, + error: function (xhr) { + abp.notify.error(xhr.responseText || 'Failed to upload attachment.'); + }, + complete: function () { + input.value = ''; + $('#email_attachment_upload_btn') + .html('Add Attachments') + .prop('disabled', false); + $('#attachment-upload-progress').hide(); + } + }); + } }); function generateEmailAttachmentButtonContent(attachmentId) { @@ -815,56 +963,6 @@ function generateEmailAttachmentButtonContent(attachmentId) {
`; } -function uploadEmailFiles(inputId) { - const emailLogId = $('#EmailId').val(); - const input = document.getElementById(inputId); - if (!input?.files?.length) return; - - const disallowedTypes = JSON.parse(decodeURIComponent($('#Extensions').val())); - const maxFileSize = decodeURIComponent($('#MaxFileSize').val()); - - let isAllowedTypeError = false; - let isMaxFileSizeError = false; - const formData = new FormData(); - - for (let file of input.files) { - const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase(); - if (disallowedTypes.includes(ext)) { - isAllowedTypeError = true; - } - if (file.size * 0.000001 > maxFileSize) { - isMaxFileSizeError = true; - } - formData.append('files', file); - } - - if (isAllowedTypeError) { - input.value = ''; - return abp.notify.error('Error', 'File type not supported'); - } - if (isMaxFileSizeError) { - input.value = ''; - return abp.notify.error('Error', 'File size exceeds ' + maxFileSize + 'MB'); - } - - $.ajax({ - url: `/api/app/attachment/email/${emailLogId}/upload`, - type: 'POST', - data: formData, - processData: false, - contentType: false, - success: function () { - PubSub.publish('reload_email_attachments_table'); - }, - error: function (xhr) { - abp.notify.error(xhr.responseText || 'Failed to upload attachment.'); - }, - complete: function () { - input.value = ''; - } - }); -} - function deleteEmailAttachment(attachmentId) { abp.message.confirm( 'Are you sure you want to delete this attachment?', diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index eb29270cf..eafb73b3c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -77,7 +77,9 @@ "ApplicationS3Folder": "Unity/Application", "AssessmentS3Folder": "Unity/Adjudication", "DisallowedFileTypes": "[ \"exe\",\"sh\",\"ksh\",\"bat\",\"cmd\" ]", - "MaxFileSize": 25 + "MaxFileSize": 25, + "EmailAttachmentMaxFileSize": 20, + "EmailAttachmentsTotalMaxFileSize": 25 }, "Geocoder": { "ElectoralDistrict": {