Raxx · internal docs

internal · gated

MooseQuest Email & Postmark Strategy

Status: Adopted — canonical email strategy for MooseQuest LLC and all projects. Owner: operator (Kristerpher Henderson) Applies to: every domain/product MooseQuest stands up (raxx.app, getraxx.com, and any future project). Reference implementation: moosequest.net (company) + raxx.app (product). Source of truth for live records: docs/ops/email-dns-state.md, docs/ops/email-routing.md, docs/ops/runbooks/postmark.md, docs/architecture/adr/0074-email-delivery-hybrid-postmark-v1.md.


1. The strategy in one sentence

Split mail by purpose, not by domain: Google Workspace carries human-to-human mail; Postmark carries app-generated (transactional) mail. Authenticate every sending domain with SPF + DKIM + DMARC, keep all secrets in the vault, and ratchet DMARC from monitor to enforce.

Principles

  1. Two systems, by purpose. - Google Workspace = human mailboxes (person ↔ person): kris@, ops@, support@ as people read them. - Postmark = programmatic / transactional mail (app → person): verification emails, receipts, password resets, alerts, ticket auto-replies. - Never send bulk/marketing on the transactional stream — it poisons deliverability reputation. Use a separate Postmark message stream (broadcast) for anything non-transactional.

  2. Authenticate or don't send. Every domain that emits mail has SPF + DKIM + DMARC. Unauthenticated mail gets junked or bounced. A domain that only receives still needs MX (+ optionally an SPF -all/~all if it never sends).

  3. DMARC ratchets, never jumps. Start p=none (monitor), watch aggregate reports for 2–4 weeks, then p=quarantine, then p=reject. All reports funnel to a single inbox: kris@moosequest.net.

  4. Secrets live in the vault, never in code or terminal history. Postmark tokens come from Infisical (/MooseQuest/postmark/...), are injected as runtime config vars, and every config:set that writes a secret is stdout-silenced.

  5. One reports inbox, one ops inbox, one support queue. DMARC rua/rufkris@. Ops/alerts → ops@ (a Google Group). Customer/SLA mail → support@ → helpdesk (FreeScout).

  6. Reputation is a shared asset. A spam-flag on one Postmark server can restrict the whole account. Keep the suppression list clean, never email un-provisioned mailboxes, and watch bounce/complaint rates.


2. Reference architecture (how it actually runs today)

Two domains, illustrating the two roles:

moosequest.net (company) raxx.app (product)
Role Human mail only Human and app mail
DNS authority Oracle Dyn (manual web console — not Terraform) Cloudflare
Human mail Google Workspace Google Workspace
App mail — (Postmark does not send from here) Postmark
DKIM selectors google._domainkey google._domainkey and pm._domainkey
DMARC p=none → ratchet (rua → kris@) p=quarantine (live since 2026-04-22)
Sends as kris@, ops@ no-reply@, billing@, support@ (Postmark)

A product domain like raxx.app carries both DKIM selectors because Google signs human-readable sends and Postmark signs app sends — both must pass independently.

Outbound flow (app mail)

App (Raptor) → Postmark API (X-Postmark-Server-Token) → recipient
                     │
                     └─ delivery / bounce / spam events → webhook → app + Slack alert

Inbound flow (tickets)

support@ does not MX-swap to Postmark. Google stays the MX of record; a Google routing rule forwards support@ to Postmark Inbound, which webhooks the message into the helpdesk (FreeScout / tickets.raxx.app). This keeps one MX while letting the app process tickets.


3. DNS records (templates)

Add these for each sending domain. Replace <domain> and selectors as noted. Records go in whatever DNS console owns the domain (Dyn for moosequest.net; Cloudflare for raxx.app/getraxx.com).

Purpose Host Type Value
Inbound (Google) <domain> (apex) MX 1 ASPMX.L.GOOGLE.COM10 ALT*.ASPMX.L.GOOGLE.COM (Google's published fleet)
SPF <domain> (apex) TXT v=spf1 include:_spf.google.com include:spf.mtasv.net ~all
DKIM — Google google._domainkey.<domain> TXT the p=… key from Workspace Admin (2048-bit)
DKIM — Postmark <selector>._domainkey.<domain> (Postmark shows the selector, e.g. pm) TXT the key from Postmark's sender-signature page
Return-Path / bounce pm-bounces.<domain> (or as Postmark specifies) CNAME pm.mtasv.net
DMARC _dmarc.<domain> TXT v=DMARC1; p=none; rua=mailto:kris@moosequest.net; fo=1

Notes: - SPF: include spf.mtasv.net only if Postmark sends from <domain>. Keep the total DNS-lookup count under 10 (each include: is one lookup). - Return-Path CNAME makes bounce mail align under your domain instead of Postmark's — required for strict DMARC alignment on the bounce path. - fo=1 requests a failure report on any SPF or DKIM failure.


4. Postmark setup (per product)

  1. Account must be approved out of sandbox before it can send to arbitrary recipients (one-time, per Postmark account).
  2. Create a Server — one per product/environment (e.g. raxx-prod, raxx-staging). Each Server has its own Server API Token and its own reputation.
  3. Verify the sender domain in the Server: Postmark gives you the DKIM record (<selector>._domainkey) and the Return-Path CNAME — add both to DNS (§3), then click Verify.
  4. Message streams: keep transactional (default) for app mail; add a broadcast stream for any marketing/bulk so it can't damage transactional reputation.
  5. Tokens → vault → runtime: - Store: /MooseQuest/postmark/POSTMARK_SERVER_API_KEY (Server token), /MooseQuest/postmark/POSTMARK_ACCOUNT_API_KEY (Account/admin token), /MooseQuest/postmark/POSTMARK_DELIVERY_WEBHOOK_SECRET. - Inject as config vars: POSTMARK_SERVER_TOKEN, POSTMARK_DELIVERY_WEBHOOK_SECRET. - Always config:set ... >/dev/null 2>&1 (the CLI echoes secrets).
  6. App send: POST to the Postmark API with header X-Postmark-Server-Token; render templates from on-disk before send; set MessageStream appropriately.
  7. Webhooks (delivery / bounce / spam): point Postmark at your app's webhook URL. If that endpoint sits behind Cloudflare Access, you must add a CF Access bypass for Postmark's IP ranges on that path, or the webhooks silently fail (HTTP 302). Postmark's IP list: postmarkapp.com/support/article/800-ips-for-postmark-servers.

5. Operations

Monitoring

Two paths (run either or both): - Path A — Postmark native: Postmark dashboard → Server → Settings → Notifications → Slack/webhook on Bounce + SpamComplaint. Zero-setup, but no dedup window and no minimum-denominator floor (so 1/1 = 100% fires pre-launch). Pre-launch, route these into a daily digest, not per-event pings. - Path B — In-app delivery monitor: Postmark webhook → app endpoint → events table → threshold alerts with a minimum-denominator floor (don't alert below N≈10) + a suppression window. Use this post-launch; remove the Path-A Slack webhook when you do, or you double-alert.

Reference thresholds: bounce > 1% (1h, min-denominator 10), spam complaint > 0.1% (24h, min-denominator 25), both with a 60-min suppression.

Suppression list

A single hard bounce (e.g. emailing a mailbox before it's provisioned) sticks and keeps re-firing alerts. Reactivate:

curl -s -X PUT \
  -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.postmarkapp.com/bounces/reactivate" \
  -d '{"Address": "ops@<domain>"}'

Secrets rotation

Rotate the Server token in Postmark → write new value to the vault path → config:set (silenced) on each app/environment → run the token smoke (✓ Postmark token valid). Never leave a half-rotated state.


6. Gotchas (learned the hard way)


7. New-project playbook (the reusable checklist)

For any new MooseQuest project/domain, in order:

  1. Decide domains + DNS authority. Company/human domain (likely a Workspace domain on Dyn) vs product domain (on Cloudflare). One product domain can do both roles.
  2. Google Workspace (human mail): add the domain → set MX → SPF (include:_spf.google.com) → generate + add Google DKIM → Start authentication → DMARC p=none (rua=mailto:kris@moosequest.net).
  3. Postmark (app mail), only if the project sends programmatic mail: approve account out of sandbox → create a Server per environment → verify sender domain (Postmark DKIM + Return-Path CNAME) → add include:spf.mtasv.net to the domain's SPF.
  4. Wire the app: Server token → vault (/MooseQuest/postmark/...) → runtime config var → send via X-Postmark-Server-Token with the right message stream → delivery/bounce webhook (CF-Access-bypass it for Postmark IPs).
  5. Inbound/tickets (if needed): Google routing rule on support@ → Postmark Inbound → helpdesk.
  6. Monitoring: Postmark native Slack alert (or daily digest pre-launch); add the in-app delivery monitor with a min-denominator floor post-launch.
  7. Ratchet DMARC to quarantine then reject after a clean 2–4 week monitoring window.

8. Validation

After DNS propagates (~5–30 min):

# SPF
dig +short TXT <domain> | grep spf
# DKIM (both selectors on a product domain)
dig +short TXT google._domainkey.<domain> | head -c 80
dig +short TXT pm._domainkey.<domain> | head -c 80
# DMARC
dig +short TXT _dmarc.<domain>
# MX
dig +short MX <domain>

End-to-end: send one test from a human address and one from the app, and check the received Authentication-Results header for all three passing:

dkim=pass header.d=<domain>
spf=pass smtp.mailfrom=<domain>
dmarc=pass

If any fails, fix before moving the DMARC policy past p=none.