From 69f8f9446e862286d631b823e836d5fa049573ed Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sun, 29 Nov 2020 14:45:16 +0300 Subject: [PATCH] batch mention email notifications in timeframe --- CHANGELOG.md | 1 + config/config.exs | 8 +- docs/administration/CLI_tasks/user.md | 33 ++ docs/configuration/cheatsheet.md | 9 + .../API/differences_in_mastoapi_responses.md | 1 + lib/mix/tasks/pleroma/app.ex | 2 +- lib/mix/tasks/pleroma/user.ex | 29 ++ lib/pleroma/emails/user_email.ex | 89 +++-- .../migration_helper/notification_backfill.ex | 11 +- lib/pleroma/notification.ex | 47 ++- lib/pleroma/user.ex | 22 +- .../web/api_spec/operations/account_operation.ex | 28 +- .../mastodon_api/controllers/account_controller.ex | 1 + lib/pleroma/web/mastodon_api/views/account_view.ex | 3 +- lib/pleroma/web/templates/email/digest.html.eex | 2 +- lib/pleroma/web/templates/email/mentions.html.eex | 439 +++++++++++++++++++++ lib/pleroma/workers/cron/email_mentions_worker.ex | 67 ++++ ...004_change_user_email_notifications_setting.exs | 32 ++ ...1214160053_add_notified_at_to_notifications.exs | 15 + ...201221060622_fill_notifications_notified_at.exs | 23 ++ .../20201222052839_add_index_to_notifications.exs | 7 + test/mix/tasks/pleroma/user_test.exs | 89 +++++ test/pleroma/notification_test.exs | 90 +++++ test/pleroma/user_test.exs | 21 +- .../web/mastodon_api/update_credentials_test.exs | 32 ++ .../web/mastodon_api/views/account_view_test.exs | 12 +- .../workers/cron/email_mentions_worker_test.exs | 107 +++++ test/support/factory.ex | 3 +- 28 files changed, 1165 insertions(+), 58 deletions(-) create mode 100644 lib/pleroma/web/templates/email/mentions.html.eex create mode 100644 lib/pleroma/workers/cron/email_mentions_worker.ex create mode 100644 priv/repo/migrations/20201128144004_change_user_email_notifications_setting.exs create mode 100644 priv/repo/migrations/20201214160053_add_notified_at_to_notifications.exs create mode 100644 priv/repo/migrations/20201221060622_fill_notifications_notified_at.exs create mode 100644 priv/repo/migrations/20201222052839_add_index_to_notifications.exs create mode 100644 test/pleroma/workers/cron/email_mentions_worker_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa76a89a..135e0ff5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Ability to define custom HTTP headers per each frontend - MRF (`NoEmptyPolicy`): New MRF Policy which will deny empty statuses or statuses of only mentions from being created by local users - New users will receive a simple email confirming their registration if no other emails will be dispatched. (e.g., Welcome, Confirmation, or Approval Required) +- Email with missed mentions in a specific period.
API Changes diff --git a/config/config.exs b/config/config.exs index 4381068ac..10b8498ef 100644 --- a/config/config.exs +++ b/config/config.exs @@ -563,10 +563,12 @@ config :pleroma, Oban, remote_fetcher: 2, attachments_cleanup: 1, new_users_digest: 1, - mute_expire: 5 + mute_expire: 5, + email_mentions: 1 ], plugins: [Oban.Plugins.Pruner], crontab: [ + {"*/15 * * * *", Pleroma.Workers.Cron.EmailMentionsWorker}, {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} ] @@ -851,6 +853,10 @@ config :pleroma, ConcurrentLimiter, [ {Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, [max_running: 5, max_waiting: 5]} ] +config :pleroma, Pleroma.Workers.Cron.EmailMentionsWorker, + enabled: false, + timeframe: 30 + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index 24fdaeab4..61774f469 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -300,3 +300,36 @@ ```sh mix pleroma.user unconfirm_all ``` + +## Update email notifications settings for user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user email_notifications [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.user email_notifications [option ...] + ``` + +### Options + +- `--digest`/`--no-digest` - whether the user should receive digest emails +- `--notifications` - what types of email notifications user should receive (can be aliased with `-n`). To disable all types pass `off` value. + +Example: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user email_notifications lain --digest -n mention + ``` + +=== "From Source" + + ```sh + mix pleroma.user email_notifications lain --digest -n mention + ``` diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 069421722..262978b7d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -715,6 +715,7 @@ Pleroma has these periodic job workers: * `Pleroma.Workers.Cron.DigestEmailsWorker` - digest emails for users with new mentions and follows * `Pleroma.Workers.Cron.NewUsersDigestWorker` - digest emails for admins with new registrations +* `Pleroma.Workers.Cron.EmailMentionsWorker` - email with missed mentions notifications in special timeframe ```elixir config :pleroma, Oban, @@ -726,6 +727,7 @@ config :pleroma, Oban, federator_outgoing: 50 ], crontab: [ + {"*/15 * * * *", Pleroma.Workers.Cron.EmailMentionsWorker}, {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} ] @@ -1154,3 +1156,10 @@ Each job has these settings: * `:max_running` - max concurrently runnings jobs * `:max_waiting` - max waiting jobs + +## Mention emails (Pleroma.Workers.Cron.EmailMentionsWorker) + +The worker sends email notifications not read in a certain timeframe. + +* `:enabled` - enables email notifications for missed mentions & chat mentions +* `:timeframe` - the period after which the sending of emails begins for missed mentions (in minutes) diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 2ff56d3ca..72949c9d7 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -107,6 +107,7 @@ Has these additional fields under the `pleroma` object: - `notification_settings`: object, can be absent. See `/api/v1/pleroma/notification_settings` for the parameters/keys returned. - `accepts_chat_messages`: boolean, but can be null if we don't have that information about a user - `favicon`: nullable URL string, Favicon image of the user's instance +- `email_notifications`: map with settings for `digest` emails (boolean) and `notifications` setting (list with notification types). ### Source diff --git a/lib/mix/tasks/pleroma/app.ex b/lib/mix/tasks/pleroma/app.ex index 0bf7ffabc..aa5c9cd54 100644 --- a/lib/mix/tasks/pleroma/app.ex +++ b/lib/mix/tasks/pleroma/app.ex @@ -21,7 +21,7 @@ defmodule Mix.Tasks.Pleroma.App do scopes = if opts[:scopes] do - String.split(opts[:scopes], ",") + String.split(opts[:scopes], ",", trim: true) else ["read", "write", "follow", "push"] end diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 53d5fc6d9..d2a545e91 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -433,6 +433,35 @@ defmodule Mix.Tasks.Pleroma.User do |> Stream.run() end + def run(["email_notifications", nickname | options]) do + start_pleroma() + + {opts, _} = + OptionParser.parse!(options, + strict: [digest: :boolean, notifications: :string], + aliases: [n: :notifications] + ) + + params = + Map.new(opts, fn + {:digest, v} -> + {"digest", v} + + {:notifications, v} -> + types = if v == "off", do: [], else: String.split(v, ",", trim: true) + {"notifications", types} + end) + + with keys when keys != [] <- Map.keys(params), + %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + {:ok, user} = User.update_email_notifications(user, params) + shell_info("Email notifications for user #{user.nickname} were successfully updated.") + else + [] -> shell_error("No changes passed") + _ -> shell_error("No local user #{nickname}") + end + end + defp set_moderator(user, value) do {:ok, user} = user diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 52f3d419d..73cea4962 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Emails.UserEmail do use Phoenix.Swoosh, view: Pleroma.Web.EmailView, layout: {Pleroma.Web.LayoutView, :email} alias Pleroma.Config + alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.Endpoint alias Pleroma.Web.Router @@ -120,6 +121,27 @@ defmodule Pleroma.Emails.UserEmail do |> html_body(html_body) end + defp prepare_mention(%Notification{type: type} = notification, acc) + when type in ["mention", "pleroma:chat_mention"] do + object = Pleroma.Object.normalize(notification.activity, fetch: false) + + if object do + object = update_in(object.data["content"], &format_links/1) + + mention = %{ + data: notification, + object: object, + from: User.get_by_ap_id(notification.activity.actor) + } + + [mention | acc] + else + acc + end + end + + defp prepare_mention(_, acc), do: acc + @doc """ Email used in digest email notifications Includes Mentions and New Followers data @@ -127,25 +149,12 @@ defmodule Pleroma.Emails.UserEmail do """ @spec digest_email(User.t()) :: Swoosh.Email.t() | nil def digest_email(user) do - notifications = Pleroma.Notification.for_user_since(user, user.last_digest_emailed_at) + notifications = Notification.for_user_since(user, user.last_digest_emailed_at) mentions = notifications |> Enum.filter(&(&1.activity.data["type"] == "Create")) - |> Enum.map(fn notification -> - object = Pleroma.Object.normalize(notification.activity, fetch: false) - - if not is_nil(object) do - object = update_in(object.data["content"], &format_links/1) - - %{ - data: notification, - object: object, - from: User.get_by_ap_id(notification.activity.actor) - } - end - end) - |> Enum.filter(& &1) + |> Enum.reduce([], &prepare_mention/2) followers = notifications @@ -165,7 +174,6 @@ defmodule Pleroma.Emails.UserEmail do unless Enum.empty?(mentions) do styling = Config.get([__MODULE__, :styling]) - logo = Config.get([__MODULE__, :logo]) html_data = %{ instance: instance_name(), @@ -176,20 +184,15 @@ defmodule Pleroma.Emails.UserEmail do styling: styling } - logo_path = - if is_nil(logo) do - Path.join(:code.priv_dir(:pleroma), "static/static/logo.svg") - else - Path.join(Config.get([:instance, :static_dir]), logo) - end + {logo_path, logo} = logo_path() new() |> to(recipient(user)) |> from(sender()) |> subject("Your digest from #{instance_name()}") |> put_layout(false) - |> render_body("digest.html", html_data) - |> attachment(Swoosh.Attachment.new(logo_path, filename: "logo.svg", type: :inline)) + |> render_body("digest.html", Map.put(html_data, :logo, logo)) + |> attachment(Swoosh.Attachment.new(logo_path, filename: logo, type: :inline)) end end @@ -242,4 +245,42 @@ defmodule Pleroma.Emails.UserEmail do |> subject("Your account archive is ready") |> html_body(html_body) end + + @spec mentions_notification_email(User.t(), [Notification.t()]) :: Swoosh.Email.t() + def mentions_notification_email(user, mentions) do + html_data = %{ + instance: instance_name(), + user: user, + mentions: Enum.reduce(mentions, [], &prepare_mention/2), + unsubscribe_link: unsubscribe_url(user, "mentions_email"), + styling: Config.get([__MODULE__, :styling]) + } + + now = NaiveDateTime.utc_now() + + {logo_path, logo} = logo_path() + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject( + "[Pleroma] New mentions from #{instance_name()} for #{ + Timex.format!(now, "{Mfull} {D}, {YYYY} at {h12}:{m} {AM}") + }" + ) + |> put_layout(false) + |> render_body("mentions.html", Map.put(html_data, :logo, logo)) + |> attachment(Swoosh.Attachment.new(logo_path, filename: logo, type: :inline)) + end + + defp logo_path do + logo_path = + if logo = Config.get([__MODULE__, :logo]) do + Path.join(Config.get([:instance, :static_dir]), logo) + else + Path.join(:code.priv_dir(:pleroma), "static/static/logo.svg") + end + + {logo_path, Path.basename(logo_path)} + end end diff --git a/lib/pleroma/migration_helper/notification_backfill.ex b/lib/pleroma/migration_helper/notification_backfill.ex index 62b710f82..e634ba326 100644 --- a/lib/pleroma/migration_helper/notification_backfill.ex +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -11,9 +11,11 @@ defmodule Pleroma.MigrationHelper.NotificationBackfill do def fill_in_notification_types do query = - from(n in Pleroma.Notification, + from(n in "notifications", where: is_nil(n.type), - preload: :activity + join: a in "activities", + on: n.activity_id == a.id, + select: %{id: n.id, activity: %{id: a.id, data: a.data}} ) query @@ -22,9 +24,8 @@ defmodule Pleroma.MigrationHelper.NotificationBackfill do if notification.activity do type = type_from_activity(notification.activity) - notification - |> Ecto.Changeset.change(%{type: type}) - |> Repo.update() + from(n in "notifications", where: n.id == ^notification.id) + |> Repo.update_all(set: [type: type]) end end) end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 7efbdc49a..8adbc07f4 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -37,6 +37,7 @@ defmodule Pleroma.Notification do field(:type, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) + field(:notified_at, :naive_datetime) timestamps() end @@ -249,7 +250,7 @@ defmodule Pleroma.Notification do 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()] + @spec for_user_since(User.t(), NaiveDateTime.t()) :: [t()] def for_user_since(user, date) do from(n in for_user_query(user), where: n.updated_at > ^date @@ -664,4 +665,48 @@ defmodule Pleroma.Notification do ) |> Repo.update_all(set: [seen: true]) end + + defp unread_mentions_in_timeframe_query(query \\ __MODULE__, args) do + types = args[:types] || ["mention", "pleroma:chat_mention"] + max_at = args[:max_at] + + from(n in query, + where: n.seen == false, + where: is_nil(n.notified_at), + where: n.type in ^types, + where: n.inserted_at <= ^max_at + ) + end + + @spec users_ids_with_unread_mentions(NaiveDateTime.t()) :: [String.t()] + def users_ids_with_unread_mentions(max_at) do + from(n in unread_mentions_in_timeframe_query(%{max_at: max_at}), + join: u in assoc(n, :user), + where: not is_nil(u.email), + distinct: n.user_id, + select: n.user_id + ) + |> Repo.all() + end + + @spec for_user_unread_mentions(User.t(), NaiveDateTime.t()) :: [t()] + def for_user_unread_mentions(%User{} = user, max_at) do + args = %{ + max_at: max_at, + types: user.email_notifications["notifications"] + } + + user + |> for_user_query() + |> unread_mentions_in_timeframe_query(args) + |> Repo.all() + end + + @spec update_notified_at([pos_integer()]) :: {non_neg_integer(), nil} + def update_notified_at(ids \\ []) do + from(n in __MODULE__, + where: n.id in ^ids + ) + |> Repo.update_all(set: [notified_at: NaiveDateTime.utc_now()]) + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index b78777141..c5ea1ba10 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -131,7 +131,11 @@ defmodule Pleroma.User do field(:hide_followers, :boolean, default: false) field(:hide_follows, :boolean, default: false) field(:hide_favorites, :boolean, default: true) - field(:email_notifications, :map, default: %{"digest" => false}) + + field(:email_notifications, :map, + default: %{"digest" => false, "notifications" => ["mention", "pleroma:chat_mention"]} + ) + field(:mascot, :map, default: nil) field(:emoji, :map, default: %{}) field(:pleroma_settings_store, :map, default: %{}) @@ -525,7 +529,8 @@ defmodule Pleroma.User do :is_discoverable, :actor_type, :accepts_chat_messages, - :disclose_client + :disclose_client, + :email_notifications ] ) |> unique_constraint(:nickname) @@ -2390,17 +2395,14 @@ defmodule Pleroma.User do |> update_and_set_cache() end + @spec update_email_notifications(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def update_email_notifications(user, settings) do - email_notifications = - user.email_notifications - |> Map.merge(settings) - |> Map.take(["digest"]) + email_notifications = Map.merge(user.email_notifications, settings) - params = %{email_notifications: email_notifications} fields = [:email_notifications] user - |> cast(params, fields) + |> cast(%{email_notifications: email_notifications}, fields) |> validate_required(fields) |> update_and_set_cache() end @@ -2431,8 +2433,8 @@ defmodule Pleroma.User do end end - @spec add_to_block(User.t(), User.t()) :: - {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()} + @spec remove_from_block(User.t(), User.t()) :: + {:ok, UserRelationship.t() | nil} | {:error, Ecto.Changeset.t()} defp remove_from_block(%User{} = user, %User{} = blocked) do with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 54e5ebc76..d0d7d751e 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -635,7 +635,8 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do description: "Discovery (listing, indexing) of this account by external services (search bots etc.) is allowed." }, - actor_type: ActorType + actor_type: ActorType, + email_notifications: email_notifications() }, example: %{ bot: false, @@ -760,6 +761,31 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } end + defp email_notifications do + %Schema{ + title: "EmailNotificationsObject", + description: "User Email notification settings", + type: :object, + properties: %{ + digest: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Whether the account receives digest email" + }, + notifications: %Schema{ + type: :array, + nullable: true, + description: "List of notification types to receive by Email", + items: %Schema{type: :string} + } + }, + example: %{ + "digest" => true, + "notifications" => ["mention", "pleroma:chat_mention"] + } + } + end + defp array_of_lists do %Schema{ title: "ArrayOfLists", diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7a1e99044..a8f11786b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -213,6 +213,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do |> Maps.put_if_present(:is_locked, params[:locked]) # Note: param name is indeed :discoverable (not an error) |> Maps.put_if_present(:is_discoverable, params[:discoverable]) + |> Maps.put_if_present(:email_notifications, params[:email_notifications]) # What happens here: # diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index ac25aefdd..4165aa890 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -279,7 +279,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do skip_thread_containment: user.skip_thread_containment, background_image: image_url(user.background) |> MediaProxy.url(), accepts_chat_messages: user.accepts_chat_messages, - favicon: favicon + favicon: favicon, + email_notifications: user.email_notifications } } |> maybe_put_role(user, opts[:for]) diff --git a/lib/pleroma/web/templates/email/digest.html.eex b/lib/pleroma/web/templates/email/digest.html.eex index 60eceff22..9afb2ca49 100644 --- a/lib/pleroma/web/templates/email/digest.html.eex +++ b/lib/pleroma/web/templates/email/digest.html.eex @@ -126,7 +126,7 @@
Image diff --git a/lib/pleroma/web/templates/email/mentions.html.eex b/lib/pleroma/web/templates/email/mentions.html.eex new file mode 100644 index 000000000..c95dc97f1 --- /dev/null +++ b/lib/pleroma/web/templates/email/mentions.html.eex @@ -0,0 +1,439 @@ + + + + + + + + + + + + <%= @email.subject %>< + + + + + + + + + + + + + + + + + + + diff --git a/lib/pleroma/workers/cron/email_mentions_worker.ex b/lib/pleroma/workers/cron/email_mentions_worker.ex new file mode 100644 index 000000000..791f09c28 --- /dev/null +++ b/lib/pleroma/workers/cron/email_mentions_worker.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.EmailMentionsWorker do + use Pleroma.Workers.WorkerHelper, queue: "email_mentions" + + @impl true + def perform(%Job{args: %{"op" => "email_mentions", "user_id" => id}}) do + user = Pleroma.User.get_cached_by_id(id) + + timeframe = + Pleroma.Config.get([__MODULE__, :timeframe], 30) + |> :timer.minutes() + + max_inserted_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(-timeframe, :millisecond) + |> NaiveDateTime.truncate(:second) + + mentions = Pleroma.Notification.for_user_unread_mentions(user, max_inserted_at) + + if mentions != [] do + user + |> Pleroma.Emails.UserEmail.mentions_notification_email(mentions) + |> Pleroma.Emails.Mailer.deliver() + |> case do + {:ok, _} -> + Enum.map(mentions, & &1.id) + + _ -> + [] + end + |> Pleroma.Notification.update_notified_at() + end + + :ok + end + + @impl true + def perform(_) do + config = Pleroma.Config.get(__MODULE__, []) + + if Keyword.get(config, :enabled, false) do + timeframe = Keyword.get(config, :timeframe, 30) + period = timeframe * 60 + + max_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(-:timer.minutes(timeframe), :millisecond) + |> NaiveDateTime.truncate(:second) + + Pleroma.Notification.users_ids_with_unread_mentions(max_at) + |> Enum.each(&insert_job(&1, unique: [period: period])) + end + + :ok + end + + defp insert_job(user_id, args) do + Pleroma.Workers.Cron.EmailMentionsWorker.enqueue( + "email_mentions", + %{"user_id" => user_id}, + args + ) + end +end diff --git a/priv/repo/migrations/20201128144004_change_user_email_notifications_setting.exs b/priv/repo/migrations/20201128144004_change_user_email_notifications_setting.exs new file mode 100644 index 000000000..6ccedfbd0 --- /dev/null +++ b/priv/repo/migrations/20201128144004_change_user_email_notifications_setting.exs @@ -0,0 +1,32 @@ +defmodule Pleroma.Repo.Migrations.ChangeUserEmailNotificationsSetting do + use Ecto.Migration + + import Ecto.Query, only: [from: 2] + + def up, do: stream_and_update_users(:up) + + def down, do: stream_and_update_users(:down) + + defp stream_and_update_users(direction) do + from(u in Pleroma.User, select: [:id, :email_notifications]) + |> Pleroma.Repo.stream() + |> Stream.each(&update_user_email_notifications_settings(&1, direction)) + |> Stream.run() + end + + defp update_user_email_notifications_settings(user, direction) do + email_notifications = change_email_notifications(user.email_notifications, direction) + + user + |> Ecto.Changeset.change(email_notifications: email_notifications) + |> Pleroma.Repo.update() + end + + defp change_email_notifications(email_notifications, :up) do + Map.put(email_notifications, "notifications", ["mention", "pleroma:chat_mention"]) + end + + defp change_email_notifications(email_notifications, :down) do + Map.delete(email_notifications, "notifications") + end +end diff --git a/priv/repo/migrations/20201214160053_add_notified_at_to_notifications.exs b/priv/repo/migrations/20201214160053_add_notified_at_to_notifications.exs new file mode 100644 index 000000000..4e9255e09 --- /dev/null +++ b/priv/repo/migrations/20201214160053_add_notified_at_to_notifications.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.AddNotifiedAtToNotifications do + use Ecto.Migration + + def up do + alter table(:notifications) do + add_if_not_exists(:notified_at, :naive_datetime) + end + end + + def down do + alter table(:notifications) do + remove_if_exists(:notified_at, :naive_datetime) + end + end +end diff --git a/priv/repo/migrations/20201221060622_fill_notifications_notified_at.exs b/priv/repo/migrations/20201221060622_fill_notifications_notified_at.exs new file mode 100644 index 000000000..f02899ab7 --- /dev/null +++ b/priv/repo/migrations/20201221060622_fill_notifications_notified_at.exs @@ -0,0 +1,23 @@ +defmodule Pleroma.Repo.Migrations.FillNotificationsNotifiedAt do + use Ecto.Migration + + import Ecto.Query, only: [from: 2] + + @types ["mention", "pleroma:chat_mention"] + + def up do + from(n in "notifications", + where: is_nil(n.notified_at), + where: n.type in ^@types + ) + |> Pleroma.Repo.update_all(set: [notified_at: NaiveDateTime.utc_now()]) + end + + def down do + from(n in "notifications", + where: not is_nil(n.notified_at), + where: n.type in ^@types + ) + |> Pleroma.Repo.update_all(set: [notified_at: nil]) + end +end diff --git a/priv/repo/migrations/20201222052839_add_index_to_notifications.exs b/priv/repo/migrations/20201222052839_add_index_to_notifications.exs new file mode 100644 index 000000000..5b0420296 --- /dev/null +++ b/priv/repo/migrations/20201222052839_add_index_to_notifications.exs @@ -0,0 +1,7 @@ +defmodule Pleroma.Repo.Migrations.AddIndexToNotifications do + use Ecto.Migration + + def change do + create_if_not_exists(index(:notifications, [:seen, :notified_at, :type, :inserted_at])) + end +end diff --git a/test/mix/tasks/pleroma/user_test.exs b/test/mix/tasks/pleroma/user_test.exs index a2178bbd1..5b170b57c 100644 --- a/test/mix/tasks/pleroma/user_test.exs +++ b/test/mix/tasks/pleroma/user_test.exs @@ -617,4 +617,93 @@ defmodule Mix.Tasks.Pleroma.UserTest do assert mod.is_confirmed end end + + describe "email_notifications" do + setup do + user = insert(:user, email_notifications: %{"digest" => false, "notifications" => []}) + [user: user] + end + + test "no changes error", %{user: user} do + Mix.Tasks.Pleroma.User.run(["email_notifications", user.nickname]) + + assert_received {:mix_shell, :error, ["No changes passed"]} + end + + test "user not found" do + Mix.Tasks.Pleroma.User.run(["email_notifications", "nickname", "--digest"]) + + assert_received {:mix_shell, :error, ["No local user nickname"]} + end + + test "all settings", %{user: user} do + assert user.email_notifications == %{"digest" => false, "notifications" => []} + + Mix.Tasks.Pleroma.User.run([ + "email_notifications", + user.nickname, + "--digest", + "-n", + "mention,pleroma:chat_mention," + ]) + + from_db = User.get_cached_by_nickname(user.nickname) + + assert from_db.email_notifications == %{ + "digest" => true, + "notifications" => ["mention", "pleroma:chat_mention"] + } + + Mix.Tasks.Pleroma.User.run([ + "email_notifications", + user.nickname, + "--no-digest", + "-n", + "off" + ]) + + from_db = User.get_cached_by_nickname(user.nickname) + assert from_db.email_notifications == %{"digest" => false, "notifications" => []} + end + + test "partial update", %{user: user} do + Mix.Tasks.Pleroma.User.run([ + "email_notifications", + user.nickname, + "--digest" + ]) + + from_db = User.get_cached_by_nickname(user.nickname) + assert from_db.email_notifications == %{"digest" => true, "notifications" => []} + + Mix.Tasks.Pleroma.User.run([ + "email_notifications", + user.nickname, + "--no-digest" + ]) + + from_db = User.get_cached_by_nickname(user.nickname) + assert from_db.email_notifications == %{"digest" => false, "notifications" => []} + + Mix.Tasks.Pleroma.User.run([ + "email_notifications", + user.nickname, + "-n", + "mention" + ]) + + from_db = User.get_cached_by_nickname(user.nickname) + assert from_db.email_notifications == %{"digest" => false, "notifications" => ["mention"]} + + Mix.Tasks.Pleroma.User.run([ + "email_notifications", + user.nickname, + "-n", + "off" + ]) + + from_db = User.get_cached_by_nickname(user.nickname) + assert from_db.email_notifications == %{"digest" => false, "notifications" => []} + end + end end diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index abf1b0410..28ac57ed2 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -1155,4 +1155,94 @@ defmodule Pleroma.NotificationTest do assert length(Notification.for_user(user)) == 1 end end + + describe "users_ids_with_unread_mentions/0" do + setup do + now = NaiveDateTime.utc_now() + inserted_at = NaiveDateTime.add(now, -61) + + insert(:notification, seen: true, type: "mention", inserted_at: inserted_at) + insert(:notification, type: "follow", inserted_at: inserted_at) + insert(:notification, type: "mention") + mention = insert(:notification, type: "mention", inserted_at: inserted_at) + chat_mention = insert(:notification, type: "pleroma:chat_mention", inserted_at: inserted_at) + + insert(:notification, + type: "mention", + notified_at: now, + inserted_at: inserted_at + ) + + [ + mention: mention, + chat_mention: chat_mention, + now: now + ] + end + + test "when mentions are in the timeframe", %{ + mention: mention, + chat_mention: chat_mention, + now: now + } do + assert Notification.users_ids_with_unread_mentions(NaiveDateTime.add(now, -60)) == [ + mention.user_id, + chat_mention.user_id + ] + end + + test "when mentions are out of the timeframe", %{now: now} do + assert Notification.users_ids_with_unread_mentions(NaiveDateTime.add(now, -62)) == [] + end + end + + describe "for_user_unread_mentions/1" do + setup do + [user, muted, blocked] = insert_list(3, :user) + {:ok, _} = User.mute(user, muted) + {:ok, _} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"}) + {:ok, _} = User.block(user, blocked) + {:ok, _} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"}) + + insert(:notification, type: "mention", user: user) + insert(:notification, type: "pleroma:chat_mention", user: user) + + inserted_at = NaiveDateTime.add(NaiveDateTime.utc_now(), -61) + Repo.update_all(Notification, set: [inserted_at: inserted_at]) + [user: user, max: NaiveDateTime.add(NaiveDateTime.utc_now(), -60)] + end + + test "when mentions are in timeframe, exclude blocks and mutes", %{user: user, max: max} do + assert Repo.aggregate(Notification, :count, :id) == 4 + assert user |> Notification.for_user_unread_mentions(max) |> length() == 2 + end + + test "when mentions are out of the timeframe, exclude blocks and mutes", %{ + user: user, + max: max + } do + assert Notification.for_user_unread_mentions(user, NaiveDateTime.add(max, -2)) == [] + end + + test "respect user notification types", %{user: user, max: max} do + user = + Map.update!( + user, + :email_notifications, + &Map.put(&1, "notifications", ["pleroma:chat_mention"]) + ) + + [mention] = Notification.for_user_unread_mentions(user, max) + assert mention.type == "pleroma:chat_mention" + end + end + + test "update_notified_at/1" do + notifs = insert_list(2, :notification) + + assert {2, nil} = + notifs + |> Enum.map(& &1.id) + |> Notification.update_notified_at() + end end diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index d81c1b8eb..060d14101 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -2239,18 +2239,19 @@ defmodule Pleroma.UserTest do end end - describe "update_email_notifications/2" do - setup do - user = insert(:user, email_notifications: %{"digest" => true}) + test "update_email_notifications/2" do + user = insert(:user, email_notifications: %{"digest" => false, "notifications" => []}) + assert user.email_notifications["digest"] == false + assert user.email_notifications["notifications"] == [] - {:ok, user: user} - end + assert {:ok, result} = + User.update_email_notifications(user, %{ + "digest" => true, + "notifications" => ["mention", "pleroma:chat_mention"] + }) - test "Notifications are updated", %{user: user} do - true = user.email_notifications["digest"] - assert {:ok, result} = User.update_email_notifications(user, %{"digest" => false}) - assert result.email_notifications["digest"] == false - end + assert result.email_notifications["digest"] + assert result.email_notifications["notifications"] == ["mention", "pleroma:chat_mention"] end describe "local_nickname/1" do diff --git a/test/pleroma/web/mastodon_api/update_credentials_test.exs b/test/pleroma/web/mastodon_api/update_credentials_test.exs index cfbe6cf0e..d24264fea 100644 --- a/test/pleroma/web/mastodon_api/update_credentials_test.exs +++ b/test/pleroma/web/mastodon_api/update_credentials_test.exs @@ -206,6 +206,38 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do assert user_data["source"]["pleroma"]["no_rich_text"] == true end + test "updates the user's email_notifications setting", %{conn: conn} do + resp = + patch(conn, "/api/v1/accounts/update_credentials", %{ + email_notifications: %{ + "digest" => true, + "notifications" => [] + } + }) + + assert user_data = json_response_and_validate_schema(resp, 200) + + assert user_data["pleroma"]["email_notifications"] == %{ + "digest" => true, + "notifications" => [] + } + + resp = + patch(conn, "/api/v1/accounts/update_credentials", %{ + email_notifications: %{ + "digest" => false, + "notifications" => ["mention", "pleroma:chat_mention"] + } + }) + + assert user_data = json_response_and_validate_schema(resp, 200) + + assert user_data["pleroma"]["email_notifications"] == %{ + "digest" => false, + "notifications" => ["mention", "pleroma:chat_mention"] + } + end + test "updates the user's name", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{"display_name" => "markorepairs"}) diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index 5373a17c3..d69224319 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -90,7 +90,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do hide_follows_count: false, relationship: %{}, skip_thread_containment: false, - accepts_chat_messages: nil + accepts_chat_messages: nil, + email_notifications: %{ + "digest" => false, + "notifications" => ["mention", "pleroma:chat_mention"] + } } } @@ -190,7 +194,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do hide_follows_count: false, relationship: %{}, skip_thread_containment: false, - accepts_chat_messages: nil + accepts_chat_messages: nil, + email_notifications: %{ + "digest" => false, + "notifications" => ["mention", "pleroma:chat_mention"] + } } } diff --git a/test/pleroma/workers/cron/email_mentions_worker_test.exs b/test/pleroma/workers/cron/email_mentions_worker_test.exs new file mode 100644 index 000000000..3ffe2561d --- /dev/null +++ b/test/pleroma/workers/cron/email_mentions_worker_test.exs @@ -0,0 +1,107 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.EmailMentionsWorkerTest do + use Pleroma.DataCase + use Oban.Testing, repo: Pleroma.Repo + + import Pleroma.Factory + import Swoosh.TestAssertions + + alias Pleroma.Workers.Cron.EmailMentionsWorker + + setup do + clear_config(EmailMentionsWorker, enabled: true, timeframe: 1) + inserted_at = NaiveDateTime.add(NaiveDateTime.utc_now(), -61) + + n1 = insert(:notification, seen: true, type: "mention", inserted_at: inserted_at) + n2 = insert(:notification, type: "follow", inserted_at: inserted_at) + n3 = insert(:notification, type: "mention") + mention = insert(:notification, type: "mention", inserted_at: inserted_at) + chat_mention = insert(:notification, type: "pleroma:chat_mention", inserted_at: inserted_at) + + n4 = + insert(:notification, + type: "mention", + notified_at: NaiveDateTime.utc_now(), + inserted_at: inserted_at + ) + + [ + mention: mention, + chat_mention: chat_mention, + other_user_ids: [n1.user_id, n2.user_id, n3.user_id, n4.user_id] + ] + end + + test "creates jobs for users", %{ + mention: mention, + chat_mention: chat_mention, + other_user_ids: ids + } do + assert EmailMentionsWorker.perform(%{}) == :ok + + assert_enqueued( + worker: EmailMentionsWorker, + args: %{op: "email_mentions", user_id: mention.user_id} + ) + + assert_enqueued( + worker: EmailMentionsWorker, + args: %{op: "email_mentions", user_id: chat_mention.user_id} + ) + + Enum.each(ids, fn id -> + refute_enqueued(worker: EmailMentionsWorker, args: %{op: "email_mentions", user_id: id}) + end) + + assert Repo.aggregate(Oban.Job, :count, :id) == 2 + + EmailMentionsWorker.perform(%{}) + + # no duplicates + assert Repo.aggregate(Oban.Job, :count, :id) == 2 + end + + test "doesn't create jobs for users without emails", %{mention: mention} do + %{user: user} = Repo.preload(mention, :user) + + user + |> Ecto.Changeset.change(email: nil) + |> Repo.update() + + assert EmailMentionsWorker.perform(%{}) == :ok + + refute_enqueued( + worker: EmailMentionsWorker, + args: %{op: "email_mentions", user_id: mention.user_id} + ) + end + + test "sends emails", %{mention: mention, chat_mention: chat_mention} do + assert EmailMentionsWorker.perform(%{}) == :ok + + mention = Repo.preload(mention, :user) + + assert EmailMentionsWorker.perform(%Oban.Job{ + args: %{"op" => "email_mentions", "user_id" => mention.user_id} + }) == :ok + + assert_email_sent( + to: {mention.user.name, mention.user.email}, + html_body: ~r/here is what you've missed!/i + ) + + chat_mention = Repo.preload(chat_mention, :user) + + assert EmailMentionsWorker.perform(%Oban.Job{ + args: %{"op" => "email_mentions", "user_id" => chat_mention.user_id} + }) == :ok + + assert_email_sent( + to: {chat_mention.user.name, chat_mention.user.email}, + html_body: ~r/here is what you've missed!/i + ) + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 5c4e65c81..136435396 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -469,7 +469,8 @@ defmodule Pleroma.Factory do def notification_factory do %Pleroma.Notification{ - user: build(:user) + user: build(:user), + activity: build(:note_activity) } end