Skip to content
Merged

Dev #2646

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
16 changes: 16 additions & 0 deletions applications/Unity.GrantManager/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Local secrets must never end up in a Docker build context / image,
# even for a locally-run `docker build` outside of CI (which never has
# these files in the first place, since they are gitignored).
**/appsettings.secrets.json
**/appsettings.Development.json
**/.env
**/.env.*
**/*.env

# Build artifacts and local tooling state - not needed in the build
# context and only bloat it / risk copying stale output.
**/bin/
**/obj/
**/node_modules/
.git/
.vs/
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ namespace Unity.Notifications.Emails;
public interface IEmailLogAttachmentAppService : IApplicationService
{
Task<List<EmailLogAttachmentDto>> GetListByEmailLogIdAsync(Guid emailLogId);
Task<List<EmailLogAttachmentDto>> GetListByTemplateIdAsync(Guid templateId);
Task DeleteAsync(Guid id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace Unity.Notifications.Emails;

public interface IEmailLogAttachmentUploadService
{
Task<EmailLogAttachmentDto> UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType);
Task<long> GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId);
Task<EmailLogAttachmentDto> UploadAsync(Guid? emailLogId, Guid? templateId, Guid? tenantId, string fileName, byte[] content, string contentType);
Task<long> GetTotalFileSizeByEmailLogIdAsync(Guid? emailLogId, Guid? templateId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ public class EmailTempateDto
public string BodyText { get; set; } = "";
public string BodyHTML { get; set; } = "";
public string SendFrom { get; set; } = "";
public string? RecipientCategory { get; set; }
public string? RecipientIdentifier { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Unity.Notifications.Integrations.RabbitMQ;
using Unity.Notifications.Settings;
using Volo.Abp;
using Volo.Abp.Data;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Domain.Services;
using Volo.Abp.Settings;
Expand All @@ -31,6 +32,8 @@ public class EmailNotificationManager(
EmailAttachmentService emailAttachmentService,
ISettingProvider settingProvider) : DomainService, IEmailNotificationManager
{
private static readonly TimeSpan BcPermanentDstOffset = TimeSpan.FromHours(-7);

public async Task<EmailLog?> CreateEmailLogAsync(EmailMessageParams email, Guid applicationId)
{
return await CreateEmailLogAsync(email, applicationId, EmailStatus.Initialized);
Expand Down Expand Up @@ -114,7 +117,22 @@ public async Task DeleteEmailLogAsync(Guid id)
}

await DeleteEmailAttachmentsAsync(id);
await emailLogsRepository.DeleteAsync(id);

try
{
await emailLogsRepository.DeleteAsync(id);
}
catch (AbpDbConcurrencyException)
{
// Handle concurrency exception - entity may have been deleted by another request
// Try to get fresh entity and delete it, or silently succeed if it's already gone
var freshEmailLog = await emailLogsRepository.FindAsync(id);
if (freshEmailLog != null)
{
await emailLogsRepository.DeleteAsync(freshEmailLog, autoSave: true);
}
// If entity doesn't exist, that's fine - it was already deleted
}
}

public async Task CancelEmailLogAsync(Guid id)
Expand Down Expand Up @@ -276,7 +294,8 @@ protected virtual async Task<dynamic> GetEmailObjectAsync(
// delayTS: desired UTC send time as Unix milliseconds; 0 = send immediately.
if (email.SendOnDateTime.HasValue)
{
emailObjectDictionary["delayTS"] = new DateTimeOffset(email.SendOnDateTime.Value, TimeSpan.Zero).ToUnixTimeMilliseconds();
var normalizedUtcSendOn = NormalizeToUtc(email.SendOnDateTime.Value);
emailObjectDictionary["delayTS"] = new DateTimeOffset(normalizedUtcSendOn).ToUnixTimeMilliseconds();
}

// templateName is not part of the CHES MessageObject schema
Expand All @@ -289,6 +308,16 @@ protected virtual async Task<dynamic> GetEmailObjectAsync(
return emailObject;
}

private static DateTime NormalizeToUtc(DateTime sendOnDateTime)
{
return sendOnDateTime.Kind switch
{
DateTimeKind.Utc => sendOnDateTime,
DateTimeKind.Local => sendOnDateTime.ToUniversalTime(),
_ => new DateTimeOffset(sendOnDateTime, BcPermanentDstOffset).UtcDateTime
};
}

protected virtual EmailLog UpdateMappedEmailLog(EmailLog emailLog, dynamic emailDynamicObject)
{
var dict = (IDictionary<string, object?>)emailDynamicObject;
Expand Down Expand Up @@ -331,9 +360,13 @@ private async Task<EmailLog> PopulateEmailLogAsync(
{
emailLog.ScheduledNotificationId = scheduledNotificationId.Value;
}
emailLog.SendOnDateTime = email.SendOnDateTime;
emailLog.Status = DetermineSendStatus(email.SendOnDateTime, status);
emailLog.EmailType = DetermineEmailType(email.SendOnDateTime);
var normalizedSendOnDateTime = email.SendOnDateTime.HasValue
? NormalizeToUtc(email.SendOnDateTime.Value)
: (DateTime?)null;

emailLog.SendOnDateTime = normalizedSendOnDateTime;
emailLog.Status = DetermineSendStatus(normalizedSendOnDateTime, status);
emailLog.EmailType = DetermineEmailType(normalizedSendOnDateTime);
return emailLog;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ public EmailAttachmentService(
}

public async Task<EmailLogAttachment> UploadAttachmentAsync(
Guid emailLogId,
Guid? emailLogId,
Guid? templateId,
Guid? tenantId,
string fileName,
byte[] fileContent,
string contentType)
{
var s3Key = BuildS3Key(tenantId, emailLogId, fileName);
var guid = emailLogId ?? templateId ?? throw new ArgumentException("Either emailLogId or templateId must be provided.");
var s3Key = BuildS3Key(tenantId, guid, fileName);
var bucket = _configuration[S3BucketConfigKey];

// Upload to S3
Expand All @@ -68,6 +70,7 @@ public async Task<EmailLogAttachment> UploadAttachmentAsync(
var attachment = new EmailLogAttachment
{
EmailLogId = emailLogId,
TemplateId = templateId,
S3ObjectKey = s3Key,
FileName = fileName,
DisplayName = fileName,
Expand Down Expand Up @@ -102,14 +105,16 @@ public async Task<EmailLogAttachment> UploadAttachmentAsync(
}

public async Task<EmailLogAttachment> UploadUserAttachmentAsync(
Guid emailLogId,
Guid? emailLogId,
Guid? templateId,
Guid? tenantId,
string fileName,
byte[] fileContent,
string contentType)
{
var uniqueKey = Guid.NewGuid();
var s3Key = BuildUserAttachmentS3Key(tenantId, emailLogId, uniqueKey, fileName);
Guid generateGuid = emailLogId ?? templateId ?? throw new ArgumentException("Either emailLogId or templateId must be provided.");
var s3Key = BuildUserAttachmentS3Key(tenantId, generateGuid, uniqueKey, fileName);
var bucket = _configuration[S3BucketConfigKey];

using var uploadStream = new MemoryStream(fileContent);
Expand All @@ -131,6 +136,7 @@ public async Task<EmailLogAttachment> UploadUserAttachmentAsync(
var attachment = new EmailLogAttachment
{
EmailLogId = emailLogId,
TemplateId = templateId,
S3ObjectKey = s3Key,
FileName = fileName,
DisplayName = fileName,
Expand Down Expand Up @@ -162,10 +168,22 @@ public async Task<List<EmailLogAttachment>> GetAttachmentsAsync(Guid emailLogId)
return await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId);
}

public async Task<long> GetTotalFileSizeAsync(Guid emailLogId)
public async Task<long> GetTotalFileSizeAsync(Guid? emailLogId, Guid? templateId)
{
var attachments = await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId);
return attachments.Sum(a => a.FileSize);
if(emailLogId != null)
{
var attachments = await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId.Value);
return attachments.Sum(a => a.FileSize);
}
else if(templateId != null)
{
var attachments = await _emailLogAttachmentRepository.GetByTemplateIdAsync(templateId.Value);
return attachments.Sum(a => a.FileSize);
}
else
{
throw new ArgumentException("Either emailLogId or templateId must be provided.");
}
}

private static string BuildUserAttachmentS3Key(Guid? tenantId, Guid emailLogId, Guid attachmentId, string fileName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Users;

namespace Unity.Notifications.Emails;
Expand All @@ -20,9 +21,29 @@ public class EmailLogAttachmentAppService(
EmailAttachmentService emailAttachmentService,
IExternalUserLookupServiceProvider externalUserLookupServiceProvider) : ApplicationService, IEmailLogAttachmentAppService, IEmailLogAttachmentUploadService
{

public async Task<List<EmailLogAttachmentDto>> GetListByEmailLogIdAsync(Guid emailLogId)
{
var attachments = await emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId);
return await GetListBydAsync(emailLogId, null);
}

public async Task<List<EmailLogAttachmentDto>> GetListByTemplateIdAsync(Guid templateId)
{
return await GetListBydAsync(null, templateId);
}

public async Task<List<EmailLogAttachmentDto>> GetListBydAsync(Guid? emailLogId, Guid? templateId)
{
var attachments = new List<EmailLogAttachment>();
if (emailLogId.HasValue)
{
attachments = await emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId.Value);
}
else if (templateId.HasValue)
{
attachments = await emailLogAttachmentRepository.GetByTemplateIdAsync(templateId.Value);
}

var dtos = new List<EmailLogAttachmentDto>();

foreach (var attachment in attachments)
Expand All @@ -45,9 +66,33 @@ public async Task<List<EmailLogAttachmentDto>> GetListByEmailLogIdAsync(Guid ema

public async Task DeleteAsync(Guid id)
{
var attachment = await emailLogAttachmentRepository.GetAsync(id);
// Idempotent delete: if already removed by another request, treat as success.
var attachment = await emailLogAttachmentRepository.FindAsync(id);
if (attachment == null)
{
return;
}

if (attachment.TemplateId.HasValue)
{
await emailAttachmentService.DeleteFromS3Async(attachment.S3ObjectKey);
try
{
await emailLogAttachmentRepository.DeleteAsync(attachment, autoSave: true);
}
catch (EntityNotFoundException)
{
// Already deleted by another request.
}
return;
}

if(attachment.EmailLogId == null)
{
throw new UserFriendlyException("Invalid email log ID.");
}

var emailLog = await emailLogsRepository.GetAsync(attachment.EmailLogId);
var emailLog = await emailLogsRepository.GetAsync(attachment.EmailLogId.Value);
if (emailLog.Status != EmailStatus.Draft)
{
throw new UserFriendlyException("Attachments can only be deleted from draft emails.");
Expand All @@ -61,17 +106,25 @@ public async Task DeleteAsync(Guid id)
{
Logger.LogError(ex, "Failed to delete S3 object {S3ObjectKey} for attachment {AttachmentId}", attachment.S3ObjectKey, id);
}
await emailLogAttachmentRepository.DeleteAsync(id);

try
{
await emailLogAttachmentRepository.DeleteAsync(attachment, autoSave: true);
}
catch (EntityNotFoundException)
{
// Already deleted by another request.
}
}

public async Task<long> GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId)
public async Task<long> GetTotalFileSizeByEmailLogIdAsync(Guid? emailLogId, Guid? templateId)
{
return await emailAttachmentService.GetTotalFileSizeAsync(emailLogId);
return await emailAttachmentService.GetTotalFileSizeAsync(emailLogId, templateId);
}

public async Task<EmailLogAttachmentDto> UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType)
public async Task<EmailLogAttachmentDto> UploadAsync(Guid? emailLogId, Guid? templateId, Guid? tenantId, string fileName, byte[] content, string contentType)
{
var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, tenantId, fileName, content, contentType);
var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, templateId, tenantId, fileName, content, contentType);

return new EmailLogAttachmentDto
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ private async Task<EmailLog> InitializeEmailAndUploadAttachments(EmailInitParams
{
await emailAttachmentService.UploadAttachmentAsync(
emailLog.Id,
null, // No templateId for user-uploaded attachments
emailLog.TenantId,
attachmentData.FileName,
attachmentData.Content,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public interface ITemplateService : IApplicationService
{
Task<EmailTemplate?> CreateAsync(EmailTempateDto templateDto);
Task<EmailTemplate?> UpdateTemplate(Guid id, EmailTempateDto templateDto);
Task<List<EmailTemplate>> GetTemplatesByTenent();
Task<List<EmailTemplate>> GetTemplatesByTenant();
Task<EmailTemplate?> GetTemplateById(Guid id);
Task DeleteTemplate(Guid id);
Task<EmailTemplate?> GetTemplateByName(string name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ ITemplateVariablesRepository templateVariablesRepository
templateDto.Description,
templateDto.Subject,
templateDto.BodyText,
templateDto.BodyHTML, templateDto.SendFrom));
templateDto.BodyHTML,
templateDto.SendFrom,
templateDto.RecipientCategory,
templateDto.RecipientIdentifier));
}

public async Task<EmailTemplate?> UpdateTemplate(Guid id, EmailTempateDto templateDto)
Expand All @@ -54,13 +57,15 @@ ITemplateVariablesRepository templateVariablesRepository
template.BodyHTML = templateDto.BodyHTML != null ? templateDto.BodyHTML : "";
template.Name = templateDto.Name;
template.SendFrom = templateDto.SendFrom;
template.RecipientCategory = templateDto.RecipientCategory;
template.RecipientIdentifier = templateDto.RecipientIdentifier;

// When being called here the current tenant is in context - verified by looking at the tenant id
EmailTemplate updatedTemplate = await _templatesRepository.UpdateAsync(template, autoSave: true);
return updatedTemplate;
}

public async Task<List<EmailTemplate>> GetTemplatesByTenent()
public async Task<List<EmailTemplate>> GetTemplatesByTenant()
{
var tenentId = _currentTenant.Id;
return await _templatesRepository.GetByTenentIdAsync(tenentId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ namespace Unity.Notifications.Emails;
public class EmailLogAttachment : AuditedAggregateRoot<Guid>, IMultiTenant
{
// Foreign key to EmailLog
public Guid EmailLogId { get; set; }
public Guid? EmailLogId { get; set; }
public Guid? TemplateId { get; set; }

// Original template ID when copying from template
public Guid? OriginTemplateId { get; set; }

// S3 storage properties
public string S3ObjectKey { get; set; } = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ namespace Unity.Notifications.Emails;
public interface IEmailLogAttachmentRepository : IBasicRepository<EmailLogAttachment, Guid>
{
Task<List<EmailLogAttachment>> GetByEmailLogIdAsync(Guid emailLogId);
Task<List<EmailLogAttachment>> GetByTemplateIdAsync(Guid templateId);
Task<List<EmailLogAttachment>> GetOriginAttachmentsByEmailLogIdAsync(Guid emailLogId);
}
Loading
Loading