Raxx · internal docs

internal · gated ↑ index

FreeScout end-to-end email pipeline test runbook

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)


Purpose

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.


Architecture note — inbound email path

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

Option B — Postmark inbound + relay bridge

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.


Pre-checks — verify all before starting the test

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

Test 1 — Inbound: email to support@raxx.app creates a FreeScout ticket

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


Test 2 — Auto-reply: customer receives acknowledgment within 2 minutes

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)


Test 3 — Outbound: operator reply arrives at the customer's inbox

# 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


Test 4 — Brand check

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.


Test 5 — Postmark activity log verification

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',[])]"

Pass criteria

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

Fail criteria and escalation

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

Timing expectations

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.


Postmark Message-ID capture (dedup reference)

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


References