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), in four tabs — General, Public form, Topics & replies, Email (each opens straight from a link with #general, #form, #topics or #email; a save that fails opens the tab holding the first error) — 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 — received through Postmark or this server (with a Generate button for a hard-to-guess inbound address, the server's status and last email received, and — for Admins, until the server routes the domain here — the setup steps and root command), and a Recently ignored emails list of the last dropped emails and why.

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

Inbound mail reaches the site one of two ways, chosen at Settings → Email tab → Receive email through: Postmark (its inbound webhook, below) or This server — the site's own mail server receives it, no provider needed (see Receive on your own mail server). Both arrive at the same decisions.

With an inbound address configured, 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 the first save in Postmark mode, 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, automated mail (an out-of-office, a bounce), an email already on a thread (same Message-ID), 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 that get past the automated-mail test. 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
duplicate_message its Message-ID is already on a thread — a second copy, or the same email handed over again

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.

Spam is quarantined, not opened. An email that passes every guard above is then run through the same content check as the form (see "Content check" under Spam protection) over its sender name, address, subject and body — with its URLs taken out, because a signature and a "Get Outlook for iOS" footer alone carry enough links to trip a check tuned on form fields. One that reads like spam becomes a ticket in the inbox's Spam tab (status quarantined, reason content_*) and nothing is sent: no confirmation (which would go to whoever the spammer typed in From), no team notice, no CRM entry, no assignment. Its allowed attachments ARE stored, unlike a quarantined form post's: the email is gone once handled, so Not spam would otherwise restore a ticket missing the customer's files. It keeps its Message-ID, so a copy handed over again is a duplicate_message, not a second spam ticket. The check runs after the per-sender cap, so spam counts toward the 5 an hour and one sender can't flood the Spam tab. A reply that reopens a closed thread is not checked: its token came from an email the site sent. On this server the router logs it as ok ticket #N quarantined as spam (<reason>). A spam email the check doesn't catch (no listed phrase, no other signal) still opens — Mark as spam on the ticket page moves it to the Spam tab.

Recently ignored emails. A dropped email still shows somewhere other than a server log: the Email tab of Settings lists the last 10, newest first, with when (hover for the site's local time), the sender, the subject and the reason in plain words, plus a Clear button. The list appears only when there's something on it, and it is fed from both Postmark and this server. It is how an owner finds:

  • a mail provider's forwarding-confirmation email that the automated-mail test dropped (the confirmation code is usually in the subject),
  • a reply from an address other than the requester's,
  • with This server, mail at any domain the server routes to this site sent to an address that isn't the inbound address (not_inbound_address): a forward still pointing at an old address after Generate made a new one (even on another routed domain), a mistyped forward, customers replying to emails sent before the address changed, or a stranger guessing addresses.

What's kept and what isn't:

  • duplicate_message is not listed, because its content is already on a ticket.
  • The same email handed over twice is listed once (matched by Message-ID and reason).
  • One sender dropped again and again for the same reason (an auto-responder loop) is one row with a count, so a flood from one address can't push everything else off the list.
  • Only the time, sender, subject and reason are kept: never the body, and never a recipient's plus part, which is a ticket's manage token.
  • Storage is one Setting, tickets.inbound_ignored (InboundIgnoredLog), written under a cache lock so webhooks arriving together don't overwrite each other.
  • Writing to it never changes what happened to the email: a failed write is reported and the message is still ignored.
  • Mail nothing on the site takes reaches features through mail:receive's InboundMailUnclaimed event, which carries the header block only (MimeMessage::parseHeadersOnly()), so such mail is never decoded. The exit code stays 0 whatever a listener does.

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. It is never linked to an account (user_id stays empty): the From address is whatever the sender typed, so matching it to a user would let anyone plant a ticket in a stranger's portal. A signed-in user with the same email address still sees it under My tickets, which matches by address when the list is shown. TextBody is preferred over StrippedTextReply on this path — stripping quoted history from a first contact can remove the entire message.

From a client site's Get Support button

On a server that issues license keys (the license server, or an agency fleet head), the desk also takes tickets from the dashboards of the sites it licenses. A site's Get Support button posts to api/membership/support-request with its fleet key or license key, and gets back the ticket number and thread link. The ticket is opened for the person who clicked, but with no automatic confirmation email: the address is the caller's word, so the widget shows the thread link instead (TicketActions::open(..., confirmRequester: false)). The site's diagnostics and any attached server errors go in a staff-only internal note, written in the ticket's own transaction (internalNote:). The content spam check is skipped because the caller is authenticated, and one member may file 10 an hour. Full behaviour: Support Requests.

Receive on your own mail server (no Postmark)

Postmark does two things a send-only server can't: it receives mail from the internet, and it turns a raw email into structured data. A WebProCMS install can do both itself.

The site's half ships in every install: php artisan mail:receive --to=<recipient> [--from=<sender>] takes one raw email on standard input, reads it (App\Support\Mail\Inbound\MimeMessage — headers, encoded words, charsets, multiparts, format=flowed, attachments; no package), strips the quoted history off a reply (QuoteStripper, which cuts only at a real quote marker or an exact -- signature line, so a customer's own "From: … / Date: …" or a -- between list items stays), and hands it to the same reply-or-new-ticket decisions as the webhook. The recipient's +token finds the thread (or the To/Cc headers', as Postmark does), and an empty or <> sender marks a bounce.

Never twice. Each message's Message-ID is stored on the ticket message it became (ticket_messages.email_message_id), in the same insert, and an email whose Message-ID is already on a thread is ignored — on both the Postmark and the server path. That covers a reply the customer also copied to the public support address, and an email handed over again after a failure part-way through.

Auto-replies never reach a thread. The same automated-mail test that keeps them from opening tickets (Auto-Submitted, Precedence, list headers, a null return path) also drops them on the reply path, so a customer's out-of-office cannot reopen their ticket after every staff reply.

Reply-To only when it will arrive. In server mode, ticket emails carry the +token Reply-To only once the server's installer has reported routing the inbound address's domain to this site (mail:inbound-hook); before that, customers reply to the normal sender. The settings page refuses an inbound address at the site's own domain — its MX record would send all the business's mail to the receive-only listener — and never creates a Postmark webhook secret in server mode.

The server's half is a root installer, served by the CMS vendor's server and shown on the Settings Email tab (to Admins) once This server is chosen:

sudo sh -c 'umask 077; d=$(mktemp -d) && curl --proto =https --proto-redir =https -fsSL https://<vendor server>/installer/inbound-mail-setup.sh -o "$d/inbound-mail-setup.sh" && bash "$d/inbound-mail-setup.sh" --route in.example.com=/path/to/site; s=$?; rm -rf "$d"; exit $s'

Like the bounce installer, root runs only the downloaded copy — never one in a site's folder, which the site's own user can rewrite.

What it changes, in full:

  1. A spool user and spool. A no-login system user, wpcmsinbound, that the mail server delivers as, and /var/spool/wpcms-inbound/mail/{tmp,new,failed}.
  2. A receive-only listener on port 25 of one address (--listen; the default is the address outgoing traffic leaves from — behind NAT, the private one). It accepts mail only for the routed domains — a one-word name before the @ (letters, digits, -, _), optionally with a +token — and refuses everything else while talking SMTP: local users, relaying, source routes, %-hacks, look-alike domains. Nothing it accepted is ever bounced.
    • sendmail: a separate sendmail process with its own configuration (/etc/wpcms-inbound/sendmail/), queue, PID file and systemd unit (wpcms-inbound.service). The main sendmail — the one every site sends through — is never edited. Before anything starts, the built configuration must pass static checks (one listener, its own files, no milter) and a sendmail -bt rule test; after it starts, a live SMTP session must show the routed domain accepted and root@host and relaying refused. Any failure rolls everything back.
    • Postfix: one extra smtpd service in master.cf bound to that address, with its own recipient check; one transport_maps entry delivering the routed domains to the spool; and wpcmsinbound_destination_recipient_limit = 1, so a message for two routed addresses is delivered (and spooled) once per address. main.cf and master.cf are backed up first. This path has been tested with simulated commands, not yet on a live Postfix server.
  3. A 1-minute job. /usr/local/sbin/wpcms-inbound-router, /etc/wpcms-inbound/{config,routes} and /etc/cron.d/wpcms-inbound: each received message goes to the site its domain is routed to, through mail:receive as the site's own user. Exit 0 removes it; 75 (the database was unreachable, a lock timed out) keeps it for up to 72 hours; anything else — 65 not an email, 70 an error retrying won't fix — moves it to failed/ (kept 14 days) for a person to look at.

It never changes DNS or a firewall. You do those: an MX record for the receiving domain pointing at the server, and TCP port 25 allowed in from anywhere (the cloud provider's security group too).

Options: --route DOMAIN=PATH (repeatable; replaces an earlier route for that domain), --remove-route DOMAIN, --listen ADDRESS, --status, --dry-run, --uninstall (refuses while messages wait in the spool unless --force). It refuses a site whose release has no mail:receive, and a server whose mail program already accepts internet mail on that address (not supported yet). Exit codes: 0 done, 1 failed (rolled back), 2 refused (nothing changed). The router logs to syslog: journalctl -t wpcms-inbound-router; the receiving sendmail as sm-wpcms-inbound.

Using it. Pick a receiving subdomain (e.g. in.yoursite.com — not one already in use), add its MX record, open port 25, run the command, then on the Settings Email tab (…/dashboard/tickets/settings#email) choose This server, press Generate for a hard-to-guess inbound address at that domain, switch on Open a ticket from any email to this address, and save. Finally, at your mail provider, forward your public support address to the inbound address — on Fastmail, change one domain setting first or the forward never leaves Fastmail (see "Forwarding from your mail provider" below). Keep the inbound address hard to guess: mail sent straight to the receiving domain skips your provider's spam filter. The settings page shows whether the server routes the domain to this site and when mail last arrived. Generate keeps the typed domain only while the server routes it to this site; otherwise it uses the first domain the server does route here.

Forwarding from your mail provider. The forward is the one step that happens at the provider, and each big provider needs a setting for it. Customers' own mail (a reply to a ticket email) reaches the server through the MX record with none of this. So when the server logs nothing for a forwarded test while a direct test works, look at the provider's setting.

  • Fastmail — tested live. An alias (Settings → Users & Sharing → Aliases) delivering to the inbound address does the forward. By default, though, Fastmail treats any subdomain of a domain it hosts as its own: mail for …@in.yoursite.com becomes in+…@yoursite.com, lands in your mailbox or catch-all, and never looks at the subdomain's MX. To fix it, go to Settings → Domains → the domain → Edit → Routing → Manage, set Subdomain addressing to "Reject mail to [email protected]", and leave Routing on "Route mail internally". Fastmail then follows in.yoursite.com's MX to this server.
  • Microsoft 365 — not yet tested live. Exchange Online's default outbound policy blocks automatic forwarding to outside addresses, so a mailbox forward is refused (the sender gets a 5.7.520 bounce). An admin either allows forwarding for the support mailbox in the outbound spam policy, or adds a mail flow rule that redirects mail for support@ to the inbound address.
  • Google Workspace — not yet tested live. A user-level forward has to be confirmed through a code Google emails to the inbound address. The admin console's Gmail routing (a rule for the support@ recipient that adds the inbound address as a recipient) avoids that step.

The router is updated only by re-running the installer, so mail:receive's options and exit codes are a contract with every router already on a server: add to them, never rename or repurpose them. The installer itself is served by the license server and stamped with the release it came from; --status says when the installed router differs from the one a fresh download carries.

Other mail servers — the manual contract: pipe each message for the inbound address, raw, into php artisan mail:receive --to=<envelope recipient> --from=<envelope sender> as the site's own user, from the site's folder. Exit codes: 0 done (handled, or deliberately ignored), 1 no input or no --to, 65 not an email, 70 an error on the site's side that retrying won't fix (keep the message for a person), 75 try again later (the database or a lock — safe to repeat: a message already on a thread is recognised by its Message-ID). Leave out --from only when the sender is unknown; pass --from= (empty) for a bounce.

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-visitor rate limiting (the browser session, not the IP) — 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. In every mode, the requester's confirmation email is held to the forms' visitor mail caps (3 an hour to one address, 100 an hour from the support form): over a cap the ticket opens without the email. A reply on the public thread page is limited to 10 per 10 minutes per ticket, so a fresh session can't reset it. 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.

Content check. The modes above only ask whether a person is at the keyboard; a person can paste spam, and an email can't be asked at all. So every new ticket — from the form in every mode except None, and every emailed one — is also read by the Form builder's content classifier (ContentScorer via TicketSpamProtection::contentFailureReason()): seeded scam phrases, gibberish, link counts, shouting, the install's own trained model and the fleet's shared fingerprints, against the same cms.spam.shield.content.* threshold. A hit is quarantined under a content_* reason (e.g. content_scam_phrase) and logged to the spam channel with surface = ticket_form or ticket_email. The form visitor gets the same quiet redirect as a Shield rejection.

Mark as spam. Spam the check missed is moved to the Spam tab from the ticket page (Mark as spam, under the details), with reason marked_by_staff. Nothing is unsent — its emails already went out when it opened — and emailed replies to it are ignored from then on (spam_thread). Not spam on such a ticket only returns it to the inbox: it re-sends nothing, because unlike a ticket held at intake it skipped nothing (Ticket::wasQuarantinedAtIntake() tells the two apart). A ticket held at intake fires the ticket.created webhook when restored — the first its integrations hear of it; a staff-marked one was announced when it opened and isn't announced again.

A spam ticket emails nobody. Every requester email (staff reply, satisfaction survey, nudge) is skipped while a ticket is spam — its address is whatever a spammer typed, and every link would 404, since the public pages skip spam tickets. So resolving one while triaging sends no survey, and its page hides Send reply (internal notes still work) until Not spam. Empty spam deletes the tickets with their attachment files (tickets/{id}/ on the local disk), via TicketActions::emptySpam().

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.

The sender is the desk, not the owner. Ticket emails are signed "Support Staff" unless the site sets its own From name (TicketSettings::fromName()). The From address falls back to the Marketing sender, which is usually the owner's own mailbox. Every customer would then save that address and write to it instead of opening a ticket. So while no From email is set (TicketSettings::usesGeneralSenderAddress()), the Sender card shows the address in use and recommends a dedicated one. The recommended setup, which is how the webprojoe desk runs: a ticket@ alias at the site's mail provider, forwarding to the inbound address (or to an inbox if reply-by-email isn't set up), entered as the From email, with the name left as Support Staff. Only ticket emails change; forms, invoices and marketing keep the Marketing sender.

Requester emails carry the per-ticket Reply-To when reply-by-email is on — in Postmark mode once the webhook secret exists, in server mode once the server reports routing the inbound address's domain to this site. 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 restoring a ticket held at intake — not one marked as spam after it opened)
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

In the requester's language. With Message Translation on, a ticket records the language its requester writes in, the requester's emails (confirmation, survey, reminder) are written in it when the site publishes it, and a staff reply can go out translated into it — subject, greeting, button and reply — with the thread keeping what was sent and the staff member's own words beside it. The team's notifications show a customer message translated when it was translated on arrival. Internal notes are never translated or sent. Full detail: Message Translation → Support tickets.

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, language (the language the requester writes in — any short code, null when unknown or opened before it existed), 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, and email_message_id (indexed; the Message-ID of the email a message arrived as, which is how an email handed over twice is recognised). Internal notes are staff-only everywhere: the public thread queries publicMessages() and emails only fire for public messages.
  • Settings (settings table): tickets.inbound_via (postmark | server), tickets.inbound_address, tickets.inbound_create_tickets, tickets.inbound_secret (Postmark only), tickets.inbound_last_received_at, and mail.inbound_pipeline — what the server's installer last reported (date, mail server, routed domains).
  • 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.