# Email deliverability runbook The CRM sends transactional email through three different surfaces. Each has a different failure mode when it lands in spam. This runbook covers how to diagnose, fix, and verify each path. ## What email the CRM sends | Surface | Trigger | Template | Default `from` | | ----------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------- | | Portal activation / password-reset | Admin invites a client to the portal | `src/lib/email/templates/portal-auth.ts` | per-port `email_settings.from_address` or `SMTP_FROM` | | Inquiry confirmation + sales notification | Public website POSTs to `/api/public/interests` or `/api/public/residential-inquiries` | `inquiry-client-confirmation.ts`, `inquiry-sales-notification.ts` | same | | GDPR export ready | Staff requests an export with `emailToClient=true` | inline in `gdpr-export.service.ts` | same | | Documenso reminders | Cadence job fires for an unsigned signer | `documenso/reminders/*` | same | Documenso _itself_ sends signing requests with its own `from` address — those don't flow through this codebase. SPF/DKIM for the Documenso sender is the Documenso operator's problem, not yours. ## DNS records For every domain that appears in a `from:` header you must publish: ### 1. SPF A single TXT record at the apex authorizing whichever provider is sending. Multiple SPF records on the same name **break SPF entirely** — combine into one. ``` v=spf1 include:_spf.google.com include:amazonses.com -all ``` The `-all` (hardfail) is correct for transactional mail. Switch to `~all` (softfail) only as a temporary diagnostic when migrating providers. ### 2. DKIM Each provider publishes its own selector. Common shapes: - Google Workspace: `google._domainkey` → 2048-bit RSA pubkey (rotate every 12 months). - Amazon SES: `xxxx._domainkey`, `yyyy._domainkey`, `zzzz._domainkey` (three CNAMEs SES gives you). - Postmark / Resend / Mailgun: one CNAME per selector. Verify alignment — the `d=` value in the DKIM signature must match the `From:` domain (relaxed alignment is fine, strict is overkill). ### 3. DMARC Start at `p=none` while you build deliverability data, then upgrade. ``` _dmarc 14400 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@portnimara.com; ruf=mailto:dmarc@portnimara.com; fo=1; adkim=r; aspf=r; pct=100" ``` `rua` (aggregate reports) is the diagnostic feed — set it before the first send so the first weekly report has data. ### 4. MX (only if you also receive) The CRM's IMAP probe (`scripts/dev-imap-probe.ts`) and the inbound thread sync rely on a real mailbox. Whoever runs that mailbox publishes the MX records — typically Google Workspace or a dedicated provider. Don't add an MX pointing at the CRM host; it doesn't accept SMTP IN. ## Per-port overrides Each port can override `from_address`, `from_name`, and SMTP creds via the admin email-settings page. When set, `getPortEmailConfig()` returns those values and `sendEmail()` uses them in preference to the global `SMTP_*` env. **The override domain still needs SPF / DKIM / DMARC** on its own DNS — without them, every send from that port lands in spam. When a customer reports "our portal invite didn't arrive": 1. Pull the port's email settings from the admin UI. Check `from_address`. 2. Run `dig TXT ` and `dig TXT _dmarc.`. Confirm SPF includes the SMTP provider's domain and DMARC exists. 3. Send a probe through `mail-tester.com`: paste the address into a test send, click the score breakdown. 4. Score < 8/10 → fix whatever's flagged before doing anything else in this runbook. ## Diagnosing a "didn't arrive" report Order matters — go top-down, stop when one of these is the answer. ### Step 1: Was the send attempted? ```bash # Tail the worker logs for the recipient address. docker compose logs worker | grep '' ``` You'll see one of three patterns: - **Nothing**: The job didn't run. Check that BullMQ actually queued it. `redis-cli LLEN bull:email:waiting` — if non-zero, the worker is dead. `docker compose logs scheduler | tail` to see why. - **`Email sent`** with a message-id: The provider accepted it. Move to Step 2. - **`SendError`**: Provider rejected. The error string says why (auth, rate limit, blocked recipient). ### Step 2: Is `EMAIL_REDIRECT_TO` set? In dev/test we set `EMAIL_REDIRECT_TO=ops@portnimara.com` so seeded fake clients don't get real email. **It must be unset in production.** ```bash # On the production host: docker exec pncrm-web printenv EMAIL_REDIRECT_TO # Should print nothing. ``` If it's set, every email is going to the redirect target with the original recipient prefixed in the subject — the customer never sees it. ### Step 3: Did it land but get filtered? Ask the recipient to check: - Spam / Junk folder - Gmail "Promotions" tab - Outlook "Other" folder (vs Focused) - The Quarantine console if they're on M365 with anti-spam enabled If found in a spam folder: the email arrived; the recipient's filter classified it. SPF/DKIM/DMARC alignment is suspect — re-run the mail-tester probe from above. ### Step 4: Was the recipient on a suppression list? Some providers (SES, Postmark) maintain a suppression list — once a domain bounces from an address, future sends are dropped silently. ```bash # SES example: aws ses list-suppressed-destinations --region eu-west-1 ``` If the recipient is suppressed, remove them and ask them to retry. The CRM doesn't track suppression locally; that's the provider's job. ## When migrating SMTP providers 1. Add the new provider's DKIM CNAMEs alongside the old ones. 2. Add the new provider's `include:` to the existing SPF record. 3. Wait 48 hours for DNS to propagate and DMARC reports to confirm both providers align. 4. Switch `SMTP_*` env to the new provider on a single staging host. 5. Send through the staging host for a week. Watch DMARC reports. 6. Cut production over. 7. Wait two weeks before removing the old provider's DNS — undelivered bounce reports keep arriving for a while. ## Testing a deliverability fix There's no automated test for "did this email reach the inbox" — that's a property of the recipient's filter, which we don't control. The closest proxy is the realapi suite: ```bash pnpm exec playwright test --project=realapi ``` It runs `tests/e2e/realapi/portal-imap-activation.spec.ts` which sends a real portal-invite email through SMTP, then polls the configured IMAP mailbox for the activation link. If it appears within 30 seconds, the SMTP→DKIM→DMARC chain is alive end-to-end. If the test times out, work backwards through this runbook. The realapi suite needs `SMTP_*` and `IMAP_*` env vars — see the "Optional dev/test-only env vars" block in `CLAUDE.md`. ## Bounce handling The CRM doesn't currently process bounces. If you start seeing volume: - Set up the provider's webhook (SES → SNS → Lambda; Postmark → webhook URL) to POST bounce events to a new `/api/webhooks/email-bounce` route. - Persist the bounced address into a `email_suppressions` table. - Have `sendEmail()` consult that table before each send. That work isn't in scope yet; this runbook just flags it as the next deliverability gap.