From c0c29e6cfd94e3c9d55550691b12260087ffeba8 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 27 Aug 2026 06:15:38 +0200 Subject: [PATCH 01/10] Add new e-mail templates --- .../deletion_full_notice_email.html.heex | 19 +++++++++++++++++++ .../email/deletion_reminder_email.html.heex | 13 +++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 lib/plausible_web/templates/email/deletion_full_notice_email.html.heex create mode 100644 lib/plausible_web/templates/email/deletion_reminder_email.html.heex diff --git a/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex b/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex new file mode 100644 index 000000000000..5f05d91e7aa2 --- /dev/null +++ b/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex @@ -0,0 +1,19 @@ +<%= if @category == :expired_trial do %> + Your Plausible trial ended a while ago and your account has remained inactive since then. +<% else %> + Your Plausible subscription lapsed a while ago and your account has remained inactive since then. +<% end %> +

+We don't believe analytics companies should keep website analytics data indefinitely after an account becomes inactive. That's why we'll permanently delete the Plausible dashboards and stats for your {@team.name} team on {date_format(@deletion_date)}. This cannot be undone. +<%= if @sites.domains != [] do %> +

+ This covers {site_list(@sites)}. +<% end %> +

+Plausible is still the simple, privacy-first alternative to Google Analytics you signed up for. No cookies. No personal data. One clear dashboard. Independent and EU-hosted. +

+Subscribe before {date_format(@deletion_date)} to keep your dashboards and historical stats. +

+If you don't plan to subscribe, export your stats before then. +

+If you have any questions, just reply to this email and we'll help. diff --git a/lib/plausible_web/templates/email/deletion_reminder_email.html.heex b/lib/plausible_web/templates/email/deletion_reminder_email.html.heex new file mode 100644 index 000000000000..c04c5241c16c --- /dev/null +++ b/lib/plausible_web/templates/email/deletion_reminder_email.html.heex @@ -0,0 +1,13 @@ +This is your final reminder. We'll permanently delete the Plausible dashboards and stats for your {@team.name} team on {date_format(@deletion_date)}. +<%= if @sites.domains != [] do %> +

+ This covers {site_list(@sites)}. +<% end %> +

+This cannot be undone. +

+Subscribe before {date_format(@deletion_date)} to keep your dashboards and historical stats. +

+If you don't plan to subscribe, export your stats before then. +

+If you have any questions, just reply to this email and we'll help. From 4ce906b342fe70bd6ed72a2ec9304c3a6ace6a8a Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 27 Aug 2026 07:30:01 +0200 Subject: [PATCH 02/10] wip --- config/runtime.exs | 4 + lib/plausible/team_deletion_schedules.ex | 141 ++++++++--- lib/plausible/teams/deletion_schedule.ex | 13 +- lib/plausible_web/email.ex | 35 ++- ...approaching_accept_traffic_until.html.heex | 12 +- .../deletion_full_notice_email.html.heex | 19 +- .../email/deletion_reminder_email.html.heex | 23 +- lib/plausible_web/views/email_view.ex | 11 + .../accept_traffic_until_notification.ex | 23 +- lib/workers/send_deletion_notifications.ex | 72 ++++++ .../team_deletion_schedules_test.exs | 228 +++++++++++++++++- .../teams/deletion_schedule_test.exs | 16 +- test/plausible_web/email_test.exs | 139 ++++++++++- test/workers/accept_traffic_until_test.exs | 64 +++++ .../send_deletion_notifications_test.exs | 210 ++++++++++++++++ 15 files changed, 920 insertions(+), 90 deletions(-) create mode 100644 lib/workers/send_deletion_notifications.ex create mode 100644 test/workers/send_deletion_notifications_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index 96ea5a8cf539..e8d2b5ce8263 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -851,6 +851,9 @@ cloud_cron = [ # {"0 7 * * *", Plausible.Workers.ScanInactiveTeams}, # Daily at 8 {"0 8 * * *", Plausible.Workers.AcceptTrafficUntil}, + # Daily at 9, after AcceptTrafficUntil + # TODO: enable + # {"0 9 * * *", Plausible.Workers.SendDeletionNotifications}, # Every Tuesday, 3:00 UTC {"0 3 * * TUE", Plausible.Workers.ClickhouseCleanSites}, # Daily at 5:00 UTC @@ -885,6 +888,7 @@ cloud_queues = [ notify_annual_renewal: 1, lock_sites: 1, scan_inactive_teams: 1, + deletion_notification_emails: 1, legacy_time_on_page_cutoff: 1, purge_cdn_cache: 1, sso_domain_ownership_verification: 32, diff --git a/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex index bed3ad876ae1..081d3009355a 100644 --- a/lib/plausible/team_deletion_schedules.ex +++ b/lib/plausible/team_deletion_schedules.ex @@ -75,6 +75,57 @@ defmodule Plausible.TeamDeletionSchedules do end end + @doc """ + Schedules due for their first notice - still scheduled and + past their first_notice_due_date. + """ + @spec due_for_first_notice(Date.t()) :: [TeamDeletionSchedule.t()] + def due_for_first_notice(today \\ Date.utc_today()) do + Repo.all( + from(sch in TeamDeletionSchedule, + where: sch.status == :scheduled, + where: sch.first_notice_due_date <= ^today, + preload: [team: [:owners, :billing_members]] + ) + ) + end + + @doc """ + Schedules due for the reminder: first notice already sent. + Applies to backlog rows too. + """ + @spec due_for_reminder(Date.t()) :: [TeamDeletionSchedule.t()] + def due_for_reminder(today \\ Date.utc_today()) do + reminder_threshold = Date.add(today, DeletionSchedule.reminder_before_deletion_days()) + + Repo.all( + from(sch in TeamDeletionSchedule, + where: sch.status == :first_notice_sent, + where: sch.deletion_date <= ^reminder_threshold, + preload: [team: [:owners, :billing_members]] + ) + ) + end + + @doc """ + Pending, non-backlog expired trial schedules for the + given team ids, keyed by `team_id` + """ + @spec pending_steady_state_trials_by_team_id([pos_integer()]) :: %{ + pos_integer() => TeamDeletionSchedule.t() + } + def pending_steady_state_trials_by_team_id([]), do: %{} + + def pending_steady_state_trials_by_team_id(team_ids) do + TeamDeletionSchedule + |> where([sch], sch.team_id in ^team_ids) + |> where([sch], sch.category == :expired_trial) + |> where([sch], sch.status == :scheduled) + |> where([sch], sch.is_backlog == false) + |> Repo.all() + |> Map.new(&{&1.team_id, &1}) + end + @type transition_result :: {:ok, TeamDeletionSchedule.t()} | {:error, {:invalid_transition, atom(), atom()}} @@ -90,61 +141,73 @@ defmodule Plausible.TeamDeletionSchedules do @spec transitions() :: %{atom() => [atom()]} def transitions, do: @transitions - @spec mark_first_notice_sent(TeamDeletionSchedule.t(), NaiveDateTime.t()) :: transition_result - def mark_first_notice_sent(schedule, now \\ NaiveDateTime.utc_now(:second)) + @spec mark_first_notice_sent(TeamDeletionSchedule.t(), keyword()) :: transition_result + def mark_first_notice_sent(schedule, opts \\ []) + + def mark_first_notice_sent(%TeamDeletionSchedule{is_backlog: true} = schedule, opts) do + now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second)) - def mark_first_notice_sent(%TeamDeletionSchedule{is_backlog: true} = schedule, now) do - transition(schedule, :first_notice_sent, %{ - first_notice_sent_at: now, - deletion_date: DeletionSchedule.backlog_deletion_date(now) - }) + transition( + schedule, + :first_notice_sent, + %{first_notice_sent_at: now, deletion_date: DeletionSchedule.backlog_deletion_date(now)}, + opts + ) end - def mark_first_notice_sent(%TeamDeletionSchedule{is_backlog: false} = schedule, now) do - transition(schedule, :first_notice_sent, %{first_notice_sent_at: now}) + def mark_first_notice_sent(%TeamDeletionSchedule{is_backlog: false} = schedule, opts) do + now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second)) + transition(schedule, :first_notice_sent, %{first_notice_sent_at: now}, opts) end - @spec mark_reminder_sent(TeamDeletionSchedule.t(), NaiveDateTime.t()) :: transition_result - def mark_reminder_sent(schedule, now \\ NaiveDateTime.utc_now(:second)) do - transition(schedule, :reminder_sent, %{reminder_sent_at: now}) + @spec mark_reminder_sent(TeamDeletionSchedule.t(), keyword()) :: transition_result + def mark_reminder_sent(schedule, opts \\ []) do + now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second)) + transition(schedule, :reminder_sent, %{reminder_sent_at: now}, opts) end - @spec mark_completed(TeamDeletionSchedule.t()) :: transition_result - def mark_completed(schedule) do - transition(schedule, :completed) + @spec mark_completed(TeamDeletionSchedule.t(), keyword()) :: transition_result + def mark_completed(schedule, opts \\ []) do + transition(schedule, :completed, %{}, opts) end - @spec cancel(TeamDeletionSchedule.t()) :: transition_result - def cancel(schedule) do - transition(schedule, :cancelled) + @spec cancel(TeamDeletionSchedule.t(), keyword()) :: transition_result + def cancel(schedule, opts \\ []) do + transition(schedule, :cancelled, %{}, opts) end - @spec snooze(TeamDeletionSchedule.t(), Date.t(), String.t() | nil) :: transition_result - def snooze(schedule, until_date, note \\ nil) do - transition(schedule, :snoozed, %{snoozed_until: until_date, snooze_note: note}) + @spec snooze(TeamDeletionSchedule.t(), Date.t(), keyword()) :: transition_result + def snooze(schedule, until_date, opts \\ []) do + note = Keyword.get(opts, :note) + transition(schedule, :snoozed, %{snoozed_until: until_date, snooze_note: note}, opts) end @doc """ Unsnoozes a schedule once its snooze has lapsed without the team resubscribing. Restarts the notice cycle from scratch """ - @spec unsnooze(TeamDeletionSchedule.t(), Date.t()) :: transition_result - def unsnooze(schedule, today \\ Date.utc_today()) do - transition(schedule, :scheduled, %{ - is_backlog: true, - first_notice_due_date: today, - first_notice_sent_at: nil, - reminder_sent_at: nil, - snoozed_until: nil, - snooze_note: nil - }) + @spec unsnooze(TeamDeletionSchedule.t(), keyword()) :: transition_result + def unsnooze(schedule, opts \\ []) do + today = Keyword.get(opts, :today, Date.utc_today()) + + transition( + schedule, + :scheduled, + %{ + is_backlog: true, + first_notice_due_date: today, + first_notice_sent_at: nil, + reminder_sent_at: nil, + snoozed_until: nil, + snooze_note: nil + }, + opts + ) end @valid_transitions for {from, tos} <- @transitions, to <- tos, do: {from, to} - defp transition(schedule, to, extra_changes \\ %{}) - - defp transition(%TeamDeletionSchedule{status: from} = schedule, to, extra_changes) + defp transition(%TeamDeletionSchedule{status: from} = schedule, to, extra_changes, _opts) when {from, to} in @valid_transitions do updated = schedule @@ -154,7 +217,15 @@ defmodule Plausible.TeamDeletionSchedules do {:ok, updated} end - defp transition(%TeamDeletionSchedule{status: from}, to, _extra_changes) do + defp transition(%TeamDeletionSchedule{status: from, team_id: team_id}, to, _extra_changes, opts) do + report_if_invalid? = Keyword.get(opts, :report_if_invalid?, false) + + if report_if_invalid? do + Sentry.capture_message("Invalid team deletion schedule transition", + extra: %{from: from, to: to, team_id: team_id} + ) + end + {:error, {:invalid_transition, from, to}} end diff --git a/lib/plausible/teams/deletion_schedule.ex b/lib/plausible/teams/deletion_schedule.ex index da129c43f994..0b88c376c680 100644 --- a/lib/plausible/teams/deletion_schedule.ex +++ b/lib/plausible/teams/deletion_schedule.ex @@ -10,16 +10,18 @@ defmodule Plausible.Teams.DeletionSchedule do @reminder_before_deletion_days 5 @backlog_deletion_offset_days 30 - @backlog_reminder_before_deletion_days 5 @backlog_release_window_days 30 + # how many domains to include in notifications + @notification_site_list_limit 3 + def trial_deletion_offset_days, do: @trial_deletion_offset_days def subscription_deletion_offset_days, do: @subscription_deletion_offset_days def first_notice_before_deletion_days, do: @first_notice_before_deletion_days def reminder_before_deletion_days, do: @reminder_before_deletion_days def backlog_deletion_offset_days, do: @backlog_deletion_offset_days - def backlog_reminder_before_deletion_days, do: @backlog_reminder_before_deletion_days def backlog_release_window_days, do: @backlog_release_window_days + def notification_site_list_limit, do: @notification_site_list_limit @spec deletion_offset_days(:expired_trial | :churned_subscription) :: pos_integer() def deletion_offset_days(:expired_trial), do: @trial_deletion_offset_days @@ -44,11 +46,4 @@ defmodule Plausible.Teams.DeletionSchedule do |> NaiveDateTime.to_date() |> Date.add(@backlog_deletion_offset_days) end - - @spec backlog_reminder_due_date(NaiveDateTime.t()) :: Date.t() - def backlog_reminder_due_date(first_notice_sent_at) do - first_notice_sent_at - |> NaiveDateTime.to_date() - |> Date.add(@backlog_deletion_offset_days - @backlog_reminder_before_deletion_days) - end end diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex index 466831ec5154..f23ab78c5292 100644 --- a/lib/plausible_web/email.ex +++ b/lib/plausible_web/email.ex @@ -556,11 +556,12 @@ defmodule PlausibleWeb.Email do |> render("approaching_accept_traffic_until.html", time: "next week", user: %{email: notification.email, name: notification.name}, - team: notification.team + team: notification.team, + deletion_date: nil ) end - def approaching_accept_traffic_until_tomorrow(notification) do + def approaching_accept_traffic_until_tomorrow(notification, deletion_date \\ nil) do base_email() |> to(notification.email) |> tag("drop-traffic-warning-final") @@ -568,7 +569,35 @@ defmodule PlausibleWeb.Email do |> render("approaching_accept_traffic_until.html", time: "tomorrow", user: %{email: notification.email, name: notification.name}, - team: notification.team + team: notification.team, + deletion_date: deletion_date + ) + end + + def deletion_full_notice_email(user, team, schedule, sites_summary) do + base_email() + |> to(user) + |> tag("deletion-full-notice") + |> subject("Your Plausible dashboards and stats will be deleted in 30 days") + |> render("deletion_full_notice_email.html", + user: user, + team: team, + category: schedule.category, + deletion_date: schedule.deletion_date, + sites_summary: sites_summary + ) + end + + def deletion_reminder_email(user, team, schedule, sites_summary) do + base_email() + |> to(user) + |> tag("deletion-reminder") + |> subject("Final notice: your Plausible dashboards and stats will be deleted in 5 days") + |> render("deletion_reminder_email.html", + user: user, + team: team, + deletion_date: schedule.deletion_date, + sites_summary: sites_summary ) end diff --git a/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex b/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex index 1afe2c13a7b9..bd91e3d298d2 100644 --- a/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex +++ b/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex @@ -1,9 +1,15 @@ -Your sites are still sending us data, but your account is no longer active. We'll stop counting your stats {@time}. +Your sites are still sending us data, but your account is no longer active. We'll stop counting new stats {@time}.

-We're writing because we think losing your privacy-first analytics is a bad trade, not just an admin notice. Without Plausible, you're likely back to Google Analytics: cookie banners, complex reports, and your visitors' data going to Google. +We're writing because we think losing your privacy-first analytics is a bad trade, not just an admin notice. Without Plausible, you're likely back to Google Analytics: cookie banners, complex reports and your visitors' data going to Google.

-Plausible is still the same thing it was when you signed up. Lightweight. No cookies. No personal data. One clear dashboard. EU-hosted infrastructure. We're a small independent team and subscriptions are what keeps us running. +Plausible is still the same thing it was when you signed up. Lightweight. No cookies. No personal data. One clear dashboard. EU-hosted infrastructure. We're a small independent team and subscriptions are what keep us running.

If you'd like to keep counting your stats without tracking your visitors, "?__team=#{@team.identifier}"}>start a Plausible subscription. +<%= if @deletion_date do %> +

+ If you don't subscribe, we'll permanently delete your Plausible dashboards and all their stats on {date_format(@deletion_date)}. This cannot be undone. +

+ If you don't plan to subscribe, export your stats before then. +<% end %>

Not sure it's the right fit? Just reply to this email and we'll help you figure it out. diff --git a/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex b/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex index 5f05d91e7aa2..a08d2b33795e 100644 --- a/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex +++ b/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex @@ -4,16 +4,17 @@ Your Plausible subscription lapsed a while ago and your account has remained inactive since then. <% end %>

-We don't believe analytics companies should keep website analytics data indefinitely after an account becomes inactive. That's why we'll permanently delete the Plausible dashboards and stats for your {@team.name} team on {date_format(@deletion_date)}. This cannot be undone. -<%= if @sites.domains != [] do %> -

- This covers {site_list(@sites)}. +We don't believe analytics companies should keep website analytics data indefinitely after an account becomes inactive. That's why we'll permanently delete the Plausible dashboards and stats for your +{@team.name} +team on {date_format(@deletion_date)}. This cannot be undone. +<%= if @sites_summary.domains != [] do %> +

This covers {domains_list(@sites_summary.domains, @sites_summary.more_count)}. <% end %>

Plausible is still the simple, privacy-first alternative to Google Analytics you signed up for. No cookies. No personal data. One clear dashboard. Independent and EU-hosted.

-Subscribe before {date_format(@deletion_date)} to keep your dashboards and historical stats. -

-If you don't plan to subscribe, export your stats before then. -

-If you have any questions, just reply to this email and we'll help. +Subscribe +before {date_format(@deletion_date)} to keep your dashboards and historical stats.

+If you don't plan to subscribe, +export your stats +before then.

If you have any questions, just reply to this email and we'll help. diff --git a/lib/plausible_web/templates/email/deletion_reminder_email.html.heex b/lib/plausible_web/templates/email/deletion_reminder_email.html.heex index c04c5241c16c..ad51c4c81e3c 100644 --- a/lib/plausible_web/templates/email/deletion_reminder_email.html.heex +++ b/lib/plausible_web/templates/email/deletion_reminder_email.html.heex @@ -1,13 +1,12 @@ -This is your final reminder. We'll permanently delete the Plausible dashboards and stats for your {@team.name} team on {date_format(@deletion_date)}. -<%= if @sites.domains != [] do %> -

- This covers {site_list(@sites)}. +This is your final reminder. We'll permanently delete the Plausible dashboards and stats for your +{@team.name} +team on {date_format(@deletion_date)}. +<%= if @sites_summary.domains != [] do %> +

This covers {domains_list(@sites_summary.domains, @sites_summary.more_count)}. <% end %> -

-This cannot be undone. -

-Subscribe before {date_format(@deletion_date)} to keep your dashboards and historical stats. -

-If you don't plan to subscribe, export your stats before then. -

-If you have any questions, just reply to this email and we'll help. +

This cannot be undone.

+Subscribe +before {date_format(@deletion_date)} to keep your dashboards and historical stats.

+If you don't plan to subscribe, +export your stats +before then.

If you have any questions, just reply to this email and we'll help. diff --git a/lib/plausible_web/views/email_view.ex b/lib/plausible_web/views/email_view.ex index 8806a9fec016..d9d41c883061 100644 --- a/lib/plausible_web/views/email_view.ex +++ b/lib/plausible_web/views/email_view.ex @@ -32,6 +32,17 @@ defmodule PlausibleWeb.EmailView do Calendar.strftime(date, "%-d %b %Y") end + def domains_list(domains, 0) do + Enum.join(domains, ", ") + end + + def domains_list(domains, more_count) do + Enum.join(domains, ", ") <> " (and #{more_count} more #{pluralize_site(more_count)})" + end + + defp pluralize_site(1), do: "site" + defp pluralize_site(_), do: "sites" + def sentry_link(trace_id, dsn \\ Sentry.Config.dsn()) do search_query = URI.encode_query(%{query: trace_id}) path = "/organizations/sentry/issues/" diff --git a/lib/workers/accept_traffic_until_notification.ex b/lib/workers/accept_traffic_until_notification.ex index 698e79573679..984f7a6a5e2d 100644 --- a/lib/workers/accept_traffic_until_notification.ex +++ b/lib/workers/accept_traffic_until_notification.ex @@ -5,6 +5,8 @@ defmodule Plausible.Workers.AcceptTrafficUntil do - their sites still receive traffic (i.e. have stats for yesterday) - `site.accept_traffic_until` is approaching either tomorrow or exactly in 7 days + If there's steady state team deletion pending, a notice is included. + Users having no sites or sites that receive no traffic, won't be notified. We make a tiny effort here to make sure we send the same notification at most once a day. """ @@ -13,6 +15,7 @@ defmodule Plausible.Workers.AcceptTrafficUntil do alias Plausible.Repo alias Plausible.ClickhouseRepo + alias Plausible.TeamDeletionSchedules def dry_run(date) do perform(nil, date, true) @@ -48,15 +51,25 @@ defmodule Plausible.Workers.AcceptTrafficUntil do group_by: [u.id, t.id] ) + pending_trial_schedules_by_team_id = + notifications + |> Enum.filter(&(&1.deadline == tomorrow)) + |> Enum.map(& &1.team.id) + |> Enum.uniq() + |> TeamDeletionSchedules.pending_steady_state_trials_by_team_id() + for notification <- notifications do case {has_stats?(notification.site_ids, today), notification.deadline} do {true, ^tomorrow} -> + schedule = Map.get(pending_trial_schedules_by_team_id, notification.team.id) + deletion_date = schedule && schedule.deletion_date + if dry_run? do IO.puts("Will send final notification to #{notification.email}") else notification |> store_sent(today) - |> PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow() + |> PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(deletion_date) |> Plausible.Mailer.send() end @@ -75,6 +88,14 @@ defmodule Plausible.Workers.AcceptTrafficUntil do end end + if not dry_run? do + pending_trial_schedules_by_team_id + |> Map.values() + |> Enum.each(fn schedule -> + TeamDeletionSchedules.mark_first_notice_sent(schedule, report_if_invalid?: true) + end) + end + {:ok, Enum.count(notifications)} end diff --git a/lib/workers/send_deletion_notifications.ex b/lib/workers/send_deletion_notifications.ex new file mode 100644 index 000000000000..574c2e0813aa --- /dev/null +++ b/lib/workers/send_deletion_notifications.ex @@ -0,0 +1,72 @@ +defmodule Plausible.Workers.SendDeletionNotifications do + @moduledoc """ + Sends the deletion pipeline reminder notices + (backlog trials, and all churned subscriptions) - steady state + teams get notified via AcceptTrafficUntil + + Re-checks each team's subscription right before firing, via + `cancel_for_team` - so that just reactivated team, doesn't get notified. + """ + + use Oban.Worker, queue: :deletion_notification_emails, max_attempts: 1 + + alias Plausible.TeamDeletionSchedules + alias Plausible.Teams + alias Plausible.Teams.DeletionSchedule + + @impl Oban.Worker + def perform(_job, today \\ Date.utc_today()) do + # Anchor to `today` + now = NaiveDateTime.new!(today, ~T[00:00:00]) + + send_first_notices(today, now) + send_reminders(today, now) + + :ok + end + + defp send_first_notices(today, now) do + for schedule <- TeamDeletionSchedules.due_for_first_notice(today) do + team = schedule.team + + if TeamDeletionSchedules.cancel_for_team(team) == 0 do + summary = sites_summary(team) + + for recipient <- team.owners ++ team.billing_members do + recipient + |> PlausibleWeb.Email.deletion_full_notice_email(team, schedule, summary) + |> Plausible.Mailer.send() + end + + TeamDeletionSchedules.mark_first_notice_sent(schedule, now: now, report_if_invalid?: true) + end + end + end + + defp send_reminders(today, now) do + for schedule <- TeamDeletionSchedules.due_for_reminder(today) do + team = schedule.team + + if TeamDeletionSchedules.cancel_for_team(team) == 0 do + summary = sites_summary(team) + + for recipient <- team.owners ++ team.billing_members do + recipient + |> PlausibleWeb.Email.deletion_reminder_email(team, schedule, summary) + |> Plausible.Mailer.send() + end + + TeamDeletionSchedules.mark_reminder_sent(schedule, now: now, report_if_invalid?: true) + end + end + end + + @spec sites_summary(Teams.Team.t()) :: %{domains: [String.t()], more_count: non_neg_integer()} + def sites_summary(team) do + limit = DeletionSchedule.notification_site_list_limit() + domains = team |> Teams.owned_sites(limit) |> Enum.map(& &1.domain) + total = Teams.owned_sites_count(team) + + %{domains: domains, more_count: max(total - length(domains), 0)} + end +end diff --git a/test/plausible/team_deletion_schedules_test.exs b/test/plausible/team_deletion_schedules_test.exs index 526d4fa65818..6b16e90c7ba5 100644 --- a/test/plausible/team_deletion_schedules_test.exs +++ b/test/plausible/team_deletion_schedules_test.exs @@ -319,7 +319,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do now = ~N[2026-08-20 10:00:00] - assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now) + assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now: now) assert updated.status == :first_notice_sent assert updated.first_notice_sent_at == now assert updated.deletion_date == ~D[2026-10-19] @@ -335,7 +335,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do now = ~N[2026-08-20 10:00:00] - assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now) + assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now: now) assert updated.status == :first_notice_sent assert updated.first_notice_sent_at == now assert updated.deletion_date == Plausible.Teams.DeletionSchedule.backlog_deletion_date(now) @@ -345,7 +345,9 @@ defmodule Plausible.TeamDeletionSchedulesTest do for status <- TeamDeletionSchedule.statuses() -- [:scheduled] do schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.mark_first_notice_sent(schedule, ~N[2026-08-20 10:00:00]) == + assert TeamDeletionSchedules.mark_first_notice_sent(schedule, + now: ~N[2026-08-20 10:00:00] + ) == {:error, {:invalid_transition, status, :first_notice_sent}} end end @@ -356,7 +358,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do schedule = insert(:team_deletion_schedule, status: :first_notice_sent) now = ~N[2026-08-20 10:00:00] - assert {:ok, updated} = TeamDeletionSchedules.mark_reminder_sent(schedule, now) + assert {:ok, updated} = TeamDeletionSchedules.mark_reminder_sent(schedule, now: now) assert updated.status == :reminder_sent assert updated.reminder_sent_at == now end @@ -365,7 +367,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do for status <- TeamDeletionSchedule.statuses() -- [:first_notice_sent] do schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.mark_reminder_sent(schedule, ~N[2026-08-20 10:00:00]) == + assert TeamDeletionSchedules.mark_reminder_sent(schedule, now: ~N[2026-08-20 10:00:00]) == {:error, {:invalid_transition, status, :reminder_sent}} end end @@ -415,7 +417,9 @@ defmodule Plausible.TeamDeletionSchedulesTest do schedule = insert(:team_deletion_schedule, status: status) assert {:ok, updated} = - TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20], "customer asked for time") + TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20], + note: "customer asked for time" + ) assert updated.status == :snoozed assert updated.snoozed_until == ~D[2026-09-20] @@ -454,7 +458,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do today = ~D[2026-08-20] - assert {:ok, updated} = TeamDeletionSchedules.unsnooze(schedule, today) + assert {:ok, updated} = TeamDeletionSchedules.unsnooze(schedule, today: today) assert updated.status == :scheduled assert updated.is_backlog assert updated.first_notice_due_date == today @@ -468,9 +472,217 @@ defmodule Plausible.TeamDeletionSchedulesTest do for status <- TeamDeletionSchedule.statuses() -- [:snoozed] do schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.unsnooze(schedule, ~D[2026-08-20]) == + assert TeamDeletionSchedules.unsnooze(schedule, today: ~D[2026-08-20]) == {:error, {:invalid_transition, status, :scheduled}} end end end + + describe "report_if_invalid? option" do + setup do + Plausible.Test.Support.Sentry.setup(self()) + :ok + end + + test "does not report to Sentry by default" do + schedule = insert(:team_deletion_schedule, status: :completed) + + assert TeamDeletionSchedules.cancel(schedule) == + {:error, {:invalid_transition, :completed, :cancelled}} + + assert [] = Sentry.Test.pop_sentry_reports() + end + + test "reports an invalid transition to Sentry when set" do + schedule = insert(:team_deletion_schedule, status: :completed) + + assert TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true) == + {:error, {:invalid_transition, :completed, :cancelled}} + + assert [report] = Sentry.Test.pop_sentry_reports() + assert report.message.formatted == "Invalid team deletion schedule transition" + assert report.extra.from == :completed + assert report.extra.to == :cancelled + assert report.extra.team_id == schedule.team_id + end + + test "does not report a successful transition even when set" do + schedule = insert(:team_deletion_schedule, status: :scheduled) + + assert {:ok, _} = TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true) + + assert [] = Sentry.Test.pop_sentry_reports() + end + end + + describe "due_for_first_notice/1" do + test "returns a scheduled row whose first_notice_due_date has arrived" do + owner = new_user() + new_site(owner: owner) + + schedule = + insert(:team_deletion_schedule, + team: team_of(owner), + status: :scheduled, + first_notice_due_date: @today + ) + + [due] = TeamDeletionSchedules.due_for_first_notice(@today) + assert due.id == schedule.id + assert [%Plausible.Auth.User{}] = due.team.owners + end + + test "returns a row whose first_notice_due_date is overdue (missed run catch-up)" do + schedule = + insert(:team_deletion_schedule, + status: :scheduled, + first_notice_due_date: Date.shift(@today, day: -3) + ) + + assert [%{id: id}] = TeamDeletionSchedules.due_for_first_notice(@today) + assert id == schedule.id + end + + test "does not return a row whose first_notice_due_date is still in the future" do + insert(:team_deletion_schedule, status: :scheduled, first_notice_due_date: @today) + + assert TeamDeletionSchedules.due_for_first_notice(Date.shift(@today, day: -1)) == [] + end + + test "does not return a row that already had its first notice sent" do + insert(:team_deletion_schedule, + status: :first_notice_sent, + first_notice_due_date: @today + ) + + assert TeamDeletionSchedules.due_for_first_notice(@today) == [] + end + + test "does not return a currently-snoozed row" do + insert(:team_deletion_schedule, + status: :snoozed, + first_notice_due_date: @today, + snoozed_until: Date.shift(@today, day: 1) + ) + + assert TeamDeletionSchedules.due_for_first_notice(@today) == [] + end + + test "returns a row whose snooze has already lapsed" do + schedule = + insert(:team_deletion_schedule, + status: :scheduled, + first_notice_due_date: @today, + snoozed_until: Date.shift(@today, day: -1) + ) + + assert [%{id: id}] = TeamDeletionSchedules.due_for_first_notice(@today) + assert id == schedule.id + end + end + + describe "due_for_reminder/1" do + test "returns a first_notice_sent row within 5 days of its deletion_date" do + schedule = + insert(:team_deletion_schedule, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 5) + ) + + assert [%{id: id}] = TeamDeletionSchedules.due_for_reminder(@today) + assert id == schedule.id + end + + test "does not return a row whose deletion_date is more than 5 days out" do + insert(:team_deletion_schedule, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 6) + ) + + assert TeamDeletionSchedules.due_for_reminder(@today) == [] + end + + test "does not return a scheduled (not yet first-notified) row" do + insert(:team_deletion_schedule, + status: :scheduled, + deletion_date: Date.shift(@today, day: 5) + ) + + assert TeamDeletionSchedules.due_for_reminder(@today) == [] + end + + test "does not return a currently-snoozed row" do + insert(:team_deletion_schedule, + status: :snoozed, + deletion_date: Date.shift(@today, day: 5), + snoozed_until: Date.shift(@today, day: 1) + ) + + assert TeamDeletionSchedules.due_for_reminder(@today) == [] + end + end + + describe "pending_steady_state_trials_by_team_id/1" do + test "returns a scheduled, non-backlog expired_trial schedule keyed by team_id" do + team = insert(:team) + + schedule = + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :scheduled, + is_backlog: false + ) + + team_id = team.id + + assert %{^team_id => result} = + TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) + + assert result.id == schedule.id + end + + test "excludes a backlog trial schedule" do + team = insert(:team) + + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :scheduled, + is_backlog: true + ) + + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} + end + + test "excludes a churned_subscription schedule" do + team = insert(:team) + + insert(:team_deletion_schedule, + team: team, + category: :churned_subscription, + status: :scheduled, + is_backlog: false + ) + + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} + end + + test "excludes a schedule whose first notice was already sent" do + team = insert(:team) + + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :first_notice_sent, + is_backlog: false + ) + + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} + end + + test "returns an empty map without querying for an empty list of team ids" do + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([]) == %{} + end + end end diff --git a/test/plausible/teams/deletion_schedule_test.exs b/test/plausible/teams/deletion_schedule_test.exs index e51dfa200899..aa69abbce449 100644 --- a/test/plausible/teams/deletion_schedule_test.exs +++ b/test/plausible/teams/deletion_schedule_test.exs @@ -47,18 +47,18 @@ defmodule Plausible.Teams.DeletionScheduleTest do assert Date.diff(delayed, on_time) == 5 end - end - describe "backlog_reminder_due_date/1" do - test "is 25 days after the first notice was sent (5 days before the 30-day backlog deletion)" do + test "reminder_due_date/1 applies uniformly on top of it (no separate backlog variant needed)" do first_notice_sent_at = ~N[2026-08-20 10:00:00] + deletion_date = DeletionSchedule.backlog_deletion_date(first_notice_sent_at) - assert DeletionSchedule.backlog_reminder_due_date(first_notice_sent_at) == ~D[2026-09-14] + assert DeletionSchedule.reminder_due_date(deletion_date) == ~D[2026-09-14] + end + end - assert Date.diff( - DeletionSchedule.backlog_deletion_date(first_notice_sent_at), - DeletionSchedule.backlog_reminder_due_date(first_notice_sent_at) - ) == 5 + describe "notification_site_list_limit/0" do + test "returns the configured cap" do + assert DeletionSchedule.notification_site_list_limit() == 3 end end end diff --git a/test/plausible_web/email_test.exs b/test/plausible_web/email_test.exs index b177440a0088..04b9ef7e6191 100644 --- a/test/plausible_web/email_test.exs +++ b/test/plausible_web/email_test.exs @@ -356,7 +356,7 @@ defmodule PlausibleWeb.EmailTest do assert body =~ "Hey John," assert body =~ - "Your sites are still sending us data, but your account is no longer active. We'll stop counting your stats next week." + "Your sites are still sending us data, but your account is no longer active. We'll stop counting new stats next week." end test "renders final warning" do @@ -379,7 +379,142 @@ defmodule PlausibleWeb.EmailTest do assert body =~ plausible_link(team: team, label: "start a Plausible subscription") assert body =~ - "Your sites are still sending us data, but your account is no longer active. We'll stop counting your stats tomorrow." + "Your sites are still sending us data, but your account is no longer active. We'll stop counting new stats tomorrow." + end + + test "final warning does not mention deletion when no deletion_date is given" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate()) + + notification = %{ + id: user.id, + email: user.email, + deadline: Date.add(Date.utc_today(), 1), + site_ids: [1, 2, 3], + name: user.name, + team: team + } + + %{html_body: body} = + PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(notification) + + refute body =~ "permanently delete" + end + + test "final warning mentions the deletion date when given" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate()) + + notification = %{ + id: user.id, + email: user.email, + deadline: Date.add(Date.utc_today(), 1), + site_ids: [1, 2, 3], + name: user.name, + team: team + } + + deletion_date = ~D[2026-10-19] + + %{html_body: body} = + PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(notification, deletion_date) + + assert body =~ + "If you don't subscribe, we'll permanently delete your Plausible dashboards and all their stats on #{PlausibleWeb.EmailView.date_format(deletion_date)}. This cannot be undone." + + assert body =~ ~s|export your stats| + end + end + + describe "deletion_full_notice_email/4" do + test "renders trial copy for an expired_trial schedule" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate(), name: "My Team") + + schedule = + build(:team_deletion_schedule, category: :expired_trial, deletion_date: ~D[2026-10-19]) + + sites_summary = %{domains: ["a.example.com", "b.example.com"], more_count: 0} + + %{html_body: body, subject: subject} = + PlausibleWeb.Email.deletion_full_notice_email(user, team, schedule, sites_summary) + + assert subject == "Your Plausible dashboards and stats will be deleted in 30 days" + assert body =~ "Your Plausible trial ended a while ago" + refute body =~ "subscription lapsed" + + assert body =~ + "we'll permanently delete the Plausible dashboards and stats for your My Team team on 19 Oct 2026. This cannot be undone." + + assert body =~ "This covers a.example.com, b.example.com." + assert body =~ PlausibleWeb.EmailView.choose_plan_url(team) + assert body =~ ~s|export your stats| + end + + test "renders subscription copy for a churned_subscription schedule" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate()) + + schedule = + build(:team_deletion_schedule, + category: :churned_subscription, + deletion_date: ~D[2026-10-19] + ) + + sites_summary = %{domains: [], more_count: 0} + + %{html_body: body} = + PlausibleWeb.Email.deletion_full_notice_email(user, team, schedule, sites_summary) + + assert body =~ "Your Plausible subscription lapsed a while ago" + refute body =~ "trial ended" + end + + test "caps the listed domains and mentions how many more there are" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate()) + schedule = build(:team_deletion_schedule, deletion_date: ~D[2026-10-19]) + sites_summary = %{domains: ["a.example.com", "b.example.com"], more_count: 7} + + %{html_body: body} = + PlausibleWeb.Email.deletion_full_notice_email(user, team, schedule, sites_summary) + + assert body =~ "This covers a.example.com, b.example.com (and 7 more sites)." + end + + test "omits the site list entirely when there are no domains to show" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate()) + schedule = build(:team_deletion_schedule, deletion_date: ~D[2026-10-19]) + sites_summary = %{domains: [], more_count: 0} + + %{html_body: body} = + PlausibleWeb.Email.deletion_full_notice_email(user, team, schedule, sites_summary) + + refute body =~ "This covers" + end + end + + describe "deletion_reminder_email/4" do + test "renders the deletion date, site list, and subscribe link" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate(), name: "My Team") + schedule = build(:team_deletion_schedule, deletion_date: ~D[2026-10-19]) + sites_summary = %{domains: ["a.example.com"], more_count: 3} + + %{html_body: body, subject: subject} = + PlausibleWeb.Email.deletion_reminder_email(user, team, schedule, sites_summary) + + assert subject == + "Final notice: your Plausible dashboards and stats will be deleted in 5 days" + + assert body =~ + "We'll permanently delete the Plausible dashboards and stats for your My Team team on 19 Oct 2026." + + assert body =~ "This covers a.example.com (and 3 more sites)." + assert body =~ "This cannot be undone." + assert body =~ PlausibleWeb.EmailView.choose_plan_url(team) + assert body =~ ~s|export your stats| end end diff --git a/test/workers/accept_traffic_until_test.exs b/test/workers/accept_traffic_until_test.exs index 3450ddd00772..9ba9397a709f 100644 --- a/test/workers/accept_traffic_until_test.exs +++ b/test/workers/accept_traffic_until_test.exs @@ -63,6 +63,70 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do assert_final_notification(user.email) end + test "tomorrow: augments the email with the deletion date and marks the schedule notified" do + tomorrow = Date.utc_today() |> Date.add(+1) + user = new_user(team: [accept_traffic_until: tomorrow]) + team = team_of(user) + + new_site(owner: user) |> populate_stats([build(:pageview)]) + + schedule = insert(:team_deletion_schedule, team: team, deletion_date: ~D[2026-10-19]) + + {:ok, 1} = AcceptTrafficUntil.perform(nil) + + assert_email_delivered_with( + to: [nil: user.email], + html_body: + ~r/permanently delete your Plausible dashboards and all their stats on 19 Oct 2026/ + ) + + assert Repo.reload!(schedule).status == :first_notice_sent + assert Repo.reload!(schedule).first_notice_sent_at + end + + test "tomorrow: does not augment or mark anything when there is no pending trial schedule" do + tomorrow = Date.utc_today() |> Date.add(+1) + user = new_user(team: [accept_traffic_until: tomorrow]) + + new_site(owner: user) |> populate_stats([build(:pageview)]) + + {:ok, 1} = AcceptTrafficUntil.perform(nil) + + assert_receive {:delivered_email, email} + assert email.to == [nil: user.email] + refute email.html_body =~ "permanently delete" + end + + test "tomorrow: ignores a backlog trial schedule (not yet spread out for sending)" do + tomorrow = Date.utc_today() |> Date.add(+1) + user = new_user(team: [accept_traffic_until: tomorrow]) + team = team_of(user) + + new_site(owner: user) |> populate_stats([build(:pageview)]) + + schedule = insert(:team_deletion_schedule, team: team, is_backlog: true) + + {:ok, 1} = AcceptTrafficUntil.perform(nil) + + assert_receive {:delivered_email, email} + refute email.html_body =~ "permanently delete" + assert Repo.reload!(schedule).status == :scheduled + end + + test "tomorrow: dry_run does not mark the schedule as notified" do + tomorrow = Date.utc_today() |> Date.add(+1) + user = new_user(team: [accept_traffic_until: tomorrow]) + team = team_of(user) + + new_site(owner: user) |> populate_stats([build(:pageview)]) + + schedule = insert(:team_deletion_schedule, team: team) + + {:ok, 1} = AcceptTrafficUntil.dry_run(Date.utc_today()) + + assert Repo.reload!(schedule).status == :scheduled + end + test "next week: sends one e-mail" do next_week = Date.utc_today() |> Date.add(+7) user = new_user(team: [accept_traffic_until: next_week]) diff --git a/test/workers/send_deletion_notifications_test.exs b/test/workers/send_deletion_notifications_test.exs new file mode 100644 index 000000000000..3851ed672a88 --- /dev/null +++ b/test/workers/send_deletion_notifications_test.exs @@ -0,0 +1,210 @@ +defmodule Plausible.Workers.SendDeletionNotificationsTest do + use Plausible.DataCase, async: true + use Bamboo.Test + + require Plausible.Billing.Subscription.Status + + alias Plausible.Billing.Subscription + alias Plausible.Workers.SendDeletionNotifications + + @today ~D[2026-08-20] + + describe "first notices" do + test "sends the full notice email to owners and billing members" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) + + insert(:team_membership, team: team, user: build(:user), role: :billing) + + schedule = + insert(:team_deletion_schedule, + team: team, + category: :churned_subscription, + status: :scheduled, + first_notice_due_date: @today, + deletion_date: ~D[2026-10-19] + ) + + SendDeletionNotifications.perform(nil, @today) + + team = Repo.preload(team, [:owners, :billing_members]) + recipients = team.owners ++ team.billing_members + + assert length(recipients) == 2 + + for recipient <- recipients do + assert_email_delivered_with( + to: [{recipient.name, recipient.email}], + subject: "Your Plausible dashboards and stats will be deleted in 30 days" + ) + end + + assert Repo.reload!(schedule).status == :first_notice_sent + assert Repo.reload!(schedule).first_notice_sent_at + end + + test "finalizes a backlog row's deletion_date anchored to when the notice actually sends" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :scheduled, + is_backlog: true, + first_notice_due_date: @today, + deletion_date: ~D[2024-01-01] + ) + + SendDeletionNotifications.perform(nil, @today) + + updated = Repo.reload!(schedule) + assert updated.status == :first_notice_sent + assert updated.deletion_date == Date.shift(@today, day: 30) + end + + test "does not touch a row whose first_notice_due_date hasn't arrived" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :scheduled, + first_notice_due_date: Date.shift(@today, day: 1) + ) + + SendDeletionNotifications.perform(nil, @today) + + refute_email_delivered_with( + subject: "Your Plausible dashboards and stats will be deleted in 30 days" + ) + + assert Repo.reload!(schedule).status == :scheduled + end + + test "cancels instead of sending when the team has reactivated since the last scan" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :scheduled, + first_notice_due_date: @today + ) + + insert(:subscription, team: team, status: Subscription.Status.active()) + + SendDeletionNotifications.perform(nil, @today) + + refute_email_delivered_with( + subject: "Your Plausible dashboards and stats will be deleted in 30 days" + ) + + assert Repo.reload!(schedule).status == :cancelled + end + end + + describe "reminders" do + test "sends the reminder email and advances status" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 3) + ) + + SendDeletionNotifications.perform(nil, @today) + + assert_email_delivered_with( + to: [{owner.name, owner.email}], + subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" + ) + + updated = Repo.reload!(schedule) + assert updated.status == :reminder_sent + assert updated.reminder_sent_at + end + + test "does not touch a row whose deletion_date is still more than 5 days out" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 6) + ) + + SendDeletionNotifications.perform(nil, @today) + + refute_email_delivered_with( + subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" + ) + + assert Repo.reload!(schedule).status == :first_notice_sent + end + + test "cancels instead of sending when the team has reactivated since the last scan" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 3) + ) + + insert(:subscription, team: team, status: Subscription.Status.active()) + + SendDeletionNotifications.perform(nil, @today) + + refute_email_delivered_with( + subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" + ) + + assert Repo.reload!(schedule).status == :cancelled + end + end + + describe "sites_summary/1" do + test "returns every domain uncapped when the team has few sites" do + owner = new_user() + new_site(owner: owner) + new_site(owner: owner) + team = team_of(owner) + + summary = SendDeletionNotifications.sites_summary(team) + + assert length(summary.domains) == 2 + assert summary.more_count == 0 + end + + test "caps the domain list and reports how many more sites exist" do + owner = new_user() + + for _ <- 1..13, do: new_site(owner: owner) + + team = team_of(owner) + + summary = SendDeletionNotifications.sites_summary(team) + + assert length(summary.domains) == 3 + assert summary.more_count == 10 + end + end +end From be370e8d7cec7fc4b715e53998606f3dcda1f585 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 31 Aug 2026 06:42:44 +0200 Subject: [PATCH 03/10] make e-mail test assertions resistant to formatting --- test/plausible_web/email_test.exs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/plausible_web/email_test.exs b/test/plausible_web/email_test.exs index 04b9ef7e6191..7121889bfedd 100644 --- a/test/plausible_web/email_test.exs +++ b/test/plausible_web/email_test.exs @@ -439,16 +439,19 @@ defmodule PlausibleWeb.EmailTest do %{html_body: body, subject: subject} = PlausibleWeb.Email.deletion_full_notice_email(user, team, schedule, sites_summary) + assert body =~ PlausibleWeb.EmailView.choose_plan_url(team) + assert body =~ ~s|export your stats| + + body = text(body) + assert subject == "Your Plausible dashboards and stats will be deleted in 30 days" assert body =~ "Your Plausible trial ended a while ago" refute body =~ "subscription lapsed" assert body =~ - "we'll permanently delete the Plausible dashboards and stats for your My Team team on 19 Oct 2026. This cannot be undone." + "we'll permanently delete the Plausible dashboards and stats for your My Team team on 19 Oct 2026. This cannot be undone." assert body =~ "This covers a.example.com, b.example.com." - assert body =~ PlausibleWeb.EmailView.choose_plan_url(team) - assert body =~ ~s|export your stats| end test "renders subscription copy for a churned_subscription schedule" do @@ -505,16 +508,19 @@ defmodule PlausibleWeb.EmailTest do %{html_body: body, subject: subject} = PlausibleWeb.Email.deletion_reminder_email(user, team, schedule, sites_summary) + assert body =~ PlausibleWeb.EmailView.choose_plan_url(team) + assert body =~ ~s|export your stats| + + body = text(body) + assert subject == "Final notice: your Plausible dashboards and stats will be deleted in 5 days" assert body =~ - "We'll permanently delete the Plausible dashboards and stats for your My Team team on 19 Oct 2026." + "We'll permanently delete the Plausible dashboards and stats for your My Team team on 19 Oct 2026." assert body =~ "This covers a.example.com (and 3 more sites)." assert body =~ "This cannot be undone." - assert body =~ PlausibleWeb.EmailView.choose_plan_url(team) - assert body =~ ~s|export your stats| end end From f767dfebe2caf29c89483c4e3154bf1e5985166b Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 31 Aug 2026 06:50:28 +0200 Subject: [PATCH 04/10] Interpolate constants in email subjects --- lib/plausible_web/email.ex | 10 ++++++++-- test/plausible_web/email_test.exs | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex index f23ab78c5292..788af7d64179 100644 --- a/lib/plausible_web/email.ex +++ b/lib/plausible_web/email.ex @@ -575,10 +575,12 @@ defmodule PlausibleWeb.Email do end def deletion_full_notice_email(user, team, schedule, sites_summary) do + days = Plausible.Teams.DeletionSchedule.first_notice_before_deletion_days() + base_email() |> to(user) |> tag("deletion-full-notice") - |> subject("Your Plausible dashboards and stats will be deleted in 30 days") + |> subject("Your Plausible dashboards and stats will be deleted in #{days} days") |> render("deletion_full_notice_email.html", user: user, team: team, @@ -589,10 +591,14 @@ defmodule PlausibleWeb.Email do end def deletion_reminder_email(user, team, schedule, sites_summary) do + days = Plausible.Teams.DeletionSchedule.reminder_before_deletion_days() + base_email() |> to(user) |> tag("deletion-reminder") - |> subject("Final notice: your Plausible dashboards and stats will be deleted in 5 days") + |> subject( + "Final notice: your Plausible dashboards and stats will be deleted in #{days} days" + ) |> render("deletion_reminder_email.html", user: user, team: team, diff --git a/test/plausible_web/email_test.exs b/test/plausible_web/email_test.exs index 7121889bfedd..ef565025d3ea 100644 --- a/test/plausible_web/email_test.exs +++ b/test/plausible_web/email_test.exs @@ -444,7 +444,8 @@ defmodule PlausibleWeb.EmailTest do body = text(body) - assert subject == "Your Plausible dashboards and stats will be deleted in 30 days" + days = Plausible.Teams.DeletionSchedule.first_notice_before_deletion_days() + assert subject == "Your Plausible dashboards and stats will be deleted in #{days} days" assert body =~ "Your Plausible trial ended a while ago" refute body =~ "subscription lapsed" @@ -513,8 +514,10 @@ defmodule PlausibleWeb.EmailTest do body = text(body) + days = Plausible.Teams.DeletionSchedule.reminder_before_deletion_days() + assert subject == - "Final notice: your Plausible dashboards and stats will be deleted in 5 days" + "Final notice: your Plausible dashboards and stats will be deleted in #{days} days" assert body =~ "We'll permanently delete the Plausible dashboards and stats for your My Team team on 19 Oct 2026." From 07169f6f3d739ccc35eb1f7ac0df3dd946bd67f3 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 31 Aug 2026 06:58:17 +0200 Subject: [PATCH 05/10] capture IO in dry run --- test/workers/accept_traffic_until_test.exs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/workers/accept_traffic_until_test.exs b/test/workers/accept_traffic_until_test.exs index 9ba9397a709f..e13a11569f2b 100644 --- a/test/workers/accept_traffic_until_test.exs +++ b/test/workers/accept_traffic_until_test.exs @@ -4,6 +4,8 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do alias Plausible.Workers.AcceptTrafficUntil + import ExUnit.CaptureIO + @moduletag :ee_only test "does not send any notifications when sites have no stats" do @@ -122,7 +124,9 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do schedule = insert(:team_deletion_schedule, team: team) - {:ok, 1} = AcceptTrafficUntil.dry_run(Date.utc_today()) + assert capture_io(fn -> + {:ok, 1} = AcceptTrafficUntil.dry_run(Date.utc_today()) + end) == "Will send final notification to #{user.email}\n" assert Repo.reload!(schedule).status == :scheduled end From 8ab38d8e53077509b4f826a14c20a9059248120a Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 31 Aug 2026 07:23:22 +0200 Subject: [PATCH 06/10] Format --- .../email/approaching_accept_traffic_until.html.heex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex b/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex index bd91e3d298d2..a42a8956c29a 100644 --- a/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex +++ b/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex @@ -7,9 +7,11 @@ Plausible is still the same thing it was when you signed up. Lightweight. No coo If you'd like to keep counting your stats without tracking your visitors, "?__team=#{@team.identifier}"}>start a Plausible subscription. <%= if @deletion_date do %>

- If you don't subscribe, we'll permanently delete your Plausible dashboards and all their stats on {date_format(@deletion_date)}. This cannot be undone. -

- If you don't plan to subscribe, export your stats before then. + If you don't subscribe, we'll permanently delete your Plausible dashboards and all their stats on {date_format( + @deletion_date + )}. This cannot be undone.

If you don't plan to subscribe, + export your stats + before then. <% end %>

Not sure it's the right fit? Just reply to this email and we'll help you figure it out. From 024fa9994a239c2544744d7c301cdea1e49180a9 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 3 Sep 2026 07:11:59 +0200 Subject: [PATCH 07/10] Advance deletion schedule before sending email --- .../accept_traffic_until_notification.ex | 66 +++++++++++-------- test/workers/accept_traffic_until_test.exs | 30 +++++++++ 2 files changed, 69 insertions(+), 27 deletions(-) diff --git a/lib/workers/accept_traffic_until_notification.ex b/lib/workers/accept_traffic_until_notification.ex index 984f7a6a5e2d..b47356ab5e55 100644 --- a/lib/workers/accept_traffic_until_notification.ex +++ b/lib/workers/accept_traffic_until_notification.ex @@ -61,44 +61,56 @@ defmodule Plausible.Workers.AcceptTrafficUntil do for notification <- notifications do case {has_stats?(notification.site_ids, today), notification.deadline} do {true, ^tomorrow} -> - schedule = Map.get(pending_trial_schedules_by_team_id, notification.team.id) - deletion_date = schedule && schedule.deletion_date - - if dry_run? do - IO.puts("Will send final notification to #{notification.email}") - else - notification - |> store_sent(today) - |> PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(deletion_date) - |> Plausible.Mailer.send() - end + send_final_notice(notification, pending_trial_schedules_by_team_id, today, dry_run?) {true, ^next_week} -> - if dry_run? do - IO.puts("Will send weekly notification to #{notification.email}") - else - notification - |> store_sent(today) - |> PlausibleWeb.Email.approaching_accept_traffic_until() - |> Plausible.Mailer.send() - end + send_weekly_notice(notification, today, dry_run?) _ -> nil end end - if not dry_run? do - pending_trial_schedules_by_team_id - |> Map.values() - |> Enum.each(fn schedule -> - TeamDeletionSchedules.mark_first_notice_sent(schedule, report_if_invalid?: true) - end) - end - {:ok, Enum.count(notifications)} end + defp send_final_notice(notification, _pending_by_team_id, _today, true = _dry_run?) do + IO.puts("Will send final notification to #{notification.email}") + end + + defp send_final_notice(notification, pending_trial_schedules_by_team_id, today, false) do + deletion_date = + case Map.get(pending_trial_schedules_by_team_id, notification.team.id) do + nil -> + nil + + schedule -> + # Advance the schedule before composing/sending the email, + # so a crash right after this point can never leave it stuck at :scheduled + # while the customer has already been told the deletion date. + case TeamDeletionSchedules.mark_first_notice_sent(schedule, report_if_invalid?: true) do + {:ok, updated} -> updated.deletion_date + {:error, _} -> nil + end + end + + notification + |> store_sent(today) + |> PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(deletion_date) + |> Plausible.Mailer.send() + end + + defp send_weekly_notice(notification, _today, true = _dry_run?) do + IO.puts("Will send weekly notification to #{notification.email}") + end + + defp send_weekly_notice(notification, today, false) do + notification + |> store_sent(today) + |> PlausibleWeb.Email.approaching_accept_traffic_until() + |> Plausible.Mailer.send() + end + defp has_stats?(site_ids, today) do ago_2d = Date.add(today, -2) diff --git a/test/workers/accept_traffic_until_test.exs b/test/workers/accept_traffic_until_test.exs index e13a11569f2b..e5ce568a5a07 100644 --- a/test/workers/accept_traffic_until_test.exs +++ b/test/workers/accept_traffic_until_test.exs @@ -86,6 +86,36 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do assert Repo.reload!(schedule).first_notice_sent_at end + test "tomorrow: marks every pending schedule in a multi-team batch" do + tomorrow = Date.utc_today() |> Date.add(+1) + + users_and_schedules = + for n <- 1..3 do + user = new_user(team: [accept_traffic_until: tomorrow]) + team = team_of(user) + new_site(owner: user) |> populate_stats([build(:pageview)]) + + schedule = + insert(:team_deletion_schedule, team: team, deletion_date: Date.add(tomorrow, n)) + + {user, schedule} + end + + {:ok, 3} = AcceptTrafficUntil.perform(nil) + + for {user, schedule} <- users_and_schedules do + updated = Repo.reload!(schedule) + assert updated.status == :first_notice_sent + assert updated.first_notice_sent_at + + assert_email_delivered_with( + to: [nil: user.email], + html_body: + ~r/permanently delete your Plausible dashboards and all their stats on #{PlausibleWeb.EmailView.date_format(updated.deletion_date)}/ + ) + end + end + test "tomorrow: does not augment or mark anything when there is no pending trial schedule" do tomorrow = Date.utc_today() |> Date.add(+1) user = new_user(team: [accept_traffic_until: tomorrow]) From c2ac0f506a28aa1e3caf44e5b6983d1d9e3a9d9b Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 3 Sep 2026 07:14:11 +0200 Subject: [PATCH 08/10] Use `Date.compare/2` --- lib/workers/accept_traffic_until_notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/workers/accept_traffic_until_notification.ex b/lib/workers/accept_traffic_until_notification.ex index b47356ab5e55..c51c047e59fe 100644 --- a/lib/workers/accept_traffic_until_notification.ex +++ b/lib/workers/accept_traffic_until_notification.ex @@ -53,7 +53,7 @@ defmodule Plausible.Workers.AcceptTrafficUntil do pending_trial_schedules_by_team_id = notifications - |> Enum.filter(&(&1.deadline == tomorrow)) + |> Enum.filter(&(Date.compare(&1.deadline, tomorrow) == :eq)) |> Enum.map(& &1.team.id) |> Enum.uniq() |> TeamDeletionSchedules.pending_steady_state_trials_by_team_id() From d4d6604990d3c55fb5fb2c69a6df17b7fed5006e Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 3 Sep 2026 07:18:29 +0200 Subject: [PATCH 09/10] Preload team using a join --- lib/plausible/team_deletion_schedules.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex index 081d3009355a..2b6e78ab0c38 100644 --- a/lib/plausible/team_deletion_schedules.ex +++ b/lib/plausible/team_deletion_schedules.ex @@ -83,9 +83,10 @@ defmodule Plausible.TeamDeletionSchedules do def due_for_first_notice(today \\ Date.utc_today()) do Repo.all( from(sch in TeamDeletionSchedule, + inner_join: t in assoc(sch, :team), where: sch.status == :scheduled, where: sch.first_notice_due_date <= ^today, - preload: [team: [:owners, :billing_members]] + preload: [team: {t, [:owners, :billing_members]}] ) ) end @@ -100,9 +101,10 @@ defmodule Plausible.TeamDeletionSchedules do Repo.all( from(sch in TeamDeletionSchedule, + inner_join: t in assoc(sch, :team), where: sch.status == :first_notice_sent, where: sch.deletion_date <= ^reminder_threshold, - preload: [team: [:owners, :billing_members]] + preload: [team: {t, [:owners, :billing_members]}] ) ) end From dd375f09f57b3f4679057d52e116cd0cf4af8ef4 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 3 Sep 2026 12:57:14 +0200 Subject: [PATCH 10/10] Update lib/workers/accept_traffic_until_notification.ex Co-authored-by: Adrian Gruntkowski --- .../accept_traffic_until_notification.ex | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/workers/accept_traffic_until_notification.ex b/lib/workers/accept_traffic_until_notification.ex index c51c047e59fe..92ccff8587e8 100644 --- a/lib/workers/accept_traffic_until_notification.ex +++ b/lib/workers/accept_traffic_until_notification.ex @@ -80,18 +80,14 @@ defmodule Plausible.Workers.AcceptTrafficUntil do defp send_final_notice(notification, pending_trial_schedules_by_team_id, today, false) do deletion_date = - case Map.get(pending_trial_schedules_by_team_id, notification.team.id) do - nil -> - nil - - schedule -> - # Advance the schedule before composing/sending the email, - # so a crash right after this point can never leave it stuck at :scheduled - # while the customer has already been told the deletion date. - case TeamDeletionSchedules.mark_first_notice_sent(schedule, report_if_invalid?: true) do - {:ok, updated} -> updated.deletion_date - {:error, _} -> nil - end + if schedule = Map.get(pending_trial_schedules_by_team_id, notification.team.id) do + # Advance the schedule before composing/sending the email, + # so a crash right after this point can never leave it stuck at :scheduled + # while the customer has already been told the deletion date. + case TeamDeletionSchedules.mark_first_notice_sent(schedule, report_if_invalid?: true) do + {:ok, updated} -> updated.deletion_date + {:error, _} -> nil + end end notification