Add admin email wizard and logging

This commit is contained in:
2026-04-26 17:01:09 -04:00
parent 404ab3c45d
commit 8491b308eb
7 changed files with 1177 additions and 276 deletions
@@ -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;
/// <summary>
/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant
/// company contacts. Emails are sent one at a time via <see cref="IEmailService"/>
/// rather than bulk API because each message requires a personalised unsubscribe link
/// containing the company's unique <c>MarketingUnsubscribeToken</c>.
/// </summary>
[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<string> 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<EmailBroadcastController> _logger;
public EmailBroadcastController(
ApplicationDbContext db,
IEmailService emailService,
IPlatformSettingsService platformSettings,
ILogger<EmailBroadcastController> logger)
{
_db = db;
_emailService = emailService;
_platformSettings = platformSettings;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> Index()
{
await PopulateViewBag();
return View(new BroadcastForm());
}
/// <summary>Returns JSON count of recipients for the current filter — used for the live preview.</summary>
[HttpGet]
public async Task<IActionResult> RecipientCount(string target, string? plan, int[]? companyIds)
public IActionResult Index() => View(new AdminEmailComposeModel());
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// <para>
/// Each email body is HTML-encoded (then line-breaks converted to
/// <c>&lt;br&gt;</c>) 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.
/// </para>
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(BroadcastForm form)
public IActionResult BackToCompose(AdminEmailComposeModel form)
{
if (!ModelState.IsValid)
NormalizeComposeModel(form);
return View("Index", form);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> BackToSelectCompanies(AdminEmailSelectionModel form)
{
NormalizeComposeModel(form);
return View("SelectCompanies", await BuildSelectionModelAsync(form));
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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", "<br>");
foreach (var (email, name, unsubToken) in recipients)
{
var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}";
var htmlBody = $@"
<div style=""font-family:sans-serif;max-width:600px;margin:0 auto"">
<p>{encodedBody}</p>
<hr style=""border:none;border-top:1px solid #eee;margin:24px 0"">
<p style=""font-size:12px;color:#999"">
This message was sent by the Powder Coating Logix platform team.<br>
<a href=""{unsubUrl}"" style=""color:#999"">Unsubscribe from platform announcements</a>
</p>
</div>";
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));
}
/// <summary>
/// Builds the list of (email, name, unsubscribe-token) tuples for the given
/// targeting criteria. Companies are excluded when <c>MarketingEmailOptOut</c>
/// is true — honouring prior unsubscribes — or when <c>PrimaryContactEmail</c>
/// is missing. The "specific" target requires at least one <c>companyIds</c>
/// entry and returns an empty list otherwise to prevent accidental all-company sends.
/// <c>IgnoreQueryFilters()</c> is required because this query spans companies.
/// </summary>
private async Task<List<(string Email, string Name, string UnsubToken)>> 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<AdminEmailSelectionModel> BuildSelectionModelAsync(AdminEmailComposeModel form)
{
var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null
? selection.CompanyIds
: Array.Empty<int>();
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<AdminEmailPreviewModel> 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)
}
};
}
/// <summary>
/// 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 <see cref="Index"/> and the
/// validation-failure branch of <see cref="Send"/>.
/// </summary>
private async Task PopulateViewBag()
private async Task<List<AdminEmailCompanyOption>> LoadCompanyOptionsAsync(IReadOnlyCollection<int> 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<List<AdminEmailRecipientContext>> LoadRecipientContextsAsync(IReadOnlyCollection<int> 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<Dictionary<int, CompanyAdminLookup>> LoadCompanyAdminLookupAsync(IReadOnlyCollection<int> 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<string?> 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<string, string> 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 $"""
<div style="font-family:Arial,Helvetica,sans-serif;max-width:700px;margin:0 auto;color:#1f2937;line-height:1.6;">
{renderedHtmlBody}
</div>
""";
}
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("<div", "<p", StringComparison.OrdinalIgnoreCase)
.Replace("</div>", "</p>", 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 "<br>";
if (tagName == "a")
{
if (isClosingTag)
return "</a>";
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 $"""<a href="{encodedHref}" target="_blank" rel="noopener noreferrer">""";
}
return isClosingTag ? $"</{tagName}>" : $"<{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<AdminEmailCompanyOption> AvailableCompanies { get; set; } = [];
}
public class AdminEmailSendRequest : AdminEmailSelectionModel;
public class AdminEmailPreviewModel : AdminEmailSendRequest
{
public List<AdminEmailRecipientPreviewRow> 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;
}