العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文
Website: escalated.dev
Embedded helpdesk module for NestJS applications. Drop-in ticketing, SLA management, knowledge base, and more.
- Ticket Management -- Full CRUD with lifecycle tracking, priorities, departments, tags, and custom fields
- SLA Policies -- Configurable response/resolution targets with business hours support
- Automations -- Time-based processing via
@nestjs/schedule(SLA checks, snooze wake-up, webhook retries) - Escalation Rules -- Automatic reassignment and notifications on SLA breach
- Macros & Canned Responses -- One-click multi-action macros and templated replies
- Custom Fields -- Dynamic fields with validation (text, number, select, checkbox, date)
- Knowledge Base -- Articles with categories, search, view tracking, and helpfulness ratings
- Webhooks -- HMAC-signed delivery with exponential backoff retry
- API Tokens -- Bearer token authentication with scoped abilities
- Roles & Permissions -- Granular permission system with NestJS guards
- Audit Logging -- Interceptor-based activity tracking for all mutations
- Import System -- Bulk import for tickets, tags, and departments
- Side Conversations -- Threaded discussions within a ticket
- Ticket Merging & Linking -- Merge duplicates, link related tickets
- Ticket Splitting -- Break a ticket into separate issues
- Ticket Snooze -- Snooze with automatic wake-up via cron
- Saved Views -- Personal and shared filtered views
- Widget API -- Public endpoints for embeddable support widget with rate limiting
- Public Ticket System -- Unauthenticated submission via widget form or inbound email, Contact-based identity with email dedupe, configurable guest policy, signed Reply-To for threaded email conversations
- Workflow Engine -- Admin-configured rules fire on ticket/reply events (conditions + actions: assign, tag, set priority/status/department, insert canned reply, etc.); evaluation logs per execution
- Real-time Broadcasting -- Socket.IO gateway for live updates (opt-in)
- Capacity Management -- Per-agent ticket limits with real-time tracking
- Skill-based Routing -- Assign tickets based on agent skills and availability
- CSAT Ratings -- Post-resolution satisfaction surveys with token-based submission
- 2FA (TOTP) -- Two-factor authentication for agents via
otplib - Guest Access -- Token-based ticket access without authentication
- Internationalization -- Translations sourced from the central
@escalated-dev/localepackage vianestjs-i18n, with a chained overlay loader for plugin-local and host-app overrides (seesrc/i18n/overrides/README.md)
- Node.js 22+ for the current dependency graph
- NestJS 11
- TypeORM 0.3.x (the published peer range)
- Any TypeORM-supported database (PostgreSQL, MySQL, SQLite, etc.)
The manifest's broader Node 20 and Nest 10 compatibility declarations are under review. See the dependency audit for the existing engine mismatch, installed-host checks and held major upgrades.
npm install @escalated-dev/escalated-nestjsimport { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EscalatedModule } from '@escalated-dev/escalated-nestjs';
import { AdminGuard, AgentGuard, CustomerGuard } from './auth'; // your own guards
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
database: 'myapp',
username: 'user',
password: 'pass',
autoLoadEntities: true,
synchronize: true, // disable in production
}),
EscalatedModule.forRoot({
routePrefix: 'escalated',
// Required: admin, agent and customer routes refuse every request without them.
adminGuard: AdminGuard,
agentGuard: AgentGuard,
customerGuard: CustomerGuard,
appName: 'My App',
appUrl: 'https://myapp.com',
enableWebsockets: false,
enableKnowledgeBase: true,
enableCsat: true,
enable2fa: false,
}),
],
})
export class AppModule {}| Option | Type | Default | Description |
|---|---|---|---|
routePrefix |
string |
'escalated' |
URL prefix for all routes |
connection |
string |
-- | Named TypeORM DataSource for Escalated's tables (default DataSource when unset) |
enableWebsockets |
boolean |
false |
Enable Socket.IO real-time broadcasting |
enableKnowledgeBase |
boolean |
true |
Enable KB articles and categories |
enableCsat |
boolean |
true |
Enable satisfaction surveys |
enable2fa |
boolean |
false |
Enable TOTP 2FA for agents |
appName |
string |
'Escalated' |
Branding name for emails |
appUrl |
string |
-- | Base URL for links |
By default Escalated's entities register against your default TypeORM DataSource. Name a different one to keep the support tables somewhere else — a schema shared with a legacy system, a separate reporting store, or simply out of your primary database:
TypeOrmModule.forRoot({ name: 'support', /* ... */ }),
EscalatedModule.forRoot({
connection: 'support',
});The name is whatever you passed to TypeOrmModule.forRoot({ name }).
Every service in the package injects its repository with a plain
@InjectRepository(X), which binds the default DataSource's token where the
decorator is written — long before your connection name is known. Escalated
registers its entities against the named DataSource and aliases those default
tokens onto it, so the whole package follows your choice with no change to how
you consume it.
Your user entity does not move. It belongs to your application, and
Escalated stores host user ids as plain unconstrained columns precisely so the
two can live on different connections — there is no foreign key that would have
to span them.
| maxFileSize | number | 10485760 | Max upload size in bytes |
| webhookMaxRetries | number | 3 | Webhook retry attempts |
| guestAccess | object | -- | Verified guest access: secret, ttlMinutes, code budgets, legacyTokens, sendCode (see docs/guest-access.md) |
| slaWarningMinutes | number | 30 | Minutes before an SLA deadline that sla.warning fires (0 = off) |
| widgetOrigins | string[] | [] | Sites that may embed the widget (see Embedding the widget) |
| adminGuard | class | -- | Guard for /escalated/admin/*. Unset refuses every admin route (see Route guards) |
| agentGuard | class | -- | Guard for /escalated/agent/*. Unset refuses every agent route |
| customerGuard | class | -- | Guard for /escalated/customer/tickets/*. Unset refuses every customer ticket route |
| userResolver | function | -- | Extract user from request |
| mail | object | -- | Outbound email config (see below) |
| inbound | object | -- | Inbound email config (see below) |
| guestPolicy | object | unassigned | Guest identity policy (see below) |
| ticketActions | object | {actions:[]}| Custom agent ticket actions (see below) |
| ticketSubjects | object | {types:[]} | Host entities a ticket is about (see below) |
Escalated owns no users or sessions, so your app decides who is an admin, an agent or a customer. Give each route group a guard:
| Option | Routes it protects |
|---|---|
adminGuard |
/escalated/admin/* |
agentGuard |
/escalated/agent/* |
customerGuard |
/escalated/customer/tickets/* |
either agentGuard or customerGuard |
/escalated/attachments/* |
Route groups fail closed. When a group's guard is not configured, every request to that group is refused with 403 and a warning is logged once. The routes are never open by default.
A guard is any NestJS CanActivate, given as a class or an instance. If you also register the class as a provider in your own module, Escalated uses that instance, so the guard can inject your services. Otherwise Escalated creates it with dependency injection inside its own module. An exception thrown by the guard (for example UnauthorizedException) is sent as the response; a guard that returns false produces 403.
Controllers read the acting user from req.user.id, so authenticate the request and set req.user in the guard, or in middleware that runs before it.
@Injectable()
export class SupportAdminGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = await this.auth.userFromRequest(req); // your authentication
if (!user) throw new UnauthorizedException();
req.user = user;
return user.isAdmin;
}
}
@Module({
imports: [
EscalatedModule.forRoot({
adminGuard: SupportAdminGuard,
agentGuard: SupportAgentGuard,
customerGuard: SupportCustomerGuard,
}),
],
providers: [AuthService, SupportAdminGuard, SupportAgentGuard, SupportCustomerGuard],
})
export class AppModule {}None of the three guards apply to these routes, which are public or carry their own authentication:
/escalated/widget/*, including widget chat, and/escalated/guest/*(public; guests prove their email with a code and read their ticket with an expiring grant, see Verified guest access)/escalated/customer/kb/*(published knowledge base articles)/escalated/webhook/email/*(X-Escalated-Inbound-Secret, see below)/escalated/api/v1/auth/*(yourapiAuthcallbacks)/escalated/n/*and/escalated/webhooks/newsletter/*(newsletter tracking, unsubscribe and ESP webhooks)
The newsletter admin routes (/admin/newsletters/*) check the newsletters.manage and newsletters.send permissions on every request.
EscalatedModule.forRoot({
mail: {
from: 'support@example.com',
transport: {
host: 'smtp.example.com',
port: 587,
auth: { user: 'x', pass: 'y' },
},
},
});When mail is absent, the MailerModule is not registered and EmailService silently no-ops — modules boot cleanly without a transport.
EscalatedModule.forRoot({
inbound: {
replyDomain: 'reply.example.com', // domain in our Message-ID + Reply-To
replySecret: '32-byte-hex-secret', // HMAC key for signed reply-to
webhookSecret: 'shared-secret', // X-Escalated-Inbound-Secret header value
provider: 'postmark', // parser adapter
},
});Webhook endpoint: POST /escalated/webhook/email/inbound. Guard requires X-Escalated-Inbound-Secret header to match webhookSecret (constant-time compare).
How an inbound email is routed:
- Finding the ticket. With
replySecretset (outbound mail then carriesreply+{id}.{hmac8}@replyDomainas its Reply-To), only that signed address identifies a ticket. Without a secret, theIn-Reply-To/Referencesheaders and a[TK-XXX]subject reference are used instead. - Sender check. A matched email becomes a reply only when the
Fromaddress (case-insensitive) is the ticket's requester: the ticket's Contact, or a Contact linked to the requester user. The reply is posted as that requester. Identity is never taken fromFrom, so an email naming an agent's address is not posted as that agent; agents reply in the app. - Everything else (no match, or a sender who is not the requester) creates a new ticket for the sender, so mail is never dropped and the matched ticket is left untouched.
From can still be forged for the requester's own address, so also have your inbound provider enforce SPF/DKIM/DMARC.
Controls the identity assigned to a ticket submitted via the public form or inbound email.
// Default: no host-app user, contact carries identity
{ mode: 'unassigned' }
// Assign all guest tickets to a shared "guest" user in the host app
{ mode: 'guest_user', guestUserId: 42 }
// Ticket stays unassigned until the guest accepts a signup invite
{ mode: 'prompt_signup', signupUrlTemplate: 'https://app.example.com/signup?token={token}' }Admins can override at runtime via PUT /escalated/admin/settings:
{ "key": "guest_policy", "type": "json", "value": { "mode": "guest_user", "guestUserId": 99 } }Escalated stores references to your host app's users (ticket requester,
assignee, reply author, etc.). By default those columns are integers, matching
a classic auto-incrementing user primary key. If your host app's user table
uses a UUID or other string primary key, set ESCALATED_USER_KEY_TYPE
before the app bootstraps so the TypeORM entity columns are created as
varchar(255) instead of int:
# .env — one of: int (default) | bigint | uuid | string
ESCALATED_USER_KEY_TYPE=uuidThis is read from the environment (not the module options) because TypeORM
column decorators are evaluated at class-load time, before
EscalatedModule.forRoot() runs. Existing integer-keyed installs need no
change — the default (int) produces exactly the same schema as before.
uuid and string both map to a portable varchar(255) that can hold a UUID
or a stringified integer id. All Escalated APIs accept a host user id as either
a number or a string (UserId).
With synchronize: true, TypeORM auto-creates tables. For production, generate migrations:
npx typeorm migration:generate -n EscalatedSetup
npx typeorm migration:runAll tables are prefixed with escalated_ to avoid conflicts.
The shared Vue 3 + Inertia screens for agents, customers and admins are served
when you set ui: { enabled: true }. They are off by default, which keeps the
package API-only (escalated-laravel defaults to on; this default will flip in a
future minor). See docs/ui.md.
Protected by agentGuard.
| Method | Path | Description |
|---|---|---|
| GET | /tickets |
List tickets with filters |
| POST | /tickets |
Create ticket |
| GET | /tickets/:id |
Show ticket with replies |
| PUT | /tickets/:id |
Update ticket |
| DELETE | /tickets/:id |
Delete ticket |
| POST | /tickets/:id/replies |
Add reply |
| POST | /tickets/:id/actions/:actionKey |
Trigger custom action |
| POST | /tickets/:id/merge/:targetId |
Merge tickets |
| POST | /tickets/:id/split |
Split ticket |
| POST | /tickets/:id/snooze |
Snooze ticket |
| GET | /tickets/:ticketId/links |
List ticket links |
| POST | /tickets/:ticketId/links |
Link tickets |
| GET | /tickets/:ticketId/side-conversations |
List side conversations |
| POST | /tickets/:ticketId/side-conversations |
Create side conversation |
| GET | /macros |
List macros |
| POST | /macros/:macroId/execute/:ticketId |
Execute macro |
| GET | /canned-responses |
List canned responses |
| GET | /saved-views |
List saved views |
| POST | /saved-views |
Create saved view |
Protected by adminGuard.
| Method | Path | Description |
|---|---|---|
| GET/PUT | /settings |
Manage settings |
| CRUD | /departments |
Manage departments |
| CRUD | /tags |
Manage tags |
| CRUD | /custom-fields |
Manage custom fields |
| CRUD | /roles |
Manage roles |
| CRUD | /sla/policies |
Manage SLA policies |
| CRUD | /sla/escalation-rules |
Manage escalation rules |
| CRUD | /sla/schedules |
Manage business schedules |
| CRUD | /webhooks |
Manage webhooks |
| CRUD | /api-tokens |
Manage API tokens |
| CRUD | /agents |
Manage agent profiles |
| CRUD | /macros |
Manage macros |
| CRUD | /workflows |
Manage workflows (see Workflows) |
| CRUD | /canned-responses |
Manage canned responses |
| CRUD | /kb/categories |
Manage KB categories |
| CRUD | /kb/articles |
Manage KB articles |
| POST | /import/tickets |
Bulk import tickets |
| POST | /2fa/generate |
Generate 2FA secret |
| POST | /2fa/enable |
Enable 2FA |
| GET | /audit-logs |
View audit logs |
The ticket routes are protected by customerGuard. The knowledge base routes are public.
| Method | Path | Description |
|---|---|---|
| GET | /tickets |
List own tickets |
| POST | /tickets |
Create ticket |
| GET | /tickets/:id |
View own ticket |
| POST | /tickets/:id/replies |
Reply to own ticket |
| POST | /tickets/:id/rate |
Submit CSAT rating |
| GET | /kb/categories |
Browse KB categories |
| GET | /kb/articles |
Browse KB articles |
| GET | /kb/search |
Search KB |
Public. No route-group guard applies.
| Method | Path | Description |
|---|---|---|
| GET | /config |
Widget settings (colour, position, greeting, departments, accepts_metadata) |
| GET | /embed.js |
Standalone widget script for other sites |
| POST | /verification |
Email a guest a one-time code (ticket, chat or lookup) |
| POST | /lookup |
Find a ticket by reference and proven email; answers fresh grants |
| POST | /tickets |
Create ticket (public). A guest sends email plus the code (verification_id, verification_code) and gets an expiring grant; a host-authenticated req.user needs no code. A requesterId in the body is ignored. Optional metadata |
| GET | /tickets/:reference |
View ticket (Authorization: Bearer <grant>) |
| POST | /tickets/:reference/replies |
Reply (Authorization: Bearer <grant>) |
| GET | /kb/search |
Search KB |
| POST | /rate/:token |
Submit CSAT |
Live chat lives under /escalated/widget/chat/. To run the widget on another
site, list that site and load embed.js there; see
Embedding the widget.
All services are exported and can be injected into your own code:
import { Injectable } from '@nestjs/common';
import { TicketService, AgentService } from '@escalated-dev/escalated-nestjs';
@Injectable()
export class MyService {
constructor(
private readonly ticketService: TicketService,
private readonly agentService: AgentService,
) {}
async assignToAvailableAgent(ticketId: number) {
const agent = await this.agentService.findAvailableAgent();
if (agent) {
await this.ticketService.update(ticketId, { assigneeId: agent.userId }, 0);
}
}
}The module emits events via @nestjs/event-emitter:
import { OnEvent } from '@nestjs/event-emitter';
import { ESCALATED_EVENTS, TicketCreatedEvent } from '@escalated-dev/escalated-nestjs';
@Injectable()
export class NotificationService {
@OnEvent(ESCALATED_EVENTS.TICKET_CREATED)
handleTicketCreated(event: TicketCreatedEvent) {
// Send notification, update external system, etc.
}
}Events: TICKET_CREATED, TICKET_UPDATED, TICKET_STATUS_CHANGED, TICKET_RESOLVED, TICKET_CLOSED, TICKET_REOPENED, TICKET_ASSIGNED, TICKET_UNASSIGNED, TICKET_ESCALATED, TICKET_PRIORITY_CHANGED, TICKET_DEPARTMENT_CHANGED, TICKET_TAG_ADDED, TICKET_TAG_REMOVED, TICKET_REPLY_CREATED (every reply, internal notes included), NOTE_CREATED (internal notes), TICKET_MERGED, TICKET_SPLIT, SLA_WARNING, SLA_BREACHED, CHAT_STARTED, CHAT_ASSIGNED, CHAT_TRANSFERRED, CHAT_MESSAGE, CHAT_ENDED, TICKET_CUSTOM_ACTION_TRIGGERED. Unassigning a ticket emits TICKET_UNASSIGNED, not TICKET_ASSIGNED. CHAT_ACCEPTED is deprecated and still emitted beside CHAT_ASSIGNED.
sla.warning fires once per deadline that falls due within slaWarningMinutes (default 30, 0 turns it off). ticket.escalated fires the first time each escalation rule matches a ticket. Existing installations run the exported SlaWarningFlags1792540800000 migration, which adds the warning flags to tickets.
Escalated registers EventEmitterModule with wildcard: true and the . delimiter, so a listener can use a pattern such as @OnEvent('escalated.ticket.*'). A listener name that contains * is treated as a pattern; names without * match exactly, as before.
Webhooks managed under /escalated/admin/webhooks receive the shared event vocabulary (escalated-developer-context ADR 2026-10-21, exported as CANONICAL_EVENTS): ticket.created, ticket.updated, ticket.status_changed, ticket.resolved, ticket.closed, ticket.reopened, ticket.assigned, ticket.unassigned, ticket.escalated, ticket.priority_changed, ticket.department_changed, ticket.tag_added, ticket.tag_removed, reply.created, note.created, sla.warning, sla.breached, chat.started, chat.assigned, chat.transferred, chat.message and chat.ended, plus ticket.merged and ticket.split. Each is a JSON POST signed with X-Escalated-Signature (HMAC-SHA256 of the body with the webhook's secret). Subscribe to * for all of them. Chat payloads carry chat: { session_id, ticket_id, status, agent_id, department_id, ... }, never the visitor's token or email.
Names from earlier releases are read as their canonical name (LEGACY_EVENT_NAMES): a subscription stored as ticket.reply_created or ticket.replied receives reply.created, ticket.tagged receives ticket.tag_added, and so on. Saving a webhook stores canonical names. Workflows saved with a legacy trigger keep running.
A webhook URL must use http or https and must resolve only to public addresses. Loopback, private, link-local (including cloud metadata) and reserved targets are refused when the webhook is saved (400) and checked again before every delivery attempt. Redirects are not followed.
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 are not people. Attach them as ticket subjects so agents see what the ticket concerns and can jump straight to it in your app.
Implement the TicketSubject contract on any host model you want attachable:
import { TicketSubject } from '@escalated-dev/escalated-nestjs';
export class Project implements TicketSubject {
ticketSubjectTitle(): string {
return this.name;
}
ticketSubjectSubtitle(): string | null {
return `Project · ${this.customer.name}`;
}
ticketSubjectUrl(): string | null {
return `/projects/${this.id}`;
}
ticketSubjectColor(): string | null {
return '#2563eb';
}
ticketSubjectIcon(): string | null {
return 'folder';
}
}Register an allowlist and optional resolver when importing the module. NestJS
does not own your host models — the resolver maps a stored type + id to a
TicketSubject for API serialization:
EscalatedModule.forRoot({
ticketSubjects: {
types: ['Project', 'Customer'],
resolver: async (type, id) => {
if (type === 'Project') return projectRepo.findOne({ where: { id } });
if (type === 'Customer') return customerRepo.findOne({ where: { id } });
return null;
},
},
});Each attached subject is serialized on the ticket as:
{
"type": "Project",
"id": "7",
"role": "project",
"title": "Acme Redesign",
"subtitle": "Project · Acme",
"url": "/projects/7",
"color": "#2563eb",
"icon": "folder",
"missing": false
}subjectId is stored as a string so integer, UUID, or string-keyed host models
all work. When the resolver is absent or returns null, title falls back to
type#id, presentation fields are null, and missing is true.
Agent API (types must be allowlisted):
POST /escalated/agent/tickets/:id/subjects— body{ type, id, role? }DELETE /escalated/agent/tickets/:id/subjects/:linkId
Programmatic attach via TicketSubjectService works for any type when the
allowlist is empty; the agent API only accepts allowlisted types.
Host applications can add custom buttons to the agent ticket screen and handle clicks with normal event listeners. Register actions when importing the module:
EscalatedModule.forRoot({
ticketActions: {
actions: [
{
key: 'sync-crm',
label: 'Sync CRM',
variant: 'primary',
confirmation: 'Sync this ticket to the CRM?',
metadata: { icon: 'refresh-cw' },
},
],
},
});label, visible, enabled, confirmation, and metadata may each be a
value or a (ticket, user) => value function for dynamic behavior. For richer
logic you can instead provide an object implementing the TicketAction
interface (key(), label(), visible(), enabled(), variant(),
confirmation(), metadata()).
The agent ticket show response exposes the visible actions as customActions
(each with a url and method). When the agent triggers one
(POST /escalated/agent/tickets/:id/actions/:actionKey), Escalated dispatches
TicketCustomActionTriggered:
import { OnEvent } from '@nestjs/event-emitter';
import {
ESCALATED_EVENTS,
TicketCustomActionTriggeredEvent,
} from '@escalated-dev/escalated-nestjs';
@Injectable()
export class CrmSyncListener {
@OnEvent(ESCALATED_EVENTS.TICKET_CUSTOM_ACTION_TRIGGERED)
handle(event: TicketCustomActionTriggeredEvent) {
if (event.action !== 'sync-crm') return;
// event.ticket, event.userId, event.payload, event.metadata
}
}The event exposes ticket, action, userId, payload, and metadata.
Escalated also records an internal note on the ticket whenever an action fires,
for auditability.
Enable WebSocket broadcasting for live ticket updates:
EscalatedModule.forRoot({
enableWebsockets: true,
// Who is connecting: return the host user id, or null for a guest.
websocketAuth: async (socket) => verifyMyToken(socket.handshake.auth.token),
// Agent rooms, the chat queue and internal notes need confirmed roles.
ticketAccess: { resolveUser: async (id) => ({ id, isAgent: true, isAdmin: false }) },
});Every connection must identify a host user or present a guest token
(auth.guestToken, the ticket's guest access token, or auth.chatToken, the
widget chat visitor token); anything else is disconnected. Without
websocketAuth, userResolver(handshakeRequest) or a user set on the
handshake request by your socket middleware is used. New tickets, queue, SLA and
assignment events go to agents only, internal notes never reach guest or
customer sockets, and guest tokens are never broadcast.
Client-side (Socket.IO):
const socket = io('/escalated', { auth: { token } });
socket.emit('join:ticket', { ticketId: 1 });
socket.on('ticket:updated', (data) => console.log('Updated:', data));
socket.on('ticket:reply', (data) => console.log('New reply:', data));git clone https://github.com/escalated-dev/escalated-nestjs.git
cd escalated-nestjs
npm ci
npm test -- --runInBand
npm run build
npm run lint
npm run format:check
npm run test:package
npm auditUse a current patched Node 22 release for development (.nvmrc selects 22.x).
CI installs the committed lockfile with strict peer checking and runs the test
suite on Node 20, 22 and 24, plus the PostgreSQL/MySQL access and migration checks.
Node 20's existing upstream engine mismatch remains open; a functional test
pass does not establish engine compatibility. The full audit also reports the
remaining unpatched braces and sprintf-js findings described above.
All 32 entities are exported and prefixed with escalated_:
Core: Ticket, TicketStatus, Reply, Attachment, TicketActivity, Tag, Department, TicketLink, SatisfactionRating
SLA: SlaPolicy, EscalationRule, BusinessSchedule, Holiday
Agents: AgentProfile, AgentCapacity, Skill
Messaging: CannedResponse, Macro, SideConversation, SideConversationReply
Admin: Role, Permission, ApiToken, Webhook, WebhookDelivery, AuditLog
Custom: CustomField, CustomFieldValue
Config: EscalatedSettings, SavedView
Knowledge Base: KbCategory, KbArticle
Newsletters (optional): NewsletterList, NewsletterListMember, NewsletterTemplate, Newsletter, NewsletterDelivery
Admin-only broadcast feature for sending Markdown emails to contacts. Off by default — when disabled, NewsletterModule is not imported and its entities are not registered.
EscalatedModule.forRoot({
appUrl: 'https://support.example.com',
enableNewsletters: true,
newsletters: {
defaultFrom: 'hi@example.com',
defaultTheme: 'default',
rateLimitPerMinute: 60,
trackingEnabled: true,
brand: {
name: 'Acme',
accent: '#2563eb',
physicalAddress: 'Acme Inc. · 123 Main St · Springfield USA',
},
},
mail: {
from: 'hi@example.com',
transport: { service: 'postmark', auth: { user: '...', pass: '...' } },
},
})Schedule the dispatcher every minute. The cron tick is currently exposed via NewsletterDispatcherService.dispatchBatch() — wire it into your host's scheduler module (or call from a controller for testing):
@Cron(CronExpression.EVERY_MINUTE)
async runNewsletterDispatch() {
await this.newsletterDispatcher.dispatchBatch();
}Custom themes are Handlebars files placed in the directory pointed to by newsletters.themesDir. Each theme receives subject, body (pre-rendered HTML), unsubscribe_url, view_in_browser_url, and brand.*.
MIT - Copyright (c) Escalated.dev. See LICENSE.
See ticket access and routing for opt-in department/account visibility and targeted SLAs.