Inbound Email Pipeline — Google Workspace → Postmark → FreeScout
System: Customer/SLA inbound email for @raxx.app support addresses → FreeScout at tickets.raxx.app
Owner: operator (Kristerpher) + SRE
Status: LIVE — first real inbound email → ticket confirmed 2026-07-17
Supersedes the inbound sections of: docs/architecture/email-routing.md, docs/ops/runbooks/freescout-e2e-test.md (the alias-domain + generic-Routing design described there is obsolete; see "How it actually works" below).
The one-line summary: a customer emails
support@raxx.app→ a Google Workspace Default routing rule accepts it and rewrites the envelope recipient to Postmark's inbound address → Postmark (Pro plan) processes it and POSTs to our API Gateway → a Lambda chain lands it as a FreeScout ticket. All four support addresses share one Postmark inbound address; the bridge sorts them into mailboxes by theTo:header.
Architecture (the working path)
Customer email to support@raxx.app
└─► Google MX (raxx.app — secondary domain in the moosequest.net Workspace)
└─► Gmail DEFAULT ROUTING rule (NOT the generic "Routing" setting):
• Envelope recipient = support@raxx.app (single recipient match)
• Change envelope recipient → Replace recipient →
bb43f79c8bfe23d564079fb431583c30@inbound.postmarkapp.com
• Add X-Gm-Original-To header
• Affects: Inbound + Internal-Receiving; account types incl. Unrecognized
└─► Postmark inbound (server `raxx-production`, id 19052422, PRO plan)
• one inbound address (hash) for the whole server
• InboundHookUrl → API Gateway (HTTP Basic auth in the URL userinfo)
└─► API Gateway tzkznyft9c.execute-api.us-east-1.amazonaws.com /v1/email/inbound
└─► authorizer Lambda raxx-email-inbound-authorizer (3 checks — see gotcha #5)
└─► webhook Lambda raxx-email-inbound-webhook
└─► SNS FIFO raxx-inbound-email.fifo
└─► SQS FIFO inbound-email-freescout-bridge.fifo
└─► bridge Lambda raxx-email-inbound-bridge
• reads To: from Postmark ToFull[0].Email
• mailbox routing map (SSM /raxx/email/freescout_mailbox_routing_map):
support@raxx.app=1 ops@raxx.app=2 account-management@raxx.app=3 privacy@raxx.app=5
• POST tickets.raxx.app/api/conversations (CF Access non_identity + FreeScout API key)
└─► FreeScout ticket in the mapped mailbox
One inbound address, many senders — how the four stay separated
Postmark provides one inbound address per server. All four support addresses
Replace-recipient to that same hash. Differentiation happens downstream: the
routing rule rewrites only the envelope, so Postmark still sees the original To:
header; the bridge reads it and maps to the right FreeScout mailbox. This is the
"generated / many addresses, one inbound endpoint" pattern from the Postmark guide
(references below). Adding a new SLA address = one more Default routing rule pointing
at the same hash + one entry in the routing map.
CC nuance: the bridge routes on
ToFull[0].Email. If an address appears only in Cc (not To), it currently falls back to mailbox 1 (support). Fine for the common case; enhance the bridge to scan all recipients if precise Cc routing is needed.
Google side: Default routing vs Routing (this was the crux)
Google Workspace has two distinct settings under Apps → Google Workspace → Gmail:
| Setting | What it does | Accepts unknown addresses? |
|---|---|---|
| Routing | Re-handles mail Google has already accepted for a known recipient (a real user/group). Rewrites/redirects. | No — a non-existent recipient bounces before this runs. |
| Default routing | Governs delivery for addresses that don't map to a user/group — the catch-all / unrecognized-recipient layer. Accepts and reroutes. | Yes — this is the one that stops the bounce. |
Use Default routing. On a secondary domain, once you delete the group, the support
address has no mailbox — so acceptance must come from Default routing, or Google returns
550 and bounces (this is exactly what happened when groups were deleted with only a
generic Routing rule in place). See the Stack Overflow reference below.
Exact rule config (per the Postmark guide + hard-won experience):
- Email messages to affect: Inbound and Internal - Receiving (the commonly-missed
one — with DMARC alignment on the sender domain Google can class some mail as internal).
- Envelope recipient → Single recipient → the address (scope it; do NOT make a
domain-wide catch-all, or spam to random @raxx.app addresses becomes tickets).
- Modify message → Add X-Gm-Original-To header (preserves the true recipient).
- Change envelope recipient → Replace recipient → bb43f79c...@inbound.postmarkapp.com.
- Account types: include Unrecognized (that's what does the work once the group is gone).
Do not also keep a generic Routing rule for the same address — one or the other.
Postmark side
- Server
raxx-production(id 19052422). Inbound requires the Pro plan ($16.50/mo) — the Basic plan ($15/mo) plan-locks the Inbound stream (padlock in Message Streams). On Basic, Postmark's MX accepts the SMTP handoff (250 OK) then silently drops the message because the inbound stream can't process it. Upgrading to Pro auto-unlocks the existing Default Inbound Stream — no reconfiguration; the inbound hash is unchanged. - Inbound webhook is driven by the server-level
InboundHookUrl(not a per-stream webhook). It points at the API Gateway endpoint and carries HTTP Basic auth in the URL. - Never change the InboundAddress hash — the Google Default routing rules point at it.
The five root causes we peeled off (2026-07 restoration)
Inbound had never actually worked end-to-end; it failed at five independent layers, each hidden behind the previous:
- Google acceptance — after alias→secondary domain conversion, support addresses were no longer auto-accepted; groups intercepted mail (a group is a terminal delivery target that swallows the message, so routing rules never fire). Fix: delete the group; use a Default routing rule (accepts unrecognized + rewrites to Postmark).
- Postmark plan gate — the account was on Basic, which locks inbound streams; mail was accepted at SMTP then dropped. Fix: upgrade to Pro (+$1.50/mo).
- Authorizer Date-header bug —
raxx-email-inbound-authorizerCheck 3 (replay protection) assumed Postmark sends an RFC 2822Dateheader. It does on outbound delivery events but not on inbound webhooks → every inbound POST Denied 403, and API Gateway's 300s authorizer cache then buried Postmark's auto-retries. Fix: skip Check 3 whenDateis absent (mirrors the existing HMAC skip). PR #4255. - Bridge BFM 403 —
raxx-email-inbound-bridgecalledtickets.raxx.app/apiwith urllib's default UA; Cloudflare Bot Fight Mode (runs before CF Access) blocked it 403 and the Lambda dropped the message as poison. Fix: explicitUser-Agent. PR #4255. - FreeScout storage perms (earlier) — root-owned Laravel cache dirs 500'd the API;
fixed by
chown www-data+ cache clear.
Durable follow-up: a Cloudflare WAF skip rule on tickets.raxx.app keyed on
CF-Access-Client-Id would make the API robust to any non-AWS automation client (folds
into the BFM re-enable work). The bridge's explicit UA is the current safeguard.
Outbound (ticket replies) — every mailbox needs Postmark SMTP
FreeScout sends an agent's reply using the mailbox's own outgoing config, and a
mailbox defaults to out_method = 1 (PHP mail()). On this box PHP mail() hands off
to local Exim, which cannot relay to remote domains (R=nonlocal: Mailing to remote
domains not supported) — the reply freezes in the Exim queue and never delivers,
silently. This bit ops@/account-management@/privacy@ on 2026-07-17 (only support@ had
been wired).
Every mailbox that will send replies must be set to Postmark SMTP, matching mailbox 1:
out_method = 3 (SMTP) out_server = smtp.postmarkapp.com out_port = 587
out_encryption = 3 (STARTTLS) out_username = out_password = <Postmark server token>
Set it in FreeScout admin (Mailbox → Connection Settings → Sending) or by copying
mailbox 1's out_* columns (the out_password blob is Laravel-encrypted with APP_KEY
and copies verbatim between rows). Clear caches as www-data, never as root (root-owned
cache dirs 500 the whole site). Verify: SELECT id,email,out_method,out_server FROM mailboxes;
→ all sending mailboxes show out_method=3 + smtp.postmarkapp.com.
Outbound sender identity: raxx.app domain DKIM is verified in Postmark, so any
@raxx.app From address delivers; no-reply@raxx.app has 189+ delivered as proof.
Watch the Postmark outbound suppression list — a recipient that hard-bounced during
testing (e.g. from May) gets suppressed and silently drops replies until removed.
Known-dark (2026-07-17): FreeScout's webhooks back to Raptor (
api.raxx.app/api/internal/freescout-webhook/{audit,agent-replied}) are 403'd by Cloudflare Bot Fight Mode on every event — same class as the inbound-bridge BFM bug. The audit trail of ticket events is dark until the CF WAF skip rule (keyed onCF-Access-Client-Id) lands (#987 / [[feedback_cf_access_does_not_bypass_bot_fight_mode]]). Does not affect email in/out.
Add a new SLA-bearing address (e.g. abuse@, dpo@)
- Google: duplicate the
support@Default routing rule; change only the Envelope recipient to the new address. Replace-recipient → the same Postmark inbound hash. Delete any group for the address first. - Routing map: add
"<address>": <freescout_mailbox_id>to SSM/raxx/email/freescout_mailbox_routing_map. - FreeScout: ensure the target mailbox exists.
- Send a test; confirm the ticket lands (see verification below).
Verify end-to-end
- Postmark: Inbound → Activity shows the message; webhook delivery is 2xx (not 403).
- Authorizer: CloudWatch
/aws/lambda/raxx-email-inbound-authorizer→postmark_auth_timestamp_skippedthen Allow (nopostmark_auth_replay_rejected). - Bridge: CloudWatch
/aws/lambda/raxx-email-inbound-bridge→conversation_createdhttp_status=201mailbox_id=<n>. - Direct FreeScout API reads from non-AWS IPs are BFM-403'd — rely on the bridge's
CloudWatch
conversation_created 201as the authoritative success signal.
References
- Postmark — How to process generated email addresses with Google Apps and Postmark:
https://postmarkapp.com/blog/how-to-process-generated-email-addresses-with-google-apps-and-postmark(the routing-rule config: Inbound + Internal-Receiving, X-Gm-Original-To, one inbound address for many generated addresses). - Stack Overflow — G Suite / Gmail: Default routing vs Routing:
https://stackoverflow.com/questions/55490522/g-suite-gmail-default-routing-vs-routing(why Default routing is required to accept + reroute mail for an address with no mailbox).