Skip to content

feat(workflows): show email sending health per mailbox provider - #89643

Draft
dmarchuk wants to merge 9 commits into
masterfrom
claude/isp-reputation-visibility-05ucuk
Draft

feat(workflows): show email sending health per mailbox provider#89643
dmarchuk wants to merge 9 commits into
masterfrom
claude/isp-reputation-visibility-05ucuk

Conversation

@dmarchuk

Copy link
Copy Markdown
Contributor

Problem

A project whose mail Gmail is filtering sees "Healthy" on the Workflows sending health page. The project-wide bounce and complaint rates pool every mailbox provider together, so one provider filtering us averages away against the others.

  • Gmail runs no per-message feedback loop, so its spam-foldering produces no complaint events at all.
  • The complaint rate can read near zero while most of a project's mail sits in spam.
  • A customer hit this, moved one lifecycle workflow back to their own sending infrastructure, and lost open and click tracking with it.

Changes

  • The sending health card gains a per-provider table: delivery rate, bounce rate, complaint rate and volume for each mailbox provider.
  • Each row carries a delivery-rate sparkline, so a drop can be dated instead of averaged into a 30-day number.
  • Providers that run no feedback loop show "Not reported" for complaint rate rather than a zero the data cannot support.
  • Complaint rate divides by DELIVERY_COMPLAINT, not by sends. AWS defines that metric as deliveries excluding recipients at ISPs it has no feedback-loop agreement with, which is what separates "nobody complained" from "this provider never tells us".
  • The sparkline y-axis is fixed to 0-100 rather than auto-scaled per row. The column is read across rows, and an auto-scaled axis draws a provider steady at 45% delivery identically to one steady at 98%.
  • SES_ISP_DIMENSIONS sets which providers to break down by. SES exposes no API to enumerate them and AWS documents the vocabulary only as "e.g. Gmail, Yahoo", so a wrong value returns zeros rather than an error and is correctable without a deploy.

The data comes from SES Virtual Deliverability Manager's BatchGetMetricData, dimensioned by EMAIL_IDENTITY and ISP. Each project's verified sending domain is its own SES identity, so identity by ISP gives per-project, per-provider numbers. Queries batch at the ten-query limit SES enforces, and counts sum across a project's verified domains.

Note

This renders nothing until Virtual Deliverability Manager is enabled on the SES account. Until then the endpoint returns an empty breakdown and the section is absent, which is what every project sees today.

Screenshots: I could not upload any. hogli pr:upload-image denied the write to PostHog/pr-assets for this session's token. The rendered result is two Storybook stories added in this PR, OneProviderFiltering and NoProviderData.

How did you test this code?

Automated, run locally:

  • 8 tests on get_identity_isp_metrics. They cover the complaint denominator (a zero DELIVERY_COMPLAINT must yield null, not 0%), batching within the ten-query limit, summing across a project's sending domains, dropping providers with no sends, and the daily series merging and ordering.
  • 3 tests on the reputation endpoint, covering the response shape, degradation to an empty list when SES raises, and the project-wide access gate on the new field.

Not run: the 3 endpoint tests above. The test fixtures need ClickHouse, and this sandbox cannot reach the container registry to start one. They first run in CI.

Not done: no manual testing in a real environment. The stack needs ClickHouse, and devbox is unreachable from this sandbox. UI verification came from rendering the stories in Storybook with mocked API responses.

Two things stay unverified until VDM is on, both worth a look at the first real response:

  • AWS documents BatchGetMetricData's Values as "a list of values (cumulative / sum)" without saying which. This reads them as per-bucket counts. If they are running totals the symptom is loud rather than silent: summed deliveries overshoot sends, and every provider pins to a 100% delivery rate against the clamp.
  • The SES_ISP_DIMENSIONS values are unconfirmed. A wrong provider name produces an absent row, not an error.

Automatic notifications

  • Publish to changelog?

Docs update

None.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

I (actually Claude, via Claude Code) wrote this. Skills invoked: /improving-drf-endpoints, /writing-tests, /writing-ui-components, /writing-user-facing-copy, /setting-up-devbox, /writing-pr-descriptions.

The work started as an analysis of whether to move off à la carte SES pricing, which is how the metrics API surfaced as the only feature in any SES plan that maps onto per-team verified domains. AWS documentation hosts are unreachable from this sandbox, so the API facts here come from the botocore sesv2 service model rather than fetched pages, and the plan pricing from the public Price List API.

Two bugs in the sparkline only became visible once rendered, which is why the stories exist. The canvas had no height constraint and bled across table rows, and its y-axis was auto-scaled per row so a steady 45% and a steady 98% drew identically.

Public artifact: this PR draws on a customer support conversation. No customer name, domain, message text, or operational metric appears in the code, fixtures, comments, commit messages, or this description. The story fixtures are invented from a list of the cases they exercise.

Follow-ups this PR does not do, all pre-existing and independent of any plan change:

  • email_spam has no emitter anywhere in the repo, so the "Marked as spam" metric can never be non-zero.
  • SES Complaint events are recorded as email_blocked, which the UI labels "blocked by the recipient server".
  • The SES webhook handler never reads bounceSubType, so suppression-list rejections already inflate the customer-facing bounce rate.
  • The project-wide complaint rate still divides by all sends, the same denominator problem this PR fixes for the per-provider numbers.
  • open.userAgent is parsed by the Zod schema then dropped before capture. It is what separates a human open from an Apple Mail prefetch.

Generated by Claude Code

claude added 5 commits August 20, 2026 07:05
Project-wide bounce and complaint rates pool every mailbox provider
together, so a project whose Gmail mail is being filtered while Outlook
is fine still reads as healthy. Add a per-provider breakdown sourced from
SES VDM's BatchGetMetricData, dimensioned by EMAIL_IDENTITY and ISP,
which maps onto each project's verified sending domains.

Complaint rate is measured against DELIVERY_COMPLAINT rather than sends.
AWS defines that metric as deliveries excluding recipients at ISPs it has
no feedback-loop agreement with, so the rate is null for providers that
report nothing back rather than a misleading zero.

Requires VDM to be enabled on the account; the endpoint returns an empty
breakdown until then, and whenever SES is unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
Surface the per-provider breakdown in the project sending health card, so
a project can see Gmail's delivery rate next to Outlook's rather than one
number pooling every provider together.

Providers that run no feedback loop show "Not reported" for complaint
rate instead of a zero the data can't support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
A 30-day average answers whether a provider is filtering us, not when it
started. Keep the per-bucket series SES returns instead of collapsing it
on arrival, and draw each provider's delivery rate as a sparkline beside
its rates.

Buckets are keyed by date so a project sending from several verified
domains merges into one series per day. Dates a provider received nothing
are dropped rather than zero-filled, which would draw a cliff that never
happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
Two problems only visible once rendered.

The sparkline was wrapped in a sized div rather than given a className,
so its own container had no height: the canvas grew to fill the table
cell, bled across row boundaries and pushed rows to ~160px tall.

Its y-axis was also auto-scaled per row, which drew a provider steady at
45% delivery identically to one steady at 98%. The column is read across
rows, so the axis is now fixed to 0-100.

Adds stories covering both, built around one provider filtering us while
the others stay healthy, plus the no-data state projects see until
Virtual Deliverability Manager is collecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
…ealth

The MCP generation step needs the services/mcp workspace installed, which
it wasn't when the frontend types were regenerated, so these were left
behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
@trunk-io

trunk-io Bot commented Aug 26, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

claude and others added 3 commits August 26, 2026 18:41
… test

The fixture missed the new required argument. The test needs ClickHouse
to run, so mypy was the only thing that could catch it locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
Both sides appended a setting to posthog/settings/ses.py: master added the
SNS topic allowlist for tenant reputation events, this branch added the ISP
dimension list. Kept both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

⚠️ Bundle size — 🔺 +4.4 KiB (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 68.43 MiB · 🔺 +4.4 KiB (+0.0%)

File Size Δ vs base
posthog-app/_parent/products/metrics/frontend/MetricsScene.js 66.2 KiB 🔺 +2.3 KiB (+3.6%)
posthog-app/_parent/products/workflows/frontend/WorkflowsScene.js 91.9 KiB 🔺 +2.0 KiB (+2.3%)

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.36 MiB · 22 files no change ███░░░░░░░ 30.3% of 4.51 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.79 MiB · 3,239 files 🔺 +8 B (+0.0%) █████████░ 90.5% of 9.71 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
306.9 KiB ../node_modules/.pnpm/posthog-js@1.420.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
260.9 KiB ../node_modules/.pnpm/posthog-js@1.420.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.js
250.4 KiB src/taxonomy/core-filter-definitions-by-group.json
154.2 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
104.7 KiB src/lib/api.ts
95.2 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

Toolbar bundle — eager 2.25 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.25 MiB · 17 files no change ████░░░░░░ 39.4% of 5.72 MiB
Deferred (lazy) 2.10 MiB · 33 files no change n/a — loads on demand
Loader dist/toolbar.js 1.1 KiB no change █░░░░░░░░░ 5.8% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
746.7 KiB dist/toolbar/toolbar-app-RBPKVAJT.css
585.2 KiB dist/toolbar/chunk-chunk-PKTQCPHM.js
484.6 KiB dist/toolbar/chunk-chunk-PQM5GZBZ.js
133.8 KiB dist/toolbar/chunk-chunk-BOEVRRG3.js
131.8 KiB dist/toolbar/chunk-chunk-T5KY5WYR.js
71.3 KiB dist/toolbar/toolbar-app-746XUZSN.js
69.0 KiB dist/toolbar/chunk-chunk-27JL52RE.js
35.6 KiB dist/toolbar/chunk-chunk-E3UZ2534.js
20.9 KiB dist/toolbar/chunk-chunk-7PWPBHHX.js
12.2 KiB dist/toolbar/chunk-chunk-PIK3PADE.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

Dist folder size — 🔺 +33.2 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1443.08 MiB · 🔺 +33.2 KiB (+0.0%)

@posthog

posthog Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

👋 Visual changes detected for this PR.

Review and approve in PostHog Visual Review

If these changes are unexpected, they may be caused by a flaky test or a broken snapshot on master. Don't approve — rerun the job or wait for a fix.

@trunk-io

trunk-io Bot commented Aug 26, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

Master replaced Sparkline's Chart.js implementation with a wrapper over
quill-charts, dropping maximumIndicator, withYScale and withXScale. The
merge was textually clean, so this only surfaced as a CI typecheck error.

Neither Sparkline nor the quill chart under it exposes a y-domain any
more, so the axis can no longer be pinned to 0-100 and each row scales to
its own range again. The column header now says so: the shape is what to
read, and the delivery rate beside it carries the level. Pinning it back
would mean changing the design system, which does not belong in this PR.

Hovering a point now shows a percentage rather than a bare number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZW86atjzcxLaLGiUn9p6M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants