Skip to main content

Documentation

No results found.
Features

Ticket System

The Ticket System gives the site a real support desk: visitors open tickets from a public /support form (with file attachments and optional topic routing) or simply by emailing the support address, follow the whole conversation on a private...

The Ticket System gives the site a real support desk: visitors open tickets from a public /support form (with file attachments and optional topic routing) or simply by emailing the support address, follow the whole conversation on a private tokenized link, and reply from that page — or straight from their email client when reply-by-email is configured. Staff work the queue from a dashboard inbox with statuses, priorities, assignment (manual, per-topic, or auto-balanced), canned responses, AI-drafted replies, SLA aging, satisfaction ratings, and response-time stats — and every requester lands in the CRM, so support conversations feed the same contact timeline as everything else on the site.


The problem

Support requests usually arrive as plain emails to a shared inbox: no statuses, no ownership, no history a teammate can pick up, and nothing connecting the person asking to the site's own contact records. The customer has no way to see where their request stands, and the team has no way to see what's still waiting on them.

The fix

A self-contained feature module under app/Features/Tickets/ adds a ticket pipeline in the install's own database: a public submit form, a per-ticket secret link for the requester, a dashboard inbox for the team, and transactional emails in both directions through the shared Marketing mail transport. Tickets create CRM contacts automatically (source support) and log to the contact's timeline.

Like every addon, it's structured as a feature module gated by feature:tickets middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations.

What the dashboard gets

Under Dashboard → Support (Tickets need Manager and up; Settings is Admin-only):

  • Stats bar — four inbox-health tiles above the queue: median time-to-first-reply and median resolution time (trailing 30 days), tickets opened this week, and average satisfaction (trailing 90 days).
  • Tickets (inbox) — Open / Awaiting reply / Resolved / Closed / All tabs with a live open-count, a Spam tab when anything is quarantined, an optional topic filter, and search across subject, requester name, and email. Each row shows the subject (with topic and rating when present), requester, priority badge, assignee, last activity, and status badge, ordered by most recent activity. Open tickets waiting on staff age visibly: the last-activity stamp turns amber past 2 days and red past 4. The sidebar nav badge shows how many tickets are waiting on the team.
  • Ticket detail — the full conversation (customer messages, staff replies, amber-highlighted internal notes, and each message's attachments) with one composer and two actions: Send reply emails the requester and hands the ticket back to them (status → Awaiting reply); Add internal note records a staff-only note that never emails anyone and never changes status. The composer takes attachments, inserts canned responses from a dropdown, and — when an AI text provider is configured under Settings → API Keys — drafts a reply from the thread with one click (Draft with AI; the draft lands in the composer for editing, never auto-sends). A details sidebar sets status, priority, topic, and assignee, and shows the requester's contact info, first-reply/resolved timestamps, the satisfaction rating and comment when given, a link to their CRM contact when the CRM is on, and the customer's private thread link.
  • Settings (admin only) — notifications, sender overrides, the public form (intro text, spam protection, rate limit), topics, canned responses, automation (auto-assign, auto-close, customer nudge, CSAT), and reply-by-email.

The dashboard also gets a Support Tickets card showing how many tickets await a staff reply (and how many are waiting on customers), linking straight to the inbox.

What visitors see

  • /support — a clean form (name, email, optional topic, subject, message, attachments) with the configurable intro text. Logged-in visitors (dashboard users and members) get name and email prefilled and a My tickets link. While typing a subject, matching Documentation articles are suggested inline ("These articles might answer it faster") so visitors can self-serve — only when the Documentation feature is on. Submitting opens the ticket, shows a confirmation banner on the thread page, and emails the requester a receipt with their private link. Bots are kept out by the configurable spam protection below.
  • /support/tickets/{token} — the requester's private thread: subject, live status pill, the full public conversation with downloadable attachments (internal notes never render here), and a reply box that also takes attachments. Replying reopens the ticket for the team — even from Resolved. Closed tickets lock the reply box and offer a link to open a new ticket. The token is a 40-character secret; there is nothing to log into and nothing guessable.
  • /support/mine — the signed-in visitor's ticket history (matched by account email or the user id stamped at submit time), each linking to its private thread. Guests are redirected to the form — their route in is the emailed thread link.

None of these pages are response-cached (per-visitor prefill and private threads), and none appear in the page editor — they're transactional app pages, like the shop checkout.

Attachments

Both sides of the conversation can attach files — up to 5 per message, 10 MB each (images, PDF, text/CSV/log, zip, Office documents; never SVG, the same script-capable-format rule as public form uploads). Files live on the private local disk, not under the public web root: downloads stream through a token-authorized route on the public thread (/support/tickets/{token}/attachments/{id}) and a Manager+-gated dashboard route, always with attachment disposition so nothing renders in the site's origin. Internal-note attachments are staff-only — the public route 404s them.

Reply by email

With a Postmark inbound address configured (Settings → Reply by email), every requester email carries a per-ticket Reply-To of {inbound-local}+{manage_token}@{inbound-domain}. The customer just hits reply in their mail client; Postmark posts the message to the module's webhook, which:

  1. authenticates via a random URL-path secret (minted on first save, shown on the settings page to paste into Postmark),
  2. looks the ticket up by the plus-part (MailboxHash),
  3. accepts the reply only when the sender address matches the requester's,
  4. strips quoted history (Postmark's StrippedTextReply) and appends it as a normal customer reply — reopening the ticket and notifying the owner.

Lookup misses, sender mismatches, closed tickets, and empty bodies return an "ignored" 200 (a non-2xx would make the provider retry forever); a per-ticket rate limit stops auto-responder loops. The webhook path is CSRF-exempt and the whole flow is off until an inbound address is saved.

Attachments on an emailed reply are carried through: Postmark delivers them base64-encoded in the webhook body, and they are decoded and stored against the message under the same allowlist and size caps as an upload.

Email to ticket (open a ticket by writing in)

Reply-by-email is bounded — a token in the address names the thread. The Open a ticket from any email to this address switch (same settings panel, tickets.inbound_create_tickets) turns the same inbound address into a full intake: an email matching no existing thread opens a new ticket, so customers can write to [email protected] instead of using the form. Point the address's MX (or a forwarding rule) at the Postmark inbound address and it behaves like any other helpdesk.

It is off by default, and that default is load-bearing — reply-by-email and open intake are different exposures, so an install that configured the former must never gain the latter from a CMS update. TicketInboundNewTicketTest asserts the shipped default by deleting the Setting row rather than setting it false.

What opens a ticket:

  • an email with no MailboxHash (someone wrote to the address directly),
  • a MailboxHash naming no surviving ticket (a stale token from a deleted thread),
  • a reply to a closed ticket — closed is terminal, and a new ticket was already the documented path forward.

What does not, each returning an "ignored" 200:

Reason Why
automated_message Auto-Submitted (RFC 3834) other than no, Precedence: bulk/list/junk/auto_reply, List-Id, List-Unsubscribe, X-Auto-Response-Suppress, X-Autoreply, or a <> null return path
system_sender mailer-daemon, postmaster, no-reply, VERP bounces+hash@, or any address the site itself sends from (ticket sender, marketing sender, mail.from.address, the inbound address)
rate_limited more than 5 new tickets an hour from one address
sender_mismatch a valid token whose sender isn't the requester — never falls through to creating a ticket, or a leaked token address would become a probe
empty_body no TextBody and no HtmlBody

Those guards are the reason the path is gated rather than always-on: an intake address that answers everything will converse with a vacation responder forever. Automated mail is dropped, not quarantined — unlike a Shield-rejected form post, creating the row is the harm.

A ticket opened this way is a normal ticket: auto-assignment, owner notification, CRM capture, the confirmation email with its thread link, and attachments all behave exactly as a form submission does. When the sender's address matches a registered user, the ticket is linked to that account so it appears under My tickets. TextBody is preferred over StrippedTextReply on this path — stripping quoted history from a first contact can remove the entire message.

Topics (categories)

Optional categories managed on the settings page. Each topic can carry its own notify email (outranks the global one for new-ticket/customer-reply alerts) and a default assignee (auto-assigned on open, outranks balanced auto-assign). When topics exist, the public form shows a "Topic" select, the inbox gets a topic filter, and the ticket detail sidebar lets staff re-categorize. Deleting a topic never touches its tickets — they just lose the label.

Assignment

Three layers, first match wins:

  1. Topic default assignee — tickets opened in that topic go straight to that person.
  2. Auto-assign (balanced) — when enabled in Automation, unassigned tickets go to the Manager+ user with the fewest open/awaiting-reply tickets (ties break deterministically to the lowest id).
  3. Manual — the detail-sidebar select.

Whenever a ticket lands on someone's plate (auto or by a colleague), the assignee gets an email with the ticket summary and a dashboard link. Self-assignment sends nothing.

Automation

All scheduled work runs through the tickets:maintenance LazyCron task (4×/day); both knobs default to 0 = off:

  • Auto-close resolved after N days — Resolved tickets untouched for N days flip to Closed (locking the thread). The CSAT ask already went out at resolve time.
  • Nudge quiet customers after N days — a ticket sitting in Awaiting reply for N days gets one "Still need help?" email. One nudge per staff reply: sending stamps nudged_at, and the next staff reply clears it.

Satisfaction ratings (CSAT)

Opt-in via the Automation section. When staff mark a ticket Resolved, the requester gets a one-click 😃 Great / 😐 Okay / 🙁 Poor email (sent once per ticket — status flapping never resends). Clicking records the rating via the manage token (first click wins) and lands on the thread with a thank-you banner plus an optional one-time comment box. Ratings surface on the ticket detail sidebar (with the comment), in the inbox row, and as the trailing-90-day average on the stats bar.

Spam protection

The public form offers the same tiers the Form builder does, chosen in Dashboard → Support → Settings → Public form:

Mode What runs
None Nothing beyond the always-on honeypot.
Honeypot + Rate Limiting (default) Hidden honeypot field plus per-IP rate limiting — attempts and window are configurable (default 5 submissions / 10 minutes).
Spam Shield (in-house) The full Shield pipeline: signed single-use challenge token, browser proof-of-work, time-on-page, and interaction telemetry. Rejected submissions are quarantined into the inbox's Spam tab (no emails, no CRM, no assignment) so false positives are recoverable — the visitor sees a quiet non-result either way, so bots learn nothing. Not spam restores the ticket and runs the skipped side effects (the requester finally gets their confirmation); Empty spam deletes the quarantine after a confirm. No third-party requests, no keys to configure.
Google reCAPTCHA v3 / Cloudflare Turnstile Invisible CAPTCHA verified server-side. Both reuse the site-wide keys from Settings → API Keys (no ticket-specific keys) and only appear in the dropdown once those keys are saved. A failed Turnstile verify resets the widget so the visitor can retry.

The honeypot field stays in the markup and is checked first in every mode — it costs humans nothing. Because the ticket form isn't a Form-builder row, its Shield challenge tokens are issued under a reserved synthetic context id (TicketSpamProtection::SHIELD_FORM_ID) that can never collide with a real form.

Status flow

Event Status becomes
Visitor opens a ticket Open (waiting on staff)
Staff sends a public reply Awaiting reply (waiting on customer)
Customer replies — thread page or email (any non-closed status) Open
Staff marks Resolved / Closed Resolved / Closed (timestamps stamped)
Customer replies on a Resolved ticket Open — resolved isn't locked
Resolved ticket ages past the auto-close window Closed

Internal notes never change status. Closed is the only locked state: the public reply box disappears, inbound email replies are ignored, and a new ticket is the path forward.

Emails

All sends go through the shared CampaignMailer transport (configured under Marketing → Settings), with the optional tickets-scoped sender override. Requester emails carry the per-ticket Reply-To when reply-by-email is on. Sends are best-effort — a mail failure is reported but never blocks the ticket write.

Email To When
Ticket received (with private link) Requester On open (and on spam restore)
New reply (with the reply text + link) Requester On each public staff reply
Still need help? (nudge) Requester Once per staff reply, after the configured quiet period
How did we do? (CSAT ask) Requester Once, on first resolve, when CSAT is on
New ticket alert (with dashboard link) Topic notify email, else global On open, when configured
Customer replied (with dashboard link) Topic notify email, else global On each customer reply, when configured
Ticket assigned to you Assignee On assignment by someone else or auto-assign

CRM integration

When the CRM feature is on, opening a ticket creates or updates a CRM contact (source support), backfills tickets.crm_contact_id, fires the contact recaptured automation for returning contacts, and logs a support interaction with the ticket subject and first message on the contact timeline. The ticket detail sidebar links straight to the contact. Merging duplicate CRM contacts repoints their tickets to the survivor. With the CRM off, tickets work standalone — the pointer column carries no FK constraint.

Data model

  • tickets — subject, status (open / pending / resolved / closed), priority (low / normal / high / urgent), optional ticket_category_id, requester name + email, optional user_id (dashboard user who opened it while logged in), assigned_to_id, crm_contact_id, spam quarantine (is_spam, spam_reason), unique manage_token, timing marks (last_reply_at, first_staff_reply_at, nudged_at, resolved_at, closed_at), and CSAT (csat_rating 1–3, csat_comment, csat_at, csat_requested_at).
  • ticket_messages — author (customer / staff), optional staff user_id, body, is_internal flag. Internal notes are staff-only everywhere: the public thread queries publicMessages() and emails only fire for public messages.
  • ticket_attachments — per-message files (path on the private disk, original name, mime, size).
  • ticket_categories — name, optional notify email, optional default assignee, sort.
  • ticket_canned_responses — title, body, sort.

Disabling the feature hides the UI and 404s all routes but preserves every ticket, message, and attachment; re-enabling picks up where it left off.