diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/mix/tasks/pleroma/digest.ex | 33 | ||||
| -rw-r--r-- | lib/mix/tasks/pleroma/instance.ex | 2 | ||||
| -rw-r--r-- | lib/pleroma/application.ex | 17 | ||||
| -rw-r--r-- | lib/pleroma/digest_email_worker.ex | 35 | ||||
| -rw-r--r-- | lib/pleroma/emails/user_email.ex | 71 | ||||
| -rw-r--r-- | lib/pleroma/jwt.ex | 9 | ||||
| -rw-r--r-- | lib/pleroma/notification.ex | 28 | ||||
| -rw-r--r-- | lib/pleroma/user.ex | 75 | ||||
| -rw-r--r-- | lib/pleroma/user/info.ex | 25 | ||||
| -rw-r--r-- | lib/pleroma/web/mailer/subscription_controller.ex | 20 | ||||
| -rw-r--r-- | lib/pleroma/web/router.ex | 2 | ||||
| -rw-r--r-- | lib/pleroma/web/templates/email/digest.html.eex | 20 | ||||
| -rw-r--r-- | lib/pleroma/web/templates/layout/email.html.eex | 10 | ||||
| -rw-r--r-- | lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex | 1 | ||||
| -rw-r--r-- | lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex | 1 | ||||
| -rw-r--r-- | lib/pleroma/web/views/email_view.ex | 5 | ||||
| -rw-r--r-- | lib/pleroma/web/views/mailer/subscription_view.ex | 3 | 
17 files changed, 353 insertions, 4 deletions
| diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex new file mode 100644 index 000000000..81c207e10 --- /dev/null +++ b/lib/mix/tasks/pleroma/digest.ex @@ -0,0 +1,33 @@ +defmodule Mix.Tasks.Pleroma.Digest do +  use Mix.Task + +  @shortdoc "Manages digest emails" +  @moduledoc """ +  Manages digest emails + +  ## Send digest email since given date (user registration date by default) +  ignoring user activity status. + +  ``mix pleroma.digest test <nickname> <since_date>`` + +  Example: ``mix pleroma.digest test donaldtheduck 2019-05-20`` +  """ +  def run(["test", nickname | opts]) do +    Mix.Pleroma.start_pleroma() + +    user = Pleroma.User.get_by_nickname(nickname) + +    last_digest_emailed_at = +      with [date] <- opts, +           {:ok, datetime} <- Timex.parse(date, "{YYYY}-{0M}-{0D}") do +        datetime +      else +        _ -> user.inserted_at +      end + +    patched_user = %{user | last_digest_emailed_at: last_digest_emailed_at} + +    _user = Pleroma.DigestEmailWorker.perform(patched_user) +    Mix.shell().info("Digest email have been sent to #{nickname} (#{user.email})") +  end +end diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 9080adb52..b9b1991c2 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -183,6 +183,7 @@ defmodule Mix.Tasks.Pleroma.Instance do          )        secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) +      jwt_secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64)        signing_salt = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8)        {web_push_public_key, web_push_private_key} = :crypto.generate_key(:ecdh, :prime256v1)        template_dir = Application.app_dir(:pleroma, "priv") <> "/templates" @@ -200,6 +201,7 @@ defmodule Mix.Tasks.Pleroma.Instance do            dbuser: dbuser,            dbpass: dbpass,            secret: secret, +          jwt_secret: jwt_secret,            signing_salt: signing_salt,            web_push_public_key: Base.url_encode64(web_push_public_key, padding: false),            web_push_private_key: Base.url_encode64(web_push_private_key, padding: false), diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 035331491..00b06f723 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -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 diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex new file mode 100644 index 000000000..18e67d39b --- /dev/null +++ b/lib/pleroma/digest_email_worker.ex @@ -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 diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 934620765..49046bb8b 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -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 diff --git a/lib/pleroma/jwt.ex b/lib/pleroma/jwt.ex new file mode 100644 index 000000000..10102ff5d --- /dev/null +++ b/lib/pleroma/jwt.ex @@ -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 diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d47229258..5d29af853 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -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() +          ]          ]        ) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 974f96852..7d18f099e 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -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 diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index b03e705c3..22eb9a182 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -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 diff --git a/lib/pleroma/web/mailer/subscription_controller.ex b/lib/pleroma/web/mailer/subscription_controller.ex new file mode 100644 index 000000000..478a83518 --- /dev/null +++ b/lib/pleroma/web/mailer/subscription_controller.ex @@ -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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d475fc973..c8c1c22dd 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -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 diff --git a/lib/pleroma/web/templates/email/digest.html.eex b/lib/pleroma/web/templates/email/digest.html.eex new file mode 100644 index 000000000..c9dd699fd --- /dev/null +++ b/lib/pleroma/web/templates/email/digest.html.eex @@ -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>
\ No newline at end of file diff --git a/lib/pleroma/web/templates/layout/email.html.eex b/lib/pleroma/web/templates/layout/email.html.eex new file mode 100644 index 000000000..f6dcd7f0f --- /dev/null +++ b/lib/pleroma/web/templates/layout/email.html.eex @@ -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>
\ No newline at end of file diff --git a/lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex new file mode 100644 index 000000000..7b476f02d --- /dev/null +++ b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex @@ -0,0 +1 @@ +<h1>UNSUBSCRIBE FAILURE</h1> diff --git a/lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex new file mode 100644 index 000000000..6dfa2c185 --- /dev/null +++ b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex @@ -0,0 +1 @@ +<h1>UNSUBSCRIBE SUCCESSFUL</h1> diff --git a/lib/pleroma/web/views/email_view.ex b/lib/pleroma/web/views/email_view.ex new file mode 100644 index 000000000..b63eb162c --- /dev/null +++ b/lib/pleroma/web/views/email_view.ex @@ -0,0 +1,5 @@ +defmodule Pleroma.Web.EmailView do +  use Pleroma.Web, :view +  import Phoenix.HTML +  import Phoenix.HTML.Link +end diff --git a/lib/pleroma/web/views/mailer/subscription_view.ex b/lib/pleroma/web/views/mailer/subscription_view.ex new file mode 100644 index 000000000..fc3d20816 --- /dev/null +++ b/lib/pleroma/web/views/mailer/subscription_view.ex @@ -0,0 +1,3 @@ +defmodule Pleroma.Web.Mailer.SubscriptionView do +  use Pleroma.Web, :view +end | 
