using MemberCenter.Application.Abstractions; using MemberCenter.Application.Models.Newsletter; using MemberCenter.Domain.Constants; using MemberCenter.Domain.Entities; using MemberCenter.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace MemberCenter.Infrastructure.Services; public sealed class NewsletterService : INewsletterService { private const string ConfirmTokenPurpose = "confirm"; private const string UnsubscribeTokenPurpose = "unsubscribe"; private const int ConfirmTokenTtlDays = 7; private const int UnsubscribeTokenTtlDays = 7; private readonly MemberCenterDbContext _dbContext; private readonly IEmailBlacklistService _emailBlacklist; public NewsletterService(MemberCenterDbContext dbContext, IEmailBlacklistService emailBlacklist) { _dbContext = dbContext; _emailBlacklist = emailBlacklist; } public async Task SubscribeAsync(Guid listId, string email, Dictionary? preferences) { if (await _emailBlacklist.IsBlacklistedAsync(email)) { return null; } var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId); if (list is null) { return null; } var subscription = await _dbContext.NewsletterSubscriptions .FirstOrDefaultAsync(s => s.ListId == listId && s.Email == email); if (subscription is null) { subscription = new NewsletterSubscription { Id = Guid.NewGuid(), ListId = listId, Email = email, Status = SubscriptionStatus.Pending, Preferences = ToJsonDocument(preferences) }; _dbContext.NewsletterSubscriptions.Add(subscription); } else { // Keep active subscriptions active; only pending/unsubscribed subscriptions need reconfirmation. if (!string.Equals(subscription.Status, SubscriptionStatus.Active, StringComparison.OrdinalIgnoreCase)) { subscription.Status = SubscriptionStatus.Pending; } subscription.Preferences = ToJsonDocument(preferences); } var confirmToken = CreateToken(); _dbContext.UnsubscribeTokens.Add(new UnsubscribeToken { Id = Guid.NewGuid(), SubscriptionId = subscription.Id, TokenHash = HashToken(confirmToken, ConfirmTokenPurpose), ExpiresAt = DateTimeOffset.UtcNow.AddDays(ConfirmTokenTtlDays) }); await _dbContext.SaveChangesAsync(); return new PendingSubscriptionResult(MapSubscription(subscription), confirmToken); } public async Task ConfirmAsync(string token) { var confirmToken = await FindTokenAsync(token, ConfirmTokenPurpose); if (confirmToken is null && token.Contains(' ')) { // Backward compatibility: legacy Base64 tokens may decode '+' as space in query string. confirmToken = await FindTokenAsync(token.Replace(' ', '+'), ConfirmTokenPurpose); } if (confirmToken?.Subscription is null) { return null; } if (await _emailBlacklist.IsBlacklistedAsync(confirmToken.Subscription.Email)) { return null; } if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow) { return null; } confirmToken.Subscription.Status = SubscriptionStatus.Active; confirmToken.ConsumedAt = DateTimeOffset.UtcNow; await _dbContext.SaveChangesAsync(); return MapSubscription(confirmToken.Subscription); } public async Task UnsubscribeAsync(string token) { var tokenHash = HashToken(token, UnsubscribeTokenPurpose); var unsubscribeToken = await _dbContext.UnsubscribeTokens .Include(t => t.Subscription) .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null); if (unsubscribeToken?.Subscription is null) { return null; } if (await _emailBlacklist.IsBlacklistedAsync(unsubscribeToken.Subscription.Email)) { return null; } unsubscribeToken.Subscription.Status = SubscriptionStatus.Unsubscribed; unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow; await _dbContext.SaveChangesAsync(); return MapSubscription(unsubscribeToken.Subscription); } public async Task IssueUnsubscribeTokenAsync(Guid listId, string email) { if (await _emailBlacklist.IsBlacklistedAsync(email)) { return null; } var subscription = await _dbContext.NewsletterSubscriptions .Where(s => s.ListId == listId && s.Email == email) .OrderByDescending(s => s.CreatedAt) .FirstOrDefaultAsync(); if (subscription is null) { return null; } var token = CreateToken(); _dbContext.UnsubscribeTokens.Add(new UnsubscribeToken { Id = Guid.NewGuid(), SubscriptionId = subscription.Id, TokenHash = HashToken(token, UnsubscribeTokenPurpose), ExpiresAt = DateTimeOffset.UtcNow.AddDays(UnsubscribeTokenTtlDays) }); await _dbContext.SaveChangesAsync(); return token; } public async Task GetPreferencesAsync(Guid listId, string email) { if (await _emailBlacklist.IsBlacklistedAsync(email)) { return null; } var subscription = await _dbContext.NewsletterSubscriptions .Where(s => s.ListId == listId && s.Email == email) .OrderByDescending(s => s.CreatedAt) .FirstOrDefaultAsync(); return subscription is null ? null : MapSubscription(subscription); } public async Task UpdatePreferencesAsync(Guid listId, string email, Dictionary preferences) { if (await _emailBlacklist.IsBlacklistedAsync(email)) { return null; } var subscription = await _dbContext.NewsletterSubscriptions .Where(s => s.ListId == listId && s.Email == email) .OrderByDescending(s => s.CreatedAt) .FirstOrDefaultAsync(); if (subscription is null) { return null; } subscription.Preferences = ToJsonDocument(preferences); await _dbContext.SaveChangesAsync(); return MapSubscription(subscription); } public async Task> ListSubscriptionsAsync(Guid listId) { var blacklisted = _dbContext.EmailBlacklist.Select(x => x.Email); var subscriptions = await _dbContext.NewsletterSubscriptions .Where(s => s.ListId == listId && !blacklisted.Contains(s.Email.ToLower())) .OrderByDescending(s => s.CreatedAt) .ToListAsync(); return subscriptions.Select(MapSubscription).ToList(); } private static string CreateToken() { var bytes = RandomNumberGenerator.GetBytes(32); return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } private static string HashToken(string token, string purpose) { using var sha = SHA256.Create(); var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes($"{purpose}:{token}")); return Convert.ToHexString(bytes).ToLowerInvariant(); } private static JsonDocument ToJsonDocument(Dictionary? value) { if (value is null) { return JsonDocument.Parse("{}"); } return JsonDocument.Parse(JsonSerializer.Serialize(value)); } private static SubscriptionDto MapSubscription(NewsletterSubscription subscription) { return new SubscriptionDto( subscription.Id, subscription.ListId, subscription.Email, subscription.Status, subscription.Preferences.RootElement.Clone(), subscription.CreatedAt); } private Task FindTokenAsync(string token, string purpose) { var tokenHash = HashToken(token, purpose); return _dbContext.UnsubscribeTokens .Include(t => t.Subscription) .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null); } }