203 lines
7.7 KiB
C#
203 lines
7.7 KiB
C#
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> websiteOptions)
|
|
{
|
|
public async Task<ReleaseUpdateEmailSendResultDto> 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<User> 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<int> SendDueDigestEmailsAsync(
|
|
TimeSpan inactiveThreshold,
|
|
TimeSpan sendInterval,
|
|
CancellationToken ct)
|
|
{
|
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
|
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
|
|
DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
|
|
|
|
List<User> 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<ReleaseUpdate> 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<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct)
|
|
{
|
|
User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct);
|
|
return sender is null ? [] : [sender];
|
|
}
|
|
|
|
private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct)
|
|
{
|
|
IQueryable<User> query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null);
|
|
|
|
if (audience == ReleaseUpdateAudience.Developers)
|
|
{
|
|
IList<User> 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 $"""
|
|
<h1>{HtmlEncode(update.Title)}</h1>
|
|
<p><strong>{HtmlEncode(update.Category.ToString())}</strong></p>
|
|
<p>{HtmlEncode(update.Summary)}</p>
|
|
{FormatBody(update.Body)}
|
|
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
|
|
""";
|
|
}
|
|
|
|
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
|
|
{
|
|
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
|
|
string listItems = string.Join(
|
|
Environment.NewLine,
|
|
updates.Select(update => $"<li><strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}</li>"));
|
|
|
|
return $"""
|
|
<h1>What's new in Socialize</h1>
|
|
<ul>{listItems}</ul>
|
|
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
|
|
""";
|
|
}
|
|
|
|
private static string FormatBody(string? body)
|
|
{
|
|
return string.IsNullOrWhiteSpace(body)
|
|
? string.Empty
|
|
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>";
|
|
}
|
|
|
|
private static string HtmlEncode(string? value)
|
|
{
|
|
return WebUtility.HtmlEncode(value ?? string.Empty);
|
|
}
|
|
}
|