Repository navigation
Conversation
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>
|
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 |
🤖 CI report✅ Trunk lane — non-backend laneThis 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) — cleanNew 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) — cleanNew 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 Total: 77.09 MiB · 🟢 -1.5 KiB (-0.0%)
Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report ✅ Eager graph — within budgetHow 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
🟢 Largest files eagerly shipped from
|
| 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%)
There was a problem hiding this comment.
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.
| } catch (error) { | ||
| blockedRecipients = recipients | ||
| reason = 'check_failed' |
There was a problem hiding this comment.
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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
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.
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
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 |
| 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) |
There was a problem hiding this comment.
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.
PR overviewThe 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 |
Problem
workflows-sandbox-sender. It replaces feat(workflows): restrict sandbox email to verified org members #127149 and feat(workflows): route sandbox email through its fixed ses identity #127150. Layer 2 is feat(workflows): provision a managed sandbox email sender #128252 (provisioning), layer 3 is feat(workflows): offer the sandbox sender in channels and the email step #128253 (UI).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)
teamSesRoutehelper. A bug there fails or misroutes sends for most teams that send workflow email: silent harm on the mail sending path.sandbox, which no team has until feat(workflows): provision a managed sandbox email sender #128252 provisions one behind the flag.WORKFLOWS_SANDBOX_SENDER_ENABLEDdefaults to off. Tests pin own-domain sends to the same tenant, configuration set and unsubscribe headers as master, with the switch on and off.WORKFLOWS_SANDBOX_SENDER_ENABLED=falseto stop every sandbox send, orgit revertthe 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:
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.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.WORKFLOWS_SANDBOX_SENDER_ENABLED(default off) skips every sandbox send with "the sandbox sender is unavailable right now".workflows sandbox email sentandworkflows 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;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;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-apiran 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 callsEmailService.executeSendEmailon the Node side."Example Org via PostHog" <max@sandbox.example.com>, tenantposthog-sandbox, sandbox configuration set, footer with the organization ID, no unsubscribe headersteam-1,posthog-messaging,List-Unsubscribeheaders, as on master2f8f63cdac1, 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:
executeSendEmail."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.workflows sandbox email sentandblockedevents, the fresh check right before delivery, and the 3-second lookup timeout. Tests below cover each.Test seams: the tests drive
EmailService.executeSendEmailagainst 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.
Release status
Docs update
docs/internal/workflows-email-sending-tiers.mdgains a "Sandbox delivery" section.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Decisions
sandbox, created only by PostHog codeWORKFLOWS_SANDBOX_SENDER_ENABLED, no feature flag in CDP Node codeNo 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-descriptionsMore context for AI agents
executeSendEmailandcheckSandboxRecipientsinnodejs/src/cdp/services/messaging/email.service.ts,organization-members.service.ts.🤖 Generated with Claude Code