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..2b6e78ab0c38 100644
--- a/lib/plausible/team_deletion_schedules.ex
+++ b/lib/plausible/team_deletion_schedules.ex
@@ -75,6 +75,59 @@ 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,
+ inner_join: t in assoc(sch, :team),
+ where: sch.status == :scheduled,
+ where: sch.first_notice_due_date <= ^today,
+ preload: [team: {t, [: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,
+ inner_join: t in assoc(sch, :team),
+ where: sch.status == :first_notice_sent,
+ where: sch.deletion_date <= ^reminder_threshold,
+ preload: [team: {t, [: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 +143,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 +219,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..788af7d64179 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,41 @@ 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
+ 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 #{days} 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
+ 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 #{days} 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..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
@@ -1,9 +1,17 @@
-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
new file mode 100644
index 000000000000..a08d2b33795e
--- /dev/null
+++ b/lib/plausible_web/templates/email/deletion_full_notice_email.html.heex
@@ -0,0 +1,20 @@
+<%= 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_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.
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..ad51c4c81e3c
--- /dev/null
+++ b/lib/plausible_web/templates/email/deletion_reminder_email.html.heex
@@ -0,0 +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_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.
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..92ccff8587e8 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,27 +51,20 @@ defmodule Plausible.Workers.AcceptTrafficUntil do
group_by: [u.id, t.id]
)
+ pending_trial_schedules_by_team_id =
+ notifications
+ |> Enum.filter(&(Date.compare(&1.deadline, tomorrow) == :eq))
+ |> 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} ->
- 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()
- |> 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
@@ -78,6 +74,39 @@ defmodule Plausible.Workers.AcceptTrafficUntil do
{: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 =
+ 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
+ |> 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/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..ef565025d3ea 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,151 @@ 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 body =~ PlausibleWeb.EmailView.choose_plan_url(team)
+ assert body =~ ~s|export your stats|
+
+ body = text(body)
+
+ 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"
+
+ 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."
+ 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 body =~ PlausibleWeb.EmailView.choose_plan_url(team)
+ assert body =~ ~s|export your stats|
+
+ body = text(body)
+
+ days = Plausible.Teams.DeletionSchedule.reminder_before_deletion_days()
+
+ assert subject ==
+ "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."
+
+ assert body =~ "This covers a.example.com (and 3 more sites)."
+ assert body =~ "This cannot be undone."
end
end
diff --git a/test/workers/accept_traffic_until_test.exs b/test/workers/accept_traffic_until_test.exs
index 3450ddd00772..e5ce568a5a07 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
@@ -63,6 +65,102 @@ 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: 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])
+
+ 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)
+
+ 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
+
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