Project

escalated

0.0
The project is in a healthy, maintained state
A full-featured support ticket system with SLA, escalation rules, and three hosting modes.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

>= 2.0
>= 7.0
 Project Readme

العربيةDeutschEnglishEspañolFrançaisItaliano日本語한국어NederlandsPolskiPortuguês (BR)РусскийTürkçe简体中文

Escalated for Rails

Tests FOSSA Status Ruby Rails License: MIT

A full-featured, embeddable support ticket system for Rails. 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.

Three hosting modes. Run entirely self-hosted, sync to a central cloud for multi-app visibility, or proxy everything to the cloud. Switch modes with a single config change.

Features

  • 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
  • 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 view reports
  • File attachments — Drag-and-drop uploads with configurable storage and size limits
  • Activity timeline — Full audit log of every action on every ticket
  • Email notifications — Configurable per-event notifications with webhook support
  • Department routing — Organize agents into departments with auto-assignment (round-robin)
  • Tagging system — Categorize tickets with colored tags
  • Guest tickets — Anonymous ticket submission with magic-link access via guest token
  • Inbound email — Create and reply to tickets via email (Mailgun, Postmark, AWS SES, IMAP)
  • Inertia.js + Vue 3 UI — Shared frontend via @escalated-dev/escalated
  • Ticket splitting — Split a reply into a new standalone ticket while preserving the original context
  • Ticket snooze — Snooze tickets with presets (1h, 4h, tomorrow, next week); rake escalated:wake_snoozed_tickets auto-wakes them on schedule
  • Saved views / custom queues — Save, name, and share filter presets as reusable ticket views
  • Embeddable support widget — Lightweight <script> widget with KB search, ticket form, and status check
  • Email threading — Outbound emails include proper In-Reply-To and References headers for correct threading in mail clients
  • Branded email templates — Configurable logo, primary color, and footer text for all outbound emails
  • Real-time broadcasting — Opt-in broadcasting via ActionCable with automatic polling fallback
  • Knowledge base toggle — Enable or disable the public knowledge base from admin settings

Requirements

  • Ruby 3.1+
  • Rails 7.1+
  • Node.js 18+ (for frontend assets)

Quick Start

bundle add escalated
npm install @escalated-dev/escalated
rails generate escalated:install
rails db:migrate

Add the Ticketable concern to your User model:

class User < ApplicationRecord
  include Escalated::Ticketable
end

Define authorization in your ApplicationController or an initializer:

# config/initializers/escalated.rb
Escalated.configure do |config|
  config.admin_check = ->(user) { user.admin? }
  config.agent_check = ->(user) { user.agent? || user.admin? }
end

Visit /support — you're live.

Frontend Setup

Escalated uses Inertia.js with Vue 3. The frontend components are provided by the @escalated-dev/escalated npm package.

Tailwind Content

Add the Escalated package to your Tailwind content config so its classes aren't purged:

// tailwind.config.js
content: [
    // ... your existing paths
    './node_modules/@escalated-dev/escalated/src/**/*.vue',
],

Page Resolver

Add the Escalated pages to your Inertia page resolver:

// app/javascript/entrypoints/application.js
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'

createInertiaApp({
  resolve: name => {
    if (name.startsWith('Escalated/')) {
      const escalatedPages = import.meta.glob(
        '../../../node_modules/@escalated-dev/escalated/src/pages/**/*.vue',
        { eager: true }
      )
      const pageName = name.replace('Escalated/', '')
      return escalatedPages[`../../../node_modules/@escalated-dev/escalated/src/pages/${pageName}.vue`]
    }

    const pages = import.meta.glob('../pages/**/*.vue', { eager: true })
    return pages[`../pages/${name}.vue`]
  },
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .mount(el)
  },
})

Theming (Optional)

Register the EscalatedPlugin to render Escalated pages inside your app's layout — no page duplication needed:

import { EscalatedPlugin } from '@escalated-dev/escalated'
import AppLayout from '@/layouts/AppLayout.vue'

createInertiaApp({
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .use(EscalatedPlugin, {
        layout: AppLayout,
        theme: {
          primary: '#3b82f6',
          radius: '0.75rem',
        }
      })
      .mount(el)
  },
})

Your layout component must accept a #header slot and a default slot. Escalated will render its sub-navigation in the header and page content in the default slot. Without the plugin, Escalated uses its own standalone layout.

See the @escalated-dev/escalated README for full theming documentation and CSS custom properties.

Hosting Modes

Self-Hosted (default)

Everything stays in your database. No external calls. Full autonomy.

Escalated.configure do |config|
  config.mode = :self_hosted
end

Synced

Local database + automatic sync to cloud.escalated.dev for unified inbox across multiple apps. If the cloud is unreachable, your app keeps working — events queue and retry.

Escalated.configure do |config|
  config.mode = :synced
  config.hosted_api_url = "https://cloud.escalated.dev/api/v1"
  config.hosted_api_key = ENV["ESCALATED_API_KEY"]
end

Cloud

All ticket data proxied to the cloud API. Your app handles auth and renders UI, but storage lives in the cloud.

Escalated.configure do |config|
  config.mode = :cloud
  config.hosted_api_url = "https://cloud.escalated.dev/api/v1"
  config.hosted_api_key = ENV["ESCALATED_API_KEY"]
end

All three modes share the same controllers, UI, and business logic. The driver pattern handles the rest.

Configuration

Create or edit config/initializers/escalated.rb:

Escalated.configure do |config|
  config.mode = :self_hosted
  config.user_class = "User"
  config.table_prefix = "escalated_"
  config.route_prefix = "support"
  config.default_priority = :medium

  # Middleware
  config.middleware = [:authenticate_user!]
  config.admin_middleware = nil

  # Tickets
  config.allow_customer_close = true
  config.auto_close_resolved_after_days = 7
  config.max_attachments_per_reply = 5
  config.max_attachment_size_kb = 10240

  # SLA
  config.sla = {
    enabled: true,
    business_hours_only: true,
    business_hours: {
      start: 9, end: 17,
      timezone: "UTC",
      working_days: [1, 2, 3, 4, 5]
    }
  }

  # Notifications
  config.notification_channels = [:email]
  config.webhook_url = nil

  # Storage (ActiveStorage)
  config.storage_service = :local
end

Scheduling

Add these to your scheduler for SLA and escalation automation:

# config/schedule.rb (whenever gem) or use solid_queue/sidekiq-cron
every 1.minute do
  runner "Escalated::CheckSlaJob.perform_now"
end

every 5.minutes do
  runner "Escalated::EvaluateEscalationsJob.perform_now"
end

every 1.day do
  runner "Escalated::CloseResolvedJob.perform_now"
end

every 1.week do
  runner "Escalated::PurgeActivitiesJob.perform_now"
end

Routes

Routes are automatically mounted when the engine loads. By default they mount at /support.

Route Method Description
/support GET Customer ticket list
/support/create GET New ticket form
/support/{ticket} GET Ticket detail
/support/agent GET Agent dashboard
/support/agent/tickets GET Agent ticket queue
/support/agent/tickets/{ticket} GET Agent ticket view
/support/admin/reports GET Admin reports
/support/admin/departments GET Department management
/support/admin/sla-policies GET SLA policy management
/support/admin/escalation-rules GET Escalation rule management
/support/admin/tags GET Tag management
/support/admin/canned-responses GET Canned response management
/support/agent/tickets/bulk POST Bulk actions on multiple tickets
/support/agent/tickets/{ticket}/follow POST Follow/unfollow a ticket
/support/agent/tickets/{ticket}/macro POST Apply a macro to a ticket
/support/agent/tickets/{ticket}/presence POST Update presence on a ticket
/support/agent/tickets/{ticket}/pin/{reply} POST Pin/unpin an internal note
/support/{ticket}/rate POST Submit satisfaction rating

Events

Connect to ticket lifecycle events via ActiveSupport::Notifications:

ActiveSupport::Notifications.subscribe("escalated.ticket_created") do |event|
  ticket = event.payload[:ticket]
  # Handle new ticket
end

Inbound Email

Create and reply to tickets from incoming emails. Supports Mailgun, Postmark, AWS SES webhooks, and IMAP polling.

Enable

# config/initializers/escalated.rb
Escalated.configure do |config|
  config.inbound_email_enabled = true
  config.inbound_email_adapter = :mailgun
  config.inbound_email_address = "support@yourapp.com"

  # Mailgun
  config.mailgun_signing_key = ENV["ESCALATED_MAILGUN_SIGNING_KEY"]

  # Postmark
  config.postmark_inbound_token = ENV["ESCALATED_POSTMARK_INBOUND_TOKEN"]

  # AWS SES
  config.ses_region = "us-east-1"
  config.ses_topic_arn = ENV["ESCALATED_SES_TOPIC_ARN"]

  # IMAP
  config.imap_host = ENV["ESCALATED_IMAP_HOST"]
  config.imap_port = 993
  config.imap_encryption = :ssl
  config.imap_username = ENV["ESCALATED_IMAP_USERNAME"]
  config.imap_password = ENV["ESCALATED_IMAP_PASSWORD"]
  config.imap_mailbox = "INBOX"
end

Webhook URLs

Provider URL
Mailgun POST /support/inbound/mailgun
Postmark POST /support/inbound/postmark
AWS SES POST /support/inbound/ses

IMAP Polling

Schedule Escalated::PollImapJob with Solid Queue, Sidekiq, or whenever:

# config/recurring.yml (Solid Queue)
poll_imap:
  class: Escalated::PollImapJob
  schedule: every minute

Features

  • Thread detection via subject reference and In-Reply-To / References headers
  • Guest tickets for unknown senders with auto-derived display names
  • Auto-reopen resolved/closed tickets on email reply
  • Duplicate detection via Message-ID headers
  • Attachment handling with configurable size and count limits
  • Audit logging of every inbound email
  • All settings configurable from admin panel with env fallback

Plugin SDK

Escalated supports framework-agnostic plugins built with the Plugin SDK. Plugins are written once in TypeScript and work across all Escalated backends.

Requirements

  • Node.js 20+
  • @escalated-dev/plugin-runtime installed in your project

Installing Plugins

npm install @escalated-dev/plugin-runtime
npm install @escalated-dev/plugin-slack
npm install @escalated-dev/plugin-jira

Enabling SDK Plugins

# config/initializers/escalated.rb
Escalated.configure do |config|
  # ... existing config ...
  config.sdk_plugins_enabled = true
end

How It Works

SDK plugins run as a long-lived Node.js subprocess managed by @escalated-dev/plugin-runtime, communicating with Rails over JSON-RPC 2.0 via stdio. The subprocess is spawned lazily on first use and automatically restarted with exponential backoff if it crashes. Every ticket lifecycle event is dual-dispatched to both Rails event handlers and the plugin runtime.

Building Your Own Plugin

import { definePlugin } from '@escalated-dev/plugin-sdk'

export default definePlugin({
  name: 'my-plugin',
  version: '1.0.0',
  actions: {
    'ticket.created': async (event, ctx) => {
      ctx.log.info('New ticket!', event)
    },
  },
})

Resources

Internationalization

Translations are sourced from the central escalated-locale RubyGem, which is the canonical source of truth for every Escalated plugin in the portfolio. The engine appends the gem's locales/*.yml files to I18n.load_path automatically — you do not need to require anything in your host app.

Override order (last wins):

  1. escalated-locale gem — central, portfolio-wide
  2. config/locales/*.yml inside this engine — plugin-local overrides
  3. config/locales/overrides/*.yml inside this engine — host-specific tweaks that should not be upstreamed
  4. The host application's own config/locales/*.yml

Missing keys in the lower layers fall through to the central gem via I18n.fallbacks.

Also Available For

Same architecture, same Vue UI, same three hosting modes — for every major backend framework.

Testing

bundle exec rspec

License

MIT