feat(workflows): show email sending health per mailbox provider - #89643
feat(workflows): show email sending health per mailbox provider#89643dmarchuk wants to merge 9 commits into
Conversation
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
|
Merging to
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 |
… 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
🤖 CI report
|
| 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%)
|
👋 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. |
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
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.
Changes
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".SES_ISP_DIMENSIONSsets 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 byEMAIL_IDENTITYandISP. 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-imagedenied the write toPostHog/pr-assetsfor this session's token. The rendered result is two Storybook stories added in this PR,OneProviderFilteringandNoProviderData.How did you test this code?
Automated, run locally:
get_identity_isp_metrics. They cover the complaint denominator (a zeroDELIVERY_COMPLAINTmust 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.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:
BatchGetMetricData'sValuesas "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.SES_ISP_DIMENSIONSvalues are unconfirmed. A wrong provider name produces an absent row, not an error.Automatic notifications
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
sesv2service 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_spamhas no emitter anywhere in the repo, so the "Marked as spam" metric can never be non-zero.Complaintevents are recorded asemail_blocked, which the UI labels "blocked by the recipient server".bounceSubType, so suppression-list rejections already inflate the customer-facing bounce rate.open.userAgentis 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