using System.ComponentModel.DataAnnotations; using System.Net; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; // Intentional exception: cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class EmailBroadcastController : Controller { private static readonly Regex ScriptRegex = new( @"<\s*(script|style|iframe|object|embed|form|input|button|textarea|select|meta|link)\b[^>]*>.*?<\s*/\s*\1\s*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex CommentRegex = new( @"", RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex TagRegex = new( @"<\s*(/?)\s*([a-z0-9]+)([^>]*)>", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex HrefRegex = new( @"href\s*=\s*(['""]?)([^'"">\s]+)\1", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly HashSet AllowedTags = [ "a", "p", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4" ]; private readonly ApplicationDbContext _db; private readonly IEmailService _emailService; private readonly IPlatformSettingsService _platformSettings; private readonly ILogger _logger; public EmailBroadcastController( ApplicationDbContext db, IEmailService emailService, IPlatformSettingsService platformSettings, ILogger logger) { _db = db; _emailService = emailService; _platformSettings = platformSettings; _logger = logger; } [HttpGet] public IActionResult Index() => View(new AdminEmailComposeModel()); [HttpPost, ValidateAntiForgeryToken] public async Task SelectCompanies(AdminEmailComposeModel form) { NormalizeComposeModel(form); if (!ValidateComposeModel(form)) return View("Index", form); var viewModel = await BuildSelectionModelAsync(form); return View(viewModel); } [HttpPost, ValidateAntiForgeryToken] public IActionResult BackToCompose(AdminEmailComposeModel form) { NormalizeComposeModel(form); return View("Index", form); } [HttpPost, ValidateAntiForgeryToken] public async Task Preview(AdminEmailSelectionModel form) { NormalizeComposeModel(form); if (!ValidateComposeModel(form)) return View("Index", new AdminEmailComposeModel { Subject = form.Subject, BodyHtml = form.BodyHtml }); if (!ValidateCompanySelection(form)) return View("SelectCompanies", await BuildSelectionModelAsync(form)); var preview = await BuildPreviewModelAsync(form); return View(preview); } [HttpPost, ValidateAntiForgeryToken] public async Task BackToSelectCompanies(AdminEmailSelectionModel form) { NormalizeComposeModel(form); return View("SelectCompanies", await BuildSelectionModelAsync(form)); } [HttpPost, ValidateAntiForgeryToken] public async Task Send(AdminEmailSendRequest form) { NormalizeComposeModel(form); if (!ValidateComposeModel(form)) return View("Index", new AdminEmailComposeModel { Subject = form.Subject, BodyHtml = form.BodyHtml }); if (!ValidateCompanySelection(form)) return View("SelectCompanies", await BuildSelectionModelAsync(form)); var recipients = await LoadRecipientContextsAsync(form.CompanyIds!); var replyToEmail = await GetAdminReplyToAsync(); const string replyToName = "Powder Coating Logix Admin"; var sent = 0; var failed = 0; var skipped = 0; foreach (var recipient in recipients) { var renderedSubject = RenderPlainTemplate(form.Subject, recipient); var renderedBody = RenderHtmlTemplate(form.BodyHtml, recipient); var plainTextBody = ConvertHtmlToPlainText(renderedBody); if (string.IsNullOrWhiteSpace(recipient.RecipientEmail)) { skipped++; await WriteLogAsync(new NotificationLog { Channel = NotificationChannel.Email, NotificationType = NotificationType.AdminEmail, Status = NotificationStatus.Skipped, RecipientName = recipient.RecipientName, Recipient = string.Empty, Subject = renderedSubject, Message = plainTextBody, ErrorMessage = "Company primary contact email is not configured.", SentAt = DateTime.UtcNow, CompanyId = recipient.CompanyId }); continue; } var wrappedHtml = WrapRenderedHtml(renderedBody); var (success, error) = await _emailService.SendEmailAsync( recipient.RecipientEmail, recipient.RecipientName, renderedSubject, plainTextBody, wrappedHtml, replyToEmail: replyToEmail, replyToName: replyToEmail is null ? null : replyToName); if (success) sent++; else failed++; await WriteLogAsync(new NotificationLog { Channel = NotificationChannel.Email, NotificationType = NotificationType.AdminEmail, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, RecipientName = recipient.RecipientName, Recipient = recipient.RecipientEmail, Subject = renderedSubject, Message = plainTextBody, ErrorMessage = error, SentAt = DateTime.UtcNow, CompanyId = recipient.CompanyId }); } TempData["Success"] = $"Admin email processed for {recipients.Count} selected compan{(recipients.Count == 1 ? "y" : "ies")}: {sent} sent, {failed} failed, {skipped} skipped."; return RedirectToAction(nameof(Index)); } private bool ValidateComposeModel(AdminEmailComposeModel form) { if (string.IsNullOrWhiteSpace(form.Subject)) ModelState.AddModelError(nameof(form.Subject), "Subject is required."); if (string.IsNullOrWhiteSpace(ConvertHtmlToPlainText(form.BodyHtml))) ModelState.AddModelError(nameof(form.BodyHtml), "Message body is required."); return ModelState.IsValid; } private bool ValidateCompanySelection(AdminEmailSelectionModel form) { if (form.CompanyIds == null || form.CompanyIds.Length == 0) ModelState.AddModelError(nameof(form.CompanyIds), "Select at least one company."); return ModelState.IsValid; } private static void NormalizeComposeModel(AdminEmailComposeModel form) { form.Subject = (form.Subject ?? string.Empty).Trim(); form.BodyHtml = SanitizeHtml(form.BodyHtml); } private async Task BuildSelectionModelAsync(AdminEmailComposeModel form) { var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null ? selection.CompanyIds : Array.Empty(); return new AdminEmailSelectionModel { Subject = form.Subject, BodyHtml = form.BodyHtml, CompanyIds = selectedIds, AvailableCompanies = await LoadCompanyOptionsAsync(selectedIds) }; } private async Task BuildPreviewModelAsync(AdminEmailSelectionModel form) { var recipients = await LoadRecipientContextsAsync(form.CompanyIds!); if (recipients.Count == 0) { return new AdminEmailPreviewModel { Subject = form.Subject, BodyHtml = form.BodyHtml, CompanyIds = form.CompanyIds, SelectedCompanies = [], EligibleCount = 0, SkippedCount = 0 }; } var sampleRecipient = recipients.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)) ?? recipients.First(); var sampleSubject = RenderPlainTemplate(form.Subject, sampleRecipient); var sampleBody = RenderHtmlTemplate(form.BodyHtml, sampleRecipient); return new AdminEmailPreviewModel { Subject = form.Subject, BodyHtml = form.BodyHtml, CompanyIds = form.CompanyIds, SelectedCompanies = recipients.Select(r => new AdminEmailRecipientPreviewRow { CompanyId = r.CompanyId, CompanyName = r.CompanyName, RecipientName = r.RecipientName, RecipientEmail = r.RecipientEmail, CompanyAdminName = r.CompanyAdminName, CompanyAdminEmail = r.CompanyAdminEmail, CanSend = !string.IsNullOrWhiteSpace(r.RecipientEmail), SkipReason = string.IsNullOrWhiteSpace(r.RecipientEmail) ? "Missing primary contact email" : null }).ToList(), EligibleCount = recipients.Count(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)), SkippedCount = recipients.Count(r => string.IsNullOrWhiteSpace(r.RecipientEmail)), SamplePreview = new AdminEmailRenderedPreview { CompanyName = sampleRecipient.CompanyName, RecipientName = sampleRecipient.RecipientName, RecipientEmail = sampleRecipient.RecipientEmail, RenderedSubject = sampleSubject, RenderedHtmlBody = WrapRenderedHtml(sampleBody) } }; } private async Task> LoadCompanyOptionsAsync(IReadOnlyCollection selectedIds) { var companies = await _db.Companies .AsNoTracking() .IgnoreQueryFilters() .Where(c => !c.IsDeleted) .OrderBy(c => c.CompanyName) .Select(c => new AdminEmailCompanyOption { CompanyId = c.Id, CompanyName = c.CompanyName, PrimaryContactName = c.PrimaryContactName, PrimaryContactEmail = c.PrimaryContactEmail, IsActive = c.IsActive }) .ToListAsync(); var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.CompanyId).ToArray()); foreach (var company in companies) { company.IsSelected = selectedIds.Contains(company.CompanyId); if (adminLookup.TryGetValue(company.CompanyId, out var admin)) { company.CompanyAdminName = admin.FullName; company.CompanyAdminEmail = admin.Email; } } return companies; } private async Task> LoadRecipientContextsAsync(IReadOnlyCollection companyIds) { var companies = await _db.Companies .AsNoTracking() .IgnoreQueryFilters() .Where(c => companyIds.Contains(c.Id) && !c.IsDeleted) .OrderBy(c => c.CompanyName) .Select(c => new { c.Id, c.CompanyName, c.PrimaryContactName, c.PrimaryContactEmail }) .ToListAsync(); var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.Id).ToArray()); return companies.Select(company => { adminLookup.TryGetValue(company.Id, out var admin); return new AdminEmailRecipientContext { CompanyId = company.Id, CompanyName = company.CompanyName, RecipientName = string.IsNullOrWhiteSpace(company.PrimaryContactName) ? company.CompanyName : company.PrimaryContactName, RecipientEmail = company.PrimaryContactEmail, FirstName = ExtractFirstName(company.PrimaryContactName, company.CompanyName), PrimaryContactName = company.PrimaryContactName, CompanyAdminName = admin?.FullName, CompanyAdminEmail = admin?.Email, CompanyAdminFirstName = ExtractFirstName(admin?.FullName, company.CompanyName) }; }).ToList(); } private async Task> LoadCompanyAdminLookupAsync(IReadOnlyCollection companyIds) { var admins = await _db.Users .AsNoTracking() .Where(u => companyIds.Contains(u.CompanyId) && u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin && u.IsActive) .OrderBy(u => u.CreatedAt) .Select(u => new { u.CompanyId, u.FirstName, u.LastName, u.Email }) .ToListAsync(); return admins .GroupBy(u => u.CompanyId) .ToDictionary( g => g.Key, g => { var admin = g.First(); return new CompanyAdminLookup { FullName = $"{admin.FirstName} {admin.LastName}".Trim(), Email = admin.Email ?? string.Empty }; }); } private async Task GetAdminReplyToAsync() { var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AdminNotificationEmail); if (string.IsNullOrWhiteSpace(raw)) return null; return raw .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault(email => email.Contains('@')); } private async Task WriteLogAsync(NotificationLog log) { try { await _db.NotificationLogs.AddAsync(log); await _db.SaveChangesAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to write admin email notification log for company {CompanyId}", log.CompanyId); } } private static string RenderPlainTemplate(string template, AdminEmailRecipientContext recipient) { var rendered = template ?? string.Empty; foreach (var replacement in BuildReplacementDictionary(recipient)) rendered = rendered.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase); return rendered.Trim(); } private static string RenderHtmlTemplate(string templateHtml, AdminEmailRecipientContext recipient) { var rendered = templateHtml ?? string.Empty; foreach (var replacement in BuildReplacementDictionary(recipient)) rendered = rendered.Replace( replacement.Key, WebUtility.HtmlEncode(replacement.Value), StringComparison.OrdinalIgnoreCase); return rendered; } private static Dictionary BuildReplacementDictionary(AdminEmailRecipientContext recipient) => new(StringComparer.OrdinalIgnoreCase) { ["{{FirstName}}"] = recipient.FirstName, ["{{FullName}}"] = recipient.RecipientName, ["{{CompanyName}}"] = recipient.CompanyName, ["{{PrimaryContactName}}"] = recipient.PrimaryContactName ?? recipient.RecipientName, ["{{PrimaryContactEmail}}"] = recipient.RecipientEmail ?? string.Empty, ["{{CompanyAdminFirstName}}"] = recipient.CompanyAdminFirstName ?? string.Empty, ["{{CompanyAdminName}}"] = recipient.CompanyAdminName ?? string.Empty, ["{{CompanyAdminEmail}}"] = recipient.CompanyAdminEmail ?? string.Empty }; private static string WrapRenderedHtml(string renderedHtmlBody) { return $"""
{renderedHtmlBody}
"""; } private static string SanitizeHtml(string? html) { if (string.IsNullOrWhiteSpace(html)) return string.Empty; var sanitized = CommentRegex.Replace(html, string.Empty); sanitized = ScriptRegex.Replace(sanitized, string.Empty); sanitized = sanitized.Replace("", "

", StringComparison.OrdinalIgnoreCase); sanitized = TagRegex.Replace(sanitized, match => { var isClosingTag = match.Groups[1].Value == "/"; var tagName = match.Groups[2].Value.ToLowerInvariant(); var attributes = match.Groups[3].Value; if (!AllowedTags.Contains(tagName)) return string.Empty; if (tagName == "br") return "
"; if (tagName == "a") { if (isClosingTag) return ""; var href = HrefRegex.Match(attributes); var hrefValue = href.Success ? href.Groups[2].Value : string.Empty; if (!IsSafeHref(hrefValue)) return string.Empty; var encodedHref = WebUtility.HtmlEncode(hrefValue); return $""""""; } return isClosingTag ? $"" : $"<{tagName}>"; }); return sanitized.Trim(); } private static bool IsSafeHref(string? href) { if (string.IsNullOrWhiteSpace(href)) return false; return href.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || href.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || href.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); } private static string ConvertHtmlToPlainText(string? html) { if (string.IsNullOrWhiteSpace(html)) return string.Empty; var plain = html; plain = Regex.Replace(plain, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase); plain = Regex.Replace(plain, @"<\s*/\s*(p|h1|h2|h3|h4|blockquote)\s*>", "\n\n", RegexOptions.IgnoreCase); plain = Regex.Replace(plain, @"<\s*li\s*>", "- ", RegexOptions.IgnoreCase); plain = Regex.Replace(plain, @"<\s*/\s*li\s*>", "\n", RegexOptions.IgnoreCase); plain = Regex.Replace(plain, @"<[^>]+>", string.Empty); plain = WebUtility.HtmlDecode(plain); plain = Regex.Replace(plain, @"\n{3,}", "\n\n"); return plain.Trim(); } private static string ExtractFirstName(string? fullName, string fallback) { if (string.IsNullOrWhiteSpace(fullName)) return fallback; var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return parts.Length == 0 ? fallback : parts[0]; } } public class AdminEmailComposeModel { [Required] public string Subject { get; set; } = string.Empty; [Required] public string BodyHtml { get; set; } = string.Empty; } public class AdminEmailSelectionModel : AdminEmailComposeModel { public int[]? CompanyIds { get; set; } public List AvailableCompanies { get; set; } = []; } public class AdminEmailSendRequest : AdminEmailSelectionModel; public class AdminEmailPreviewModel : AdminEmailSendRequest { public List SelectedCompanies { get; set; } = []; public int EligibleCount { get; set; } public int SkippedCount { get; set; } public AdminEmailRenderedPreview SamplePreview { get; set; } = new(); } public class AdminEmailCompanyOption { public int CompanyId { get; set; } public string CompanyName { get; set; } = string.Empty; public string? PrimaryContactName { get; set; } public string? PrimaryContactEmail { get; set; } public string? CompanyAdminName { get; set; } public string? CompanyAdminEmail { get; set; } public bool IsActive { get; set; } public bool IsSelected { get; set; } } public class AdminEmailRecipientPreviewRow { public int CompanyId { get; set; } public string CompanyName { get; set; } = string.Empty; public string RecipientName { get; set; } = string.Empty; public string? RecipientEmail { get; set; } public string? CompanyAdminName { get; set; } public string? CompanyAdminEmail { get; set; } public bool CanSend { get; set; } public string? SkipReason { get; set; } } public class AdminEmailRenderedPreview { public string CompanyName { get; set; } = string.Empty; public string RecipientName { get; set; } = string.Empty; public string? RecipientEmail { get; set; } public string RenderedSubject { get; set; } = string.Empty; public string RenderedHtmlBody { get; set; } = string.Empty; } internal sealed class AdminEmailRecipientContext { public int CompanyId { get; init; } public string CompanyName { get; init; } = string.Empty; public string RecipientName { get; init; } = string.Empty; public string? RecipientEmail { get; init; } public string FirstName { get; init; } = string.Empty; public string? PrimaryContactName { get; init; } public string? CompanyAdminFirstName { get; init; } public string? CompanyAdminName { get; init; } public string? CompanyAdminEmail { get; init; } } internal sealed class CompanyAdminLookup { public string FullName { get; init; } = string.Empty; public string Email { get; init; } = string.Empty; }