Skip to content

feat(cdp): send sandbox email through its ses identity to org members - #128250

Closed
Silthus wants to merge 4 commits into
masterfrom
workflows-sandbox-skeleton/send
Closed

Silthus wants to merge 4 commits into
masterfrom
workflows-sandbox-skeleton/send

Conversation

@Silthus

@Silthus Silthus commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Impact

Watch Workflows sandbox sender: provisioned, sent and blocked: sandbox emails sent rise from zero for teams with the flag, while blocked sends stay a small share of attempts. Rolls up to Workflows activation rate. Baseline since 2026-11-04.

Merge danger

Caution

🔴 Merge risk: high · two-way door (merge-risk rubric)

  • Every workflow email send now builds its SES route (tenant, configuration set, CC/BCC, unsubscribe headers) through a new teamSesRoute helper. A bug there fails or misroutes sends for most teams that send workflow email: silent harm on the mail sending path.
  • What limits it: the sandbox branches run only for an integration with provider sandbox, which no team has until feat(workflows): provision a managed sandbox email sender #128252 provisions one behind the flag. WORKFLOWS_SANDBOX_SENDER_ENABLED defaults to off. Tests pin own-domain sends to the same tenant, configuration set and unsubscribe headers as master, with the switch on and off.
  • Revert: set WORKFLOWS_SANDBOX_SENDER_ENABLED=false to stop every sandbox send, or git revert the merge commit to restore the old route code. Nothing to clean up: with the switch off this PR sends nothing new.

Changes

A workflow email step on the sandbox sender now delivers to verified organization members, and to nobody else:

  • Recipients. The worker checks To, CC and BCC against the team's active organization members with a verified email. Any other address skips the whole send, with a log that names the blocked addresses and says what to do: verify your own domain, or ask the member to verify their email.
  • Checked twice. Once before the send is prepared, and again with a fresh database read right before delivery, because a member can leave while the send waits on suppression checks.
  • Fails closed. A member lookup that errors or takes longer than 3 seconds skips the send.
  • Fixed identity. Sandbox sends go out from the address, SES tenant and configuration set in SES_SANDBOX_*, never the team's tenant. A custom From or Reply-To on the step is ignored, with a log line. Open and click tracking stay off, and no unsubscribe header is added, because only members receive it.
  • Footer. Each sandbox email ends with This email was sent by {sender name} with the PostHog sandbox sender. Organization ID: {id}. in both the text and the HTML part. A template whose HTML cannot keep the footer fails with a message instead of sending without it.
  • Switch. WORKFLOWS_SANDBOX_SENDER_ENABLED (default off) skips every sandbox send with "the sandbox sender is unavailable right now".
  • No tier budget. Sandbox sends do not spend the team's email sending tier budget.
  • Analytics. workflows sandbox email sent and workflows sandbox email blocked (at most one per team, reason and hour) feed the Impact insight.

Own-domain sends take the same path as before. The only change on that path is mechanical: the tenant, configuration set and CC/BCC values move into one route object, so the sandbox can pass its own.

The send path now forks on the sender's provider:

flowchart LR
    A([Email step]):::phYellow --> B[Resolve sender]
    B --> C[Suppression and tier budget]
    C --> D[Send through team tenant]
    D --> E([SES]):::phRed
    classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff;
    classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000;
Loading
flowchart LR
    A([Email step]):::phYellow --> B[Resolve sender]
    B -->|own domain| C[Suppression and tier budget]
    C --> D[Send through team tenant]
    B -->|sandbox| S{Switch on?}
    S -->|no| X([Skipped, logged]):::phGray
    S -->|yes| M{All recipients verified members?}
    M -->|no| X
    M -->|yes| F[Suppression, footer, fresh member check]
    F --> G[Send through sandbox tenant]
    D --> E([SES]):::phRed
    G --> E
    classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff;
    classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000;
    classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;
Loading

Left out, in the stack above: daily caps, the tenant pause gate, the per-organization kill switch and the delivered event (#128410 to #128412).

How did you test this code?

Live run. Run on a devbox against the top of the stack, 3f446c9, which carries this head's code unchanged. Django and the Node cdp-api ran on private ports with a seeded org: one verified member, one unverified member, and one outsider from another org. SES was a local server that records each SESv2 request. No real SES call was made. Each send used the workflow editor's "Run test" path, which calls EmailService.executeSendEmail on the Node side.

Send Switch Result What SES received
Verified member in To On Sent From "Example Org via PostHog" <max@sandbox.example.com>, tenant posthog-sandbox, sandbox configuration set, footer with the organization ID, no unsubscribe headers
Member in To, outsider in CC On Skipped: "Blocked addresses: outsider@www.example.com. Verify your own domain to send to anyone." Nothing
Member in To, outsider in BCC On Skipped, same message Nothing
Outsider in To On Skipped, same message Nothing
Unverified member in To On Skipped: "Verify your email address in PostHog, or ask these members to verify theirs." Nothing
Member, step sets a custom From and Reply-To On Sent, logs "Ignoring custom sender and Reply-To settings" The fixed identity, no Reply-To
Verified member Off Skipped: "the sandbox sender is unavailable right now" Nothing
Own-domain sender to the outsider On and off Sent Tenant team-1, posthog-messaging, List-Unsubscribe headers, as on master
  • The own-domain SES request is identical to master's (base 2f8f63cdac1, same step), with per-send ids masked: 0 diff lines.
SES request for the verified-member send
{
  "FromEmailAddress": "\"Example Org via PostHog\" <max@sandbox.example.com>",
  "ReplyToAddresses": null,
  "Destination": { "ToAddresses": ["member@www.example.com"] },
  "TenantName": "posthog-sandbox",
  "ConfigurationSetName": "posthog-sandbox-config",
  "FeedbackForwardingEmailAddress": "max@sandbox.example.com",
  "Subject": "Sandbox proof",
  "Text": "Hello from the sandbox proof\n\nThis email was sent by Example Org via PostHog with the PostHog sandbox sender. Organization ID: 01a1a57d-084f-0000-a98a-a716e91ec904.",
  "Headers": ["X-PostHog-Tracking-Code", "Auto-Submitted"]
}

Gaps:

  • Not run: a real SES send. The sandbox tenant and domain do not exist until the infra PRs apply.
  • Not run: a live workflow through the Cyclotron email workers. Their dev consumers read shared Kafka topics on the box. The test path runs the same executeSendEmail.
  • Known limit: a member whose stored address has a quoted local part, such as "member"@example.com, never matches, so the send is skipped. A production read found this form rare. A shared address canonicalizer follows in the caps stack.
  • Production read: accounts with no recorded verification state count as unverified, as in agentic provisioning.
  • Not observed live: the workflows sandbox email sent and blocked events, the fresh check right before delivery, and the 3-second lookup timeout. Tests below cover each.

Test seams: the tests drive EmailService.executeSendEmail against a real test Postgres, with SES at the client boundary, and assert what SES receives or the skip log and event.

  • email.service.test.ts: own-domain sends keep the team route with no member lookup, switch on and off; sandbox sends use the fixed identity, sandbox tenant and footer; outsiders, unverified members, removed members and ambiguous CC mailboxes are blocked; a stalled lookup fails closed.
  • organization-members.service.test.ts: the lookup fails closed after 3 seconds or once its one-minute snapshot expires, keeps at most five lookups open, and a fresh check never joins an older lookup.
  • sandbox-email-sender.test.ts: the footer stays outside and below the template's content and stays visible, preview text stays escaped, and HTML that cannot keep the footer is rejected.

Test rationale: each group catches a recipient, routing or footer regression on the sandbox path that no existing email test covers. The own-domain case catches a regression of every other send through the shared route.

  • CI: green on this head.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

  • docs/internal/workflows-email-sending-tiers.md gains a "Sandbox delivery" section.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Decisions

Question Answer Who Where
Who gets a sandbox email Verified members of the team's organization, checked at send time over To, CC and BCC 👤 Human decision record
Sender identity Fixed From address on a dedicated PostHog domain, "Acme via PostHog" display name, no From or Reply-To override 👤 Human decision record
Where it can send Workflow email steps and test sends only, not broadcasts or transactional email 👤 Human decision record
How the sender is stored One managed email integration per team, provider sandbox, created only by PostHog code 👤 Human decision record
SES setup One shared identity, configuration set and tenant for all sandbox sends 👤 Human decision record
Landing order Three-PR skeleton, then the flag to our team, then caps and polish (replaced landing the twelve-layer stack at once) 👤 Human decision record
Node gate Environment switch WORKFLOWS_SANDBOX_SENDER_ENABLED, no feature flag in CDP Node code 👤 Human decision record
Member check in the skeleton Keep it, although the ask was a skeleton with no limits 👍 Accepted default decision record
Behavior with everything off No change while the flag and switch are off 👍 Accepted default decision record
Earlier PRs Close #127149 to #127153, #127136 and #127137, each linked to its successor 👍 Accepted default decision record
Tier budget Sandbox sends never spend the team's tier budget 🤖 Agent decision record
When members are checked Before preparing the send and again fresh before delivery 🤖 Agent decision record
Member lookup fails Skip the send with a log 🤖 Agent decision record
Blocked-send events At most one per team, reason and hour 🤖 Agent decision record
How a team gets the sender Opening Channels with the flag on creates it 🤖 Agent decision record
Managed sender guards Nobody can edit, verify, delete or default the sandbox sender 🤖 Agent decision record
Quoted member addresses Skip the send, and canonicalize in the caps stack instead of respinning the skeleton 🤖 Agent decision record

No open questions.

Involvement: 2 grilling rounds answered (charting and the re-slice), 1 GPT-6.1 Sol review round per PR plus a final read of the fixes, live-run and prod-shape proof captured.

Agent: Claude Code, Claude Opus 5.5

Skills: /writing-tests, /writing-pr-descriptions

More context for AI agents

🤖 Generated with Claude Code

A `sandbox` email integration now sends through a fixed SES tenant,
configuration set and From address, behind WORKFLOWS_SANDBOX_SENDER_ENABLED
(default off) and the SES_SANDBOX_* settings. Every recipient must be a
verified member of the team's active organization, checked again right
before delivery. Sends through any other integration are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus self-assigned this Nov 3, 2026
@trunk-io

trunk-io Bot commented Nov 3, 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

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

✅ Duplication (Python) — clean

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Duplication (TypeScript) — clean

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Bundle size — 🟢 -1.5 KiB (-0.0%)

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

Total: 77.09 MiB · 🟢 -1.5 KiB (-0.0%)

File Size Δ vs base
posthog-app/src/scenes/analytics/AnalyticsList.js 31.7 KiB 🟢 -1.8 KiB (-5.4%)

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.02 MiB · 25 files 🟢 -200 B (-0.0%) █████████░ 89.0% of 1.14 MiB
logged-out boot: index + App + bootApp (preloaded by every page, including /login)
src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
3.00 MiB · 623 files 🟢 -143 B (-0.0%) ██████████ 98.9% of 3.03 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
3.86 MiB · 1,233 files 🟢 -143 B (-0.0%) ██████████ 96.4% of 4.01 MiB
dashboard scene
src/scenes/dashboard/Dashboard.tsx
8.26 MiB · 3,027 files 🔺 +3.6 KiB (+0.0%) ████████░░ 84.0% of 9.84 MiB
today home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
4.71 MiB · 1,757 files 🟢 -134 B (-0.0%) ██████████ 95.6% of 4.93 MiB
AI-first home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
5.97 MiB · 2,255 files 🟢 -134 B (-0.0%) ██████████ 95.2% of 6.27 MiB
events scene
src/scenes/activity/explore/EventsScene.tsx
7.29 MiB · 2,611 files 🔺 +3.6 KiB (+0.0%) ████████░░ 77.5% of 9.41 MiB
web analytics scene
src/scenes/web-analytics/WebAnalyticsScene.tsx
8.21 MiB · 2,939 files 🔺 +3.6 KiB (+0.0%) ████████░░ 78.2% of 10.49 MiB
replay detail scene
src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
7.43 MiB · 2,654 files 🔺 +3.5 KiB (+0.0%) █████████░ 91.4% of 8.13 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 products/product_analytics/frontend/insights/activityDescriptions stays out of src/index.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/svg/ stays out of src/index.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/components/ stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/layout/navigation-3000/navigationLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/scenes/dashboard/dashboardLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/lemon-ui/LemonMarkdown/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/RichContentEditor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/CodeSnippet/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/zod/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/logic/billing/billingLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/ProductEmptyState/productSetupPreloadLogic.ts stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/kea-test-utils/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/product_analytics/frontend/insights/activityDescriptions stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/dashboards/frontend/widgets/previews/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/panel-layout/ProjectTree/menus/SessionReplayMenuItems.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/navigation/projectNoticeLogic.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/navigation/ImpersonationNotice/ImpersonationNotice.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/cohorts/cohortSavedToast.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/wizard/frontend/runs/WizardRunDetailsDrawer.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/TodayShell.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/TodayContextPanelFloating.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/TodaySidePanel.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/TodayTopBar.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/todayRailItems.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/subscriptions/frontend/components/Subscriptions/exportNudge/exportNudgeLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/billing/paymentEntryLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/conversations/frontend/components/SidePanel/sidepanelTicketsLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/zod/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/authentication/auth/loginLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/authentication/shared/passkeyLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/surveys/constants.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/@tiptap/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/prosemirror- stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/re2js/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/react-markdown/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/Command/Command.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 packages/quill/packages/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 zod/v4/locales/de.js stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/svg/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/components/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/queries/validators.js stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/queries/web-analytics-validators.js stays out of src/scenes/dashboard/Dashboard.tsx
🟢 node_modules/re2js/ stays out of src/scenes/dashboard/Dashboard.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/scenes/debug/HogDebug.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/queries/QueryEditor/QueryEditor.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 src/scenes/project-homepage/today/TodayReportPage.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 src/queries/Query/Query.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 packages/quill/packages/charts/ stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 products/posthog_ai/frontend/components/tool/widgets/VisualizationWidget.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 products/posthog_ai/frontend/components/tool/widgets/RecordingsWidget.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 src/queries/Query/Query.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 products/posthog_ai/frontend/api/primitives.ts stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 src/scenes/notebooks/Notebook/notebookLogic.ts stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/validators.js stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/web-analytics-validators.js stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 node_modules/re2js/ stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/nodes/InsightViz/InsightViz.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/nodes/DataVisualization/DataVisualization.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/nodes/WebOverview/WebOverview.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/scenes/debug/HogDebug.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/QueryEditor/QueryEditor.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 src/scenes/web-analytics/LiveMetricsDashboard/LiveWebAnalyticsMetrics.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 products/product_analytics/frontend/insights/trends/WorldMap/countryVectors.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 src/queries/validators.js stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 node_modules/re2js/ stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 src/scenes/debug/HogDebug.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 src/queries/QueryEditor/QueryEditor.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 src/scenes/settings/SettingsMap.tsx stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 @jspm/core/nodelibs/browser/fs stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 node_modules/re2js/ stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 node_modules/d3/src/ stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.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.7 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
4.0 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.9 KiB src/lib/utils/bootTiming.ts
1.7 KiB src/index.tsx
1.7 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.2 KiB src/RootErrorBoundary.tsx
1.1 KiB src/lib/utils/deferredAppContext.ts
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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.6 KiB src/products.tsx
88.6 KiB src/lib/api.ts
68.7 KiB src/lib/lemon-ui/icons/icons.tsx
41.9 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
36.4 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
28.4 KiB src/lib/constants.tsx
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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.6 KiB src/products.tsx
88.6 KiB src/lib/api.ts
68.7 KiB src/lib/lemon-ui/icons/icons.tsx
41.9 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
36.4 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
32.3 KiB src/lib/utils/eventUsageLogic.ts
28.4 KiB src/lib/constants.tsx
Largest files eagerly shipped from src/scenes/dashboard/Dashboard.tsx
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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
114.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/surveys-preview.js
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
88.6 KiB src/lib/api.ts
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
88.6 KiB src/lib/api.ts
68.7 KiB src/lib/lemon-ui/icons/icons.tsx
68.0 KiB ../products/posthog_ai/frontend/logics/runStreamLogic.ts
41.9 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
36.4 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
88.6 KiB src/lib/api.ts
68.7 KiB src/lib/lemon-ui/icons/icons.tsx
68.0 KiB ../products/posthog_ai/frontend/logics/runStreamLogic.ts
41.9 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
41.7 KiB src/scenes/hog-functions/sub-templates/sub-templates.ts
Largest files eagerly shipped from src/scenes/activity/explore/EventsScene.tsx
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
88.6 KiB src/lib/api.ts
75.5 KiB src/scenes/dashboard/dashboardLogic.tsx
Largest files eagerly shipped from src/scenes/web-analytics/WebAnalyticsScene.tsx
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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
114.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/surveys-preview.js
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
88.6 KiB src/lib/api.ts
Largest files eagerly shipped from src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
Size File
355.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
321.5 KiB ../node_modules/.pnpm/posthog-js@1.444.4_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/rrweb.js
224.1 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
88.6 KiB src/lib/api.ts

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

✅ Frontend layers — no new upward imports, 0 removed

Static imports from a lower frontend layer into a higher one (layers, lowest first: kernel, shell, query, features). Compared with the base branch, so an import that a PR also adds to layer-baseline.txt still shows here. Sizes are the source bytes on the eager path of every logged-in page.

Layer Eager source bytes Δ vs base
kernel 2.99 MiB no change
shell 2.38 MiB 🔺 +201 B (+0.0%)
query 56.4 KiB no change
features 356.2 KiB 🔺 +165 B (+0.0%)

Download the interactive layer graph for this PR.

Posted automatically by check-layers

✅ Toolbar bundle — eager 2.38 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.38 MiB · 19 files 🟢 -200 B (-0.0%) ████░░░░░░ 41.5% of 5.72 MiB
Deferred (lazy) 2.11 MiB · 42 files no change n/a — loads on demand
Loader dist/toolbar.js 1.2 KiB no change █░░░░░░░░░ 6.0% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
953.8 KiB dist/toolbar/toolbar-app-U6EAK37Z.css
727.1 KiB dist/toolbar/chunk-chunk-X4FJRA2O.js
261.3 KiB dist/toolbar/chunk-chunk-UHL3KK24.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
130.3 KiB dist/toolbar/chunk-chunk-6XEPZWNR.js
75.7 KiB dist/toolbar/toolbar-app-MI4MXWXH.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
33.8 KiB dist/toolbar/chunk-chunk-2ZAR5II4.js
21.7 KiB dist/toolbar/chunk-chunk-K3GS2I3X.js
6.8 KiB dist/toolbar/chunk-chunk-DV7IWQNF.js

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

✅ Dist folder size — 🔺 +9.2 KiB (+0.0%)

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

Total: 970.91 MiB · 🔺 +9.2 KiB (+0.0%)

✅ Playwright — all passed

All tests passed.

View test results →

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 98d3a84: 1 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment on lines +1181 to +1183
} catch (error) {
blockedRecipients = recipients
reason = 'check_failed'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Reschedule sandbox sends when membership checks fail

When Postgres fails or the membership lookup exceeds its three-second deadline, checkSandboxRecipients converts the error into a terminal skipped send. skipSandboxSend leaves finished true, so valid recipients permanently lose emails during transient database or pool contention. Reschedule check_failed outcomes with bounded backoff while keeping delivery blocked.

Silthus and others added 2 commits November 3, 2026 17:40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decision record for the sandbox sender skeleton: #128250, #128252 and #128253. Each PR's Decisions table links here.

  • 👤 Who gets a sandbox email Only verified members of the team's organization, checked at send time over To, CC and BCC.

  • 👤 Sender identity A fixed From address on a PostHog-owned domain that is used for nothing else. The display name is the organization name plus "via PostHog", such as "Acme via PostHog". No From or Reply-To override is allowed.

  • 👤 Where the sandbox sender can send Workflow email steps and test sends only. It is not offered for broadcasts or transactional email.

  • 👤 How the sender is stored One managed email integration per team, with provider sandbox, created only by PostHog code.

  • 👤 SES setup One shared SES identity, configuration set and tenant for every team's sandbox sends.

  • 👤 Landing order A three-PR skeleton lands first (send, provisioning, picker), then the flag goes to our own team, then caps and polish stack on top. This replaced landing the earlier twelve-layer stack in one go.

  • 👍 Member check in the skeleton Keep the verified-member check in the skeleton, although the ask was a skeleton with no limits, because the first team to get it has live workflows to real people.

  • 👍 Behavior with everything off Every PR in the stack changes nothing while workflows-sandbox-sender and WORKFLOWS_SANDBOX_SENDER_ENABLED are off.

  • 👍 Earlier PRs Close feat(workflows): restrict sandbox email to verified org members #127149 to feat(workflows): show the managed sandbox sender in channels #127153, feat(workflows): let staff turn off the sandbox sender per organization #127136 and feat(cdp): skip sandbox sends of a blocked organization #127137, each with a link to its successor.

  • 👤 Node gate The CDP worker uses an environment switch, WORKFLOWS_SANDBOX_SENDER_ENABLED, and no feature flag, as CDP Node code does elsewhere.

  • 🤖 Tier budget Sandbox sends never spend the team's email sending tier budget.

  • 🤖 When members are checked Twice: once before the send is prepared, and again with a fresh read right before delivery. A member can leave while the send waits.

  • 🤖 When the member lookup fails The send is skipped with a log, never sent.

  • 🤖 Blocked-send events At most one workflows sandbox email blocked event per team, reason and hour, so a retry loop cannot flood analytics.

  • 🤖 How a team gets the sender Opening Channels with the flag on creates it. There is no separate setup step.

  • 🤖 Managed sender guards Nobody can edit, verify, delete or make the sandbox sender the project default.

  • 🤖 Quoted member addresses A member stored with a quoted local part, such as "member"@example.com, gets no sandbox email; the send is skipped with a log. A shared address canonicalizer follows in the caps stack instead of a respin of the skeleton.

  • 👍 PR shape Split the skeleton into 9 small PRs in two native stacks: Node (#128943 → #128944 → #128945 → #128946) and product (#128947 → #128948 → #128949 → #128950 → #128951). This replaced the 3-PR skeleton, which was too big to review.

  • 👍 Member lookup cache in the skeleton Dropped. Every member check is one fresh indexed lookup with a 3 second timeout. The cache returns with the caps stack, before any other team gets the flag.

  • 🤖 Member lookup that gets its connection late Runs no query. The bound on waiting lookups stays out with the cache and returns in the caps stack; until then only the internal team sends through the sandbox.

  • 👍 Safety stack shape Re-split feat(cdp): cap sandbox sends per project and recipient each day #128410 to feat(workflows): sandbox first-run sender and switch banner #128414 into small PRs on top of the two new stacks, then close the old five.

  • 🤖 Pause state lookups under a slow database Same bounds as the member lookup through one shared helper: 3 second deadline, at most 5 open, no query after a late connection. A timeout still skips the send. Retries share one 5 second budget: each read gets only the time left (#129054).

  • 🤖 Fresh pause read during a reload #129054 reads the row again when a reload message overtakes a fresh read, so the check right before delivery never answers from state older than the message.

  • 🤖 Member snapshot scope One cached snapshot per project for up to a minute, not one per organization. Keying by organization would add a team-to-organization step to every lookup, and a sandbox organization has few projects.

  • 🤖 Daily cap calls when Valkey stalls Workers use the bounded pool with 1 second timeouts; without it the cap check fails closed.

  • 🤖 Sandbox sender restrictions with the flag off Save checks and broadcast conversion apply to any existing sandbox sender whatever the flag says; the flag only decides who gets one.

  • 👍 The 3-PR skeleton Back to draft during the split, then closed with links to the new PRs.

  • 🤖 Sandbox sender in the shared email editor picker #128947 leaves it out of the picker that broadcasts and template test sends share, and out of the transactional sender form, so no layer before #128951 can offer it for a broadcast. feat(workflows): offer the sandbox sender in workflow email steps #128951 adds it back for workflow email steps only.

  • 🤖 "Before you send" warning with a sandbox-only team Left to #129068, which gives it a flag-aware sandbox rule, instead of a second rule in feat(workflows): keep a sandbox sender out of own-sender surfaces #128947 that feat(workflows): add logic to move sandbox steps to an own sender #129068 would replace.

  • 🤖 A broadcast that already names the sandbox sender #128947 blocks it on the Content step and at launch with "The sandbox sender can't send broadcasts", so a draft from an older save or an API edit cannot launch from it. The backend save check in #129061 stays the guard for other clients.

  • 🤖 Editing a broadcast that names the sandbox sender #128947 keeps the saved senders until a pick in From replaces them, so an edit to the subject or another field never clears the sender in silence.

  • 🤖 Sandbox daily caps with a repeated address #129051 charges both caps once per delivered copy: every To, CC and BCC entry takes one project token, and an address takes one token for each time it is listed. This replaces counting distinct spellings, which let one address listed in To, CC and BCC get three copies for one token.

  • 🤖 Offline queue on optional Valkey pools Unchanged from master for existing callers. Only the sandbox cap pools turn it off; bounding the email-validation pool is left to its own PR.

  • 🤖 Command timeout on the blocked-event store None. The store has its own Redis pool of 10 clients, a call waits at most 1 second for one, and a capture gives up at 1 second. The pool replaces deadline-slots there, which moves to #129049 with the member lookup. This replaces the round 1 command timeout, which let timed-out commands pile up on the connection.

  • 🤖 Pause check failure reason pause_check_failed, apart from check_failed, so a failed pause read is told apart from a failed member check.

  • 🤖 Duplicate SNS delivery notifications Not deduplicated, like master's email_delivered.

  • 🤖 Claim that reaches Valkey late #129048 accepts a claim for 2 seconds after it is issued: the 250 ms command timeout plus clock skew. A later claim takes no tokens. One that lands inside the window after its caller gave up can only make the daily cap stricter until the bucket refills.

  • 🤖 Refund after a bucket resets #129048 ends a claim's return window before its shortest bucket TTL can run out, so a return never credits a bucket that expired and started again.

  • 🤖 Sandbox cap Valkey that stops replying #129048 reconnects a fail-fast client after a command timeout, so timed-out claims do not pile up on a live connection. The email validation pool keeps master's behavior.

  • 🤖 Comments in the sandbox sender code #129050 and #129053 replace their explanatory comments with names (StoredAddressByRecipient, pickStoredMailbox) that carry the same meaning. #128945, #129049, #129051, #129052, #129054 and #129056 drop their remaining comments.

  • 🤖 Sandbox footer hidden by template CSS #128945 pins the footer on top of template boxes, resets masks, clipping and text tricks on it and its ancestors, and rejects HTML that leaves an Outlook branch open. Outlook branches are read from comments only. A stylesheet pseudo-element can still paint over the footer, so the From name and the text part also name the sending organization.

  • 🤖 Outlook conditions #128945 checks the footer once per Outlook and old IE version, keeping only the conditional branches that version renders. HTML whose footer one of those clients would hide is rejected, and so is a condition the check cannot read.

  • 🤖 Sandbox email size #128945 rejects sandbox emails over 75 KB of HTML, checked on the template and on the built email, so Gmail's clipping never hides the footer. Editor emails are far below the bound.

  • 🤖 Sandbox parse limits #128945 also rejects nesting deeper than 256 elements, more than 64 attributes on one tag, and an Outlook condition over 100 characters, so a hostile template fails at once instead of blocking the worker. No active editor email reaches these limits. #128946 documents all four limits in the sending tiers doc.

  • 🤖 Repeated html and body tags #128945 rejects a template whose repeated <html> or <body> tags merge more than 64 distinct attributes into one element, the same bound as one tag, and skips the merge for a repeated tag with none, so the merge cannot block the worker. A name that repeats counts once.

  • 🤖 Sends from code and the sandbox sender #128948 never picks the sandbox sender for a send from code. A send that names an address on the sandbox domain is refused as having no verified sender, and a send with no address chooses only from the team's own verified senders.

  • 🤖 Workflow edits on a transactional email #129061 closes the path where a workflow edit puts the sandbox sender on a transactional email. #128948 only refuses a template save, and needs no send-time check because no sandbox sender exists before the stack lands with the flag.

  • 🤖 Who sets up the sandbox sender #128950: any project member who opens Channels with the flag on. The endpoint is idempotent and keeps one sender per project.

  • 🤖 Sender name refresh #128950 sets the sender up once on each Channels visit, also when the flag arrives after the page loads, so an organization rename reaches the sender name.

  • 🤖 Failed sandbox tenant reads #129058 counts throttling, an unreachable endpoint, timeouts, a dropped connection and SES 5xx as transient, and credential, permission, TLS and proxy failures as error. The alert, outside this repo, also has to fire on sustained transient failures.

  • 🤖 Blocking one organization #129059: staff add a row in the Django admin and delete it to lift the block. While it exists, the setup endpoint returns 404; existing sandbox senders stay, and the email worker skips their sends.

  • 🤖 Sandbox sends at the window edge #129060 keeps master's cutoff for the tier history and the allowance, and reads email_sent and email_sandbox_sent in one query. Per workflow step and clock hour, both count the smaller of the in-window email_sent and the hour's email_sent minus email_sandbox_sent, so a pair split by the cutoff cancels no other sends. A team without sandbox sends gets master's numbers.

  • 🤖 Member suggestions in the test send #129063 refreshes members incrementally when the dialog reopens. A member removed or newly verified since the first load can show a wrong warning until the page reloads. The warning never blocks the send, and the worker re-reads verified members before delivery.

  • 🤖 Sandbox-only project on a broadcast #129065 tells the user to set up an email sender, because broadcasts cannot use the sandbox sender. A workflow keeps the members note.

  • 🤖 Broadcast offer for sandbox workflows #129066 offers the move to broadcasts only when every email step sends from a listed own sender, so a sandbox or unlisted sender never gets the offer. Sender lookup and setup use the current environment's id.

  • 🤖 When the editor sets up the sandbox sender #129067 sets it up only when an inserted email step can take it: no sender yet, not a broadcast, the flag on, and no verified own sender. Opening the editor sets up nothing.

  • 🤖 Sandbox switch banner #129069 cannot be dismissed. It shows only while the project has a verified own sender and the workflow still sends from the sandbox sender, and one click leaves that state.

  • 🤖 Copies of a stored destination mapping #129061 lets each stored sandbox sender, per input key, exempt only the first submitted mapping that keeps it. Splitting a mapping's two sandbox keys over two copies stays allowed, because it adds no new sandbox sender. A mapping that leaves the input to a schema default is refused; sending the value saves it.

  • 🤖 Sandbox switch while a publish or discard runs #129068 disables the switch until the request ends, so the reload that follows cannot drop the change.

  • 🤖 Sandbox switch banner for viewers #129068 shows it, with the switch disabled and the access reason, so a viewer learns why the emails reach only members.

  • 🤖 Sandbox switch confirmation #129068 confirms the change in the editor. The save follows the editor's autosave, like any other edit.

  • 🤖 Mixed rollout of the tenant mirror #129058 was run against master's worker code: the new reload message reaches no old subscriber, and the old worker keeps running. This replaced a cross-version gap, which the standing rule does not cover.

  • 🤖 Review rounds past the cap #128950 (rounds 10 to 14), #129058 (round 7), #129060 (rounds 7 to 10) and #129061 (round 7) each checked one small fix or a master merge, under the standing rule until 2026-11-26. Each round's reason is in the proof store.

  • 🤖 Master merge for failing warehouse tests every product layer merges master bottom-up, which brings master's fix for the warehouse-source refresh_schemas tests that failed on #129059 and #129060. #128950 resolved a constants.tsx conflict to master's side.

  • 🤖 Judge gaps under the standing rule #128948, #128949, #129058, #129061 and #129065 record a judge gap. On feat(workflows): mirror the sandbox SES tenant pause state in Django #129058 the alert lives in monitoring config outside this repo. On feat(workflows): reject the sandbox sender outside workflow steps #129061 the broadcast conversion check lands in #129062, the next layer. Each reason is in the proof store.

  • 🤖 Live run for the switch logic #129068 has no UI, so its live run drives the logic through the banner of #129069, which merges its head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review November 4, 2026 08:23
@parameterai

parameterai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Risk: No findings

This PR adds a sandbox email sender for workflows: sends go through a fixed PostHog SES identity and are restricted to verified members of the team's own organization, checked before preparation and again fresh before delivery, with fail-closed member lookups, a mandatory identification footer, and a default-off environment switch. The recipient gating — the control that keeps a PostHog-domain sender from emailing outsiders — is consistent and fail-closed everywhere I probed: the checked To/CC/BCC list is byte-identical to what SES receives, and every ambiguous address form blocks the send. The one risk I could not rule out is the footer's visual enforcement, which verifies DOM structure and inline styles but not paint order.

Sentinel reviewed b03756a · Review settings

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team November 4, 2026 08:24
extractEmailsFromAddressList(deliverableParams.bcc).length
const capDelay = await this.claimTeamSendingBudget(invocation, isTest, capRecipients)
// Sandbox sends never spend the team's tier budget: they reach only its own members.
const capDelay = isSandbox ? null : await this.claimTeamSendingBudget(invocation, isTest, capRecipients)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Missing sandbox sending budget

isSandbox ? null skips the hourly/daily project caps even when enforcement is enabled, while per-workflow pacing is optional and no sandbox budget replaces those caps. Once sandbox delivery is enabled and provisioned, repeated sends to the same verified member can consume shared sending capacity: membership limits destinations, not volume, and SES's daily recipient quota applies across the account. (docs.aws.amazon.com)

Add a fail-closed sandbox hourly/daily budget charged per To/CC/BCC recipient, including test sends, before calling SES.

@veria-ai

veria-ai Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

PR overview

The PR enables sandbox email delivery to organization members through the sandbox’s SES identity.

One issue remains open: sandbox sends bypass hourly and daily project caps without a replacement sending budget. Once sandbox delivery is enabled and provisioned, repeated sends to a verified member can consume shared SES quota and reduce capacity available for other email delivery; restricting recipients to members does not limit sending volume.

Open issues (1)

Fixed/addressed: 0 · PR risk: 5/10

@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Split into smaller PRs. The Node part continues in the stack #128943 → #128944 → #128945 → #128946.

@Silthus Silthus closed this Nov 4, 2026
This was referenced Nov 4, 2026
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.

1 participant