System: FreeScout helpdesk — tickets.raxx.app
Owner: operator (Kristerpher) + sre-agent
Parent epic: #707 — FreeScout productionization — first-customer-email readiness
Resolves: #711
Last reviewed: 2026-05-04
Status: DRAFT — awaiting pipeline completion (see Blockers section)
This runbook verifies the full support@raxx.app email pipeline before the first customer uses it. It is the final gate in the #707 epic. Execute it only after all upstream cards are complete.
A passed execution must be recorded in a comment on #711 with timestamps and ticket ID.
Important: FreeScout core receives inbound email via IMAP/POP3 polling only. Postmark does not offer IMAP/POP3 access — it delivers inbound email via webhook only. These two systems are architecturally incompatible for inbound without a bridge layer.
The correct inbound architecture for the support@raxx.app pipeline is one of the following:
Use a real IMAP-capable mailbox for support@raxx.app (Google Workspace, Zoho, Fastmail, etc.). Configure the MX record to route to that provider. FreeScout polls it via IMAP. Postmark is used for outbound SMTP only.
customer email → MX → IMAP provider (support@raxx.app inbox)
FreeScout cron (every 1 min) → IMAP poll → creates ticket
FreeScout reply → Postmark SMTP → customer
Keep Postmark as the inbound MX processor. Build or host a small relay that receives the
Postmark inbound webhook POST and creates a FreeScout conversation via POST /api/conversations.
customer email → MX (Postmark inbound hash) → Postmark webhook → relay
relay → POST /api/conversations (FreeScout API) → ticket created
FreeScout reply → Postmark SMTP → customer
Relay requirements: public HTTPS endpoint, verified Postmark IP allowlist, idempotent on retry (Postmark retries on non-200), Message-ID dedup to prevent duplicate tickets.
Decision required from Kristerpher before #709 can be implemented. See #709 for the open scope question. This runbook covers execution steps for Option A (the path most likely to ship fastest). Option B steps are noted as variants.
Run these in order. Do not proceed past a failed pre-check without resolving it.
# 1. FreeScout is reachable
curl -sS -o /dev/null -w "FreeScout HTTP: %{http_code}\n" "https://tickets.raxx.app/"
# Expected: 200 (login page after CF Access) or 302 (CF Access redirect — ok, means CF is up)
# 2. Mailbox exists with correct email
curl -sS "https://tickets.raxx.app/api/mailboxes" \
-H "X-FreeScout-API-Key: $(infisical secrets get FREESCOUT_API_KEY \
--path /MooseQuest/freescout/ --plain)" \
| python3 -c "import sys,json; mbs=json.load(sys.stdin)['_embedded']['mailboxes']; \
[print('mailbox:', m['id'], m.get('email')) for m in mbs]; \
print('count:', len(mbs))"
# Expected: mailbox with email=support@raxx.app listed (ID > 0)
# 3. MX record is pointing at the correct inbound address
dig MX raxx.app +short
# Expected: depends on chosen inbound option.
# Option A: MX points to IMAP provider (e.g., aspmx.l.google.com. or similar)
# Option B: MX points to inbound.postmarkapp.com.
# 4. SMTP outbound is working (Postmark server accessible)
curl -sS "https://api.postmarkapp.com/server" \
-H "X-Postmark-Server-Token: $(infisical secrets get POSTMARK_SERVER_API_KEY \
--path /MooseQuest/postmark/ --plain)" \
-H "Accept: application/json" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
print('Postmark server:', d.get('Name'), '— DeliveryType:', d.get('DeliveryType'))"
# Expected: server name shows, no error
# 5. (Option B only) Inbound webhook is configured in Postmark
curl -sS "https://api.postmarkapp.com/server" \
-H "X-Postmark-Server-Token: $(infisical secrets get POSTMARK_SERVER_API_KEY \
--path /MooseQuest/postmark/ --plain)" \
-H "Accept: application/json" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
print('InboundHookUrl:', d.get('InboundHookUrl', '(not set)'))"
# Expected (Option B): URL pointing to the relay endpoint
# 6. FreeScout queue worker is running (required for outbound email)
# Verify via FreeScout admin UI: System > Status — "Queue worker" should show green
Who: use an email address that is NOT @raxx.app (e.g., a personal Gmail) Timing: allow up to 5 minutes from send to ticket appearance
# a. Send a test email from your external address to support@raxx.app
# Subject: "E2E pipeline test YYYY-MM-DD HH:MM UTC"
# Body: anything; include a unique string for identification (e.g., a UUID)
# Send from: a Gmail or personal domain address — not kris@moosequest.net
# b. (Option A) FreeScout polls IMAP on its configured schedule (default: every 1 min).
# Monitor the FreeScout admin UI: navigate to the Support mailbox conversations list.
# Alternatively, poll via API:
MAILBOX_ID=<from pre-check step 2>
curl -sS "https://tickets.raxx.app/api/conversations?mailboxId=${MAILBOX_ID}&status=active" \
-H "X-FreeScout-API-Key: $(infisical secrets get FREESCOUT_API_KEY \
--path /MooseQuest/freescout/ --plain)" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
convos=d['_embedded']['conversations']; \
[print('ticket', c['id'], c.get('subject'), c.get('status')) for c in convos[:5]]"
# Expected: the test email appears as a new conversation within 5 minutes
# c. Record the FreeScout ticket ID (needed for pass record)
Failure mode: ticket does not appear within 5 minutes
- Check FreeScout queue worker status (System > Status)
- Option A: check IMAP credentials are correct in the mailbox settings (Settings > Mailboxes > [mailbox] > Fetching)
- Option A: check that FreeScout's fetch-emails cron is running: artisan freescout:fetch-emails
- Option B: check Postmark inbound activity log for POSTs to the relay; check relay logs
- Check for MX propagation issues: dig MX raxx.app +short
This test requires the auto-reply to be configured on the mailbox (see #713).
# After sending the test email in Test 1, check the sender's inbox.
# Expected within 2 minutes of the ticket being created in FreeScout:
# Subject: "Re: E2E pipeline test YYYY-MM-DD HH:MM UTC — Got it"
# Body: contains the response-time commitment text (per #713)
# From: no-reply@raxx.app (check raw headers)
# Reply-To: support@raxx.app (check raw headers)
Verify raw headers in Gmail:
1. Open the auto-reply in Gmail
2. Click the three-dot menu → "Show original"
3. Confirm: From: Raxx Support <no-reply@raxx.app> (or support@raxx.app)
4. Confirm: Reply-To: support@raxx.app
5. Confirm: no "FreeScout" or "freescout.net" in From, Subject, or visible body
Failure mode: no auto-reply received - Verify auto-reply is enabled: Settings > Mailboxes > [Support] > Auto Reply tab - Check that the FreeScout queue worker is processing outbound email (System > Status) - Check Postmark outbound activity log for the auto-reply delivery attempt - Check that the test email is not being classified as "auto-submitted" (which FreeScout suppresses by default)
# a. In the FreeScout UI, open the test ticket created in Test 1.
# b. Type a short reply: "This is an operator test reply for the E2E pipeline test."
# c. Click Reply (Send).
# d. Check the sender's (external) inbox within 5 minutes.
Verify:
- Reply received within 5 minutes
- From header: no-reply@raxx.app (check raw headers — same as above)
- Reply-To header: support@raxx.app
- No "FreeScout" branding in subject, body, or footer
- Postmark activity log shows the outbound delivery (dashboard → Activity)
Check Postmark outbound delivery:
curl -sS "https://api.postmarkapp.com/messages/outbound?count=5&offset=0" \
-H "X-Postmark-Server-Token: $(infisical secrets get POSTMARK_SERVER_API_KEY \
--path /MooseQuest/postmark/ --plain)" \
-H "Accept: application/json" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
[print(m.get('ReceivedAt'), m.get('Status'), m.get('To'), m.get('Subject','')[:40]) \
for m in d.get('Messages',[])]"
# Expected: the test reply shows Status=Sent or Delivered
Failure mode: reply not received
- Check FreeScout System > Status — queue worker running?
- Check SMTP credentials on the mailbox (Postmark server token in Settings > Mailboxes > [Support] > Outgoing)
- Check Postmark activity for bounces or spam classification
- Inspect FreeScout Laravel log: /var/www/html/freescout/storage/logs/laravel-YYYY-MM-DD.log
- Look for: Swift_TransportException or SMTP auth failures
After Test 3, with the operator reply in the external inbox:
If FreeScout branding leaks, this is a fail criterion that unblocks the brand-templates card (#712). Do not mark this runbook as passed until branding is clean.
Verify both sides appear in the Postmark server activity:
# Check recent Postmark inbound and outbound in dashboard:
# https://account.postmarkapp.com/servers/19052422/streams/inbound/activity
# https://account.postmarkapp.com/servers/19052422/streams/outbound/activity
# Via API — check message metadata (no full body available via API)
curl -sS "https://api.postmarkapp.com/messages/inbound?count=5&offset=0" \
-H "X-Postmark-Server-Token: $(infisical secrets get POSTMARK_SERVER_API_KEY \
--path /MooseQuest/postmark/ --plain)" \
-H "Accept: application/json" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
[print(m.get('Date'), m.get('From'), m.get('Subject','')[:40]) \
for m in d.get('InboundMessages',[])]"
All five tests must pass. Record the following in a comment on #711:
| Item | Value |
|---|---|
| Run date/time (UTC) | YYYY-MM-DD HH:MM UTC |
| Test email sent from | (external address — obfuscate if desired) |
| FreeScout ticket ID | #N |
| Time: send → ticket created | Xm Ys |
| Time: ticket created → auto-reply received | Xm Ys |
| Time: operator reply sent → customer received | Xm Ys |
| Postmark-ID of inbound message | (from "Show original" → Message-ID header) |
| Brand check | pass |
| Operator | Kristerpher |
| Failure | Escalation path |
|---|---|
| MX not propagated | Revisit #708 |
| Mailbox does not exist | Revisit #710 |
| IMAP credentials fail | Revisit mailbox config in FreeScout admin |
| Inbound email not arriving (Option B) | Revisit #709 relay bridge |
| Postmark SMTP 401/530 | Check Postmark server token in vault: /MooseQuest/postmark/POSTMARK_SERVER_API_KEY |
| Auto-reply not firing | Revisit #713 mailbox auto-reply config |
| FreeScout branding in email | Unblocks #712 (brand-templates card) |
| Queue worker down | See docs/ops/runbooks/freescout.md → failure mode B |
| Step | Expected time |
|---|---|
| MX DNS propagation | Up to 24h after MX record set (usually <1h for Cloudflare-managed zones) |
| Postmark inbound → webhook delivery | <30s after Postmark receives the email |
| FreeScout IMAP poll → ticket created | <2 min (1-min cron by default) |
| FreeScout outbound → Postmark delivery | <60s (queue worker runs every minute) |
| Postmark delivery → recipient inbox | <60s for major providers |
Total round-trip (Option A, all systems nominal): under 5 minutes from customer send to auto-reply in customer inbox.
When examining the raw headers of the inbound email in the FreeScout ticket, capture:
- Message-ID header value (e.g., <abc123@mail.gmail.com>) — this is the sender's ID
- X-PM-Message-Id header if present — Postmark's own delivery tracking ID
FreeScout deduplicates on the Message-ID header. If you see duplicate tickets for the same test
email, check whether the IMAP polling is fetching an already-processed email twice (mark-as-read
flag may not be updating).
docs/ops/runbooks/freescout.mdhttps://api-docs.freescout.net/