Skip to content

feat(workflows): let staff turn off the sandbox sender per organization - #586

Draft
Silthus wants to merge 48 commits into
workflows-sandbox-delivery/sandbox-metricfrom
workflows-sandbox/org-block
Draft

Silthus wants to merge 48 commits into
workflows-sandbox-delivery/sandbox-metricfrom
workflows-sandbox/org-block

Conversation

@Silthus

@Silthus Silthus commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Problem

PostHog staff have no way to stop the sandbox sender for one abusive organization short of deactivating the whole organization.

  • A free signup can send sandbox email until staff lock the entire organization out of the app and the API.
  • A later automatic cutoff, driven by alerts, also needs a switch to flip.

Stack: V3, protect shared reputation: #607 → #608 → #609 → #610 → this PR → #587. #587 makes the email worker honor the block, on top of this layer's table.

This fork PR moves to an upstream PostHog/posthog PR after review.

Impact

Pays into Grow Workflows through activation rate (measured here). In scope: Messaging, email sending. Expected move: small, up, as a rollout guard for the sandbox sender. Run cost: n/a.

Note

🟢 Merge risk: low · two-way door (merge-risk rubric)

  • The migration creates one new table and changes no existing table. Its foreign keys carry no database constraint, so no lock touches posthog_organization.
  • Only the sandbox sender ensure endpoint reads the block, behind the workflows-sandbox-sender flag.

Changes

  • Staff can block the sandbox sender for one organization in the Django admin, under "Sandbox sender organization blocks". Deleting the row lifts the block.
  • The admin records the organization, a reason, the staff user who added the block, and when. Its pages name a block by its organization.

The block list after staff add a block, with organization, reason, author and time

The delete confirmation names the organization that gets unblocked

  • A blocked organization's projects get no new sandbox sender: the ensure endpoint returns 404, as it does when the sender is unavailable. A new project cannot get around the block.
  • Existing sandbox sender rows stay. feat(cdp): skip sandbox sends of a blocked organization #587 makes the email worker skip their sends.
  • SandboxSenderOrganizationBlock has no team_id. It is in the IDOR coverage list and in the organization-scoped semgrep rules, because it has an organization foreign key.
  • The block ships in its own layer because the repo's CI forbids a Django migration and Node changes in one PR. So the migration lands before any worker reads the table.

Warning

The migration is 0040_sandbox_sender_organization_block, on #607's 0039_sandbox_sender_tenant_state. When the stack moves upstream, check the number against master on that day.

How did you test this code?

Test rationale:

  • test_sandbox_sender.py, "ensure is not found while the organization is blocked and keeps existing senders". Catches a new project that gets a sandbox sender while its organization is blocked, a block that reaches other organizations, and a blocked project that still gets its existing sender back or loses it. A second organization keeps its own block for the whole test. Red before the service change: the endpoint returned 200.
  • test_sandbox_sender_organization_block_admin.py posts the real admin add and delete forms as a staff user. It catches a block saved without its author, an admin page staff cannot use, or a delete confirmation that does not name the organization. Dropping the author assignment, or the model's __str__, makes it fail.

Live run at 8ee50d709c7, reused for this head. The PR's own change is byte-identical to the one the live run covered, apart from the migration number and its dependency. The ensure action is the same on the old and the new base. The API and admin tests pass on this head against a freshly migrated database.

runserver ran from clean checkouts against a fully migrated lane database. The steps used session login, the real endpoint, and the real admin forms. The base server wrote the first project's sandbox sender.

Step Server Request Status Sandbox senders after Blocks after
1 base ensure, first project 200 first none
2 this PR ensure, first project 200 first none
3 this PR admin add form 302 first one, with the staff user
4 this PR ensure, first project 404 first, kept one
5 this PR ensure, second project 404 first one
6 this PR admin delete 302 first none
7 this PR ensure, second project 200 first, second none

UI walk at 8d92e19e7bc. A staff user added a block, opened it, and deleted it in the real admin, rendered at 1280x800. The screenshots above come from this walk. Later commits remove comments and restack the same files onto #610.

Cross-version. The base email worker ignores a block row and keeps sending. The worker that honors it arrives in #587.

Gaps.

  • Prod shape: not measured. The new table is empty in production, so every organization takes the not-blocked branch at rollout. Read-only production access arrives on 2026-11-02.
  • CI: the repo's full CI cannot run on this fork. Depot runner jobs never start, the Semgrep detection job is skipped, two weekly-report script tests assert on the upstream repo name, and the lane check counts the stack's lower layers. Local substitutes are listed below.

Release status

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

Docs update

docs/internal/workflows-email-sending-tiers.md describes the staff block under sandbox sender provisioning: the admin row, the 404, and that existing senders stay. #587 adds the worker side.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

  • Skills: impact, django-migrations, writing-pr-descriptions, pr-description.
  • Restack: the head merges feat(workflows): record sandbox deliveries as their own metric #610's branch into this branch without a force-push. The merge keeps only this PR's change on top of the new base, with the migration renumbered to 0040.
  • Proofs: fleet proof holds the live run and the cross-version run from 8ee50d709c7, and the UI walk from 8d92e19e7bc, reused for this head with the reason above, plus the prod-shape gap.
  • Review: GPT-6.1 Sol at high effort through fleet proof review. Round 1 read the combined commit 82d5d74 and found three issues, all fixed: the split into this PR and feat(cdp): skip sandbox sends of a blocked organization #587, the cold-cache Node test (now in feat(cdp): skip sandbox sends of a blocked organization #587), and a direct save_model admin test (fc8dccdf088). The final read on fc8dccdf088 found nothing. After the restack, a final read on the PR's diff against feat(workflows): offer a switch from the sandbox sender #625 found that the block test did not prove organization scoping. 99424e33241 keeps a block on a second organization in that test, and dropping the organization filter now fails it. The final read on 99424e33241 found nothing. The gate's judge then asked for a UI walk of the admin. The walk showed "SandboxSenderOrganizationBlock object (7)" on the delete confirmation, so 8d92e19e7bc names the organization there. The judge also flagged code comments against the no-comments rule, and 11f1440e84b removes them. Final reads on both found nothing. After the V3 restack, a final read found that the block test skipped projects that already have a sender, and that the docs waited for feat(cdp): skip sandbox sends of a blocked organization #587. 9258b97383a covers both, and the final read on it found nothing.
  • Lane gate, green locally on this head's tree: the sandbox sender API and admin tests on a fresh database, ruff, makemigrations --check, hogli product:lint workflows, the IDOR coverage script, and the pre-push hogli ci:preflight --strict.
  • fleet proof gate: passes on 9258b97383a, with the CI gap accepted.
  • No duplicate: the change builds on this fork's sandbox sender stack, which no other PR covers.
  • Public artifact: every fixture and address is invented.
Question Answer Who Where
Where do the block and its worker sit in the stack? In V3, protect shared reputation: after the sandbox metric (#610), with #587 right above #586 👤 Human comment
How do staff turn off the sandbox sender for one organization? A staff-managed block table in the Django admin, its own reason and message, and no new senders. No customer UI. 👤 Human comment
Accept the CI gap for this fork PR? Yes: no depot job runs on the fork, and the local lane gate covers the changed code 👍 Accepted default comment
One layer or two? Two: the table and endpoint here, the worker in #587, because CI forbids a migration and Node changes together 🤖 Agent comment
Which migration number? 0040 on #607's 0039_sandbox_sender_tenant_state, checked against master when the stack moves upstream 🤖 Agent comment

Involvement: no grilling rounds, one cross-model review round and seven final reads, proof captured.

🤖 Generated with Claude Code

Silthus and others added 13 commits October 24, 2026 12:44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A deactivated organization now has no verified members, so the sandbox
sender blocks every send within the one-minute member snapshot window.
Only an explicit false deactivates, matching the web middleware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus self-assigned this Oct 26, 2026
Adds SandboxSenderOrganizationBlock, a staff-managed row in the Django
admin with the organization, a reason, who added it and when. While a row
exists, the ensure endpoint creates no new sandbox sender for the
organization's projects. Deleting the row lifts the block. The migration
creates the new table only.

The email worker starts reading the block in the next layer, so the
migration lands before any worker queries the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 13 commits October 26, 2026 08:11
…in forms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…liver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…working

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…status

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndbox-product/channels-row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…banner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sandbox-product/save-validation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nges type

A stored email step that turns into a generic function step no longer keeps
its sandbox sender as an unchanged value, so the step-type rule rejects it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 16 commits October 27, 2026 08:10
…reopens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sandbox sender has no delivery route in the worker yet. A send through it
now skips with a reason in the logs instead of passing the provider switch as
a success. This lands before any API can create a sandbox sender row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workflow email steps list the managed sandbox sender last, tagged
Sandbox, with a members-only note once it is selected. Picking it
replaces any rotation and clears the custom sender and Reply-To;
adding another sender drops it again. Broadcasts, destinations and
transactional templates keep leaving it out.

Ported from #450 in place in EmailTemplater.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picking the sandbox sender in a workflow email step now asks first
when it would replace several senders or a custom sender or Reply-To.
Before you send to an audience, a project with only the sandbox sender
learns that it reaches only verified members of the organization.

Ported from #450.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A generic function step can use the email template too, and saving
the sandbox sender there fails. The step now passes its action type,
and only a function_email step outside broadcasts offers the sandbox
sender. A rendered test checks that both pickers send a pick through
chooseSenders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…warnings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workflow whose email steps use the sandbox sender shows a banner once
the project has a verified own sender. The banner switches every sandbox
email step to that sender as an unsaved change, behind the
workflows-sandbox-sender flag.

Refs Silthus/personal#202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also document the switch banner next to the sandbox sender setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…box stack

Merge the new base, workflows-sandbox-product/switch-banner, into this branch, and keep only this PR's change on top of it. The migration is now 0039, after the base's 0038.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus changed the base branch from workflows-sandbox/switch-banner to workflows-sandbox-product/switch-banner October 27, 2026 12:31
@github-actions

Copy link
Copy Markdown

🤖 CI report

ℹ️ MCP agent API — agent-facing tool changes

What this PR changes for agents, from the tool schema snapshots and tool definitions.

Tools added (25): aeo-prompts-archive (138 chars), aeo-prompts-checks-list (570 chars), aeo-prompts-get (138 chars), aeo-prompts-import (863 chars), aeo-prompts-list (637 chars), aeo-prompts-stats-get (138 chars), aeo-prompts-topics-list (33 chars), aeo-prompts-update (1,584 chars), aeo-prompts-versions-list (138 chars), anomaly-insight-get (952 chars), anomaly-search (1,265 chars), feature-requests-link-github-create, inbox-ranking-outcomes-list (33 chars), inbox-report-reviewers-update (3,495 chars), inbox-reviewer-exclusions-create (605 chars), inbox-reviewer-exclusions-get (147 chars), inbox-reviewer-exclusions-list (33 chars), inbox-reviewer-exclusions-revoke (147 chars), inbox-reviewer-exclusions-update (512 chars), integrations-email-status-retrieve, integrations-email-verify-create, message-categories-create (1,184 chars), message-categories-get (138 chars), message-categories-list (207 chars), message-categories-update (1,014 chars)
Tools removed (3): endpoint-materialization-conditions, endpoint-materialization-suggestion, vision-scanners-suggest-tags

Tools changed (69):

Tool Params Scopes Annotations Schema chars
activity-summary-get description changed
activity-summary-versions-list description changed
batch-export-create 28,053 -> 27,660
batch-export-update 27,812 -> 27,419
broadcasts-create +source_record 19,354 -> 19,647
canvas-draft-create 5,868 -> 6,170
canvas-edit-create description changed 4,975 -> 5,277
canvas-layout-patch description changed
canvas-layout-publish description changed
canvas-publish-create description changed 6,349 -> 6,651
canvas-validate-create 5,738 -> 6,040
cohorts-add-persons-to-static-cohort-partial-update description changed 251 -> 343
conversations-tickets-list 3,871 -> 3,923
conversations-views-create 4,891 -> 5,095
conversations-views-update 5,011 -> 5,215
create-feature-flag schema changed
dashboard-create +group_display +groups -group_titles (rename?) description changed 3,489 -> 4,743
dashboard-create-tile description changed
dashboard-insights-run +dashboard_tile_ids -tile_ids (rename?) description changed 2,568 -> 2,588
dashboard-reorder-tiles description changed
dashboard-update +group_display +groups -group_titles (rename?) description changed 7,470 -> 8,728
dashboard-update-text-tile description changed
dashboard-widget-catalog-list 381 -> 397
dashboard-widgets-batch-add description changed 3,223 -> 3,289
dashboard-widgets-batch-update description changed 2,083 -> 2,134
data-catalog-metric-approve-prepare +expected_updated_at 77 -> 650
data-catalog-metric-create +owner 2,245 -> 2,654
data-catalog-metric-update +owner description changed 2,054 -> 2,463
data-quality-subjects +include_columns +limit +offset +search +subject_type description changed 33 -> 1,025
error-tracking-settings-get description changed
error-tracking-settings-update +ingestion_enabled description changed 822 -> 1,058
experiment-holdouts-create schema changed
experiment-holdouts-partial-update schema changed
file-download-batch-exports-count-rows-create 12,599 -> 12,206
file-download-batch-exports-create 19,115 -> 18,722
generate-app-url 10,663 -> 10,749
inbox-report-actions-list description changed
inbox-report-actions-run +message description changed 570 -> 1,122
inbox-report-artefacts-create 2,061 -> 2,075
inbox-report-artefacts-update description changed

...and 29 more changed tools.

Silthus and others added 3 commits October 27, 2026 13:43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nder block

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions for this PR:

@Silthus
Silthus force-pushed the workflows-sandbox-product/switch-banner branch from e63cf5b to ca428c7 Compare October 28, 2026 10:00
…sandbox metric

Merge workflows-sandbox-delivery/sandbox-metric (#610) without a force-push.
The block migration moves to 0040, above 0039_sandbox_sender_tenant_state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus changed the base branch from workflows-sandbox-product/switch-banner to workflows-sandbox-delivery/sandbox-metric October 28, 2026 10:57
Silthus added a commit that referenced this pull request Oct 28, 2026
…ock layer

Merge workflows-sandbox/org-block (#586) without a force-push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is blocked

Document the staff block next to the sandbox sender provisioning endpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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