FreeScout Support Pipeline
The customer-support system (design: docs/planning/plans/2026-07-15-support-system.md). One funnel, three surfaces, one help desk: everything lands in the self-hosted FreeScout instance (support.show.fm, moved from support.podcasterplus.com on 2026-09-01 and shared across businesses — mailbox 6 = [email protected], mailbox 7 = [email protected]; the legacy mailboxes 1 = [email protected] and 5 = [email protected] keep receiving forever and stay in the allowlist), while SaaS customers run the full ticket lifecycle in-app and staff work in FreeScout.
Architecture
IN-APP (authed) MARKETING /support (public)
Help launcher (? in top bar) SvelteKit form + Turnstile + D1 audit
POST /api/support/tickets ◄── POST /api/support/public (bearer)
│ (marketing Worker, server-to-server)
▼
resolveCustomerContext → computeSlaTier → computeRouting
▼
FreeScout API (create conversation, custom fields, tags)
│ ▲
▼ │ GET /api/support/context (bearer + IP allowlist)
FreeScout ──webhook──► crm_support_threads ──► in-app "My tickets"
(PodcasterPlusSupport sidebar module pulls live context)Key modules (app repo)
| Module | Purpose |
|---|---|
src/lib/support/resolve-context.ts | Email/user → accounts, plan, status, LTV, risk, open threads. effectivePlanKey demotes canceled/incomplete to free; past_due keeps its plan |
src/lib/support/sla.ts | priority_support_sla grant is the authoritative priority boundary; plan key splits levels within each half |
src/lib/support/routing.ts | §6 matrix: support@ only for identified accounts with technical/billing issues; urgency combines, never overrides |
src/lib/support/freescout-client.ts | Outbound API client. Status changes REQUIRE byUser (FREESCOUT_API_USER_ID, the "Workflow" system user). customerVisibleThreads is the internal-note leak boundary |
src/lib/support/docs-search.ts | Tier-1 deflection seam (getSuggestedArticles) — swappable for semantic/AI later |
src/lib/support/thread-body.ts | Thread HTML → plain text, then quoted-history stripping (threadBodyToText) |
src/api/routes/support/index.ts | tickets / my-threads / threads detail+reply+close / public / context |
docs-public/scripts/build-search-index.mjs | Emits the deflection index twice: full to the docs Worker, Blocks-free to the app's static/ |
Thread rendering (#298)
- Sides come from identity, not thread type.
threadDirection()treats a thread as the reader's own only when it is customer-filed AND carries the reader's address (or the conversation customer's).type === 'customer'alone is FreeScout's record of who FILED the thread: an agent replying by email from an address the instance does not know as a user gets filed as a customer thread against a new customer, and the reply then rendered as the reader's own message. - Quoted history is stripped by
threadBodyToTextbefore the text leaves the server: the HTML markers first (gmail_quote,x-em-replyforwardheader, Outlook'sappendonsend/divRplyFwdMsg,blockquote), then plain-text ones (------ Original Message ------,On … wrote:, an OutlookFrom:/Sent:/To:block). FreeScout strips quotes on some fetched mail and not on others, so this is ours to do. It never returns an empty message: an unrecognised quote survives, which is the accepted limitation. - The mirror is nudged, not trusted.
/my-threadsreadscrm_support_threads, which only the webhook refreshes, so the reply and close handlers move the row themselves (active/closed) rather than leaving the customer looking at a stale label. Fail-soft; the webhook stays authoritative. - The customer-reply EVENT outranks the status on its payload. FreeScout can serialise a conversation as
pendingwhile deliveringconvo.customer.reply.created, which left the customer's list saying "Awaiting your reply" after they had replied. A customer reply mirrors asactiveunless the conversation is closed. Deliberately one-sided: agent replies keep the status FreeScout sends. - Agent replies raise
support.agent_repliedfor the app user whoseuser_profiles.emailmatches the helpdesk customer (many helpdesk customers are not app users — a miss is normal). In-app only:emailSupported: false, because the helpdesk has already emailed the reply. Best effort inside the webhook, and idempotent under FreeScout's ten retries via a dedupe id carryingthreadsCount. The link is/dashboard?support=tickets&ticket=<conversation id>; the dashboard load carries both params through its redirect to/p/{slug}, andHelpLauncheropens the panel on them.
Security invariants
- Internal notes never reach customers: thread proxying whitelists
customer/messagetypes,publishedstate only; bodies are converted to plain text (no HTML survives — no sanitizer to get wrong). - Ownership twice: mirror row must belong to the caller's CRM contact AND the live conversation's customer email must match; failures are uniform 404s.
- Email enumeration: the public path never reflects account existence; the resolver runs server-side only.
- Metering: support receipts use
sendSupportReceiptEmail— platform-operational, unmetered, never consumes tenantemail_sends_per_monthquota. - The context endpoint returns rich customer data by email — bearer + optional
SUPPORT_CONTEXT_IP_ALLOWLIST; never browser-reachable.
Env (app)
FREESCOUT_API_URL, FREESCOUT_API_KEY (secret), FREESCOUT_MAILBOX_MAP (id→address allowlist, shared with the inbound webhook; additive across the rebrand: [email protected],[email protected],[email protected],[email protected]), FREESCOUT_CUSTOM_FIELD_MAP (one <mailboxId>:key=id,… block per mailbox the app writes to, so the show.fm mailboxes 6 and 7 each need their own block), FREESCOUT_API_USER_ID, SUPPORT_PUBLIC_API_SECRET, SUPPORT_CONTEXT_SECRET, PUBLIC_ADMIN_URL.
Custom fields (every mailbox the app writes to)
Source / SLA tier / Category / Subscription status / Computed priority / Self-declared urgency (dropdowns, set by OPTION KEY not label) + CRM link (text). Field ids differ per mailbox (legacy support@ ids 1–7, legacy hello@ ids 8–14, verified live 2026-07-19); the show.fm mailboxes carry their own id set, recorded in FREESCOUT_CUSTOM_FIELD_MAP. A mailbox missing from the map gets conversations with NO custom fields, silently.
Related
- Inbound webhook + mirror:
src/api/routes/webhooks/freescout.ts,crm_support_threads(admin CRM §3.7) - Marketing form:
showfm-www/src/routes/support/(D1-first, email fallback when the app API is down) - Agent sidebar module:
github.com/Minim-Digital/FreeScout-PodcasterPlus-Support - Receipts: FreeScout auto-responders are OFF by design; the app/marketing send their own via Resend, with the conversation number in the subject (Ticket Number module threads replies)