Skip to content

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)

ModulePurpose
src/lib/support/resolve-context.tsEmail/user → accounts, plan, status, LTV, risk, open threads. effectivePlanKey demotes canceled/incomplete to free; past_due keeps its plan
src/lib/support/sla.tspriority_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.tsOutbound 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.tsTier-1 deflection seam (getSuggestedArticles) — swappable for semantic/AI later
src/lib/support/thread-body.tsThread HTML → plain text, then quoted-history stripping (threadBodyToText)
src/api/routes/support/index.tstickets / my-threads / threads detail+reply+close / public / context
docs-public/scripts/build-search-index.mjsEmits 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 threadBodyToText before the text leaves the server: the HTML markers first (gmail_quote, x-em-replyforwardheader, Outlook's appendonsend/divRplyFwdMsg, blockquote), then plain-text ones (------ Original Message ------, On … wrote:, an Outlook From:/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-threads reads crm_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 pending while delivering convo.customer.reply.created, which left the customer's list saying "Awaiting your reply" after they had replied. A customer reply mirrors as active unless the conversation is closed. Deliberately one-sided: agent replies keep the status FreeScout sends.
  • Agent replies raise support.agent_replied for the app user whose user_profiles.email matches 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 carrying threadsCount. The link is /dashboard?support=tickets&ticket=<conversation id>; the dashboard load carries both params through its redirect to /p/{slug}, and HelpLauncher opens the panel on them.

Security invariants

  • Internal notes never reach customers: thread proxying whitelists customer/message types, published state 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 tenant email_sends_per_month quota.
  • 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.

  • 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)

Internal documentation - Not for public distribution