batch mention email notifications in timeframe

parent 42e495df
Pipeline #34346 passed with stages
in 6 minutes
......@@ -35,7 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- OAuth improvements and fixes: more secure session-based authentication (by token that could be revoked anytime), ability to revoke belonging OAuth token from any client etc.
- Ability to set ActivityPub aliases for follower migration.
- Configurable background job limits for RichMedia (link previews) and MediaProxyWarmingPolicy
- Email with missed mentions in a specific period.
<details>
<summary>API Changes</summary>
......
......@@ -553,10 +553,12 @@
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}
]
......@@ -837,6 +839,10 @@
{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"
......@@ -302,3 +302,36 @@
```sh
mix pleroma.user unconfirm_all
```
## Update email notifications settings for user
=== "OTP"
```sh
./bin/pleroma_ctl user email_notifications <nickname> [option ...]
```
=== "From Source"
```sh
mix pleroma.user email_notifications <nickname> [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
```
......@@ -691,6 +691,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,
......@@ -702,6 +703,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}
]
......@@ -1122,3 +1124,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)
......@@ -77,6 +77,7 @@ Has these additional fields under the `pleroma` object:
- `notification_settings`: object, can be absent. See `/api/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
......
......@@ -21,7 +21,7 @@ def run(["create" | options]) do
scopes =
if opts[:scopes] do
String.split(opts[:scopes], ",")
String.split(opts[:scopes], ",", trim: true)
else
["read", "write", "follow", "push"]
end
......
......@@ -427,6 +427,35 @@ def run(["list"]) 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
......
......@@ -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
......@@ -106,6 +107,27 @@ def approval_pending_email(user) 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
......@@ -113,25 +135,12 @@ def approval_pending_email(user) 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
......@@ -151,7 +160,6 @@ def digest_email(user) do
unless Enum.empty?(mentions) do
styling = Config.get([__MODULE__, :styling])
logo = Config.get([__MODULE__, :logo])
html_data = %{
instance: instance_name(),
......@@ -162,20 +170,15 @@ def digest_email(user) 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
......@@ -228,4 +231,42 @@ def backup_is_ready_email(backup, admin_user_id \\ nil) 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
......@@ -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 @@ def fill_in_notification_types 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
......
......@@ -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
......@@ -253,7 +254,7 @@ def for_user(user, opts \\ %{}) 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
......@@ -668,4 +669,48 @@ def mark_context_as_read(%User{id: id}, context) 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
......@@ -131,7 +131,11 @@ defmodule Pleroma.User 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(: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: %{})
......@@ -516,7 +520,8 @@ def update_changeset(struct, params \\ %{}) do
:pleroma_settings_store,
:is_discoverable,
:actor_type,
:accepts_chat_messages
:accepts_chat_messages,
:email_notifications
]
)
|> unique_constraint(:nickname)
......@@ -2365,17 +2370,14 @@ def remove_pinnned_activity(user, %Pleroma.Activity{id: id, data: data}) 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
......@@ -2406,8 +2408,8 @@ defp add_to_block(%User{} = user, %User{} = blocked) 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}")
......
......@@ -632,7 +632,8 @@ defp update_credentials_request 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,
......@@ -757,6 +758,31 @@ defp mute_request 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",
......
......@@ -213,6 +213,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p
|> 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:
#
......
......@@ -277,7 +277,8 @@ defp do_render("show.json", %{user: user} = opts) 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])
......
......@@ -126,7 +126,7 @@
<div align="center" class="img-container center"
style="padding-right: 0px;padding-left: 0px;">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr style="line-height:0px"><td style="padding-right: 0px;padding-left: 0px;" align="center"><![endif]--><img
align="center" alt="Image" border="0" class="center" src="cid:logo.svg"
align="center" alt="Image" border="0" class="center" src="cid:<%= @logo %>"
style="text-decoration: none; -ms-interpolation-mode: bicubic; border: 0; height: 80px; width: auto; max-height: 80px; display: block;"
title="Image" height="80" />
<!--[if mso]></td></tr></table><![endif]-->
......
This diff is collapsed.
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# 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
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