Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
5a5be19
feat(workflows): provision a managed sandbox email sender
Silthus Oct 19, 2026
c9a0c73
feat(workflows): route sandbox email through its fixed ses identity
Silthus Oct 19, 2026
f279cc9
feat(workflows): validate sandbox sender use at save time
Silthus Oct 19, 2026
30fb28b
feat(workflows): restrict sandbox email to verified org members
Silthus Oct 19, 2026
222ac4e
feat(workflows): stop sandbox email for deactivated organizations
Silthus Oct 26, 2026
3f2f72d
feat(workflows): cap sandbox sends and refund ses throttles
Silthus Oct 19, 2026
0d48e51
feat(workflows): offer the sandbox sender in workflow email steps
Silthus Oct 19, 2026
f1e44aa
feat(workflows): test templates with the sandbox sender
Silthus Oct 19, 2026
74e68e0
feat(workflows): store shared sandbox tenant state
Silthus Oct 19, 2026
b2c3131
feat(workflows): honor shared sandbox tenant pauses
Silthus Oct 19, 2026
637101b
feat(workflows): separate sandbox delivery history
Silthus Oct 19, 2026
0c8ff86
feat(workflows): resolve the first-run email sender
Silthus Oct 19, 2026
8418420
feat(workflows): offer a switch from the sandbox sender
Silthus Oct 19, 2026
8ee50d7
feat(workflows): let staff block the sandbox sender per organization
Silthus Oct 26, 2026
fc8dccd
test(workflows): add and delete an organization block through the adm…
Silthus Oct 26, 2026
e7d8226
feat(workflows): provision a managed sandbox email sender
Silthus Oct 19, 2026
5da89ec
feat(workflows): offer the sandbox sender only with its ses tenant set
Silthus Oct 27, 2026
6868e08
feat(workflows): keep the sandbox sender out of sends until it can de…
Silthus Oct 27, 2026
525bfee
chore(workflows): check only the sandbox team in the tenant sweep test
Silthus Oct 27, 2026
8ee685e
feat(workflows): show the managed sandbox sender in channels
Silthus Oct 27, 2026
69e32ff
feat(workflows): validate sandbox sender use at save time
Silthus Oct 19, 2026
bab461f
fix(workflows): match worker sender selection and keep cleanup saves …
Silthus Oct 27, 2026
53c404e
fix(workflows): keep the sandbox sender out of test sends and domain …
Silthus Oct 27, 2026
9d1d1a9
Merge branch 'workflows-sandbox-product/sender-row' into workflows-sa…
Silthus Oct 27, 2026
163f551
fix(workflows): drop stale sandbox setup results and cover the retry …
Silthus Oct 27, 2026
f68d190
Merge branch 'workflows-sandbox-product/channels-row' into workflows-…
Silthus Oct 27, 2026
1b49508
fix(workflows): keep the step-type rule when a sandbox email step cha…
Silthus Oct 27, 2026
1b7fc9f
test(workflows): cover a stale sandbox setup response after Channels …
Silthus Oct 27, 2026
c9dc185
Merge branch 'workflows-sandbox-product/channels-row' into workflows-…
Silthus Oct 27, 2026
7d92de3
fix(cdp): skip email sends through the sandbox sender in the worker
Silthus Oct 27, 2026
9b5065f
Merge branch 'workflows-sandbox-product/worker-skip' into workflows-s…
Silthus Oct 27, 2026
1f4ddaf
Merge branch 'workflows-sandbox-product/sender-row' into workflows-sa…
Silthus Oct 27, 2026
356d02f
Merge branch 'workflows-sandbox-product/channels-row' into workflows-…
Silthus Oct 27, 2026
38ff1d9
feat(workflows): offer the sandbox sender in workflow email steps
Silthus Oct 27, 2026
bffa8da
feat(workflows): confirm before the sandbox sender replaces senders
Silthus Oct 27, 2026
3e0abcb
fix(workflows): offer the sandbox sender only in email action steps
Silthus Oct 27, 2026
ccf8489
test(workflows): cover own senders beside the sandbox sender in send …
Silthus Oct 27, 2026
edb5bb0
Merge branch 'workflows-sandbox-product/email-step-picker' into workf…
Silthus Oct 27, 2026
ae5de13
fix(workflows): require the action type for step sender eligibility
Silthus Oct 27, 2026
c569c18
Merge branch 'workflows-sandbox-product/email-step-picker' into workf…
Silthus Oct 27, 2026
282568a
feat(workflows): offer a switch from the sandbox sender
Silthus Oct 27, 2026
e63cf5b
test(workflows): prove the sandbox switch saves the new sender
Silthus Oct 27, 2026
4c45ce7
chore(workflows): move the organization block onto the re-sliced sand…
Silthus Oct 27, 2026
99424e3
test(workflows): prove a block only covers its own organization
Silthus Oct 27, 2026
8d92e19
fix(workflows): name the organization on admin pages for a sandbox se…
Silthus Oct 28, 2026
11f1440
chore(workflows): drop comments from the organization block model
Silthus Oct 28, 2026
a3dab8e
chore(workflows): restack sandbox sender organization block onto the …
Silthus Oct 28, 2026
9258b97
test(workflows): keep existing sandbox senders while an organization …
Silthus Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/scripts/check-idor-model-coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ def get_scoped_models() -> tuple[dict[str, set[str]], set[str], set[str], set[st
"RoleMembership",
"LinkedIdentityProviderConfig",
"IdJagIdentity",
"SandboxSenderOrganizationBlock",
# --- User-scoped (cross-tenant by design) ---
"NotificationViewed",
"SCIMProvisionedUser",
Expand Down
2 changes: 2 additions & 0 deletions .semgrep/rules/security/idor-team-scoped-models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,7 @@ rules:
|Role
|RoleExternalReference
|RoleMembership
|SandboxSenderOrganizationBlock
)$
- focus-metavariable: $SINK
pattern-sanitizers:
Expand Down Expand Up @@ -1459,6 +1460,7 @@ rules:
|Role
|RoleExternalReference
|RoleMembership
|SandboxSenderOrganizationBlock
)$
- pattern-not: $M.objects. ... .$METHOD2(..., organization=$O, ...)
- pattern-not: $M.objects. ... .$METHOD2(..., organization_id=$O, ...)
Expand Down
2 changes: 2 additions & 0 deletions docs/internal/workflows-email-sending-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook

The `workflows-sandbox-sender` flag enables a PostHog-managed email integration per project through `POST /api/projects/:project_id/integrations/email_sandbox_sender/`.
The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name.
Staff turn the sandbox sender off for one organization by adding a `SandboxSenderOrganizationBlock` row in the Django admin, and lift the block by deleting the row.
While the block exists, the endpoint returns 404 for the organization's projects. Existing sandbox senders stay, and the email worker does not read the block.
Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN`, `SES_SANDBOX_FROM_ADDRESS`, and `SES_SANDBOX_TENANT_NAME` in Django; any empty value leaves the endpoint unavailable.
The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from.
Workflow email steps and test sends offer it as a sender.
Expand Down
1 change: 1 addition & 0 deletions products/workflows/backend/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
hog_flow_admin,
hog_flow_batch_job_admin,
hog_flow_template_admin,
sandbox_sender_organization_block_admin,
team_workflows_config_admin,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import cast

from django.contrib import admin
from django.forms import ModelForm
from django.http import HttpRequest

from posthog.models.user import User

from products.workflows.backend.models import SandboxSenderOrganizationBlock


@admin.register(SandboxSenderOrganizationBlock)
class SandboxSenderOrganizationBlockAdmin(admin.ModelAdmin):
list_display = ("organization", "reason", "created_by", "created_at")
fields = ("organization", "reason", "created_by", "created_at")
readonly_fields = ("created_by", "created_at")
raw_id_fields = ("organization",)
search_fields = ("organization__id", "organization__name", "reason")
list_select_related = ("organization", "created_by")
ordering = ("-created_at",)

def save_model(
self,
request: HttpRequest,
obj: SandboxSenderOrganizationBlock,
form: ModelForm | None,
change: bool,
) -> None:
if not change:
obj.created_by = cast(User, request.user)
super().save_model(request, obj, form, change)
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Generated by Django 5.2.18 on 2026-10-26 06:46

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("posthog", "1411_personalapikey_expires_at"),
("workflows", "0039_sandbox_sender_tenant_state"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="SandboxSenderOrganizationBlock",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("reason", models.TextField()),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"created_by",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"organization",
models.OneToOneField(
db_constraint=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="posthog.organization",
),
),
],
),
]
2 changes: 1 addition & 1 deletion products/workflows/backend/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0039_sandbox_sender_tenant_state
0040_sandbox_sender_organization_block
2 changes: 2 additions & 0 deletions products/workflows/backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .hog_flow_optimization import HogFlowOptimization
from .hog_flow_revision import HogFlowRevision
from .hog_flow_schedule.hog_flow_schedule import HogFlowSchedule
from .sandbox_sender_organization_block import SandboxSenderOrganizationBlock
from .sandbox_sender_tenant_state import SandboxSenderTenantState
from .team_workflows_config import TeamWorkflowsConfig
from .workflow_idea import WorkflowIdea
Expand All @@ -16,6 +17,7 @@
"HogFlowRevision",
"HogFlowSchedule",
"HogFlowTemplate",
"SandboxSenderOrganizationBlock",
"SandboxSenderTenantState",
"TeamWorkflowsConfig",
"WorkflowIdea",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.db import models


class SandboxSenderOrganizationBlock(models.Model):
organization = models.OneToOneField(
"posthog.Organization", on_delete=models.CASCADE, db_constraint=False, related_name="+"
)
reason = models.TextField()
created_by = models.ForeignKey(
"posthog.User", on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False, related_name="+"
)
created_at = models.DateTimeField(auto_now_add=True)

def __str__(self) -> str:
return self.organization.name
7 changes: 6 additions & 1 deletion products/workflows/backend/services/sandbox_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from posthog.plugins.plugin_server_api import reload_integrations_on_workers

from products.workflows.backend.facade.contracts import SandboxEmailSender, SandboxSenderUnavailable
from products.workflows.backend.models.sandbox_sender_organization_block import SandboxSenderOrganizationBlock

logger = structlog.get_logger(__name__)

Expand Down Expand Up @@ -54,9 +55,13 @@ def _sandbox_sender_enabled(team: Team) -> bool:
return False


def _organization_blocked(team: Team) -> bool:
return SandboxSenderOrganizationBlock.objects.filter(organization_id=team.organization_id).exists()


def ensure_sandbox_email_sender(team_id: int) -> SandboxEmailSender:
team = Team.objects.select_related("organization").get(id=team_id)
if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team):
if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team) or _organization_blocked(team):
raise SandboxSenderUnavailable()

config = _sandbox_sender_config(team.organization.name)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from posthog.test.base import BaseTest

from django.test import Client, override_settings

from products.workflows.backend.models import SandboxSenderOrganizationBlock

ADMIN_URL = "/admin/workflows/sandboxsenderorganizationblock/"


@override_settings(STORAGES={"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}})
class TestSandboxSenderOrganizationBlockAdmin(BaseTest):
def setUp(self) -> None:
super().setUp()
self.user.is_staff = True
self.user.save()
self.organization.name = "Blocked example organization"
self.organization.save()
self.client = Client()
self.client.force_login(self.user)

def test_staff_add_a_block_with_its_author_and_delete_it(self) -> None:
added = self.client.post(
f"{ADMIN_URL}add/", data={"organization": str(self.organization.id), "reason": "Abuse report"}
)

assert added.status_code == 302, added.content
block = SandboxSenderOrganizationBlock.objects.get(organization=self.organization)
assert (block.reason, block.created_by) == ("Abuse report", self.user)

confirmation = self.client.get(f"{ADMIN_URL}{block.pk}/delete/")
assert self.organization.name in confirmation.content.decode()

deleted = self.client.post(f"{ADMIN_URL}{block.pk}/delete/", data={"post": "yes"})

assert deleted.status_code == 302, deleted.content
assert not SandboxSenderOrganizationBlock.objects.filter(organization=self.organization).exists()
23 changes: 22 additions & 1 deletion products/workflows/backend/tests/api/test_sandbox_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
from rest_framework import status

from posthog.models.integration import Integration
from posthog.models.organization import OrganizationMembership
from posthog.models.organization import Organization, OrganizationMembership
from posthog.models.personal_api_key import PersonalAPIKey
from posthog.models.utils import hash_key_value

from products.workflows.backend.models import SandboxSenderOrganizationBlock

if TYPE_CHECKING:
from rest_framework.response import _MonkeyPatchedResponse

Expand Down Expand Up @@ -114,6 +116,25 @@ def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable(
assert not self._sandbox_rows().exists()
assert self._provisioned_events() == []

def test_ensure_is_not_found_while_the_organization_is_blocked_and_keeps_existing_senders(self) -> None:
other_organization = Organization.objects.create(name="Other organization")
SandboxSenderOrganizationBlock.objects.create(organization=other_organization, reason="Abuse report")
block = SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report")

blocked = self._ensure()

assert blocked.status_code == status.HTTP_404_NOT_FOUND, blocked.json()
assert not self._sandbox_rows().exists()
assert self._provisioned_events() == []
block.delete()
unblocked = self._ensure()
assert unblocked.status_code == status.HTTP_200_OK, unblocked.json()
assert self._sandbox_rows().count() == 1
SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report")
reblocked = self._ensure()
assert reblocked.status_code == status.HTTP_404_NOT_FOUND, reblocked.json()
assert self._sandbox_rows().count() == 1

@parameterized.expand(
[
("read scope", "integration:read", status.HTTP_403_FORBIDDEN),
Expand Down
Loading