Digest emails
authorRoman Chvanikov <chvanikoff@gmail.com>
Sat, 20 Apr 2019 12:42:19 +0000 (19:42 +0700)
committerRoman Chvanikov <chvanikoff@gmail.com>
Sat, 20 Apr 2019 12:42:19 +0000 (19:42 +0700)
19 files changed:
config/config.exs
lib/mix/tasks/pleroma/instance.ex
lib/mix/tasks/pleroma/sample_config.eex
lib/pleroma/application.ex
lib/pleroma/digest_email_worker.ex [new file with mode: 0644]
lib/pleroma/emails/user_email.ex
lib/pleroma/jwt.ex [new file with mode: 0644]
lib/pleroma/quantum_scheduler.ex [new file with mode: 0644]
lib/pleroma/user.ex
lib/pleroma/web/mailer/subscription_controller.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
lib/pleroma/web/templates/email/digest.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/layout/email.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex [new file with mode: 0644]
lib/pleroma/web/views/email_view.ex [new file with mode: 0644]
lib/pleroma/web/views/mailer/subscription_view.ex [new file with mode: 0644]
mix.exs
mix.lock

index 25dc91eb11a0d62e8c846f727f1012d120ea5328..2663b1ebd244b3eab1c2633b52871a4cf1e896de 100644 (file)
@@ -468,6 +468,8 @@ config :pleroma, Pleroma.ScheduledActivity,
 
 config :pleroma, :email_notifications,
   digest: %{
+    # Globally enable or disable digest emails
+    active: true,
     # When to send digest email, in crontab format (https://en.wikipedia.org/wiki/Cron)
     # 0 0 * * 0 - once a week at midnight on Sunday morning
     schedule: "0 0 * * 0",
index 6cee8d63035de9303effc164c1219fed4f17bc4c..d276df93acf0ace3caf5593a909f4d9ef46c2b42 100644 (file)
@@ -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)
index 52bd57cb7efae12da86e755e433fc1a15979145d..ec7d8821e8342ac76dfd7b6a5a7425f44a24ca1a 100644 (file)
@@ -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 %>"
index eeb415084041cdec1e212942a9d75f9b6e94a38e..76f8d9bcdfa8fb185b9e0212e036dbb271e72a2b 100644 (file)
@@ -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() ++
         [
@@ -125,7 +126,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
@@ -183,4 +186,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 (file)
index 0000000..fa6067a
--- /dev/null
@@ -0,0 +1,45 @@
+defmodule Pleroma.DigestEmailWorker do
+  import Ecto.Query
+  require Logger
+
+  # alias Pleroma.User
+
+  def run() do
+    Logger.warn("Running digester")
+    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("? #> '{\"email_notifications\",\"digest\"}' @> 'true'", u.info),
+      where: u.last_digest_emailed_at < datetime_add(^now, ^negative_interval, "day"),
+      select: u
+    )
+    |> Pleroma.Repo.all()
+    |> run(:pre)
+  end
+
+  defp run(v, :pre) do
+    Logger.warn("Running for #{length(v)} users")
+    run(v)
+  end
+
+  defp run([]), do: :ok
+
+  defp run([user | users]) do
+    with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(user) do
+      Logger.warn("Sending to #{user.nickname}")
+      Pleroma.Emails.Mailer.deliver_async(email)
+    else
+      _ ->
+        Logger.warn("Skipping #{user.nickname}")
+    end
+
+    Pleroma.User.touch_last_digest_emailed_at(user)
+
+    run(users)
+  end
+end
index 8502a0d0c6d5ab189582fdbc2982f9a7c64d89a2..64f8551122c7db5ab9e58fdb4345e83bc0a53c2e 100644 (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
@@ -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 (file)
index 0000000..10102ff
--- /dev/null
@@ -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/quantum_scheduler.ex b/lib/pleroma/quantum_scheduler.ex
new file mode 100644 (file)
index 0000000..9a3df81
--- /dev/null
@@ -0,0 +1,4 @@
+defmodule Pleroma.QuantumScheduler do
+  use Quantum.Scheduler,
+    otp_app: :pleroma
+end
index 7053dfaf3ce821e0bdde8d8b43f8dfce8708ac73..2509d23666f00ae5e144a76dbce13d591e708c20 100644 (file)
@@ -1484,4 +1484,40 @@ defmodule Pleroma.User do
           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
 end
diff --git a/lib/pleroma/web/mailer/subscription_controller.ex b/lib/pleroma/web/mailer/subscription_controller.ex
new file mode 100644 (file)
index 0000000..2334eba
--- /dev/null
@@ -0,0 +1,18 @@
+defmodule Pleroma.Web.Mailer.SubscriptionController do
+  use Pleroma.Web, :controller
+
+  alias Pleroma.{JWT, Repo, 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
index 8b665d61b4211c58426fe8eda718805d3f47ef9d..09e51e60268d45dffc03c34f724d32bbc466b4c7 100644 (file)
@@ -562,6 +562,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 (file)
index 0000000..93c9c88
--- /dev/null
@@ -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 (file)
index 0000000..f6dcd7f
--- /dev/null
@@ -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 (file)
index 0000000..7b476f0
--- /dev/null
@@ -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 (file)
index 0000000..6dfa2c1
--- /dev/null
@@ -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 (file)
index 0000000..b63eb16
--- /dev/null
@@ -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 (file)
index 0000000..fc3d208
--- /dev/null
@@ -0,0 +1,3 @@
+defmodule Pleroma.Web.Mailer.SubscriptionView do
+  use Pleroma.Web, :view
+end
diff --git a/mix.exs b/mix.exs
index da2e284f818b56d99febe954f27d3bb104bccd04..6bb1055380f1ee30293025977f7ddb44f610cc8c 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -93,6 +93,7 @@ defmodule Pleroma.Mixfile do
       {:ex_doc, "~> 0.20.2", only: :dev, runtime: false},
       {:web_push_encryption, "~> 0.2.1"},
       {:swoosh, "~> 0.20"},
+      {:phoenix_swoosh, "~> 0.2"},
       {:gen_smtp, "~> 0.13"},
       {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test},
       {:floki, "~> 0.20.0"},
@@ -111,7 +112,8 @@ defmodule Pleroma.Mixfile do
       {:prometheus_process_collector, "~> 1.4"},
       {:recon, github: "ferd/recon", tag: "2.4.0"},
       {:quack, "~> 0.1.1"},
-      {:quantum, "~> 2.3"}
+      {:quantum, "~> 2.3"},
+      {:joken, "~> 2.0"}
     ] ++ oauth_deps
   end
 
index 6e322240a6a58462036bdb38a683fd009555b0d1..73aed012f2b47195e6532874535561173fa9eb45 100644 (file)
--- a/mix.lock
+++ b/mix.lock
@@ -37,6 +37,7 @@
   "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
   "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
   "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
+  "joken": {:hex, :joken, "2.0.1", "ec9ab31bf660f343380da033b3316855197c8d4c6ef597fa3fcb451b326beb14", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"},
   "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
   "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"},
   "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
@@ -55,6 +56,7 @@
   "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
   "phoenix_html": {:hex, :phoenix_html, "2.13.2", "f5d27c9b10ce881a60177d2b5227314fc60881e6b66b41dfe3349db6ed06cf57", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
   "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
+  "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm"},
   "pleroma_job_queue": {:hex, :pleroma_job_queue, "0.2.0", "879e660aa1cebe8dc6f0aaaa6aa48b4875e89cd961d4a585fd128e0773b31a18", [:mix], [], "hexpm"},
   "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"},
   "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},