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:
- authenticates via a random URL-path secret (minted on first save, shown on the settings page to paste into Postmark),
- looks the ticket up by the plus-part (
MailboxHash), - accepts the reply only when the sender address matches the requester's,
- 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
MailboxHashnaming 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:
- Topic default assignee — tickets opened in that topic go straight to that person.
- 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).
- 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.
| 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), optionalticket_category_id, requester name + email, optionaluser_id(dashboard user who opened it while logged in),assigned_to_id,crm_contact_id, spam quarantine (is_spam,spam_reason), uniquemanage_token, timing marks (last_reply_at,first_staff_reply_at,nudged_at,resolved_at,closed_at), and CSAT (csat_rating1–3,csat_comment,csat_at,csat_requested_at).ticket_messages—author(customer/staff), optional staffuser_id, body,is_internalflag. Internal notes are staff-only everywhere: the public thread queriespublicMessages()and emails only fire for public messages.ticket_attachments— per-message files (pathon 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.