diff --git a/apps/frontend/src/pages/settings.vue b/apps/frontend/src/pages/settings.vue
index 9a5f41c8b9..1237409561 100644
--- a/apps/frontend/src/pages/settings.vue
+++ b/apps/frontend/src/pages/settings.vue
@@ -35,6 +35,13 @@
icon: ShieldIcon,
}
: null,
+ auth.user?.email
+ ? {
+ link: '/settings/notifications',
+ label: formatMessage(commonSettingsMessages.notifications),
+ icon: BellIcon,
+ }
+ : null,
auth.user
? {
link: '/settings/authorizations',
@@ -83,6 +90,7 @@
+
+
+
+
+
diff --git a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json
deleted file mode 100644
index 921f7f92d9..0000000000
--- a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Int8"
- },
- {
- "ordinal": 1,
- "name": "status: PayoutStatus",
- "type_info": "Varchar"
- }
- ],
- "parameters": {
- "Left": []
- },
- "nullable": [
- false,
- false
- ]
- },
- "hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286"
-}
diff --git a/apps/labrinth/.sqlx/query-434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29.json b/apps/labrinth/.sqlx/query-434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29.json
new file mode 100644
index 0000000000..1398949cb0
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO users_notifications_preferences (\n user_id, channel, notification_type, enabled\n )\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (COALESCE(user_id, -1), channel, notification_type)\n DO UPDATE SET enabled = $4\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Varchar",
+ "Varchar",
+ "Bool"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "434f55e473f787e2b401195440e03fb132a2c6a37c6d040f51ccdd0feb44ee29"
+}
diff --git a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json
deleted file mode 100644
index 89bd8147dc..0000000000
--- a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "status: PayoutStatus",
- "type_info": "Varchar"
- }
- ],
- "parameters": {
- "Left": []
- },
- "nullable": [
- false
- ]
- },
- "hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3"
-}
diff --git a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json
deleted file mode 100644
index 469c30168a..0000000000
--- a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Int8",
- "Text",
- "Text",
- "Varchar",
- "Int8"
- ]
- },
- "nullable": []
- },
- "hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02"
-}
diff --git a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json
deleted file mode 100644
index 52e020ebf2..0000000000
--- a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Int8",
- "Text",
- "Varchar",
- "Int8"
- ]
- },
- "nullable": []
- },
- "hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606"
-}
diff --git a/apps/labrinth/src/database/models/users_notifications_preferences_item.rs b/apps/labrinth/src/database/models/users_notifications_preferences_item.rs
index 1ba5e1ce01..259f7ed782 100644
--- a/apps/labrinth/src/database/models/users_notifications_preferences_item.rs
+++ b/apps/labrinth/src/database/models/users_notifications_preferences_item.rs
@@ -108,4 +108,31 @@ impl UserNotificationPreference {
Ok(())
}
+
+ pub async fn upsert(
+ user_id: DBUserId,
+ channel: NotificationChannel,
+ notification_type: NotificationType,
+ enabled: bool,
+ exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result<(), DatabaseError> {
+ sqlx::query!(
+ "
+ INSERT INTO users_notifications_preferences (
+ user_id, channel, notification_type, enabled
+ )
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (COALESCE(user_id, -1), channel, notification_type)
+ DO UPDATE SET enabled = $4
+ ",
+ user_id.0,
+ channel.as_str(),
+ notification_type.as_str(),
+ enabled,
+ )
+ .execute(exec)
+ .await?;
+
+ Ok(())
+ }
}
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index e213bbf611..c4546958d4 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -10,6 +10,7 @@ pub mod gotenberg;
pub mod medal;
pub mod moderation;
pub mod mural;
+pub mod notification_preferences;
pub mod pats;
pub mod search;
pub mod session;
@@ -34,6 +35,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(statuses::config)
.configure(medal::config)
.configure(external_notifications::config)
+ .configure(notification_preferences::config)
.configure(mural::config)
.configure(delphi::config),
);
diff --git a/apps/labrinth/src/routes/internal/notification_preferences.rs b/apps/labrinth/src/routes/internal/notification_preferences.rs
new file mode 100644
index 0000000000..d642fbf170
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/notification_preferences.rs
@@ -0,0 +1,145 @@
+use crate::auth::get_user_from_headers;
+use crate::database::models::notifications_type_item::NotificationTypeItem;
+use crate::database::models::users_notifications_preferences_item::UserNotificationPreference;
+use crate::database::redis::RedisPool;
+use crate::database::PgPool;
+use crate::models::pats::Scopes;
+use crate::models::v3::notifications::{NotificationChannel, NotificationType};
+use crate::queue::session::AuthQueue;
+use crate::routes::ApiError;
+use actix_web::web::{self, Data};
+use actix_web::{HttpRequest, HttpResponse, get, patch};
+use serde::{Deserialize, Serialize};
+
+pub fn config(cfg: &mut web::ServiceConfig) {
+ cfg.service(get_email_preferences);
+ cfg.service(set_email_preferences);
+}
+
+#[derive(Serialize)]
+pub struct NotificationPreferenceEntry {
+ pub notification_type: NotificationType,
+ pub channel: NotificationChannel,
+ pub enabled: bool,
+}
+
+#[derive(Serialize)]
+pub struct EmailPreferencesResponse {
+ pub notification_types: Vec,
+ pub preferences: Vec,
+}
+
+#[get("email_preferences")]
+pub async fn get_email_preferences(
+ req: HttpRequest,
+ pool: Data,
+ redis: Data,
+ session_queue: Data,
+) -> Result {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::SESSION_ACCESS,
+ )
+ .await?
+ .1;
+
+ let notification_types =
+ NotificationTypeItem::list(&**pool, &redis).await?;
+
+ let exposed_types: Vec = notification_types
+ .iter()
+ .filter(|t| t.expose_in_user_preferences)
+ .map(|t| t.name)
+ .collect();
+
+ let user_prefs = UserNotificationPreference::get_user_or_default(
+ user.id.into(),
+ &**pool,
+ )
+ .await?;
+
+ let preferences: Vec = user_prefs
+ .into_iter()
+ .filter(|p| {
+ exposed_types.contains(&p.notification_type)
+ })
+ .map(|p| NotificationPreferenceEntry {
+ notification_type: p.notification_type,
+ channel: p.channel,
+ enabled: p.enabled,
+ })
+ .collect();
+
+ Ok(HttpResponse::Ok().json(EmailPreferencesResponse {
+ notification_types: exposed_types,
+ preferences,
+ }))
+}
+
+#[derive(Deserialize)]
+pub struct UpdatePreferenceEntry {
+ pub notification_type: NotificationType,
+ pub channel: NotificationChannel,
+ pub enabled: bool,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateEmailPreferencesRequest {
+ pub preferences: Vec,
+}
+
+#[patch("email_preferences")]
+pub async fn set_email_preferences(
+ req: HttpRequest,
+ pool: Data,
+ redis: Data,
+ session_queue: Data,
+ body: web::Json,
+) -> Result {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::SESSION_ACCESS,
+ )
+ .await?
+ .1;
+
+ let notification_types =
+ NotificationTypeItem::list(&**pool, &redis).await?;
+
+ let exposed_types: Vec = notification_types
+ .iter()
+ .filter(|t| t.expose_in_user_preferences)
+ .map(|t| t.name)
+ .collect();
+
+ let mut transaction = pool.begin().await?;
+
+ for pref in &body.preferences {
+ if !exposed_types.contains(&pref.notification_type) {
+ return Err(ApiError::InvalidInput(format!(
+ "Notification type '{}' is not configurable",
+ pref.notification_type.as_str()
+ )));
+ }
+
+ UserNotificationPreference::upsert(
+ user.id.into(),
+ pref.channel,
+ pref.notification_type,
+ pref.enabled,
+ &mut transaction,
+ )
+ .await?;
+ }
+
+ transaction.commit().await?;
+
+ Ok(HttpResponse::NoContent().finish())
+}
+
diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts
index e2c1db7914..9218b90f86 100644
--- a/packages/ui/src/utils/common-messages.ts
+++ b/packages/ui/src/utils/common-messages.ts
@@ -646,6 +646,10 @@ export const commonSettingsMessages = defineMessages({
id: 'settings.language.title',
defaultMessage: 'Language',
},
+ notifications: {
+ id: 'settings.notifications.title',
+ defaultMessage: 'Email notifications',
+ },
pats: {
id: 'settings.pats.title',
defaultMessage: 'Personal access tokens',