Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
143 changes: 108 additions & 35 deletions lib/plausible/team_deletion_schedules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Comment thread
aerosol marked this conversation as resolved.
end

@type transition_result ::
{:ok, TeamDeletionSchedule.t()} | {:error, {:invalid_transition, atom(), atom()}}

Expand All @@ -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
Expand All @@ -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

Expand Down
13 changes: 4 additions & 9 deletions lib/plausible/teams/deletion_schedule.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
41 changes: 38 additions & 3 deletions lib/plausible_web/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -556,19 +556,54 @@ 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")
|> subject("Your stats stop tomorrow")
|> 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
<br /><br />
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.
<br /><br />
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.
<br /><br />
If you'd like to keep counting your stats without tracking your visitors, <a href={plausible_url() <> "?__team=#{@team.identifier}"}>start a Plausible subscription</a>.
<%= if @deletion_date do %>
<br /><br />
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. <br /><br /> If you don't plan to subscribe,
<a href="https://plausible.io/docs/export-stats">export your stats</a>
before then.
<% end %>
<br /><br />
Not sure it's the right fit? Just reply to this email and we'll help you figure it out.
Original file line number Diff line number Diff line change
@@ -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 %>
<br /><br />
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
<strong>{@team.name}</strong>
team on {date_format(@deletion_date)}. This cannot be undone.
<%= if @sites_summary.domains != [] do %>
<br /><br /> This covers {domains_list(@sites_summary.domains, @sites_summary.more_count)}.
<% end %>
<br /><br />
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.
<br /><br />
<a href={choose_plan_url(@team)}>Subscribe</a>
before {date_format(@deletion_date)} to keep your dashboards and historical stats. <br /><br />
If you don't plan to subscribe,
<a href="https://plausible.io/docs/export-stats">export your stats</a>
before then. <br /><br /> If you have any questions, just reply to this email and we'll help.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
This is your final reminder. We'll permanently delete the Plausible dashboards and stats for your
<strong>{@team.name}</strong>
team on {date_format(@deletion_date)}.
<%= if @sites_summary.domains != [] do %>
<br /><br /> This covers {domains_list(@sites_summary.domains, @sites_summary.more_count)}.
<% end %>
<br /><br /> This cannot be undone. <br /><br />
<a href={choose_plan_url(@team)}>Subscribe</a>
before {date_format(@deletion_date)} to keep your dashboards and historical stats. <br /><br />
If you don't plan to subscribe,
<a href="https://plausible.io/docs/export-stats">export your stats</a>
before then. <br /><br /> If you have any questions, just reply to this email and we'll help.
11 changes: 11 additions & 0 deletions lib/plausible_web/views/email_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
Loading
Loading