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
-
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. -
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/~allif it never sends). -
DMARC ratchets, never jumps. Start
p=none(monitor), watch aggregate reports for 2–4 weeks, thenp=quarantine, thenp=reject. All reports funnel to a single inbox:kris@moosequest.net. -
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 everyconfig:setthat writes a secret is stdout-silenced. -
One reports inbox, one ops inbox, one support queue. DMARC
rua/ruf→kris@. Ops/alerts →ops@(a Google Group). Customer/SLA mail →support@→ helpdesk (FreeScout). -
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.COM … 10 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)
- Account must be approved out of sandbox before it can send to arbitrary recipients (one-time, per Postmark account).
- 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. - 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. - Message streams: keep
transactional(default) for app mail; add abroadcaststream for any marketing/bulk so it can't damage transactional reputation. - 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. - Alwaysconfig:set ... >/dev/null 2>&1(the CLI echoes secrets). - App send: POST to the Postmark API with header
X-Postmark-Server-Token; render templates from on-disk before send; setMessageStreamappropriately. - 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)
- Click "Start authentication" in Google Admin after adding the Google DKIM record (Apps → Google Workspace → Gmail → Authenticate email). The DNS record alone does nothing until you activate signing — skip it and you get
dkim=fail+ DMARC bounces. - Don't set
p=rejectearly. Sit atp=none, read the aggregate reports, then ratchet. Premature enforcement bounces legitimate mail. - CF-Access-bypass the Postmark webhook path for Postmark's IPs, or delivery/bounce events never arrive.
- Provision the mailbox before anything emails it. Emailing
ops@/billing@before the Google Group exists creates a permanent hard-bounce suppression. - Postmark out-of-sandbox approval is required before sending to arbitrary recipients — do it early, it's a review.
- One reputation per account. A spam spike on one Server can restrict the whole Postmark account; keep complaint rates low.
7. New-project playbook (the reusable checklist)
For any new MooseQuest project/domain, in order:
- 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.
- Google Workspace (human mail): add the domain → set MX → SPF (
include:_spf.google.com) → generate + add Google DKIM → Start authentication → DMARCp=none(rua=mailto:kris@moosequest.net). - 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.netto the domain's SPF. - Wire the app: Server token → vault (
/MooseQuest/postmark/...) → runtime config var → send viaX-Postmark-Server-Tokenwith the right message stream → delivery/bounce webhook (CF-Access-bypass it for Postmark IPs). - Inbound/tickets (if needed): Google routing rule on
support@→ Postmark Inbound → helpdesk. - Monitoring: Postmark native Slack alert (or daily digest pre-launch); add the in-app delivery monitor with a min-denominator floor post-launch.
- Ratchet DMARC to
quarantinethenrejectafter 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.
9. Related references
docs/ops/email-dns-state.md— live DNS record state formoosequest.netdocs/ops/email-routing.md—raxx.appmailbox routing mapdocs/ops/runbooks/postmark.md— Postmark operational runbook (diagnose/suppression/rotation)docs/architecture/email-routing.md+docs/architecture/durable-email-delivery.md— delivery architecturedocs/architecture/adr/0074-email-delivery-hybrid-postmark-v1.md— the Postmark decisiondocs/business/business-email.md— Google Workspace multi-domain reference- Consoles: Oracle Dyn
portal.dynect.net(moosequest.net DNS) · Cloudflare (raxx.app DNS) · Postmarkaccount.postmarkapp.com· Google Workspaceadmin.google.com