العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文
A full-featured, embeddable support ticket system for ASP.NET Core. Drop it into any app -- get a complete helpdesk with SLA tracking, escalation rules, agent workflows, and a customer portal. No external services required.
escalated.dev -- Learn more, view demos, and compare Cloud vs Self-Hosted options.
- Ticket lifecycle -- Create, assign, reply, resolve, close, reopen with configurable status transitions
- SLA engine -- Per-priority response and resolution targets, business hours calculation, automatic breach detection
- Escalation rules -- Condition-based rules that auto-escalate, reprioritize, reassign, or notify
- Automations -- Time-based rules with conditions and actions
- Agent dashboard -- Ticket queue with filters, bulk actions, internal notes, canned responses
- Customer portal -- Self-service ticket creation, replies, and status tracking
- Admin panel -- Manage departments, SLA policies, escalation rules, tags, and more
- Macros and canned responses -- Batch actions and reusable reply templates
- Custom fields -- Dynamic metadata with conditional display logic
- Knowledge base -- Articles, categories, search, and feedback
- File attachments -- Upload support with configurable storage and size limits
- Activity timeline -- Full audit log of every action on every ticket
- Webhooks -- HMAC-SHA256 signed with retry logic
- API tokens -- Bearer auth with ability-based scoping
- Roles and permissions -- Fine-grained access control
- Audit logging -- All mutations tracked with old/new values
- Import system -- Multi-step wizard with pluggable adapters
- Side conversations -- Internal team threads on tickets
- Ticket merging and linking -- Merge duplicate tickets and relate issues
- Ticket subjects -- Attach host-app entities (Project, Customer, asset) a ticket is about
- Ticket splitting -- Split a reply into a new ticket
- Ticket snooze -- Snooze until a future date with auto-wake background service
- Email threading -- In-Reply-To/References/Message-ID headers for proper threading
- Inbound email -- Single webhook endpoint with Postmark + Mailgun + AWS SES parsers, signed Reply-To verification, and Message-ID-based ticket resolution
- Saved views -- Personal and shared filter presets
- Embeddable widget API -- Public endpoints for KB search, guest tickets, status lookup
- Real-time updates -- SignalR hubs for live ticket updates (opt-in)
- Capacity management -- Per-agent workload limits by channel
- Skill-based routing -- Match agents to tickets by skill tags
- CSAT ratings -- Satisfaction surveys on resolved tickets
- 2FA -- TOTP setup and verification with recovery codes
- Guest access -- Anonymous ticket creation with magic token lookup
- Inertia.js + Vue 3 UI -- Shared frontend via
@escalated-dev/escalated
- .NET 8.0+
- Entity Framework Core 8.0+
- SQL Server, SQLite, or PostgreSQL
- Node.js 18+ (for frontend assets)
dotnet add package Escalated// Program.cs
using Escalated.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEscalated(builder.Configuration, options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Escalated")));
// Your authentication: ASP.NET Core Identity, OpenID Connect, JWT bearer, cookies.
// Escalated authorizes whoever it signs in (see Authorization below).
builder.Services.AddAuthentication(/* ... */);
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapEscalated();
app.Run();// appsettings.json
{
"ConnectionStrings": {
"Escalated": "Server=localhost;Database=MyApp;Trusted_Connection=true;"
},
"Escalated": {
"RoutePrefix": "support",
"TicketReferencePrefix": "ESC",
"DefaultPriority": "medium",
"AllowCustomerClose": true,
"AutoCloseResolvedAfterDays": 7,
"Sla": {
"Enabled": true,
"BusinessHoursOnly": false,
"BusinessHours": {
"Start": "09:00",
"End": "17:00",
"Timezone": "UTC",
"Days": [1, 2, 3, 4, 5]
}
},
"EnableRealTime": false,
"Mail": {
"Domain": "support.yourapp.com",
"InboundSecret": "a-long-random-value"
}
}
}The Mail.InboundSecret is symmetric -- it signs outbound Reply-To addresses and verifies inbound webhook requests, so forged emails targeting a stolen reply address are rejected via timing-safe HMAC.
dotnet ef migrations add InitialEscalated --context EscalatedDbContext
dotnet ef database update --context EscalatedDbContextVisit /support -- you're live.
EscalatedDbContext has its own connection string, so Escalated's tables go
wherever ConnectionStrings:Escalated points. Nothing requires that to be your
application's database — it can be a schema shared with a legacy system, a
separate reporting store, or simply a database you would rather not mix support
data into:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=true;",
"Escalated": "Server=support-db;Database=Support;Trusted_Connection=true;"
}
}Nothing else changes. AddEscalated already resolves that string, and
dotnet ef database update --context EscalatedDbContext migrates only that
database — your own DbContext keeps its own migrations.
The two connections need not even be the same provider:
builder.Services.AddEscalated(builder.Configuration, options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Escalated")));Escalated owns no User entity. The admin users page reads your users through
IUserDirectory, which you implement against your DbContext, with your
query, on your connection:
builder.Services.AddSingleton<IUserDirectory, MyUserDirectory>();Registered before AddEscalated, your implementation wins; without one, a
NullUserDirectory returns an empty page rather than querying a database that
may have no users table in it.
That is also why no Escalated entity declares a foreign key to your users:
Ticket.RequesterId, Ticket.AssignedTo and Reply.AuthorId are plain
unconstrained columns, so the two databases need never meet. Tests fail the
build if an entity, a foreign key or any raw SQL in the package names a
host-owned table.
No query joins the two, because no database can join across two connections. The cost is that Escalated cannot filter or sort its tables by a column that lives on your user — assignment, skill routing and agent load all resolve ids from Escalated's tables first, then ask your directory for the people.
Escalated stores references to your app's users (ticket requester, assignee,
reply author, etc.) as strings, so it works whether your user primary key
is an int, a Guid, or any other string. A numeric id is simply stored and
exchanged as its string form.
Your IUserDirectory implementation therefore exchanges user ids as string:
public Task<UserDirectoryEntry?> FindAsync(string id, CancellationToken ct = default)
{
// look up your user by its (string) id and return:
// new UserDirectoryEntry(user.Id.ToString(), user.Name, user.Email)
}Upgrade note: earlier versions used
inthere (FindAsync(int id)andUserDirectoryEntry(int Id, ...)); admin user APIs also now return user ids as JSON strings. Update yourIUserDirectoryimplementation to thestringsignatures shown above. Integer ids keep working — pass them as strings.
Escalated owns no authentication. It authorizes whoever your application signed
in, reading them from HttpContext.User. Register your authentication and call
UseAuthentication() and UseAuthorization() before MapControllers().
Every controller requires one of three policies:
| Policy | Surface | Default rule |
|---|---|---|
EscalatedPolicies.Admin (escalated-admin) |
/support/admin/*, /admin/newsletters* |
Signed in and holds the escalated-admin role |
EscalatedPolicies.Agent (escalated-agent) |
/support/agent/* |
Signed in and holds escalated-agent or escalated-admin |
EscalatedPolicies.Customer (escalated-customer) |
/support/tickets*, /support/attachments/* |
Signed in |
The widget, guest, inbound email, newsletter tracking and newsletter ESP webhook
endpoints are public, as is /support/api/v1/auth/*. They authenticate with guest
tokens, shared secrets or your ApiAuth callbacks instead.
Nothing is allowed by default. An anonymous request gets 401, and a signed-in user without the role gets 403. An application with no authentication configured gets an ASP.NET Core configuration error, not an open endpoint.
The roles are rows in Escalated's RoleUsers table: the same ones the admin users
page grants and revokes (PATCH /support/admin/users/{id}/role). Grant the first
admin directly:
var admin = new Role { Name = "Escalated Admin", Slug = AdminUsersController.AdminRoleSlug, IsSystem = true };
db.Roles.Add(admin);
await db.SaveChangesAsync();
db.RoleUsers.Add(new RoleUser { RoleId = admin.Id, UserId = "your-user-id" });
await db.SaveChangesAsync();Or replace a policy with your own rule. A policy registered under the same name,
before or after AddEscalated, takes precedence:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(EscalatedPolicies.Admin, p => p.RequireRole("SupportLead"));
options.AddPolicy(EscalatedPolicies.Agent, p => p.RequireRole("SupportLead", "SupportAgent"));
});The newsletter admin pages also check a permission: newsletters.manage, plus
newsletters.send to send. The escalated-admin role holds both. If you replace
the admin policy, attach them to your own roles in Escalated's RolePermissions
table; NewsletterPermissionSeeder creates the two permissions and attaches them
to escalated-admin.
The acting user is always the signed-in user: the requester of a ticket a customer
creates, the author of a reply, the causer recorded on ticket activity, the owner
of a mention inbox. Escalated reads the id from the NameIdentifier claim, then
sub, then id. If your user id lives in another claim, set a resolver:
builder.Services.Configure<EscalatedOptions>(o =>
o.UserIdResolver = user => user.FindFirst("employee_id")?.Value);Ids in the query string or the request body (requesterId, agentId, userId,
currentUserId, authorId, causerId, createdBy) are not read. A customer
sees and acts on only the tickets they requested. Staff can download any
attachment; a customer can download only attachments on their own tickets, and
never one on an internal note.
A ticket has a requester (the person who raised it) and a subject line (free text). Sometimes a ticket is also about one or more host-app entities — a Project, a Customer, an asset — that aren't people. Attach them as ticket subjects so agents see what the ticket concerns and can jump straight to it in your app.
Implement ITicketSubject on any attachable host model:
public class Project : ITicketSubject
{
public string TicketSubjectTitle() => Name;
public string? TicketSubjectSubtitle() => Customer == null ? null : $"Project · {Customer.Name}";
public string? TicketSubjectUrl() => $"/projects/{Id}";
public string? TicketSubjectColor() => "#2563eb";
public string? TicketSubjectIcon() => "folder";
}Register a resolver so Escalated can load host models by stored (type, id):
builder.Services.AddSingleton<ITicketSubjectResolver, MyTicketSubjectResolver>();Attach, detach, or sync subjects programmatically via TicketSubjectService, or
through the admin API when types are allowlisted in config:
"Escalated": {
"TicketSubjects": {
"Types": [
"App.Models.Project",
"App.Models.Customer"
]
}
}Each attached subject is serialized on ticket responses as
{ type, id, role, title, subtitle, url, color, icon, missing }. When the
resolver cannot find a host record, missing is true and title falls back
to TypeName#id. subject_id is stored as a string, so integer, UUID, or
string-keyed host models all work.
Admin endpoints:
POST /support/admin/tickets/{id}/subjects { "type", "id", "role?" }
DELETE /support/admin/tickets/{ticketId}/subjects/{subjectLinkId}
Programmatic attach works for any type when the allowlist is empty; the admin API only accepts allowlisted types.
Point your Postmark, Mailgun, or AWS SES (via SNS HTTP subscription) inbound webhook at:
POST /support/webhook/email/inbound?adapter=postmark
POST /support/webhook/email/inbound?adapter=mailgun
POST /support/webhook/email/inbound?adapter=ses
The adapter can be selected via the query parameter or the X-Escalated-Adapter header. Your provider must attach the shared secret as an X-Escalated-Inbound-Secret header.
The service resolves inbound messages to existing tickets via, in order: canonical Message-ID headers, signed Reply-To verification, and subject-reference tags. Unmatched messages with real content create a new ticket; SNS subscription confirmations and empty body+subject messages are skipped.
See the inbound email docs for provider setup, the response shape, and a ready-to-paste curl test recipe.
Escalated ships a Vue component library and default pages via the @escalated-dev/escalated npm package. Integrate with Inertia.js for seamless SPA rendering inside your existing layout.
npm install @escalated-dev/escalatedsrc/Escalated/
Models/ # 40+ EF Core entity models
Data/ # EscalatedDbContext with full relationship mapping
Services/ # Business logic (ticket, SLA, merge, split, snooze, etc.)
Controllers/
Admin/ # Admin panel API (CRUD for all settings)
Agent/ # Agent ticket queue and actions
Customer/ # Customer self-service portal
Widget/ # Public widget API (KB search, guest tickets)
Middleware/ # API token auth, permissions, rate limiting
Events/ # Domain events (TicketCreated, SlaBreached, etc.)
Notifications/ # Email notification interfaces and templates
Configuration/ # DI registration, options, endpoint mapping
Hubs/ # SignalR hub for real-time updates
Enums/ # TicketStatus, TicketPriority, ActivityType
Escalated includes 40+ EF Core entities covering the full helpdesk domain:
| Category | Models |
|---|---|
| Core | Ticket, Reply, Attachment, TicketActivity, TicketStatusModel, TicketLink, TicketTag, Tag, Department, SatisfactionRating |
| SLA | SlaPolicy, EscalationRule, BusinessSchedule, Holiday, Automation |
| Agents | AgentProfile, AgentCapacity, Skill, AgentSkill |
| Messaging | CannedResponse, Macro, SideConversation, SideConversationReply, InboundEmail |
| Admin | Role, Permission, ApiToken, Webhook, WebhookDelivery, Plugin, AuditLog |
| Custom | CustomField, CustomFieldValue, CustomObject, CustomObjectRecord |
| Import | ImportJob, ImportSourceMap |
| Config | EscalatedSettings, SavedView |
| Knowledge Base | Article, ArticleCategory |
All models have proper relationships, indexes, and query filters configured in EscalatedDbContext.
| Service | Responsibility |
|---|---|
TicketService |
Full ticket CRUD, status transitions, replies, tags, departments |
SlaService |
Policy attachment, breach detection, warning checks, first response recording |
AssignmentService |
Agent assignment, unassignment, auto-assign by workload |
EscalationService |
Evaluate condition-based rules, execute escalation actions |
AutomationRunner |
Time-based automation evaluation and action execution |
MacroService |
Apply macro action sequences to tickets |
TicketMergeService |
Merge source into target with reply transfer |
TicketSplitService |
Split a reply into a new linked ticket |
TicketSnoozeService |
Snooze/unsnooze with background wake service |
WebhookDispatcher |
HMAC-signed webhook delivery with retry logic |
CapacityService |
Per-agent concurrent ticket limits |
SkillRoutingService |
Match agents by skills to ticket tags |
BusinessHoursCalculator |
Business hours date math with holiday support |
TwoFactorService |
TOTP secret generation, verification, recovery codes |
AuditLogService |
Log and query entity mutations |
KnowledgeBaseService |
Article/category CRUD, search, feedback |
SavedViewService |
Personal and shared filter presets |
SideConversationService |
Internal threaded conversations on tickets |
ImportService |
Multi-step import with pluggable adapters |
SettingsService |
Key-value settings store |
Every ticket action dispatches a domain event:
| Event | When |
|---|---|
TicketCreatedEvent |
New ticket created |
TicketStatusChangedEvent |
Status transition |
TicketAssignedEvent |
Agent assigned |
TicketUnassignedEvent |
Agent removed |
ReplyCreatedEvent |
Public reply added |
InternalNoteAddedEvent |
Agent note added |
SlaBreachedEvent |
SLA deadline missed |
SlaWarningEvent |
SLA deadline approaching |
TicketEscalatedEvent |
Ticket escalated |
TicketResolvedEvent |
Ticket resolved |
TicketClosedEvent |
Ticket closed |
TicketReopenedEvent |
Ticket reopened |
TicketPriorityChangedEvent |
Priority changed |
DepartmentChangedEvent |
Department changed |
TagAddedEvent |
Tag added |
TagRemovedEvent |
Tag removed |
TicketCustomActionTriggeredEvent |
Agent triggered a custom ticket action |
Every event is delivered, in order, to subscribed outbound webhooks, to matching
Workflows, and then to each IEscalatedEventDispatcher your host application
registers. Implement one to receive these events:
public class MyEventHandler : IEscalatedEventDispatcher
{
public async Task DispatchAsync<TEvent>(TEvent @event, CancellationToken ct) where TEvent : class
{
if (@event is TicketCreatedEvent created)
{
// Handle new ticket
}
}
}
// Register in DI, before or after AddEscalated
services.AddSingleton<IEscalatedEventDispatcher, MyEventHandler>();Registering a dispatcher adds a listener. It does not replace Escalated's own handling, so webhooks and Workflows keep running, and you can register several.
Webhooks subscribe to event names (or * for all of them):
| Webhook event | Domain event |
|---|---|
ticket.created |
TicketCreatedEvent |
ticket.updated |
TicketUpdatedEvent |
ticket.status_changed |
TicketStatusChangedEvent |
ticket.resolved |
TicketResolvedEvent |
ticket.closed |
TicketClosedEvent |
ticket.reopened |
TicketReopenedEvent |
ticket.assigned |
TicketAssignedEvent |
ticket.unassigned |
TicketUnassignedEvent |
ticket.escalated |
TicketEscalatedEvent |
ticket.priority_changed |
TicketPriorityChangedEvent |
ticket.department_changed |
DepartmentChangedEvent |
ticket.tagged |
TagAddedEvent |
ticket.untagged |
TagRemovedEvent |
reply.created |
ReplyCreatedEvent |
internal_note.added |
InternalNoteAddedEvent |
sla.breached |
SlaBreachedEvent |
sla.warning |
SlaWarningEvent |
Each delivery is a signed POST (X-Escalated-Event, and X-Escalated-Signature
when the webhook has a secret) with the body
{ "event", "payload", "timestamp" }. payload.ticket carries id,
reference, subject, status and priority; reply events add payload.reply,
tag events payload.tag, and ticket.assigned adds payload.agent_id. Every
attempt is recorded as a WebhookDelivery.
Host applications can add custom buttons to the agent ticket screen and handle clicks via the event dispatcher. Register actions in configuration:
{
"Escalated": {
"TicketActions": [
{
"Key": "sync-crm",
"Label": "Sync CRM",
"Variant": "primary",
"Confirmation": "Sync this ticket to the CRM?",
"Metadata": { "icon": "refresh-cw" }
}
]
}
}Visible actions are exposed on the agent ticket response as custom_actions
(each with a url and method). Triggering one
(POST /support/agent/tickets/{id}/actions/{action}) validates the action is
visible (404) and enabled (403), records an internal note for auditability, and
dispatches TicketCustomActionTriggeredEvent to your IEscalatedEventDispatcher:
if (@event is TicketCustomActionTriggeredEvent triggered && triggered.Action == "sync-crm")
{
// triggered.Ticket, triggered.UserId, triggered.Payload, triggered.Metadata
}For dynamic per-ticket visibility, register your own ITicketActionRegistry.
| Method | Route | Description |
|---|---|---|
| GET | /support/tickets |
List customer tickets |
| POST | /support/tickets |
Create ticket |
| GET | /support/tickets/{id} |
View ticket |
| POST | /support/tickets/{id}/reply |
Reply to ticket |
| POST | /support/tickets/{id}/close |
Close ticket |
| POST | /support/tickets/{id}/reopen |
Reopen ticket |
| Method | Route | Description |
|---|---|---|
| GET | /support/agent/tickets |
Ticket queue with filters |
| GET | /support/agent/tickets/{id} |
Ticket detail |
| POST | /support/agent/tickets/{id}/reply |
Reply |
| POST | /support/agent/tickets/{id}/note |
Internal note |
| POST | /support/agent/tickets/{id}/assign |
Assign agent |
| POST | /support/agent/tickets/{id}/status |
Change status |
| POST | /support/agent/tickets/{id}/priority |
Change priority |
| POST | /support/agent/tickets/{id}/macro |
Apply macro |
| POST | /support/agent/tickets/bulk |
Bulk actions |
| GET | /support/agent/tickets/dashboard |
Agent workload |
| Method | Route | Description |
|---|---|---|
| GET/POST | /support/admin/departments |
Manage departments |
| GET/POST | /support/admin/tags |
Manage tags |
| GET/POST | /support/admin/sla-policies |
Manage SLA policies |
| GET/POST | /support/admin/escalation-rules |
Manage escalation rules |
| GET/POST | /support/admin/webhooks |
Manage webhooks |
| GET/POST | /support/admin/api-tokens |
Manage API tokens |
| GET/POST | /support/admin/macros |
Manage macros |
| GET/POST | /support/admin/automations |
Manage automations |
| GET/POST | /support/admin/custom-fields |
Manage custom fields |
| GET/POST | /support/admin/business-hours |
Business schedules |
| GET/POST | /support/admin/skills |
Manage skills |
| GET/POST | /support/admin/roles |
Manage roles |
| GET | /support/admin/audit-logs |
Query audit logs |
| GET/POST | /support/admin/settings |
App settings |
| GET/PUT | /support/admin/settings/public-tickets |
Runtime guest-policy mode (unassigned / guest_user / prompt_signup). See docs.escalated.dev/public-tickets. |
| POST | /support/admin/tickets/{id}/merge |
Merge tickets |
| POST | /support/admin/tickets/{id}/split |
Split ticket |
| POST | /support/admin/tickets/{id}/snooze |
Snooze ticket |
| POST | /support/admin/tickets/{id}/link |
Link tickets |
| Method | Route | Description |
|---|---|---|
| GET | /support/widget/kb/search |
Search knowledge base |
| POST | /support/widget/tickets |
Create guest ticket |
| GET | /support/widget/tickets/{token} |
Lookup by guest token |
| POST | /support/widget/tickets/{token}/reply |
Guest reply |
| POST | /support/widget/tickets/{token}/rate |
Submit CSAT rating |
| POST | /support/widget/kb/articles/{id}/feedback |
Article feedback |
Enable SignalR for live ticket updates:
{
"Escalated": {
"EnableRealTime": true
}
}// Program.cs
app.MapHub<EscalatedHub>("/support/hub");Clients join ticket-specific groups to receive updates:
connection.invoke("JoinTicket", ticketId);
connection.on("TicketUpdated", (data) => { /* handle */ });Protect API endpoints with bearer token authentication:
app.UseMiddleware<ApiTokenAuthMiddleware>();Tokens are stored as SHA-256 hashes. Create tokens via the admin API endpoint.
app.UseMiddleware<EscalatedRateLimitMiddleware>(60, 60); // 60 requests per 60 secondsEscalated for ASP.NET Core consumes its translation catalog from the
central Escalated.Locale
NuGet package. AddEscalated() registers a chained
IStringLocalizer stack that resolves keys in this order:
- Plugin-local overrides --
.resxfiles undersrc/Escalated/Resources/Overrides/. Empty by default. - Central catalog -- embedded JSON resources from
Escalated.Locale, accessed throughEscalated.Locale.LocaleProvider.
Adding a locale upstream lights it up across every host plugin without
a code change here. Drop a resx into Resources/Overrides/ only when
you need a .NET-specific string that should not be back-ported to the
shared catalog. See src/Escalated/Resources/Overrides/README.md for
naming conventions.
dotnet testTests use xUnit with Moq and EF Core InMemory provider. Coverage includes:
- Ticket CRUD and status transitions
- SLA breach detection and warnings
- Ticket split, merge, and snooze
- Assignment and workload calculation
- Webhook subscription matching
- 2FA secret generation and verification
- Capacity management
- Model validation and enum behavior
- Escalated for Laravel -- Laravel Composer package
- Escalated for Rails -- Ruby on Rails engine
- Escalated for Django -- Django reusable app
- Escalated for AdonisJS -- AdonisJS v6 package
- Escalated for ASP.NET Core -- ASP.NET Core package (you are here)
- Shared Frontend -- Vue 3 + Inertia.js UI components
Same architecture, same Vue UI -- for every major backend framework.
Models + renderer for the admin-only newsletter broadcast feature. EF Core migration not bundled — host integrators generate one with dotnet ef migrations add NewsletterSystem after referencing this package.
using Escalated.Services.Newsletter;
var renderer = new NewsletterRenderer(new NewsletterRendererOptions
{
BaseUrl = "https://support.example.com",
DefaultTheme = "default",
TrackingEnabled = true,
ThemesDir = Path.Combine(env.ContentRootPath, "Views/NewsletterThemes"),
MarkdownToHtml = md => Markdig.Markdown.ToHtml(md),
Brand = new NewsletterBrand
{
Name = "Acme",
Accent = "#2563eb",
PhysicalAddress = "Acme Inc. · 123 Main St",
},
});
var html = renderer.Render(delivery, newsletter, contact, template);Ships: Models/Newsletter/*.cs (5 EF Core entities), Models/Contact.cs (gains MarketingOptOutAt), Services/Newsletter/NewsletterRenderer.cs (full renderer), Views/NewsletterThemes/{default,branded}.html (starter themes).
Follow-up PR: EF Core migration in-package, planner/dispatcher/tracker services using EscalatedDbContext, ASP.NET controllers.
MIT