Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 26 additions & 6 deletions posthog/tasks/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -2200,32 +2200,46 @@ def send_conversation_restore_email(email: str, team_id: int, restore_url: str)
logger.info(f"Sent conversation restore email to {email} for team {team.id}")


# Upper bound for erasing analytics event data after a project or organization is deleted. The
# metadata delete is immediate, but event data is removed by a weekly batch job, so a deletion
# waits at most one cycle.
DATA_ERASURE_WINDOW_DAYS = 7
Comment on lines +2203 to +2206

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Seven days is not an erasure upper bound

must_fix bug

Why we think it's a valid issue
  • Checked: What actually drains a DeletionType.Team row after enqueue_clickhouse_deletion_activity writes it (posthog/temporal/delete_teams/activities.py:124-135), and on what cadence.
  • Found: The sweep is Dagster's deletes_job (posthog/dags/deletes.py:1000-1031), which reads pending Q(deletion_type=DeletionType.Team) rows at posthog/dags/deletes.py:509-511. It has no cron of its own. It is triggered only by run_deletes_after_squash (posthog/dags/deletes.py:1033-1045), a run_status_sensor that fires on squash_person_overrides SUCCESS.
  • Found: squash_schedule uses SQUASH_PERSON_OVERRIDES_SCHEDULE (posthog/dags/person_overrides.py:380-388), default "0 22 * * 6" — Saturday 22:00 UTC (posthog/settings/dagster.py:20-21). So the wait to the next cycle alone reaches nearly 7 full days, before the drain does any work.
  • Found: The drain then adds hours on top of that wait, by the repo's own account. posthog/dags/person_overrides.py:386-387 and posthog/dags/deletes.py:1039-1040 both state "mutation waits can span hours" and raise retry_max_attempts to 20 for it. A deletion arriving just after a Saturday drain begins therefore completes at about send-time + 7 days + hours, past the emailed date. That is the ordinary outcome for that arrival window, not a rare edge.
  • Found: The chain is conditional on success. The sensor fires on dagster.DagsterRunStatus.SUCCESS only, so a failed squash means no deletes run and the drain slips another full week. The elevated retry budget exists because these runs do hit transient failures.
  • Found: sharded_events_recent is never swept — it sits in TTL_ONLY_TABLES (posthog/models/deletion_targets.py:183). The internal doc docs/internal/clickhouse-deletion-coverage.md:97 states the case directly: the table partitions by day with ttl_only_drop_parts = 1, so "the real worst case is about 8 days plus TTL-merge lag, not a flat 7". Its clock runs from inserted_at, so events ingested just before deletion outlive a 7-day promise regardless of when the drain runs.
  • Found — corroboration: deletes_job skips cohortpeople entirely; the entry is commented out at posthog/dags/deletes.py:1017-1018 with "the mutations run here overload the cluster pretty badly". So some team data this path is expected to clear is not cleared at all.
  • Impact: The email prints a specific calendar date and says the data "is permanently erased by" it. No component enforces that date, and two independent mechanisms exceed it under normal operation. A customer who files the date as a compliance record holds a false record. The code comment introducing the constant (posthog/tasks/email.py:2203-2206) asserts "a deletion waits at most one cycle", which omits the cycle-boundary wait, the multi-hour job runtime, the success-gated trigger, and the TTL-only table.
Issue description

The deletion pipeline does not guarantee this seven-day deadline. sharded_events_recent has a documented worst case of about eight days plus TTL merge lag. The weekly deletion job can also start late, run for hours, or retry. Both emails can therefore promise permanent erasure while event rows still exist.

Suggested fix

Remove the fixed erasure date until the system has an enforced deadline. Otherwise, use a conservative SLA that covers TTL behavior, job runtime, and retries.

Prompt to fix with AI (copy-paste)
## Context
@posthog/tasks/email.py#L2203-2206
@posthog/tasks/email.py#L2225
@posthog/tasks/email.py#L2259

<issue_description>
The deletion pipeline does not guarantee this seven-day deadline. `sharded_events_recent` has a documented worst case of about eight days plus TTL merge lag. The weekly deletion job can also start late, run for hours, or retry. Both emails can therefore promise permanent erasure while event rows still exist.
</issue_description>

<issue_validation>
- **Checked:** What actually drains a `DeletionType.Team` row after `enqueue_clickhouse_deletion_activity` writes it (`posthog/temporal/delete_teams/activities.py:124-135`), and on what cadence.
- **Found:** The sweep is Dagster's `deletes_job` (`posthog/dags/deletes.py:1000-1031`), which reads pending `Q(deletion_type=DeletionType.Team)` rows at `posthog/dags/deletes.py:509-511`. It has no cron of its own. It is triggered only by `run_deletes_after_squash` (`posthog/dags/deletes.py:1033-1045`), a `run_status_sensor` that fires on `squash_person_overrides` SUCCESS.
- **Found:** `squash_schedule` uses `SQUASH_PERSON_OVERRIDES_SCHEDULE` (`posthog/dags/person_overrides.py:380-388`), default `"0 22 * * 6"` — Saturday 22:00 UTC (`posthog/settings/dagster.py:20-21`). So the wait to the next cycle alone reaches nearly 7 full days, before the drain does any work.
- **Found:** The drain then adds hours on top of that wait, by the repo's own account. `posthog/dags/person_overrides.py:386-387` and `posthog/dags/deletes.py:1039-1040` both state "mutation waits can span hours" and raise `retry_max_attempts` to 20 for it. A deletion arriving just after a Saturday drain begins therefore completes at about send-time + 7 days + hours, past the emailed date. That is the ordinary outcome for that arrival window, not a rare edge.
- **Found:** The chain is conditional on success. The sensor fires on `dagster.DagsterRunStatus.SUCCESS` only, so a failed squash means no deletes run and the drain slips another full week. The elevated retry budget exists because these runs do hit transient failures.
- **Found:** `sharded_events_recent` is never swept — it sits in `TTL_ONLY_TABLES` (`posthog/models/deletion_targets.py:183`). The internal doc `docs/internal/clickhouse-deletion-coverage.md:97` states the case directly: the table partitions by day with `ttl_only_drop_parts = 1`, so "the real worst case is about 8 days plus TTL-merge lag, not a flat 7". Its clock runs from `inserted_at`, so events ingested just before deletion outlive a 7-day promise regardless of when the drain runs.
- **Found — corroboration:** `deletes_job` skips `cohortpeople` entirely; the entry is commented out at `posthog/dags/deletes.py:1017-1018` with "the mutations run here overload the cluster pretty badly". So some team data this path is expected to clear is not cleared at all.
- **Impact:** The email prints a specific calendar date and says the data "is permanently erased by" it. No component enforces that date, and two independent mechanisms exceed it under normal operation. A customer who files the date as a compliance record holds a false record. The code comment introducing the constant (`posthog/tasks/email.py:2203-2206`) asserts "a deletion waits at most one cycle", which omits the cycle-boundary wait, the multi-hour job runtime, the success-gated trigger, and the TTL-only table.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Remove the fixed erasure date until the system has an enforced deadline. Otherwise, use a conservative SLA that covers TTL behavior, job runtime, and retries.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — the finding is correct against the current code, and I'm escalating rather than editing it unattended because the fix is a product/compliance decision, not a mechanical change. What I verified: (a) sharded_events_recent is reclaimed by a 7-day TTL keyed on inserted_at with ttl_only_drop_parts=1, and the internal deletion-coverage doc puts the real worst case at ~8 days plus TTL-merge lag — and because that clock runs from ingestion, events ingested just before a deletion can outlive a flat 7 days measured from send time; (b) the drain (deletes_job) runs only weekly, gated on the Saturday 22:00 UTC squash succeeding, so a deletion arriving right after a drain waits ~7 days for the next cycle before any work, the run itself 'can span hours', and a failed squash slips it another full week; (c) cohortpeople deletion is disabled outright. So the emailed date is not an enforced upper bound and can be exceeded under normal operation — a real problem for a customer filing it as a compliance record. A human needs to choose the direction, because none of the options are provable or mechanical here: (1) drop the specific date and reword to a non-guarantee (but that undoes this PR's purpose), (2) commit to a conservative SLA that genuinely covers TTL behavior + job runtime + retries + the cohortpeople gap (but no finite number is truly guaranteed given unbounded merge lag and the success-gated weekly trigger), or (3) enforce a real deadline in the deletion pipeline. I left the code unchanged pending that decision.



@shared_task(**EMAIL_TASK_KWARGS)
@skip_team_scope_audit
def send_project_deleted_email(
user_id: int,
project_name: str,
) -> None:
"""Send email notification when project deletion is complete."""
"""Send email notification when project deletion starts.

The email goes out once the project metadata is removed. Analytics event data is then erased
by a scheduled batch job, so the email states the window rather than claiming completion.
"""
user = User.objects.filter(id=user_id).first()
if not user:
logger.warning(f"User {user_id} not found for project deletion email")
return

data_erased_by = (timezone.now() + datetime.timedelta(days=DATA_ERASURE_WINDOW_DAYS)).strftime("%B %d, %Y")
message = EmailMessage(
use_http=True,
campaign_key=f"project_deleted_{user_id}_{timezone.now().timestamp()}",
subject=f"Your project '{project_name}' has been deleted",
subject=f"Your project '{project_name}' is being deleted",
template_name="project_deleted",
template_context={
"project_name": project_name,
# team_name aliases project_name for the remote Customer.io template; keep it until
# template 54 is confirmed to no longer read team_name.
"team_name": project_name,
"data_erased_by": data_erased_by,
"site_url": settings.SITE_URL,
Comment on lines 2226 to 2237

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Customer.io ignores the changed subject and HTML

must_fix bug

Why we think it's a valid issue
  • Checked: The send path both tasks take. send_project_deleted_email sets use_http=True (posthog/tasks/email.py:2227) and send_organization_deleted_email does the same (posthog/tasks/email.py:2261).
  • Found: _send_email at posthog/email.py:516-522 branches on use_http and is_http_email_service_available() and calls _send_via_http(to, campaign_key, template_name, properties). The subject and html_body arguments it received are dropped on that branch. is_http_email_service_available() at posthog/email.py:53-58 returns bool(settings.CUSTOMER_IO_API_KEY), so any instance with that key set takes the branch.
  • Found: _send_via_http builds the payload at posthog/email.py:234-239 from transactional_message_id (resolved by get_customer_io_template_id) plus message_data. The header comment at posthog/email.py:197-198 states it plainly: the sender is "set up to send via templates so all the configuration is done in the customer.io - i.e. no subject, body, etc."
  • Found: CUSTOMER_IO_TEMPLATE_ID_MAP maps "project_deleted": "54" and "organization_deleted": "55" (posthog/email.py:139-140), so both emails resolve to remote templates.
  • Found: The handbook confirms this split. docs/published/handbook/engineering/developing-locally.md:479 says Cloud emails go via the Customer.io HTTP API, and line 498-499 describes the Django file under posthog/templates/ as "an SMTP backup". I found no code or command that pushes local template content to Customer.io; the other CUSTOMER_IO hits in the repo belong to the warehouse source and destination integrations, not to transactional email.
  • Checked: Whether the new context value at least reaches Customer.io. It does — EmailMessage.__init__ sets self.properties = sanitize_email_properties(template_context) (posthog/email.py:563), send() forwards it (posthog/email.py:602), and it becomes message_data. But the remote template must reference data_erased_by for it to appear, and nothing in this diff makes that true.
  • Impact: On Cloud, the reworded body, the new erased-by date, and the "is being deleted" subject never reach the customer. Customer.io keeps rendering its stored copy for templates 54 and 55, which is the premature "has been deleted / and all its data" text this PR exists to remove. The two new tests at posthog/tasks/test/test_email.py:408-433 assert on mocked_email_messages[0].html_body and .subject, values the HTTP path discards, so they pass while production output is unchanged. Merging as-is marks the reported bug resolved without changing what a Cloud customer reads.
Issue description

Both messages set use_http=True. When Customer.io is configured, _send_email sends only the template ID and template_context. It ignores the subject and rendered HTML. Customer.io can therefore keep sending the old deletion-complete text.

Suggested fix

Update Customer.io templates 54 and 55 before rollout. Change each remote subject and body. Make both templates read data_erased_by.

Prompt to fix with AI (copy-paste)
## Context
@posthog/tasks/email.py#L2226-2234
@posthog/tasks/email.py#L2260-2269

<issue_description>
Both messages set `use_http=True`. When Customer.io is configured, `_send_email` sends only the template ID and `template_context`. It ignores the subject and rendered HTML. Customer.io can therefore keep sending the old deletion-complete text.
</issue_description>

<issue_validation>
- **Checked:** The send path both tasks take. `send_project_deleted_email` sets `use_http=True` (`posthog/tasks/email.py:2227`) and `send_organization_deleted_email` does the same (`posthog/tasks/email.py:2261`).
- **Found:** `_send_email` at `posthog/email.py:516-522` branches on `use_http and is_http_email_service_available()` and calls `_send_via_http(to, campaign_key, template_name, properties)`. The `subject` and `html_body` arguments it received are dropped on that branch. `is_http_email_service_available()` at `posthog/email.py:53-58` returns `bool(settings.CUSTOMER_IO_API_KEY)`, so any instance with that key set takes the branch.
- **Found:** `_send_via_http` builds the payload at `posthog/email.py:234-239` from `transactional_message_id` (resolved by `get_customer_io_template_id`) plus `message_data`. The header comment at `posthog/email.py:197-198` states it plainly: the sender is "set up to send via templates so all the configuration is done in the customer.io - i.e. no subject, body, etc."
- **Found:** `CUSTOMER_IO_TEMPLATE_ID_MAP` maps `"project_deleted": "54"` and `"organization_deleted": "55"` (`posthog/email.py:139-140`), so both emails resolve to remote templates.
- **Found:** The handbook confirms this split. `docs/published/handbook/engineering/developing-locally.md:479` says Cloud emails go via the Customer.io HTTP API, and line 498-499 describes the Django file under `posthog/templates/` as "an SMTP backup". I found no code or command that pushes local template content to Customer.io; the other `CUSTOMER_IO` hits in the repo belong to the warehouse source and destination integrations, not to transactional email.
- **Checked:** Whether the new context value at least reaches Customer.io. It does — `EmailMessage.__init__` sets `self.properties = sanitize_email_properties(template_context)` (`posthog/email.py:563`), `send()` forwards it (`posthog/email.py:602`), and it becomes `message_data`. But the remote template must reference `data_erased_by` for it to appear, and nothing in this diff makes that true.
- **Impact:** On Cloud, the reworded body, the new erased-by date, and the "is being deleted" subject never reach the customer. Customer.io keeps rendering its stored copy for templates 54 and 55, which is the premature "has been deleted / and all its data" text this PR exists to remove. The two new tests at `posthog/tasks/test/test_email.py:408-433` assert on `mocked_email_messages[0].html_body` and `.subject`, values the HTTP path discards, so they pass while production output is unchanged. Merging as-is marks the reported bug resolved without changing what a Cloud customer reads.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Update Customer.io templates 54 and 55 before rollout. Change each remote subject and body. Make both templates read `data_erased_by`.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed - on Cloud this is a real gap. When CUSTOMER_IO_API_KEY is set, _send_email takes the HTTP path and _send_via_http sends only the Customer.io template ID plus message_data; the subject and rendered HTML built from the Django templates are dropped (see the note at email.py:197-198). Since project_deleted and organization_deleted map to Customer.io templates 54 and 55, the reworded body, the new erased-by date, and the 'is being deleted' subject only take effect on the SMTP backup path. Cloud customers keep getting whatever templates 54 and 55 currently say.

I can't fix this from the repository: the copy that Cloud sends lives in Customer.io, and there is no code here that pushes local template content to that service. This needs a human with Customer.io access to update transactional templates 54 (project_deleted) and 55 (organization_deleted) so their subject and body match the new wording, and to reference the data_erased_by variable that this PR now passes in message_data. That update should land together with (or before) this PR rolling out, otherwise the Cloud email is unchanged.

One related note for whoever verifies: the two new tests assert on html_body and subject, which the Customer.io path discards, so they exercise only the SMTP backup - they'll stay green even if the Cloud templates are never updated. I left them as-is since they're still correct for the SMTP path.

Comment thread
posthog[bot] marked this conversation as resolved.
},
)
message.add_user_recipient(user)
message.send()
logger.info(f"Sent project deletion confirmation email to user {user_id} for project {project_name}")
logger.info(f"Sent project deletion email to user {user_id} for project {project_name}")


@shared_task(**EMAIL_TASK_KWARGS)
Expand All @@ -2235,26 +2249,32 @@ def send_organization_deleted_email(
organization_name: str,
project_names: list[str],
) -> None:
"""Send email notification when organization deletion is complete."""
"""Send email notification when organization deletion starts.

The email goes out once the organization metadata is removed. Analytics event data is then
erased by a scheduled batch job, so the email states the window rather than claiming completion.
"""
user = User.objects.filter(id=user_id).first()
if not user:
logger.warning(f"User {user_id} not found for organization deletion email")
return

data_erased_by = (timezone.now() + datetime.timedelta(days=DATA_ERASURE_WINDOW_DAYS)).strftime("%B %d, %Y")
message = EmailMessage(
use_http=True,
campaign_key=f"organization_deleted_{user_id}_{timezone.now().timestamp()}",
subject=f"Your organization '{organization_name}' has been deleted",
subject=f"Your organization '{organization_name}' is being deleted",
template_name="organization_deleted",
template_context={
"organization_name": organization_name,
"project_names": project_names,
"data_erased_by": data_erased_by,
"site_url": settings.SITE_URL,
},
)
message.add_user_recipient(user)
message.send()
logger.info(f"Sent organization deletion confirmation email to user {user_id} for organization {organization_name}")
logger.info(f"Sent organization deletion email to user {user_id} for organization {organization_name}")


@shared_task(ignore_result=True)
Expand Down
30 changes: 30 additions & 0 deletions posthog/tasks/test/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
send_matview_failure_immediate_email,
send_member_join,
send_new_ticket_notification,
send_organization_deleted_email,
send_password_reset,
send_posthog_ai_access_request,
send_project_deleted_email,
send_project_secret_api_key_exposed,
send_provisioning_welcome,
send_wizard_pr_ready_email,
Expand Down Expand Up @@ -402,6 +404,34 @@ def test_send_provisioning_welcome_without_partner(self, MockEmailMessage: Magic
assert "Set your password" in mocked_email_messages[0].html_body
assert "via" not in mocked_email_messages[0].html_body

@freeze_time("2022-01-02 00:00:00")
def test_send_project_deleted_email_states_erasure_window(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
_org, user = create_org_team_and_user("2022-01-01 00:00:00", "deleter@posthog.com")

send_project_deleted_email(user_id=user.id, project_name="My project")

assert len(mocked_email_messages) == 1
html = mocked_email_messages[0].html_body
# Event data is erased by a weekly batch job, so the email must give the upper-bound date
# and must not claim the data is already gone.
assert "January 09, 2022" in html
assert "started deleting its data" in html
assert mocked_email_messages[0].subject == "Your project 'My project' is being deleted"

@freeze_time("2022-01-02 00:00:00")
def test_send_organization_deleted_email_states_erasure_window(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
_org, user = create_org_team_and_user("2022-01-01 00:00:00", "deleter@posthog.com")

send_organization_deleted_email(user_id=user.id, organization_name="My org", project_names=["A", "B"])

assert len(mocked_email_messages) == 1
html = mocked_email_messages[0].html_body
assert "January 09, 2022" in html
assert "started deleting the data" in html
assert mocked_email_messages[0].subject == "Your organization 'My org' is being deleted"

@patch("posthog.tasks.email.ph_scoped_capture")
def test_send_wizard_pr_ready_email_uses_customer_io_context(
self, _mock_ph_scoped_capture: MagicMock, MockEmailMessage: MagicMock
Expand Down
6 changes: 3 additions & 3 deletions posthog/templates/email/organization_deleted.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{% extends "email/base.html" %} {% load posthog_assets %} {% block section %}
<p>
As requested, we've deleted your organization <strong>{{ organization_name }}</strong>{% if project_names %} and its project{{ project_names|length|pluralize }}: <strong>{{ project_names|join:", " }}</strong>{% endif %}.
As requested, we've removed your organization <strong>{{ organization_name }}</strong>{% if project_names %} and its project{{ project_names|length|pluralize }}: <strong>{{ project_names|join:", " }}</strong>{% endif %}, and started deleting the data.
</p>
<p>
This can't be undone, but hey, if you ever want to start fresh, we'll be here.
The analytics data is permanently erased by <strong>{{ data_erased_by }}</strong>. This can't be undone, but hey, if you ever want to start fresh, we'll be here.
</p>
{% endblock %}{% load posthog_filters %} {% block heading %}Organization deleted{% endblock %}
{% endblock %}{% load posthog_filters %} {% block heading %}Deleting your organization{% endblock %}
6 changes: 3 additions & 3 deletions posthog/templates/email/project_deleted.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{% extends "email/base.html" %} {% load posthog_assets %} {% block section %}
<p>
As requested, we've deleted <strong>{{ project_name }}</strong> and all its data.
As requested, we've removed <strong>{{ project_name }}</strong> and started deleting its data.
</p>
<p>
This can't be undone, but hey, if you ever want to start fresh, we'll be here.
Its analytics data is permanently erased by <strong>{{ data_erased_by }}</strong>. This can't be undone, but hey, if you ever want to start fresh, we'll be here.
Comment on lines +3 to +6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The copy promises erasure for data outside the event deletion process

must_fix bug

Why we think it's a valid issue
  • Checked: The 7-day path the copy relies on — DATA_ERASURE_WINDOW_DAYS = 7 at posthog/tasks/email.py:2206, the AsyncDeletion rows it enqueues at posthog/temporal/delete_teams/activities.py:124-135, and the tables that job clears.
  • Found: posthog/models/async_deletion/delete_events.py:22-31 lists TABLES_TO_DELETE_TEAM_DATA_FROM as person, person_distinct_id, person_distinct_id2, groups, cohortpeople, person_static_cohort, plugin_log_entries, plus the events tables. The comment above it states: "Session recording, dead letter queue, logs deletion will be handled by TTL". Session replay data is not on this path.
  • Checked: Whether the deletion workflow waits for session-replay erasure before the email goes out.
  • Found: posthog/temporal/delete_teams/workflows.py:88-104 starts queue_recording_deletions_activity fire-and-forget, and the comment says it is best-effort: "Recordings left behind are reaped by their own retention/TTL". The activity only calls temporal.start_workflow per team (posthog/tasks/tasks.py:1244-1260); it never awaits the child workflows. On failure the workflow logs a warning and carries on, then sends the email at workflows.py:205-211.
  • Found: Recording metadata survives the recording deletion. PurgeDeletedRecordingMetadataWorkflow at posthog/temporal/session_replay/delete_recordings/workflow.py:311-332 purges rows marked is_deleted=1 only after a grace period, and posthog/temporal/session_replay/delete_recordings/types.py:120 sets grace_period_days: int = 10. The schedule at posthog/temporal/schedule.py:583-585 passes PurgeDeletedMetadataInput(), so it runs on that 10-day default.
  • Impact: The email now states an absolute date — "Its analytics data is permanently erased by ", date = send time plus 7 days — for data classes the 7-day job does not touch. Replay metadata is still present at day 7 by design, and replay data can persist far longer when the fire-and-forget start fails. This diff introduces the date, so the wrong promise is new, not pre-existing. The PR's own goal is a date a customer can file under a compliance regime, so a date that is false for one product's data defeats that goal.
  • Note on the fix: "analytics event data" as suggested is too narrow in the other direction — the 7-day job also clears persons, distinct IDs, groups, and cohort membership. The wording must cover those and exclude session replay and logs.
Issue description

The new deadline applies to “analytics data,” but the seven-day process covers only event tables. The workflow does not wait for session-recording deletion. Deleted recording metadata also has a ten-day grace period.

Suggested fix

Change both templates to say “analytics event data.” If the broader claim is required, wait for every deletion workflow and verified store.

Prompt to fix with AI (copy-paste)
## Context
@posthog/templates/email/project_deleted.html#L3-6

<issue_description>
The new deadline applies to “analytics data,” but the seven-day process covers only event tables. The workflow does not wait for session-recording deletion. Deleted recording metadata also has a ten-day grace period.
</issue_description>

<issue_validation>
- **Checked:** The 7-day path the copy relies on — `DATA_ERASURE_WINDOW_DAYS = 7` at `posthog/tasks/email.py:2206`, the `AsyncDeletion` rows it enqueues at `posthog/temporal/delete_teams/activities.py:124-135`, and the tables that job clears.
- **Found:** `posthog/models/async_deletion/delete_events.py:22-31` lists `TABLES_TO_DELETE_TEAM_DATA_FROM` as `person`, `person_distinct_id`, `person_distinct_id2`, `groups`, `cohortpeople`, `person_static_cohort`, `plugin_log_entries`, plus the `events` tables. The comment above it states: "Session recording, dead letter queue, logs deletion will be handled by TTL". Session replay data is not on this path.
- **Checked:** Whether the deletion workflow waits for session-replay erasure before the email goes out.
- **Found:** `posthog/temporal/delete_teams/workflows.py:88-104` starts `queue_recording_deletions_activity` fire-and-forget, and the comment says it is best-effort: "Recordings left behind are reaped by their own retention/TTL". The activity only calls `temporal.start_workflow` per team (`posthog/tasks/tasks.py:1244-1260`); it never awaits the child workflows. On failure the workflow logs a warning and carries on, then sends the email at `workflows.py:205-211`.
- **Found:** Recording metadata survives the recording deletion. `PurgeDeletedRecordingMetadataWorkflow` at `posthog/temporal/session_replay/delete_recordings/workflow.py:311-332` purges rows marked `is_deleted=1` only after a grace period, and `posthog/temporal/session_replay/delete_recordings/types.py:120` sets `grace_period_days: int = 10`. The schedule at `posthog/temporal/schedule.py:583-585` passes `PurgeDeletedMetadataInput()`, so it runs on that 10-day default.
- **Impact:** The email now states an absolute date — "Its analytics data is permanently erased by <date>", date = send time plus 7 days — for data classes the 7-day job does not touch. Replay metadata is still present at day 7 by design, and replay data can persist far longer when the fire-and-forget start fails. This diff introduces the date, so the wrong promise is new, not pre-existing. The PR's own goal is a date a customer can file under a compliance regime, so a date that is false for one product's data defeats that goal.
- **Note on the fix:** "analytics event data" as suggested is too narrow in the other direction — the 7-day job also clears persons, distinct IDs, groups, and cohort membership. The wording must cover those and exclude session replay and logs.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Change both templates to say “analytics event data.” If the broader claim is required, wait for every deletion workflow and verified store.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed against the current code — this is a real over-claim, and it affects both the project and organization emails. The weekly batch job that erases data after deletion covers the event tables plus person, distinct-id, group, and cohort-membership data, but it deliberately leaves session recordings and logs to their own TTL (see the note above TABLES_TO_DELETE_TEAM_DATA_FROM). So "Its analytics data is permanently erased by <date>" promises a deadline the pipeline doesn't meet for session-replay data.

I'm escalating rather than rewording it myself, because the right copy is a product/legal call, not a mechanical swap:

  1. The obvious swap to "analytics event data" trades one inaccuracy for another — it drops the person, group, and cohort data that the 7-day job does erase, which is exactly the personal data a compliance-driven customer cares about.
  2. Explicitly carving out session recordings means stating their erasure timeline, and that isn't a fixed date — recordings are TTL-based, deleted-recording metadata has a ~10-day grace, and replay can persist longer if the best-effort deletion start fails.
  3. This also overlaps with the separate thread arguing that 7 days isn't a true upper bound. If that holds, the email may drop the specific date entirely, which would change this wording anyway.

What a human needs to decide: whether to (a) scope the dated promise to only the data the weekly job actually erases (events + person/group/cohort) and explicitly say session recordings and logs follow their own retention, or (b) drop the specific date. Whichever is chosen should be applied to both project_deleted.html and organization_deleted.html.

</p>
{% endblock %}{% load posthog_filters %} {% block heading %}Project deleted{% endblock %}
{% endblock %}{% load posthog_filters %} {% block heading %}Deleting your project{% endblock %}
Loading