1cb7a8ca4a
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers, routing all data access through IUnitOfWork. Added IPlainRepository<T> for the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote) that intentionally don't extend BaseEntity and therefore can't use the constrained IRepository<T>. Added permanent-exception comments to the 18 controllers that legitimately retain direct DbContext access (Identity infra, cross-tenant platform ops, bulk streaming exports). Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup gate that reflects over every Controller subclass and throws at boot if any non-exempt controller injects ApplicationDbContext. The app cannot start with a violation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
599 lines
22 KiB
C#
599 lines
22 KiB
C#
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<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;
|
|
}
|
|
|
|
[HttpGet]
|
|
public IActionResult Index() => View(new AdminEmailComposeModel());
|
|
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<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)
|
|
{
|
|
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<AdminEmailSelectionModel> BuildSelectionModelAsync(AdminEmailComposeModel form)
|
|
{
|
|
var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null
|
|
? selection.CompanyIds
|
|
: Array.Empty<int>();
|
|
|
|
return new AdminEmailSelectionModel
|
|
{
|
|
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 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<List<AdminEmailCompanyOption>> LoadCompanyOptionsAsync(IReadOnlyCollection<int> 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<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 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<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;
|
|
}
|