Merge branch 'feature/digest-email' into 'develop'

Feature/digest email

See merge request pleroma/pleroma!1078
This commit is contained in:
lain 2019-08-08 14:38:33 +00:00
commit 29807ef6a5
33 changed files with 658 additions and 17 deletions

View file

@ -162,7 +162,9 @@ defmodule Pleroma.Application do
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Pleroma.Supervisor]
Supervisor.start_link(children, opts)
result = Supervisor.start_link(children, opts)
:ok = after_supervisor_start()
result
end
defp setup_instrumenters do
@ -227,4 +229,17 @@ defmodule Pleroma.Application do
:hackney_pool.child_spec(pool, options)
end
end
defp after_supervisor_start do
with digest_config <- Application.get_env(:pleroma, :email_notifications)[:digest],
true <- digest_config[:active] do
PleromaJobQueue.schedule(
digest_config[:schedule],
:digest_emails,
Pleroma.DigestEmailWorker
)
end
:ok
end
end

View file

@ -0,0 +1,35 @@
defmodule Pleroma.DigestEmailWorker do
import Ecto.Query
@queue_name :digest_emails
def perform do
config = Pleroma.Config.get([:email_notifications, :digest])
negative_interval = -Map.fetch!(config, :interval)
inactivity_threshold = Map.fetch!(config, :inactivity_threshold)
inactive_users_query = Pleroma.User.list_inactive_users_query(inactivity_threshold)
now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
from(u in inactive_users_query,
where: fragment(~s(? #> '{"email_notifications","digest"}' @> 'true'), u.info),
where: u.last_digest_emailed_at < datetime_add(^now, ^negative_interval, "day"),
select: u
)
|> Pleroma.Repo.all()
|> Enum.each(&PleromaJobQueue.enqueue(@queue_name, __MODULE__, [&1]))
end
@doc """
Send digest email to the given user.
Updates `last_digest_emailed_at` field for the user and returns the updated user.
"""
@spec perform(Pleroma.User.t()) :: Pleroma.User.t()
def perform(user) do
with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(user) do
Pleroma.Emails.Mailer.deliver_async(email)
end
Pleroma.User.touch_last_digest_emailed_at(user)
end
end

View file

@ -5,7 +5,7 @@
defmodule Pleroma.Emails.UserEmail do
@moduledoc "User emails"
import Swoosh.Email
use Phoenix.Swoosh, view: Pleroma.Web.EmailView, layout: {Pleroma.Web.LayoutView, :email}
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Router
@ -87,4 +87,73 @@ defmodule Pleroma.Emails.UserEmail do
|> subject("#{instance_name()} account confirmation")
|> html_body(html_body)
end
@doc """
Email used in digest email notifications
Includes Mentions and New Followers data
If there are no mentions (even when new followers exist), the function will return nil
"""
@spec digest_email(Pleroma.User.t()) :: Swoosh.Email.t() | nil
def digest_email(user) do
new_notifications =
Pleroma.Notification.for_user_since(user, user.last_digest_emailed_at)
|> Enum.reduce(%{followers: [], mentions: []}, fn
%{activity: %{data: %{"type" => "Create"}, actor: actor} = activity} = notification,
acc ->
new_mention = %{
data: notification,
object: Pleroma.Object.normalize(activity),
from: Pleroma.User.get_by_ap_id(actor)
}
%{acc | mentions: [new_mention | acc.mentions]}
%{activity: %{data: %{"type" => "Follow"}, actor: actor} = activity} = notification,
acc ->
new_follower = %{
data: notification,
object: Pleroma.Object.normalize(activity),
from: Pleroma.User.get_by_ap_id(actor)
}
%{acc | followers: [new_follower | acc.followers]}
_, acc ->
acc
end)
with [_ | _] = mentions <- new_notifications.mentions do
html_data = %{
instance: instance_name(),
user: user,
mentions: mentions,
followers: new_notifications.followers,
unsubscribe_link: unsubscribe_url(user, "digest")
}
new()
|> to(recipient(user))
|> from(sender())
|> subject("Your digest from #{instance_name()}")
|> render_body("digest.html", html_data)
else
_ ->
nil
end
end
@doc """
Generate unsubscribe link for given user and notifications type.
The link contains JWT token with the data, and subscription can be modified without
authorization.
"""
@spec unsubscribe_url(Pleroma.User.t(), String.t()) :: String.t()
def unsubscribe_url(user, notifications_type) do
token =
%{"sub" => user.id, "act" => %{"unsubscribe" => notifications_type}, "exp" => false}
|> Pleroma.JWT.generate_and_sign!()
|> Base.encode64()
Router.Helpers.subscription_url(Pleroma.Web.Endpoint, :unsubscribe, token)
end
end

9
lib/pleroma/jwt.ex Normal file
View file

@ -0,0 +1,9 @@
defmodule Pleroma.JWT do
use Joken.Config
@impl true
def token_config do
default_claims(skip: [:aud])
|> add_claim("aud", &Pleroma.Web.Endpoint.url/0, &(&1 == Pleroma.Web.Endpoint.url()))
end
end

View file

@ -18,6 +18,8 @@ defmodule Pleroma.Notification do
import Ecto.Query
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "notifications" do
field(:seen, :boolean, default: false)
belongs_to(:user, User, type: Pleroma.FlakeId)
@ -31,7 +33,7 @@ defmodule Pleroma.Notification do
|> cast(attrs, [:seen])
end
def for_user_query(user, opts) do
def for_user_query(user, opts \\ []) do
query =
Notification
|> where(user_id: ^user.id)
@ -75,6 +77,25 @@ defmodule Pleroma.Notification do
|> Pagination.fetch_paginated(opts)
end
@doc """
Returns notifications for user received since given date.
## Examples
iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33])
[%Pleroma.Notification{}, %Pleroma.Notification{}]
iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33])
[]
"""
@spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()]
def for_user_since(user, date) do
from(n in for_user_query(user),
where: n.updated_at > ^date
)
|> Repo.all()
end
def set_read_up_to(%{id: user_id} = _user, id) do
query =
from(
@ -82,7 +103,10 @@ defmodule Pleroma.Notification do
where: n.user_id == ^user_id,
where: n.id <= ^id,
update: [
set: [seen: true]
set: [
seen: true,
updated_at: ^NaiveDateTime.utc_now()
]
]
)

View file

@ -57,6 +57,7 @@ defmodule Pleroma.User do
field(:search_type, :integer, virtual: true)
field(:tags, {:array, :string}, default: [])
field(:last_refreshed_at, :naive_datetime_usec)
field(:last_digest_emailed_at, :naive_datetime)
has_many(:notifications, Notification)
has_many(:registrations, Registration)
embeds_one(:info, User.Info)
@ -1419,6 +1420,80 @@ defmodule Pleroma.User do
target.ap_id not in user.info.muted_reblogs
end
@doc """
The function returns a query to get users with no activity for given interval of days.
Inactive users are those who didn't read any notification, or had any activity where
the user is the activity's actor, during `inactivity_threshold` days.
Deactivated users will not appear in this list.
## Examples
iex> Pleroma.User.list_inactive_users()
%Ecto.Query{}
"""
@spec list_inactive_users_query(integer()) :: Ecto.Query.t()
def list_inactive_users_query(inactivity_threshold \\ 7) do
negative_inactivity_threshold = -inactivity_threshold
now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
# Subqueries are not supported in `where` clauses, join gets too complicated.
has_read_notifications =
from(n in Pleroma.Notification,
where: n.seen == true,
group_by: n.id,
having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"),
select: n.user_id
)
|> Pleroma.Repo.all()
from(u in Pleroma.User,
left_join: a in Pleroma.Activity,
on: u.ap_id == a.actor,
where: not is_nil(u.nickname),
where: fragment("not (?->'deactivated' @> 'true')", u.info),
where: u.id not in ^has_read_notifications,
group_by: u.id,
having:
max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or
is_nil(max(a.inserted_at))
)
end
@doc """
Enable or disable email notifications for user
## Examples
iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => false}}}, "digest", true)
Pleroma.User{info: %{email_notifications: %{"digest" => true}}}
iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => true}}}, "digest", false)
Pleroma.User{info: %{email_notifications: %{"digest" => false}}}
"""
@spec switch_email_notifications(t(), String.t(), boolean()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def switch_email_notifications(user, type, status) do
info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status})
change(user)
|> put_embed(:info, info)
|> update_and_set_cache()
end
@doc """
Set `last_digest_emailed_at` value for the user to current time
"""
@spec touch_last_digest_emailed_at(t()) :: t()
def touch_last_digest_emailed_at(user) do
now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
{:ok, updated_user} =
user
|> change(%{last_digest_emailed_at: now})
|> update_and_set_cache()
updated_user
end
@spec toggle_confirmation(User.t()) :: {:ok, User.t()} | {:error, Changeset.t()}
def toggle_confirmation(%User{} = user) do
need_confirmation? = !user.info.confirmation_pending

View file

@ -45,6 +45,7 @@ defmodule Pleroma.User.Info do
field(:hide_follows, :boolean, default: false)
field(:hide_favorites, :boolean, default: true)
field(:pinned_activities, {:array, :string}, default: [])
field(:email_notifications, :map, default: %{"digest" => false})
field(:mascot, :map, default: nil)
field(:emoji, {:array, :map}, default: [])
field(:pleroma_settings_store, :map, default: %{})
@ -95,6 +96,30 @@ defmodule Pleroma.User.Info do
|> validate_required([:notification_settings])
end
@doc """
Update email notifications in the given User.Info struct.
Examples:
iex> update_email_notifications(%Pleroma.User.Info{email_notifications: %{"digest" => false}}, %{"digest" => true})
%Pleroma.User.Info{email_notifications: %{"digest" => true}}
"""
@spec update_email_notifications(t(), map()) :: Ecto.Changeset.t()
def update_email_notifications(info, settings) do
email_notifications =
info.email_notifications
|> Map.merge(settings)
|> Map.take(["digest"])
params = %{email_notifications: email_notifications}
fields = [:email_notifications]
info
|> cast(params, fields)
|> validate_required(fields)
end
def add_to_note_count(info, number) do
set_note_count(info, info.note_count + number)
end

View file

@ -0,0 +1,20 @@
defmodule Pleroma.Web.Mailer.SubscriptionController do
use Pleroma.Web, :controller
alias Pleroma.JWT
alias Pleroma.Repo
alias Pleroma.User
def unsubscribe(conn, %{"token" => encoded_token}) do
with {:ok, token} <- Base.decode64(encoded_token),
{:ok, claims} <- JWT.verify_and_validate(token),
%{"act" => %{"unsubscribe" => type}, "sub" => uid} <- claims,
%User{} = user <- Repo.get(User, uid),
{:ok, _user} <- User.switch_email_notifications(user, type, false) do
render(conn, "unsubscribe_success.html", email: user.email)
else
_err ->
render(conn, "unsubscribe_failure.html")
end
end
end

View file

@ -608,6 +608,8 @@ defmodule Pleroma.Web.Router do
post("/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request)
get("/push/subscriptions/:id", Websub.WebsubController, :websub_subscription_confirmation)
post("/push/subscriptions/:id", Websub.WebsubController, :websub_incoming)
get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
end
pipeline :activitypub do

View file

@ -0,0 +1,20 @@
<h1>Hey <%= @user.nickname %>, here is what you've missed!</h1>
<h2>New Mentions:</h2>
<ul>
<%= for %{data: mention, object: object, from: from} <- @mentions do %>
<li><%= link from.nickname, to: mention.activity.actor %>: <%= raw object.data["content"] %></li>
<% end %>
</ul>
<%= if @followers != [] do %>
<h2><%= length(@followers) %> New Followers:</h2>
<ul>
<%= for %{data: follow, from: from} <- @followers do %>
<li><%= link from.nickname, to: follow.activity.actor %></li>
<% end %>
</ul>
<% end %>
<p>You have received this email because you have signed up to receive digest emails from <b><%= @instance %></b> Pleroma instance.</p>
<p>The email address you are subscribed as is <%= @user.email %>. To unsubscribe, please go <%= link "here", to: @unsubscribe_link %>.</p>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><%= @email.subject %></title>
</head>
<body>
<%= render @view_module, @view_template, assigns %>
</body>
</html>

View file

@ -0,0 +1 @@
<h1>UNSUBSCRIBE FAILURE</h1>

View file

@ -0,0 +1 @@
<h1>UNSUBSCRIBE SUCCESSFUL</h1>

View file

@ -0,0 +1,5 @@
defmodule Pleroma.Web.EmailView do
use Pleroma.Web, :view
import Phoenix.HTML
import Phoenix.HTML.Link
end

View file

@ -0,0 +1,3 @@
defmodule Pleroma.Web.Mailer.SubscriptionView do
use Pleroma.Web, :view
end