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