using System.Net; using System.Security.Claims; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Configuration; using Socialize.Api.Infrastructure.Emailer.Contracts; using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Organizations.Services; using Socialize.Api.Modules.ReleaseCommunications.Contracts; using Socialize.Api.Modules.ReleaseCommunications.Data; namespace Socialize.Api.Modules.ReleaseCommunications.Services; internal class ReleaseUpdateEmailService( AppDbContext dbContext, UserManager userManager, IEmailSender emailSender, IOptionsSnapshot websiteOptions) { public async Task SendManualUpdateEmailAsync( ReleaseUpdate update, Guid senderUserId, bool testMode, bool confirmResend, CancellationToken ct) { if (update.Status != ReleaseUpdateStatus.Published) { throw new InvalidOperationException("Only published release updates can be emailed."); } if (!testMode && update.ManualEmailSentAt.HasValue && !confirmResend) { throw new InvalidOperationException("This release update was already emailed. Confirm resend to send it again."); } IReadOnlyCollection recipients = testMode ? await GetTestRecipientsAsync(senderUserId, ct) : await GetAudienceRecipientsAsync(update.Audience, ct); DateTimeOffset now = DateTimeOffset.UtcNow; foreach (User recipient in recipients.Where(recipient => !string.IsNullOrWhiteSpace(recipient.Email))) { await emailSender.SendEmailAsync( recipient.Email!, $"What's new in Socialize: {update.Title}", BuildSingleUpdateEmail(update)); } if (!testMode) { update.ManualEmailSentByUserId = senderUserId; update.ManualEmailSentAt = now; update.ManualEmailAudience = update.Audience.ToString(); update.ManualEmailRecipientCount = recipients.Count; update.UpdatedAt = now; } return new ReleaseUpdateEmailSendResultDto(recipients.Count, now, testMode); } public async Task SendDueDigestEmailsAsync( TimeSpan inactiveThreshold, TimeSpan sendInterval, CancellationToken ct) { DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold); DateTimeOffset lastSentBefore = now.Subtract(sendInterval); List ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, ct); int sentCount = 0; foreach (User user in ownerUsers) { if (string.IsNullOrWhiteSpace(user.Email) || !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)) { continue; } DateTimeOffset? lastDigestSentAt = await dbContext.ReleaseUpdateEmailDigestReceipts .Where(receipt => receipt.UserId == user.Id) .OrderByDescending(receipt => receipt.SentAt) .Select(receipt => (DateTimeOffset?)receipt.SentAt) .FirstOrDefaultAsync(ct); if (!ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore)) { continue; } ReleaseUpdateAudienceContext audienceContext = await ReleaseUpdateVisibility.GetAudienceContextAsync( dbContext, new ClaimsPrincipal(new ClaimsIdentity()), user.Id, ct); List unreadUpdates = await dbContext.ReleaseUpdates .VisibleTo(audienceContext) .Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt => receipt.ReleaseUpdateId == update.Id && receipt.UserId == user.Id)) .OrderByDescending(update => update.PublishedAt) .Take(10) .ToListAsync(ct); if (unreadUpdates.Count == 0) { continue; } await emailSender.SendEmailAsync( user.Email, "What's new in Socialize", BuildDigestEmail(unreadUpdates)); dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt { Id = Guid.NewGuid(), UserId = user.Id, SentAt = now, UpdateCount = unreadUpdates.Count, }); sentCount++; } await dbContext.SaveChangesAsync(ct); return sentCount; } private async Task> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct) { User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct); return sender is null ? [] : [sender]; } private async Task> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct) { IQueryable query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null); if (audience == ReleaseUpdateAudience.Developers) { IList developers = await userManager.GetUsersInRoleAsync(KnownRoles.Developer); return developers.Where(user => user.EmailConfirmed && !string.IsNullOrWhiteSpace(user.Email)).ToList(); } if (audience == ReleaseUpdateAudience.OrganizationOwners) { Guid[] ownerUserIds = await dbContext.Organizations .Select(organization => organization.OwnerUserId) .Concat(dbContext.OrganizationMemberships .Where(membership => membership.Role == OrganizationRoles.Owner) .Select(membership => membership.UserId)) .Distinct() .ToArrayAsync(ct); query = query.Where(user => ownerUserIds.Contains(user.Id)); } return await query.OrderBy(user => user.Email).ToListAsync(ct); } private string BuildSingleUpdateEmail(ReleaseUpdate update) { string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates?updateId={update.Id}"; return $"""

{HtmlEncode(update.Title)}

{HtmlEncode(update.Category.ToString())}

{HtmlEncode(update.Summary)}

{FormatBody(update.Body)}

Open What's New

"""; } private string BuildDigestEmail(IReadOnlyCollection updates) { string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates"; string listItems = string.Join( Environment.NewLine, updates.Select(update => $"
  • {HtmlEncode(update.Title)}
    {HtmlEncode(update.Summary)}
  • ")); return $"""

    What's new in Socialize

      {listItems}

    Open What's New

    """; } private static string FormatBody(string? body) { return string.IsNullOrWhiteSpace(body) ? string.Empty : $"

    {HtmlEncode(body).Replace(Environment.NewLine, "
    ", StringComparison.Ordinal)}

    "; } private static string HtmlEncode(string? value) { return WebUtility.HtmlEncode(value ?? string.Empty); } }