diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/mix/tasks/pleroma/instance.ex | 2 | ||||
| -rw-r--r-- | lib/mix/tasks/pleroma/sample_config.eex | 2 | ||||
| -rw-r--r-- | lib/pleroma/application.ex | 22 | ||||
| -rw-r--r-- | lib/pleroma/digest_email_worker.ex | 32 | ||||
| -rw-r--r-- | lib/pleroma/emails/user_email.ex | 59 | ||||
| -rw-r--r-- | lib/pleroma/jwt.ex | 9 | ||||
| -rw-r--r-- | lib/pleroma/notification.ex | 26 | ||||
| -rw-r--r-- | lib/pleroma/quantum_scheduler.ex | 4 | ||||
| -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 | 
18 files changed, 314 insertions, 4 deletions
| diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 6cee8d630..d276df93a 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -125,6 +125,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) @@ -142,6 +143,7 @@ defmodule Mix.Tasks.Pleroma.Instance do            dbpass: dbpass,            version: Pleroma.Mixfile.project() |> Keyword.get(:version),            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/mix/tasks/pleroma/sample_config.eex b/lib/mix/tasks/pleroma/sample_config.eex index 52bd57cb7..ec7d8821e 100644 --- a/lib/mix/tasks/pleroma/sample_config.eex +++ b/lib/mix/tasks/pleroma/sample_config.eex @@ -76,3 +76,5 @@ config :web_push_encryption, :vapid_details,  #  storage_url: "https://swift-endpoint.prodider.com/v1/AUTH_<tenant>/<container>",  #  object_url: "https://cdn-endpoint.provider.com/<container>"  # + +config :joken, default_signer: "<%= jwt_secret %>" diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 76df3945e..acc314df1 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -105,7 +105,8 @@ defmodule Pleroma.Application do            id: :cachex_idem          ),          worker(Pleroma.FlakeId, []), -        worker(Pleroma.ScheduledActivityWorker, []) +        worker(Pleroma.ScheduledActivityWorker, []), +        worker(Pleroma.QuantumScheduler, [])        ] ++          hackney_pool_children() ++          [ @@ -126,7 +127,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 @@ -187,4 +190,19 @@ 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], +         %Crontab.CronExpression{} = schedule <- +           Crontab.CronExpression.Parser.parse!(digest_config[:schedule]) do +      Pleroma.QuantumScheduler.new_job() +      |> Quantum.Job.set_name(:digest_emails) +      |> Quantum.Job.set_schedule(schedule) +      |> Quantum.Job.set_task(&Pleroma.DigestEmailWorker.run/0) +      |> Pleroma.QuantumScheduler.add_job() +    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..65013f77e --- /dev/null +++ b/lib/pleroma/digest_email_worker.ex @@ -0,0 +1,32 @@ +defmodule Pleroma.DigestEmailWorker do +  import Ecto.Query + +  def run do +    config = Application.get_env(:pleroma, :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() +    |> run() +  end + +  defp run([]), do: :ok + +  defp run([user | users]) 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) + +    run(users) +  end +end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 8502a0d0c..64f855112 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 @@ -92,4 +92,61 @@ 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}} = notification, acc -> +          new_mention = %{data: notification, from: Pleroma.User.get_by_ap_id(actor)} +          %{acc | mentions: [new_mention | acc.mentions]} + +        %{activity: %{data: %{"type" => "Follow"}, actor: actor}} = notification, acc -> +          new_follower = %{data: notification, 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 844264307..6b6607f0f 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -17,6 +17,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) @@ -58,6 +60,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( @@ -65,7 +86,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/quantum_scheduler.ex b/lib/pleroma/quantum_scheduler.ex new file mode 100644 index 000000000..9a3df81f6 --- /dev/null +++ b/lib/pleroma/quantum_scheduler.ex @@ -0,0 +1,4 @@ +defmodule Pleroma.QuantumScheduler do +  use Quantum.Scheduler, +    otp_app: :pleroma +end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 653dec95f..e5b1219b2 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -54,6 +54,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) @@ -1391,6 +1392,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 6397e2737..e88ee4164 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -43,6 +43,7 @@ defmodule Pleroma.User.Info do      field(:hide_favorites, :boolean, default: true)      field(:pinned_activities, {:array, :string}, default: [])      field(:flavour, :string, default: nil) +    field(:email_notifications, :map, default: %{"digest" => false})      field(:mascot, :map, default: nil)      field(:emoji, {:array, :map}, default: []) @@ -80,6 +81,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 08c74a742..8b7dc9af6 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -610,6 +610,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    scope "/", Pleroma.Web 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..93c9c884f --- /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, from: from} <- @mentions do %> +  <li><%= link from.nickname, to: mention.activity.actor %>: <%= raw mention.activity.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 | 
