Add and remove recipients for email reports (#28)

* Add and remove recipients for email reports

* Remove unused google_settings controller action

* Background job sends email reports to multiple recipients

* Add a way to unsubscribe for recipients who cannot log in

* Fix view on plausible link

* Include bounce rate in email report
This commit is contained in:
Uku Taht 2020-01-22 11:16:53 +02:00 committed by GitHub
parent 66ef40c91d
commit c96f364ad8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 488 additions and 121 deletions

View File

@ -2,89 +2,98 @@ defmodule Mix.Tasks.SendEmailReports do
use Mix.Task
use Plausible.Repo
require Logger
alias Plausible.Stats
def run(args) do
def run(_args) do
Application.ensure_all_started(:plausible)
execute(args)
execute(Timex.now())
end
@doc"""
The email report should be sent on Monday at 9am according to the timezone
of the site. This job runs every hour to be able to send it with hourly precision.
"""
def execute(_args \\ []) do
send_weekly_emails()
send_monthly_emails()
def execute(job_start) do
send_weekly_emails(job_start)
send_monthly_emails(job_start)
end
defp send_weekly_emails() do
defp send_weekly_emails(job_start) do
sites = Repo.all(
from s in Plausible.Site,
join: wr in Plausible.Site.WeeklyReport, on: wr.site_id == s.id,
left_join: se in "sent_weekly_reports", on: se.site_id == s.id and se.year == fragment("EXTRACT(isoyear from (now() at time zone ?))", s.timezone) and se.week == fragment("EXTRACT(week from (now() at time zone ?))", s.timezone),
left_join: se in "sent_weekly_reports", on: se.site_id == s.id and se.year == fragment("EXTRACT(isoyear from (? at time zone ?))", ^job_start, s.timezone) and se.week == fragment("EXTRACT(week from (? at time zone ?))", ^job_start, s.timezone),
where: is_nil(se), # We haven't sent a report for this site on this week
where: fragment("EXTRACT(dow from (now() at time zone ?))", s.timezone) == 1, # It's monday in the local timezone
where: fragment("EXTRACT(hour from (now() at time zone ?))", s.timezone) >= 9, # It's after 9am
where: fragment("EXTRACT(dow from (? at time zone ?))", ^job_start, s.timezone) == 1, # It's monday in the local timezone
where: fragment("EXTRACT(hour from (? at time zone ?))", ^job_start, s.timezone) >= 9, # It's after 9am
preload: [weekly_report: wr]
)
for site <- sites do
email = site.weekly_report.email
query = Plausible.Stats.Query.from(site.timezone, %{"period" => "7d"})
query = Stats.Query.from(site.timezone, %{"period" => "7d"})
IO.puts("Sending weekly report for #{site.domain} to #{email}")
for email <- site.weekly_report.recipients do
Logger.info("Sending weekly report for #{site.domain} to #{email}")
unsubscribe_link = PlausibleWeb.Endpoint.url() <> "/sites/#{site.domain}/weekly-report/unsubscribe?email=#{email}"
send_report(email, site, "Weekly", unsubscribe_link, query)
end
send_report(email, site, query)
weekly_report_sent(site)
weekly_report_sent(site, job_start)
end
end
defp send_monthly_emails() do
defp send_monthly_emails(job_start) do
sites = Repo.all(
from s in Plausible.Site,
join: mr in Plausible.Site.MonthlyReport, on: mr.site_id == s.id,
left_join: se in "sent_monthly_reports", on: se.site_id == s.id and se.year == fragment("EXTRACT(year from (now() at time zone ?))", s.timezone) and se.month == fragment("EXTRACT(month from (now() at time zone ?))", s.timezone),
left_join: se in "sent_monthly_reports", on: se.site_id == s.id and se.year == fragment("EXTRACT(year from (? at time zone ?))", ^job_start, s.timezone) and se.month == fragment("EXTRACT(month from (? at time zone ?))", ^job_start, s.timezone),
where: is_nil(se), # We haven't sent a report for this site this month
where: fragment("EXTRACT(day from (now() at time zone ?))", s.timezone) == 1, # It's the 1st of the month in the local timezone
where: fragment("EXTRACT(hour from (now() at time zone ?))", s.timezone) >= 9, # It's after 9am
where: fragment("EXTRACT(day from (? at time zone ?))", ^job_start, s.timezone) == 1, # It's the 1st of the month in the local timezone
where: fragment("EXTRACT(hour from (? at time zone ?))", ^job_start, s.timezone) >= 9, # It's after 9am
preload: [monthly_report: mr]
)
for site <- sites do
email = site.monthly_report.email
last_month = Timex.now(site.timezone) |> Timex.shift(months: -1) |> Timex.beginning_of_month |> Timex.format!("{ISOdate}")
query = Plausible.Stats.Query.from(site.timezone, %{"period" => "month", "date" => last_month})
last_month = job_start |> Timex.Timezone.convert(site.timezone) |> Timex.shift(months: -1) |> Timex.beginning_of_month
query = Stats.Query.from(site.timezone, %{"period" => "month", "date" => Timex.format!(last_month, "{ISOdate}")})
IO.puts("Sending monthly report for #{site.domain} to #{email}")
for email <- site.monthly_report.recipients do
Logger.info("Sending monthly report for #{site.domain} to #{email}")
unsubscribe_link = PlausibleWeb.Endpoint.url() <> "/sites/#{site.domain}/monthly-report/unsubscribe?email=#{email}"
send_report(email, site, Timex.format!(last_month, "{Mfull}"), unsubscribe_link, query)
end
send_report(email, site, query)
monthly_report_sent(site)
monthly_report_sent(site, job_start)
end
end
defp send_report(email, site, query) do
{pageviews, unique_visitors} = Plausible.Stats.pageviews_and_visitors(site, query)
{change_pageviews, change_visitors} = Plausible.Stats.compare_pageviews_and_visitors(site, query, {pageviews, unique_visitors})
referrers = Plausible.Stats.top_referrers(site, query)
pages = Plausible.Stats.top_pages(site, query)
settings_link = PlausibleWeb.Endpoint.url() <> "/#{site.domain}/settings#email-reports"
view_link = PlausibleWeb.Endpoint.url() <> "/#{site.domain}?period=7d"
defp send_report(email, site, name, unsubscribe_link, query) do
{pageviews, unique_visitors} = Stats.pageviews_and_visitors(site, query)
{change_pageviews, change_visitors} = Stats.compare_pageviews_and_visitors(site, query, {pageviews, unique_visitors})
bounce_rate = Stats.bounce_rate(site, query)
prev_bounce_rate = Stats.bounce_rate(site, Stats.Query.shift_back(query))
change_bounce_rate = if prev_bounce_rate > 0, do: bounce_rate - prev_bounce_rate
referrers = Stats.top_referrers(site, query)
pages = Stats.top_pages(site, query)
PlausibleWeb.Email.weekly_report(email, site,
unique_visitors: unique_visitors,
change_visitors: change_visitors,
pageviews: pageviews,
change_pageviews: change_pageviews,
bounce_rate: bounce_rate,
change_bounce_rate: change_bounce_rate,
referrers: referrers,
settings_link: settings_link,
view_link: view_link,
unsubscribe_link: unsubscribe_link,
view_link: view_link(site, query),
pages: pages,
query: query
query: query,
name: name
) |> Plausible.Mailer.deliver_now()
end
defp weekly_report_sent(site) do
{year, week} = Timex.now(site.timezone) |> DateTime.to_date |> Timex.iso_week
defp weekly_report_sent(site, time) do
{year, week} = time |> DateTime.to_date |> Timex.iso_week
Repo.insert_all("sent_weekly_reports", [%{
site_id: site.id,
@ -94,8 +103,8 @@ defmodule Mix.Tasks.SendEmailReports do
}])
end
defp monthly_report_sent(site) do
date = Timex.now(site.timezone) |> DateTime.to_date
defp monthly_report_sent(site, time) do
date = DateTime.to_date(time)
Repo.insert_all("sent_monthly_reports", [%{
site_id: site.id,
@ -104,4 +113,13 @@ defmodule Mix.Tasks.SendEmailReports do
timestamp: Timex.now()
}])
end
defp view_link(site, %Plausible.Stats.Query{period: "7d"}) do
PlausibleWeb.Endpoint.url() <> "/#{site.domain}?period=7d"
end
defp view_link(site, %Plausible.Stats.Query{period: "month", date_range: range}) do
month = Timex.format!(range.first, "{ISOdate}")
PlausibleWeb.Endpoint.url() <> "/#{site.domain}?period=month&date=#{month}"
end
end

View File

@ -1,10 +1,9 @@
defmodule Plausible.Site.MonthlyReport do
use Ecto.Schema
import Ecto.Changeset
@mail_regex ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/
schema "monthly_reports" do
field :email, :string
field :recipients, {:array, :string}
belongs_to :site, Plausible.Site
timestamps()
@ -12,9 +11,18 @@ defmodule Plausible.Site.MonthlyReport do
def changeset(settings, attrs \\ %{}) do
settings
|> cast(attrs, [:site_id, :email])
|> validate_required([:site_id, :email])
|> validate_format(:email, @mail_regex)
|> cast(attrs, [:site_id, :recipients])
|> validate_required([:site_id, :recipients])
|> unique_constraint(:site)
end
def add_recipient(report, recipient) do
report
|> change(recipients: report.recipients ++ [recipient])
end
def remove_recipient(report, recipient) do
report
|> change(recipients: List.delete(report.recipients, recipient))
end
end

View File

@ -1,10 +1,9 @@
defmodule Plausible.Site.WeeklyReport do
use Ecto.Schema
import Ecto.Changeset
@mail_regex ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/
schema "weekly_reports" do
field :email, :string
field :recipients, {:array, :string}
belongs_to :site, Plausible.Site
timestamps()
@ -12,9 +11,18 @@ defmodule Plausible.Site.WeeklyReport do
def changeset(settings, attrs \\ %{}) do
settings
|> cast(attrs, [:site_id, :email])
|> validate_required([:site_id, :email])
|> validate_format(:email, @mail_regex)
|> cast(attrs, [:site_id, :recipients])
|> validate_required([:site_id, :recipients])
|> unique_constraint(:site)
end
def add_recipient(report, recipient) do
report
|> change(recipients: report.recipients ++ [recipient])
end
def remove_recipient(report, recipient) do
report
|> change(recipients: List.delete(report.recipients, recipient))
end
end

View File

@ -82,17 +82,15 @@ defmodule PlausibleWeb.SiteController do
end
weekly_report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
weekly_report_changeset = weekly_report && Plausible.Site.WeeklyReport.changeset(weekly_report, %{})
monthly_report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
monthly_report_changeset = monthly_report && Plausible.Site.WeeklyReport.changeset(monthly_report, %{})
goals = Goals.for_site(site.domain)
conn
|> assign(:skip_plausible_tracking, true)
|> render("settings.html",
site: site,
weekly_report_changeset: weekly_report_changeset,
monthly_report_changeset: monthly_report_changeset,
weekly_report: weekly_report,
monthly_report: monthly_report,
search_console_domains: search_console_domains,
goals: goals,
changeset: Plausible.Site.changeset(site, %{})
@ -172,13 +170,13 @@ defmodule PlausibleWeb.SiteController do
Plausible.Site.WeeklyReport.changeset(%Plausible.Site.WeeklyReport{}, %{
site_id: site.id,
email: conn.assigns[:current_user].email
recipients: [conn.assigns[:current_user].email]
})
|> Repo.insert!
conn
|> put_flash(:success, "Success! You will receive an email report every Monday going forward")
|> redirect(to: "/" <> site.domain <> "/settings")
|> redirect(to: "/" <> site.domain <> "/settings#email-reports")
end
def disable_weekly_report(conn, %{"website" => website}) do
@ -187,18 +185,31 @@ defmodule PlausibleWeb.SiteController do
conn
|> put_flash(:success, "Success! You will not receive weekly email reports going forward")
|> redirect(to: "/" <> site.domain <> "/settings")
|> redirect(to: "/" <> site.domain <> "/settings#email-reports")
end
def update_weekly_settings(conn, %{"website" => website, "weekly_report" => weekly_report}) do
def add_weekly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
|> Plausible.Site.WeeklyReport.changeset(weekly_report)
|> Plausible.Site.WeeklyReport.add_recipient(recipient)
|> Repo.update!
conn
|> put_flash(:success, "Email address saved succesfully")
|> redirect(to: "/#{site.domain}/settings")
|> put_flash(:success, "Succesfully added #{recipient} as a recipient for the weekly report")
|> redirect(to: "/#{site.domain}/settings#email-reports")
end
def remove_weekly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
|> Plausible.Site.WeeklyReport.remove_recipient(recipient)
|> Repo.update!
conn
|> put_flash(:success, "Succesfully removed #{recipient} as a recipient for the weekly report")
|> redirect(to: "/#{site.domain}/settings#email-reports")
end
def enable_monthly_report(conn, %{"website" => website}) do
@ -206,13 +217,13 @@ defmodule PlausibleWeb.SiteController do
Plausible.Site.MonthlyReport.changeset(%Plausible.Site.MonthlyReport{}, %{
site_id: site.id,
email: conn.assigns[:current_user].email
recipients: [conn.assigns[:current_user].email]
})
|> Repo.insert!
conn
|> put_flash(:success, "Success! You will receive an email report every month going forward")
|> redirect(to: "/" <> site.domain <> "/settings")
|> redirect(to: "/" <> site.domain <> "/settings#email-reports")
end
def disable_monthly_report(conn, %{"website" => website}) do
@ -221,32 +232,31 @@ defmodule PlausibleWeb.SiteController do
conn
|> put_flash(:success, "Success! You will not receive monthly email reports going forward")
|> redirect(to: "/" <> site.domain <> "/settings")
|> redirect(to: "/" <> site.domain <> "/settings#email-reports")
end
def update_monthly_settings(conn, %{"website" => website, "monthly_report" => monthly_report}) do
def add_monthly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
|> Plausible.Site.WeeklyReport.changeset(monthly_report)
|> Plausible.Site.MonthlyReport.add_recipient(recipient)
|> Repo.update!
conn
|> put_flash(:success, "Email address saved succesfully")
|> redirect(to: "/#{site.domain}/settings")
|> put_flash(:success, "Succesfully added #{recipient} as a recipient for the monthly report")
|> redirect(to: "/#{site.domain}/settings#email-reports")
end
def google_settings(conn, %{"website" => website}) do
def remove_monthly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Repo.preload(:google_auth)
verified_domains = Plausible.Google.Api.fetch_verified_properties(site.google_auth)
Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
|> Plausible.Site.MonthlyReport.remove_recipient(recipient)
|> Repo.update!
render(conn,
"google_settings.html",
site: site,
verified_domains: verified_domains,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
conn
|> put_flash(:success, "Succesfully removed #{recipient} as a recipient for the monthly report")
|> redirect(to: "/#{site.domain}/settings#email-reports")
end
defp insert_site(user_id, params) do

View File

@ -0,0 +1,25 @@
defmodule PlausibleWeb.UnsubscribeController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Site.{WeeklyReport, MonthlyReport}
def weekly_report(conn, %{"website" => website, "email" => email}) do
site = Repo.get_by(Plausible.Site, domain: website)
Repo.get_by(WeeklyReport, site_id: site.id)
|> WeeklyReport.remove_recipient(email)
|> Repo.update!
render(conn, "success.html", interval: "weekly", site: website, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def monthly_report(conn, %{"website" => website, "email" => email}) do
site = Repo.get_by(Plausible.Site, domain: website)
Repo.get_by(MonthlyReport, site_id: site.id)
|> MonthlyReport.remove_recipient(email)
|> Repo.update!
render(conn, "success.html", interval: "monthly", site: website, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
end

View File

@ -91,7 +91,7 @@ defmodule PlausibleWeb.Email do
|> to(email)
|> from("Plausible Insights <info@plausible.io>")
|> tag("weekly-report")
|> subject("Weekly report for #{site.domain}")
|> subject("#{assigns[:name]} report for #{site.domain}")
|> render("weekly_report.html", Keyword.put(assigns, :site, site))
end
end

View File

@ -102,10 +102,16 @@ defmodule PlausibleWeb.Router do
post "/sites/:website/make-private", SiteController, :make_private
post "/sites/:website/weekly-report/enable", SiteController, :enable_weekly_report
post "/sites/:website/weekly-report/disable", SiteController, :disable_weekly_report
put "/sites/:website/weekly-report", SiteController, :update_weekly_settings
post "/sites/:website/weekly-report/recipients", SiteController, :add_weekly_report_recipient
delete "/sites/:website/weekly-report/recipients/:recipient", SiteController, :remove_weekly_report_recipient
post "/sites/:website/monthly-report/enable", SiteController, :enable_monthly_report
post "/sites/:website/monthly-report/disable", SiteController, :disable_monthly_report
put "/sites/:website/monthly-report", SiteController, :update_monthly_settings
post "/sites/:website/monthly-report/recipients", SiteController, :add_monthly_report_recipient
delete "/sites/:website/monthly-report/recipients/:recipient", SiteController, :remove_monthly_report_recipient
get "/sites/:website/weekly-report/unsubscribe", UnsubscribeController, :weekly_report
get "/sites/:website/monthly-report/unsubscribe", UnsubscribeController, :monthly_report
get "/:website/snippet", SiteController, :add_snippet
get "/:website/settings", SiteController, :settings
get "/:website/goals", SiteController, :goals

View File

@ -149,7 +149,7 @@ body {
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 0px; font-family: Arial, sans-serif"><![endif]-->
<div style="color:#3d4852;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:0px;padding-left:10px;">
<div style="font-size: 12px; line-height: 14px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; color: #3d4852;">
<p style="font-size: 14px; line-height: 19px; text-align: right; margin: 0;"><span style="font-size: 16px;"><span style="line-height: 19px; font-size: 16px;"><strong><span style="line-height: 19px; font-size: 16px;">Weekly report for <%= @site.domain %></span></strong></span></span></p>
<p style="font-size: 14px; line-height: 19px; text-align: right; margin: 0;"><span style="font-size: 16px;"><span style="line-height: 19px; font-size: 16px;"><strong><span style="line-height: 19px; font-size: 16px;"><%= @name %> report for <%= @site.domain %></span></strong></span></span></p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
@ -299,14 +299,43 @@ body {
<!--[if (!mso)&(!IE)]><!-->
<div style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;">
<!--<![endif]-->
<div></div>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 5px; padding-left: 5px; padding-top: 5px; padding-bottom: 5px; font-family: Arial, sans-serif"><![endif]-->
<div style="color:#8795a1;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;line-height:120%;padding-top:5px;padding-right:5px;padding-bottom:5px;padding-left:5px;">
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 14px; text-align: left; color: #8795a1;"><span style="font-size: 12px; line-height: 14px;"><strong>BOUNCE RATE</strong></span></div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 5px; padding-left: 5px; padding-top: 0px; padding-bottom: 5px; font-family: Arial, sans-serif"><![endif]-->
<div style="color:#555555;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;line-height:120%;padding-top:0px;padding-right:5px;padding-bottom:5px;padding-left:5px;">
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 14px; color: #555555;">
<p style="font-size: 12px; line-height: 14px; text-align: left; margin: 0;"><strong><span style="font-size: 20px; line-height: 24px;"><%= @bounce_rate %>%</span></strong></p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 5px; padding-left: 5px; padding-top: 0px; padding-bottom: 5px; font-family: Arial, sans-serif"><![endif]-->
<div style="color:#1f9d55;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;line-height:120%;padding-top:0px;padding-right:5px;padding-bottom:5px;padding-left:5px;">
<%= cond do %>
<% @change_bounce_rate == nil -> %>
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 14px; color: #8795a1;">
<p style="font-size: 12px; line-height: 14px; text-align: left; margin: 0;"><strong>N/A</strong></p>
</div>
<% @change_bounce_rate <= 0 -> %>
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 14px; color: #1f9d55;">
<p style="font-size: 12px; line-height: 14px; text-align: left; margin: 0;"><strong><%= @change_bounce_rate %>%</strong></p>
</div>
<% @change_bounce_rate > 0 -> %>
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 14px; color: #ff4457;">
<p style="font-size: 12px; line-height: 14px; text-align: left; margin: 0;"><strong>+<%= @change_bounce_rate %>%</strong></p>
</div>
<% end %>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
<!--[if (mso)|(IE)]></td><td align="center" width="160" style="background-color:transparent;width:160px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]-->
</div>
</div>
</div>
@ -580,7 +609,7 @@ body {
<!--<![endif]-->
<div align="center" class="button-container" style="padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding-top: 10px; padding-right: 10px; padding-bottom: 10px; padding-left: 10px" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://plausible.io/plausible.io" style="height:31.5pt; width:189.75pt; v-text-anchor:middle;" arcsize="10%" stroke="false" fillcolor="#5661b3"><w:anchorlock/><v:textbox inset="0,0,0,0"><center style="color:#ffffff; font-family:Arial, sans-serif; font-size:16px"><![endif]--><a href="<%= @view_link %>" style="-webkit-text-size-adjust: none; text-decoration: none; display: inline-block; color: #ffffff; background-color: #5661b3; border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; width: auto; width: auto; border-top: 1px solid #5661b3; border-right: 1px solid #5661b3; border-bottom: 1px solid #5661b3; border-left: 1px solid #5661b3; padding-top: 5px; padding-bottom: 5px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; text-align: center; mso-border-alt: none; word-break: keep-all;" target="_blank"><span style="padding-left:20px;padding-right:20px;font-size:16px;display:inline-block;">
<span style="font-size: 16px; line-height: 32px;">View last 7 days on Plausible</span>
<span style="font-size: 16px; line-height: 32px;">View on Plausible</span>
</span></a>
<!--[if mso]></center></v:textbox></v:roundrect></td></tr></table><![endif]-->
</div>
@ -642,7 +671,7 @@ body {
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]-->
<div style="color:#3d4852;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 14px; color: #3d4852;">
<p style="font-size: 14px; line-height: 14px; margin: 0;"><span style="font-size: 12px;">You are receiving this email because you have enabled weekly email reports for <%= link(@site.domain, rel: "nofollow", style: "text-decoration: none; color: #3d4852", to: "#") %>. <a href="<%= @settings_link %>" rel="noopener" style="text-decoration: underline; color: #0068A5;" target="_blank">Click here </a>to manage your notification settings.</span></p>
<p style="font-size: 14px; line-height: 14px; margin: 0;"><span style="font-size: 12px;">Don't want to receive these emails? <a href="<%= @unsubscribe_link %>" rel="noopener" style="text-decoration: underline; color: #0068A5;" target="_blank">Click here</a> to unsubscribe.</span></p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->

View File

@ -23,5 +23,7 @@
<symbol id="feather-maximize" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></symbol>
<symbol id="feather-download" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></symbol>
<symbol id="feather-mail" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-mail"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,16 +0,0 @@
<%= form_for @conn, "/google", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<h2>Connect to property</h2>
<p class="text-grey-dark mt-4">Select the Google Search Console property you would like to pull keyword data from</p>
<div class="my-6">
<%= label f, :domain, class: "block text-grey-darker text-sm font-bold mb-2" %>
<div class="inline-block relative w-full">
<%= select f, :domain, @verified_domains, class: "block appearance-none w-full bg-grey-lighter text-grey-darker cursor-pointer hover:border-grey p-2 pr-8 rounded leading-normal focus:outline-none" %>
<div class="pointer-events-none absolute pin-y pin-r flex items-center px-2 text-red">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/></svg>
</div>
</div>
</div>
<%= submit "Enable Integration →", class: "button mt-4 w-full" %>
<% end %>

View File

@ -115,14 +115,12 @@
</div>
<div class="max-w-md mx-auto bg-white shadow-md rounded rounded-t-none border-t-2 border-indigo-lightest px-8 pt-6 pb-8 mt-10" id="email-reports">
<div>
<h2>Email reports</h2>
</div>
<h2>Email reports</h2>
<div class="my-4 border-b border-grey-light"></div>
<div class="my-8 flex items-center">
<%= if @weekly_report_changeset do %>
<%= if @weekly_report do %>
<%= button(to: "/sites/#{@site.domain}/weekly-report/disable", method: :post, class: "border rounded-full border-grey flex items-center cursor-pointer w-8 bg-green justify-end") do %>
<span class="rounded-full border w-4 h-4 border-grey shadow-inner bg-white shadow"></span>
<% end %>
@ -131,22 +129,28 @@
<span class="rounded-full border w-4 h-4 border-grey shadow-inner bg-white shadow"></span>
<% end %>
<% end %>
<span class="ml-2">Receive a weekly email report every Monday</span>
<span class="ml-2">Send a weekly email report every Monday</span>
</div>
<%= if @weekly_report_changeset do %>
<%= if @weekly_report do %>
<div class="text-sm text-grey-darker mt-6">
<%= form_for @weekly_report_changeset, "/sites/#{@site.domain}/weekly-report", [class: "max-w-xs"], fn f -> %>
<%= label f, :email, "Email address", class: "block text-grey-darker text-sm font-bold mb-2" %>
<div class="flex">
<%= email_input f, :email, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:border-grey-light", style: "flex-grow: 2" %>
<%= submit "Update", class: "button rounded-l-none" %>
<h4 class="my-2">Weekly report recipients</h4>
<%= for recipient <- @weekly_report.recipients do %>
<div class="p-3 flex justify-between bg-grey-lighter rounded my-2 max-w-sm">
<span><svg class="feather mr-1" style="transform: translateY(0.05em)"><use xlink:href="#feather-mail" /></svg><%= recipient %></span>
<%= button("", to: "/sites/#{@site.domain}/weekly-report/recipients/#{recipient}", method: :delete) %>
</div>
<% end %>
<%= form_for @conn, "/sites/#{@site.domain}/weekly-report/recipients", fn f -> %>
<div class="flex justify-between my-2 max-w-sm">
<%= email_input f, :recipient, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:border-grey-light", style: "flex-grow: 2", placeholder: "recipient@example.com" %>
<%= submit "Add recipient", class: "button rounded-l-none whitespace-no-wrap" %>
</div>
<% end %>
</div>
<% end %>
<div class="my-4 border-b border-grey-light"></div>
<div class="my-8 border-b border-grey-light"></div>
<div class="my-8 flex items-center">
<%= if @monthly_report_changeset do %>
<%= if @monthly_report do %>
<%= button(to: "/sites/#{@site.domain}/monthly-report/disable", method: :post, class: "border rounded-full border-grey flex items-center cursor-pointer w-8 bg-green justify-end") do %>
<span class="rounded-full border w-4 h-4 border-grey shadow-inner bg-white shadow"></span>
<% end %>
@ -155,15 +159,21 @@
<span class="rounded-full border w-4 h-4 border-grey shadow-inner bg-white shadow"></span>
<% end %>
<% end %>
<span class="ml-2">Receive a monthly email report on 1st of the month</span>
<span class="ml-2">Send a monthly email report on 1st of the month</span>
</div>
<%= if @monthly_report_changeset do %>
<%= if @monthly_report do %>
<div class="text-sm text-grey-darker mt-6">
<%= form_for @monthly_report_changeset, "/sites/#{@site.domain}/monthly-report", [class: "max-w-xs"], fn f -> %>
<%= label f, :email, "Email address", class: "block text-grey-darker text-sm font-bold mb-2" %>
<div class="flex">
<%= email_input f, :email, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:border-grey-light", style: "flex-grow: 2" %>
<%= submit "Update", class: "button rounded-l-none" %>
<h4 class="my-2">Monthly report recipients</h4>
<%= for recipient <- @monthly_report.recipients do %>
<div class="p-3 flex justify-between bg-grey-lighter rounded my-2 max-w-sm">
<span><svg class="feather mr-1" style="transform: translateY(0.05em)"><use xlink:href="#feather-mail" /></svg><%= recipient %></span>
<%= button("", to: "/sites/#{@site.domain}/monthly-report/recipients/#{recipient}", method: :delete) %>
</div>
<% end %>
<%= form_for @conn, "/sites/#{@site.domain}/monthly-report/recipients", fn f -> %>
<div class="flex justify-between my-2 max-w-sm">
<%= email_input f, :recipient, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:border-grey-light", style: "flex-grow: 2", placeholder: "recipient@example.com" %>
<%= submit "Add recipient", class: "button rounded-l-none whitespace-no-wrap" %>
</div>
<% end %>
</div>

View File

@ -0,0 +1,4 @@
<div class="w-full max-w-sm mx-auto bg-white shadow-md rounded px-8 py-6 mt-8"]>
<h2>Unsubscribe successful</h2>
<p class="mt-4">You will no longer receive a <%= @interval %> analytics report for <%= @site %></p>
</div>

View File

@ -0,0 +1,3 @@
defmodule PlausibleWeb.UnsubscribeView do
use PlausibleWeb, :view
end

View File

@ -0,0 +1,28 @@
defmodule Plausible.Repo.Migrations.AddRecipients do
use Ecto.Migration
def up do
alter table(:weekly_reports) do
add :recipients, {:array, :citext}, null: false, default: []
end
execute "UPDATE weekly_reports SET recipients = array_append(recipients, email)"
alter table(:weekly_reports) do
remove :email
end
alter table(:monthly_reports) do
add :recipients, {:array, :citext}, null: false, default: []
end
execute "UPDATE monthly_reports SET recipients = array_append(recipients, email)"
alter table(:monthly_reports) do
remove :email
end
end
def down do
end
end

View File

@ -0,0 +1,95 @@
defmodule Mix.Tasks.EmailReportsTest do
use Plausible.DataCase
use Bamboo.Test
alias Mix.Tasks.SendEmailReports
describe "weekly reports" do
test "sends weekly report on Monday 9am local timezone" do
site = insert(:site, timezone: "US/Eastern")
insert(:weekly_report, site: site, recipients: ["user@email.com"])
time = Timex.now() |> Timex.beginning_of_week |> Timex.shift(hours: 14) # 2pm UTC is 10am EST
SendEmailReports.execute(time)
assert_email_delivered_with(subject: "Weekly report for #{site.domain}", to: [nil: "user@email.com"])
end
test "does not send a report on Monday before 9am in local timezone" do
site = insert(:site, timezone: "US/Eastern")
insert(:weekly_report, site: site, recipients: ["user@email.com"])
time = Timex.now() |> Timex.beginning_of_week |> Timex.shift(hours: 12) # 12pm UTC is 8am EST
SendEmailReports.execute(time)
assert_no_emails_delivered()
end
test "does not send a report on Tuesday" do
site = insert(:site)
insert(:weekly_report, site: site, recipients: ["user@email.com"])
time = Timex.now() |> Timex.beginning_of_week |> Timex.shift(days: 1, hours: 10)
SendEmailReports.execute(time)
assert_no_emails_delivered()
end
test "does not send the same report multiple times on the same week" do
site = insert(:site)
insert(:weekly_report, site: site, recipients: ["user@email.com"])
time = Timex.now() |> Timex.beginning_of_week |> Timex.shift(hours: 10)
SendEmailReports.execute(time)
assert_email_delivered_with(subject: "Weekly report for #{site.domain}", to: [nil: "user@email.com"])
SendEmailReports.execute(time)
assert_no_emails_delivered()
end
end
describe "monthly_reports" do
test "sends monthly report on the 1st of the month after 9am local timezone" do
site = insert(:site, timezone: "US/Eastern")
insert(:monthly_report, site: site, recipients: ["user@email.com"])
{:ok, time, _} = DateTime.from_iso8601("2019-04-01T14:00:00Z")
SendEmailReports.execute(time)
assert_email_delivered_with(subject: "March report for #{site.domain}", to: [nil: "user@email.com"])
end
test "does not send a report on the 1st of the month before 9am in local timezone" do
site = insert(:site, timezone: "US/Eastern")
insert(:monthly_report, site: site, recipients: ["user@email.com"])
time = Timex.now() |> Timex.beginning_of_month |> Timex.shift(hours: 12) # 12pm UTC is 8am EST
SendEmailReports.execute(time)
assert_no_emails_delivered()
end
test "does not send a report on the 2nd of the month" do
site = insert(:site)
insert(:monthly_report, site: site, recipients: ["user@email.com"])
time = Timex.now() |> Timex.beginning_of_month |> Timex.shift(days: 1, hours: 10)
SendEmailReports.execute(time)
assert_no_emails_delivered()
end
test "does not send the same report multiple times on the same month" do
site = insert(:site)
insert(:monthly_report, site: site, recipients: ["user@email.com"])
{:ok, time, _} = DateTime.from_iso8601("2019-02-01T11:00:00Z")
SendEmailReports.execute(time)
assert_email_delivered_with(subject: "January report for #{site.domain}", to: [nil: "user@email.com"])
SendEmailReports.execute(time)
assert_no_emails_delivered()
end
end
end

View File

@ -187,4 +187,102 @@ defmodule PlausibleWeb.SiteControllerTest do
assert Repo.aggregate(Plausible.Goal, :count, :id) == 0
end
end
describe "POST /sites/:website/weekly-report/enable" do
setup [:create_user, :log_in, :create_site]
test "creates a weekly report record with the user email", %{conn: conn, site: site, user: user} do
post(conn, "/sites/#{site.domain}/weekly-report/enable")
report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
assert report.recipients == [user.email]
end
end
describe "POST /sites/:website/weekly-report/disable" do
setup [:create_user, :log_in, :create_site]
test "deletes the weekly report record", %{conn: conn, site: site} do
insert(:weekly_report, site: site)
post(conn, "/sites/#{site.domain}/weekly-report/disable")
refute Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
end
end
describe "POST /sites/:website/weekly-report/recipients" do
setup [:create_user, :log_in, :create_site]
test "adds a recipient to the weekly report", %{conn: conn, site: site} do
insert(:weekly_report, site: site)
post(conn, "/sites/#{site.domain}/weekly-report/recipients", recipient: "user@email.com")
report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
assert report.recipients == ["user@email.com"]
end
end
describe "DELETE /sites/:website/weekly-report/recipients/:recipient" do
setup [:create_user, :log_in, :create_site]
test "removes a recipient from the weekly report", %{conn: conn, site: site} do
insert(:weekly_report, site: site, recipients: ["recipient@email.com"])
delete(conn, "/sites/#{site.domain}/weekly-report/recipients/recipient@email.com")
report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
assert report.recipients == []
end
end
describe "POST /sites/:website/monthly-report/enable" do
setup [:create_user, :log_in, :create_site]
test "creates a monthly report record with the user email", %{conn: conn, site: site, user: user} do
post(conn, "/sites/#{site.domain}/monthly-report/enable")
report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
assert report.recipients == [user.email]
end
end
describe "POST /sites/:website/monthly-report/disable" do
setup [:create_user, :log_in, :create_site]
test "deletes the monthly report record", %{conn: conn, site: site} do
insert(:monthly_report, site: site)
post(conn, "/sites/#{site.domain}/monthly-report/disable")
refute Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
end
end
describe "POST /sites/:website/monthly-report/recipients" do
setup [:create_user, :log_in, :create_site]
test "adds a recipient to the monthly report", %{conn: conn, site: site} do
insert(:monthly_report, site: site)
post(conn, "/sites/#{site.domain}/monthly-report/recipients", recipient: "user@email.com")
report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
assert report.recipients == ["user@email.com"]
end
end
describe "DELETE /sites/:website/monthly-report/recipients/:recipient" do
setup [:create_user, :log_in, :create_site]
test "removes a recipient from the monthly report", %{conn: conn, site: site} do
insert(:monthly_report, site: site, recipients: ["recipient@email.com"])
delete(conn, "/sites/#{site.domain}/monthly-report/recipients/recipient@email.com")
report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
assert report.recipients == []
end
end
end

View File

@ -0,0 +1,32 @@
defmodule PlausibleWeb.UnsubscribeControllerTest do
use PlausibleWeb.ConnCase
use Plausible.Repo
describe "GET /sites/:website/weekly-report/unsubscribe" do
test "removes a recipient from the weekly report without them having to log in", %{conn: conn} do
site = insert(:site)
insert(:weekly_report, site: site, recipients: ["recipient@email.com"])
conn = get(conn, "/sites/#{site.domain}/weekly-report/unsubscribe?email=recipient@email.com")
assert html_response(conn, 200) =~ "Unsubscribe successful"
report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
assert report.recipients == []
end
end
describe "GET /sites/:website/monthly-report/unsubscribe" do
test "removes a recipient from the weekly report without them having to log in", %{conn: conn} do
site = insert(:site)
insert(:monthly_report, site: site, recipients: ["recipient@email.com"])
conn = get(conn, "/sites/#{site.domain}/monthly-report/unsubscribe?email=recipient@email.com")
assert html_response(conn, 200) =~ "Unsubscribe successful"
report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
assert report.recipients == []
end
end
end

View File

@ -50,8 +50,7 @@ defmodule Plausible.Factory do
%Plausible.Event{
hostname: hostname,
pathname: "/",
new_visitor: true,
user_id: UUID.uuid4(),
new_visitor: true, user_id: UUID.uuid4(),
}
end
@ -90,4 +89,12 @@ defmodule Plausible.Factory do
created: Timex.now()
}
end
def weekly_report_factory do
%Plausible.Site.WeeklyReport{}
end
def monthly_report_factory do
%Plausible.Site.MonthlyReport{}
end
end