diff --git a/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs b/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs index 38760cd..b097146 100644 --- a/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs +++ b/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs @@ -10,6 +10,7 @@ public class NotificationLogDto public NotificationType NotificationType { get; set; } public string NotificationTypeDisplay => NotificationType switch { + NotificationType.AdminEmail => "Admin Email", NotificationType.QuoteSent => "Quote Sent", NotificationType.QuoteApproved => "Quote Approved", NotificationType.JobStatusChanged => "Job Status Changed", diff --git a/src/PowderCoating.Core/Enums/NotificationEnums.cs b/src/PowderCoating.Core/Enums/NotificationEnums.cs index 51e9384..50435d6 100644 --- a/src/PowderCoating.Core/Enums/NotificationEnums.cs +++ b/src/PowderCoating.Core/Enums/NotificationEnums.cs @@ -17,5 +17,6 @@ public enum NotificationType SubscriptionExpiryReminder = 10, SubscriptionExpired = 11, SmsInboundStop = 12, - SmsInboundHelp = 13 + SmsInboundHelp = 13, + AdminEmail = 14 } diff --git a/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs b/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs index b66570b..c2ff3df 100644 --- a/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs +++ b/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs @@ -1,207 +1,597 @@ +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; -/// -/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant -/// company contacts. Emails are sent one at a time via -/// rather than bulk API because each message requires a personalised unsubscribe link -/// containing the company's unique MarketingUnsubscribeToken. -/// [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; } - /// - /// Renders the broadcast compose form, pre-populating ViewBag with plan configs - /// and the active company list so the targeting dropdowns are populated without - /// a separate AJAX call. - /// - public async Task Index() - { - await PopulateViewBag(); - return View(new BroadcastForm()); - } - - /// Returns JSON count of recipients for the current filter — used for the live preview. [HttpGet] - public async Task RecipientCount(string target, string? plan, int[]? companyIds) + public IActionResult Index() => View(new AdminEmailComposeModel()); + + [HttpPost, ValidateAntiForgeryToken] + public async Task SelectCompanies(AdminEmailComposeModel form) { - var recipients = await BuildRecipientListAsync(target, plan, companyIds); - return Json(new { count = recipients.Count }); + NormalizeComposeModel(form); + + if (!ValidateComposeModel(form)) + return View("Index", form); + + var viewModel = await BuildSelectionModelAsync(form); + return View(viewModel); } - /// - /// Sends the composed broadcast email to all recipients matching the chosen - /// targeting criteria. Aborts early (with a validation error) if no recipients - /// are found, to prevent accidental empty sends. - /// - /// Each email body is HTML-encoded (then line-breaks converted to - /// <br>) and wrapped in a branded container that appends a - /// per-company unsubscribe footer. Failures are counted and reported in the - /// success banner rather than aborting the remainder of the batch, so a single - /// bad address does not block delivery to every other recipient. - /// - /// [HttpPost, ValidateAntiForgeryToken] - public async Task Send(BroadcastForm form) + public IActionResult BackToCompose(AdminEmailComposeModel form) { - if (!ModelState.IsValid) + 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) { - await PopulateViewBag(); - return View("Index", form); - } + var renderedSubject = RenderPlainTemplate(form.Subject, recipient); + var renderedBody = RenderHtmlTemplate(form.BodyHtml, recipient); + var plainTextBody = ConvertHtmlToPlainText(renderedBody); - var recipients = await BuildRecipientListAsync(form.Target, form.PlanFilter, form.CompanyIds); - - if (recipients.Count == 0) - { - TempData["Error"] = "No recipients matched the selected criteria."; - await PopulateViewBag(); - return View("Index", form); - } - - int sent = 0, failed = 0; - var baseUrl = $"{Request.Scheme}://{Request.Host}"; - var encodedBody = System.Net.WebUtility.HtmlEncode(form.Body).Replace("\n", "
"); - - foreach (var (email, name, unsubToken) in recipients) - { - var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}"; - var htmlBody = $@" -
-

{encodedBody}

-
-

- This message was sent by the Powder Coating Logix platform team.
- Unsubscribe from platform announcements -

-
"; + 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( - email, name, form.Subject, form.Body, htmlBody); + recipient.RecipientEmail, + recipient.RecipientName, + renderedSubject, + plainTextBody, + wrappedHtml, + replyToEmail: replyToEmail, + replyToName: replyToEmail is null ? null : replyToName); if (success) sent++; - else + else failed++; + + await WriteLogAsync(new NotificationLog { - failed++; - _logger.LogWarning("Broadcast email failed for {Email}: {Error}", email, error); - } + 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"] = $"Broadcast sent: {sent} delivered, {failed} failed. Total recipients: {recipients.Count}."; + 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)); } - /// - /// Builds the list of (email, name, unsubscribe-token) tuples for the given - /// targeting criteria. Companies are excluded when MarketingEmailOptOut - /// is true — honouring prior unsubscribes — or when PrimaryContactEmail - /// is missing. The "specific" target requires at least one companyIds - /// entry and returns an empty list otherwise to prevent accidental all-company sends. - /// IgnoreQueryFilters() is required because this query spans companies. - /// - private async Task> BuildRecipientListAsync( - string? target, string? planFilter, int[]? companyIds) + private bool ValidateComposeModel(AdminEmailComposeModel form) { - var companyQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted && c.IsActive && !string.IsNullOrEmpty(c.PrimaryContactEmail) - && !c.MarketingEmailOptOut); + if (string.IsNullOrWhiteSpace(form.Subject)) + ModelState.AddModelError(nameof(form.Subject), "Subject is required."); - target ??= "active"; + if (string.IsNullOrWhiteSpace(ConvertHtmlToPlainText(form.BodyHtml))) + ModelState.AddModelError(nameof(form.BodyHtml), "Message body is required."); - switch (target) + 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 { - case "active": - companyQuery = companyQuery.Where(c => - c.SubscriptionStatus == SubscriptionStatus.Active || - c.SubscriptionStatus == SubscriptionStatus.GracePeriod); - break; - case "plan": - if (!string.IsNullOrWhiteSpace(planFilter) && int.TryParse(planFilter, out var planInt)) - companyQuery = companyQuery.Where(c => c.SubscriptionPlan == planInt); - break; - case "status_grace": - companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod); - break; - case "status_expired": - companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.Expired); - break; - case "specific": - if (companyIds != null && companyIds.Length > 0) - companyQuery = companyQuery.Where(c => companyIds.Contains(c.Id)); - else - return new List<(string, string, string)>(); - break; - case "all": - default: - break; + 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 companies = await companyQuery - .Select(c => new { c.PrimaryContactEmail, c.CompanyName, c.MarketingUnsubscribeToken }) - .ToListAsync(); + var sampleRecipient = recipients.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)) + ?? recipients.First(); - return companies - .Where(c => !string.IsNullOrWhiteSpace(c.PrimaryContactEmail)) - .Select(c => (c.PrimaryContactEmail!, c.CompanyName, c.MarketingUnsubscribeToken)) - .ToList(); + 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) + } + }; } - /// - /// Hydrates ViewBag with the data sets needed by the broadcast compose view: - /// active subscription plan configs (for the plan-filter dropdown), - /// all non-deleted active companies (for the specific-company picker), - /// and a live count of active/grace-period companies shown in the UI summary. - /// Centralised here so it can be called from both and the - /// validation-failure branch of . - /// - private async Task PopulateViewBag() + private async Task> LoadCompanyOptionsAsync(IReadOnlyCollection selectedIds) { - ViewBag.PlanConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters() - .Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync(); - - ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted && c.IsActive) + var companies = await _db.Companies + .AsNoTracking() + .IgnoreQueryFilters() + .Where(c => !c.IsDeleted) .OrderBy(c => c.CompanyName) - .Select(c => new { c.Id, c.CompanyName }) + .Select(c => new AdminEmailCompanyOption + { + CompanyId = c.Id, + CompanyName = c.CompanyName, + PrimaryContactName = c.PrimaryContactName, + PrimaryContactEmail = c.PrimaryContactEmail, + IsActive = c.IsActive + }) .ToListAsync(); - ViewBag.ActiveCount = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .CountAsync(c => !c.IsDeleted && c.IsActive && - (c.SubscriptionStatus == SubscriptionStatus.Active || - c.SubscriptionStatus == SubscriptionStatus.GracePeriod)); + 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 BroadcastForm +public class AdminEmailComposeModel { - public string Target { get; set; } = "active"; - public string? PlanFilter { get; set; } - public int[]? CompanyIds { get; set; } - - [System.ComponentModel.DataAnnotations.Required] + [Required] public string Subject { get; set; } = string.Empty; - [System.ComponentModel.DataAnnotations.Required] - public string Body { 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; } diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml index 2b1cad3..2a4161b 100644 --- a/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml +++ b/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml @@ -1,172 +1,173 @@ @using PowderCoating.Web.Controllers -@model BroadcastForm +@model AdminEmailComposeModel @{ - ViewData["Title"] = "Email Broadcast"; + ViewData["Title"] = "Admin Email"; } @section Styles { } -
-
-

Email Broadcast

+
+
+
+

Admin Email Wizard

+

Step 1 of 3: write the subject and rich-text message.

+
+
Super Admin Only
@if (TempData["Success"] != null) { -
@TempData["Success"]
- } - @if (TempData["Error"] != null) - { -
@TempData["Error"]
+
@TempData["Success"]
} -
- @Html.AntiForgeryToken() +
+
+ + @Html.AntiForgeryToken() -
- @* Left: recipients *@ -
-
-
Recipients
-
-
- - +
+ + + +
+ +
+ +
+ + + + + + + +
+
@Html.Raw(Model.BodyHtml)
+ + +
+ +
+
+
+ The email sends one company at a time to that company's Primary Contact Email. + Rich text is supported, and the preview step will render one merged sample before anything sends.
- - - - - -
- @ViewBag.ActiveCount company email(s) will receive this message. +
+
+
+
+
Available Merge Tokens
+
+ + + + + + + + +
+
Click a token to insert it into the editor.
+
-
- @* Right: compose *@ -
-
-
Compose
-
-
- - - -
-
- - - -
-
- - This will send a real email to the primary contact address of each matching company. Double-check your recipient selection before sending. -
- -
+
+
-
+
- +
@section Scripts { + } diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml new file mode 100644 index 0000000..f844f99 --- /dev/null +++ b/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml @@ -0,0 +1,135 @@ +@using PowderCoating.Web.Controllers +@model AdminEmailPreviewModel +@{ + ViewData["Title"] = "Preview Admin Email"; +} + +
+
+
+

Admin Email Wizard

+

Step 3 of 3: preview one merged sample, then send sequentially.

+
+
+ @Model.EligibleCount ready to send + @if (Model.SkippedCount > 0) + { + @Model.SkippedCount missing email + } +
+
+ +
+
+
+
Sample Preview
+
+
Recipient Sample
+
+
@Model.SamplePreview.RecipientName
+
@Model.SamplePreview.RecipientEmail
+
@Model.SamplePreview.CompanyName
+
+ +
Rendered Subject
+
@Model.SamplePreview.RenderedSubject
+ +
Rendered HTML Body
+
+ @Html.Raw(Model.SamplePreview.RenderedHtmlBody) +
+
+
+
+ +
+
+
Delivery Summary
+
+
+ The system will process each selected company one at a time. + The sample shown on the left uses the first available recipient after token replacement. +
+
+
+ +
+
Selected Companies
+
+ + + + + + + + + + + @foreach (var row in Model.SelectedCompanies) + { + + + + + + + } + +
CompanyRecipientCompany AdminReady
+
@row.CompanyName
+
#@row.CompanyId
+
+
@row.RecipientName
+
@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)
+
+
@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)
+ @if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail)) + { +
@row.CompanyAdminEmail
+ } +
+ @if (row.CanSend) + { + Ready + } + else + { + @row.SkipReason + } +
+
+
+
+
+ +
+ @Html.AntiForgeryToken() + + + @foreach (var companyId in Model.CompanyIds ?? Array.Empty()) + { + + } + +
+ + + @if (Model.EligibleCount > 0) + { + + } + else + { + + } +
+ +
diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml new file mode 100644 index 0000000..e942eb6 --- /dev/null +++ b/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml @@ -0,0 +1,170 @@ +@using PowderCoating.Web.Controllers +@model AdminEmailSelectionModel +@{ + ViewData["Title"] = "Choose Companies"; +} + +
+
+
+

Admin Email Wizard

+

Step 2 of 3: choose which companies should receive this message.

+
+
@Model.AvailableCompanies.Count company records
+
+ +
+
+
+
+
Subject
+
@Model.Subject
+
+
+
Message Summary
+
Rich-text message prepared. Merge tokens will render on the preview step.
+
+
+
+
+ +
+ @Html.AntiForgeryToken() + + + +
+
+
+
+ +
+
+ + + 0 selected +
+
+ + + +
+ + + + + + + + + + + + + @foreach (var company in Model.AvailableCompanies) + { + + + + + + + + + } + +
CompanyPrimary ContactEmailCompany AdminStatus
+ + +
@company.CompanyName
+
#@company.CompanyId
+
@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName) + @if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail)) + { + Missing + } + else + { + @company.PrimaryContactEmail + } + +
@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)
+ @if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail)) + { +
@company.CompanyAdminEmail
+ } +
+ @if (company.IsActive) + { + Active + } + else + { + Inactive + } +
+
+ +
+ + +
+
+
+
+
+ +@section Scripts { + + +} diff --git a/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs b/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs new file mode 100644 index 0000000..3b3f06f --- /dev/null +++ b/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs @@ -0,0 +1,203 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Shared.Constants; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class EmailBroadcastControllerTests +{ + [Fact] + public async Task Preview_RendersMergedSampleAndSanitizesHtml() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 7, + CompanyId = 7, + CompanyName = "River City Powder", + PrimaryContactName = "Jamie Rivera", + PrimaryContactEmail = "jamie@example.com", + IsActive = true + }); + context.Users.Add(new ApplicationUser + { + Id = "admin-7", + CompanyId = 7, + CompanyRole = AppConstants.CompanyRoles.CompanyAdmin, + FirstName = "Alex", + LastName = "Admin", + Email = "alex@example.com", + UserName = "alex@example.com", + IsActive = true + }); + await context.SaveChangesAsync(); + + var controller = CreateController(context); + + var result = await controller.Preview(new AdminEmailSelectionModel + { + Subject = "Update for {{CompanyName}}", + BodyHtml = "

Hi {{FirstName}}, contact {{CompanyAdminName}} at {{CompanyAdminEmail}}.

", + CompanyIds = [7] + }); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + + Assert.Equal("Update for River City Powder", model.SamplePreview.RenderedSubject); + Assert.Contains("Hi Jamie", model.SamplePreview.RenderedHtmlBody); + Assert.Contains("Alex Admin", model.SamplePreview.RenderedHtmlBody); + Assert.Contains("alex@example.com", model.SamplePreview.RenderedHtmlBody); + Assert.DoesNotContain("(); + emailService + .Setup(x => x.SendEmailAsync( + "morgan@example.com", + "Morgan Lee", + "Notice for Summit Coatings", + It.IsAny(), + It.IsAny(), + null, + null, + null, + "admin-notify@example.com", + "Powder Coating Logix Admin")) + .ReturnsAsync((true, (string?)null)); + + var platformSettings = new Mock(); + platformSettings + .Setup(x => x.GetAsync(PlatformSettingKeys.AdminNotificationEmail)) + .ReturnsAsync("admin-notify@example.com,backup@example.com"); + + var controller = CreateController(context, emailService, platformSettings); + + var result = await controller.Send(new AdminEmailSendRequest + { + Subject = "Notice for {{CompanyName}}", + BodyHtml = "

Hello {{FirstName}},

Thanks for using {{CompanyName}}.

", + CompanyIds = [11] + }); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var log = await context.NotificationLogs.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(NotificationType.AdminEmail, log.NotificationType); + Assert.Equal(NotificationStatus.Sent, log.Status); + Assert.Equal("morgan@example.com", log.Recipient); + Assert.Equal("Notice for Summit Coatings", log.Subject); + Assert.Contains("Hello Morgan", log.Message); + emailService.VerifyAll(); + } + + [Fact] + public async Task Send_WhenPrimaryContactEmailMissing_WritesSkippedLogWithoutSending() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 13, + CompanyId = 13, + CompanyName = "No Inbox Inc", + PrimaryContactName = "Taylor Noemail", + PrimaryContactEmail = string.Empty, + IsActive = true + }); + await context.SaveChangesAsync(); + + var emailService = new Mock(); + var controller = CreateController(context, emailService); + + var result = await controller.Send(new AdminEmailSendRequest + { + Subject = "Heads up for {{CompanyName}}", + BodyHtml = "

Hello {{FirstName}}

", + CompanyIds = [13] + }); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var log = await context.NotificationLogs.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(NotificationType.AdminEmail, log.NotificationType); + Assert.Equal(NotificationStatus.Skipped, log.Status); + Assert.Equal(string.Empty, log.Recipient); + Assert.Equal("Company primary contact email is not configured.", log.ErrorMessage); + emailService.Verify( + x => x.SendEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + private static EmailBroadcastController CreateController( + ApplicationDbContext context, + Mock? emailService = null, + Mock? platformSettings = null) + { + var controller = new EmailBroadcastController( + context, + (emailService ?? new Mock()).Object, + (platformSettings ?? CreatePlatformSettings()).Object, + Mock.Of>()); + + var httpContext = new DefaultHttpContext(); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + controller.TempData = new TempDataDictionary(httpContext, Mock.Of()); + + return controller; + } + + private static Mock CreatePlatformSettings() + { + var settings = new Mock(); + settings.Setup(x => x.GetAsync(It.IsAny())).ReturnsAsync((string?)null); + return settings; + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } +}