From 6ee5e4df19a78da3ab92acefbe3c8302f81ef225 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Tue, 2 Jun 2026 17:34:04 +0100 Subject: [PATCH] feat: access labrinth backend (#6284) * feat: redirect `/hosting` to archon * feat: server invite notification type * feat: direct email notification endpoint * feat: revoke notification endpoint * feat: specify users to remove notifications from * refactor: insert notifications before sending emails * refactor: rename endpoint * refactor: remove archon redirect * style: mark field unused * feat: dedup external notifications * feat: add server invite email templates * style: remove unnecessary format --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com> --- apps/frontend/src/templates/emails/index.ts | 4 + .../templates/emails/server/ServerInvited.vue | 82 +++++++++ .../emails/server/ServerInvitedNoAccount.vue | 80 +++++++++ ...18427375b203777a68fd6ece1a50feaf41df9.json | 23 +++ ...951273c087bc112a9848e9bdda37c7fe61747.json | 23 +++ ...60525120000_server-invite-notification.sql | 28 +++ .../src/database/models/notification_item.rs | 66 ++++++- apps/labrinth/src/models/v2/notifications.rs | 21 +++ apps/labrinth/src/models/v3/notifications.rs | 41 +++++ apps/labrinth/src/queue/email/templates.rs | 25 +++ .../routes/internal/external_notifications.rs | 165 +++++++++++++++++- 11 files changed, 550 insertions(+), 8 deletions(-) create mode 100644 apps/frontend/src/templates/emails/server/ServerInvited.vue create mode 100644 apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue create mode 100644 apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json create mode 100644 apps/labrinth/.sqlx/query-a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747.json create mode 100644 apps/labrinth/migrations/20260525120000_server-invite-notification.sql diff --git a/apps/frontend/src/templates/emails/index.ts b/apps/frontend/src/templates/emails/index.ts index 719611b64..015c1fdeb 100644 --- a/apps/frontend/src/templates/emails/index.ts +++ b/apps/frontend/src/templates/emails/index.ts @@ -32,6 +32,10 @@ export default { 'project-invited': () => import('./project/ProjectInvited.vue'), 'project-transferred': () => import('./project/ProjectTransferred.vue'), + // Server + 'server-invited': () => import('./server/ServerInvited.vue'), + 'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'), + // Organizations 'organization-invited': () => import('./organization/OrganizationInvited.vue'), } as Record Promise<{ default: Component }>> diff --git a/apps/frontend/src/templates/emails/server/ServerInvited.vue b/apps/frontend/src/templates/emails/server/ServerInvited.vue new file mode 100644 index 000000000..b3876aa3e --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvited.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue new file mode 100644 index 000000000..07b6f7cbe --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue @@ -0,0 +1,80 @@ + + + diff --git a/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json b/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json new file mode 100644 index 000000000..b7e45e18a --- /dev/null +++ b/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM notifications\n WHERE body @> $1::jsonb\n AND user_id = ANY($2::bigint[])\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Jsonb", + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9" +} diff --git a/apps/labrinth/.sqlx/query-a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747.json b/apps/labrinth/.sqlx/query-a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747.json new file mode 100644 index 000000000..174fafcae --- /dev/null +++ b/apps/labrinth/.sqlx/query-a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT user_id\n FROM notifications\n WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747" +} diff --git a/apps/labrinth/migrations/20260525120000_server-invite-notification.sql b/apps/labrinth/migrations/20260525120000_server-invite-notification.sql new file mode 100644 index 000000000..db1fb6758 --- /dev/null +++ b/apps/labrinth/migrations/20260525120000_server-invite-notification.sql @@ -0,0 +1,28 @@ +INSERT INTO notifications_types + (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) +VALUES ('server_invite', 1, FALSE, TRUE); + +INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled) +VALUES (NULL, 'email', 'server_invite', FALSE); + +INSERT INTO notifications_templates + (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback) +VALUES + ( + 'email', + 'server_invite', + 'You''ve been invited to a server', + 'https://modrinth.com/_internal/templates/email/server-invited', + CONCAT( + 'Hi {user.name},', + CHR(10), + CHR(10), + 'Modrinth user {inviter.name} has invited you to help manage {server.name} on Modrinth Hosting with the {server.role} role.', + CHR(10), + CHR(10), + 'To accept or reject this invitation, open your Modrinth notifications: https://modrinth.com/dashboard/notifications', + CHR(10), + CHR(10), + 'If you were not expecting this invitation, contact the server owner or reach out to Modrinth Support at https://support.modrinth.com' + ) + ); diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs index c25858ef2..5ad8c897b 100644 --- a/apps/labrinth/src/database/models/notification_item.rs +++ b/apps/labrinth/src/database/models/notification_item.rs @@ -129,12 +129,11 @@ impl NotificationBuilder { Ok(()) } - pub async fn insert_many( + async fn insert_many_records( &self, - users: Vec, + users: &[DBUserId], transaction: &mut PgTransaction<'_>, - redis: &RedisPool, - ) -> Result<(), DatabaseError> { + ) -> Result, DatabaseError> { let notification_ids = generate_many_notification_ids(users.len(), &mut *transaction) .await?; @@ -163,6 +162,20 @@ impl NotificationBuilder { .execute(&mut *transaction) .await?; + Ok(notification_ids) + } + + pub async fn insert_many( + &self, + users: Vec, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let notification_ids = + self.insert_many_records(&users, transaction).await?; + + let users_raw_ids = users.iter().map(|x| x.0).collect::>(); + let notification_types = notification_ids .iter() .map(|_| self.body.notification_type().as_str()) @@ -181,6 +194,19 @@ impl NotificationBuilder { Ok(()) } + /// Like [`insert_many`], but skips queuing deliveries so the caller can + /// manually send the notifications. + pub async fn insert_many_without_delivery( + &self, + users: Vec, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + self.insert_many_records(&users, transaction).await?; + DBNotification::clear_user_notifications_cache(&users, redis).await?; + Ok(()) + } + pub async fn insert_many_deliveries( transaction: &mut PgTransaction<'_>, redis: &RedisPool, @@ -571,6 +597,38 @@ impl DBNotification { Ok(Some(())) } + pub async fn remove_many_matching_body( + body_filter: &serde_json::Value, + users: &[DBUserId], + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, + ) -> Result { + let user_ids = users.iter().map(|x| x.0).collect::>(); + + let ids = sqlx::query!( + " + SELECT id + FROM notifications + WHERE body @> $1::jsonb + AND user_id = ANY($2::bigint[]) + ", + body_filter, + &user_ids + ) + .fetch(&mut *transaction) + .map_ok(|x| DBNotificationId(x.id)) + .try_collect::>() + .await?; + + if ids.is_empty() { + return Ok(0); + } + + Self::remove_many(&ids, transaction, redis).await?; + + Ok(ids.len()) + } + pub async fn clear_user_notifications_cache( user_ids: impl IntoIterator, redis: &RedisPool, diff --git a/apps/labrinth/src/models/v2/notifications.rs b/apps/labrinth/src/models/v2/notifications.rs index 66252e5db..0f8894239 100644 --- a/apps/labrinth/src/models/v2/notifications.rs +++ b/apps/labrinth/src/models/v2/notifications.rs @@ -11,6 +11,7 @@ use crate::models::{ use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Serialize, Deserialize)] pub struct LegacyNotification { @@ -66,6 +67,12 @@ pub enum LegacyNotificationBody { team_id: TeamId, role: String, }, + ServerInvite { + server_id: Uuid, + server_name: String, + invited_by: UserId, + role: String, + }, StatusChange { project_id: ProjectId, old_status: ProjectStatus, @@ -166,6 +173,9 @@ impl LegacyNotification { NotificationBody::OrganizationInvite { .. } => { Some("organization_invite".to_string()) } + NotificationBody::ServerInvite { .. } => { + Some("server_invite".to_string()) + } NotificationBody::StatusChange { .. } => { Some("status_change".to_string()) } @@ -269,6 +279,17 @@ impl LegacyNotification { team_id, role, }, + NotificationBody::ServerInvite { + server_id, + server_name, + invited_by, + role, + } => LegacyNotificationBody::ServerInvite { + server_id, + server_name, + invited_by, + role, + }, NotificationBody::StatusChange { project_id, old_status, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index dc9ea448a..a2842b7b9 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -12,6 +12,7 @@ use crate::routes::ApiError; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Serialize, Deserialize)] pub struct Notification { @@ -34,6 +35,7 @@ pub enum NotificationType { ProjectUpdate, TeamInvite, OrganizationInvite, + ServerInvite, StatusChange, ModeratorMessage, LegacyMarkdown, @@ -67,6 +69,7 @@ impl NotificationType { NotificationType::ProjectUpdate => "project_update", NotificationType::TeamInvite => "team_invite", NotificationType::OrganizationInvite => "organization_invite", + NotificationType::ServerInvite => "server_invite", NotificationType::StatusChange => "status_change", NotificationType::ModeratorMessage => "moderator_message", NotificationType::LegacyMarkdown => "legacy_markdown", @@ -104,6 +107,7 @@ impl NotificationType { "project_update" => NotificationType::ProjectUpdate, "team_invite" => NotificationType::TeamInvite, "organization_invite" => NotificationType::OrganizationInvite, + "server_invite" => NotificationType::ServerInvite, "status_change" => NotificationType::StatusChange, "moderator_message" => NotificationType::ModeratorMessage, "legacy_markdown" => NotificationType::LegacyMarkdown, @@ -156,6 +160,12 @@ pub enum NotificationBody { team_id: TeamId, role: String, }, + ServerInvite { + server_id: Uuid, + server_name: String, + invited_by: UserId, + role: String, + }, StatusChange { project_id: ProjectId, old_status: ProjectStatus, @@ -267,6 +277,9 @@ impl NotificationBody { NotificationBody::OrganizationInvite { .. } => { NotificationType::OrganizationInvite } + NotificationBody::ServerInvite { .. } => { + NotificationType::ServerInvite + } NotificationBody::StatusChange { .. } => { NotificationType::StatusChange } @@ -418,6 +431,34 @@ impl From for Notification { }, ], ), + NotificationBody::ServerInvite { + server_id: _, + server_name, + role, + .. + } => ( + "You have been invited to join a server!".to_string(), + format!( + "An invite has been sent for you to be {role} of {server_name}" + ), + "#".to_string(), + vec![ + NotificationAction { + name: "Accept".to_string(), + action_route: ( + "POST".to_string(), + String::new(), + ), + }, + NotificationAction { + name: "Deny".to_string(), + action_route: ( + "POST".to_string(), + String::new(), + ), + }, + ], + ), NotificationBody::StatusChange { old_status, new_status, diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs index e1d3b97d0..5638947da 100644 --- a/apps/labrinth/src/queue/email/templates.rs +++ b/apps/labrinth/src/queue/email/templates.rs @@ -61,6 +61,10 @@ const ORGINVITE_INVITER_NAME: &str = "organizationinvite.inviter.name"; const ORGINVITE_ORG_NAME: &str = "organizationinvite.organization.name"; const ORGINVITE_ROLE_NAME: &str = "organizationinvite.role.name"; +const SERVERINVITE_INVITER_NAME: &str = "inviter.name"; +const SERVERINVITE_SERVER_NAME: &str = "server.name"; +const SERVERINVITE_ROLE_NAME: &str = "server.role"; + const STATUSCHANGE_PROJECT_NAME: &str = "statuschange.project.name"; const STATUSCHANGE_OLD_STATUS: &str = "statuschange.old.status"; const STATUSCHANGE_NEW_STATUS: &str = "statuschange.new.status"; @@ -735,6 +739,27 @@ async fn collect_template_variables( title: title.to_string(), }), + NotificationBody::ServerInvite { + server_name, + invited_by, + role, + .. + } => { + let inviter = DBUser::get_id( + DBUserId(invited_by.0 as i64), + &mut *exec, + redis, + ) + .await? + .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?; + + map.insert(SERVERINVITE_INVITER_NAME, inviter.username); + map.insert(SERVERINVITE_SERVER_NAME, server_name.clone()); + map.insert(SERVERINVITE_ROLE_NAME, role.clone()); + + Ok(EmailTemplate::Static(map)) + } + NotificationBody::ProjectUpdate { .. } | NotificationBody::ModeratorMessage { .. } | NotificationBody::LegacyMarkdown { .. } diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs index def435071..e6a3e17a9 100644 --- a/apps/labrinth/src/routes/internal/external_notifications.rs +++ b/apps/labrinth/src/routes/internal/external_notifications.rs @@ -1,23 +1,34 @@ use crate::auth::get_user_from_headers; use crate::database::PgPool; use crate::database::models::ids::DBUserId; +use crate::database::models::notification_item::DBNotification; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::user_item::DBUser; use crate::database::redis::RedisPool; use crate::models::users::Role; -use crate::models::v3::notifications::NotificationBody; +use crate::models::v3::notifications::{ + NotificationBody, NotificationDeliveryStatus, +}; use crate::models::v3::pats::Scopes; +use crate::queue::email::EmailQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::guards::external_notification_key_guard; -use actix_web::HttpRequest; +use actix_web::http::StatusCode; use actix_web::web; -use actix_web::{HttpResponse, post}; +use actix_web::{ + CustomizeResponder, HttpRequest, HttpResponse, Responder, delete, post, +}; use ariadne::ids::UserId; +use eyre::eyre; +use lettre::message::Mailbox; use serde::Deserialize; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(create).service(send_custom_email); + cfg.service(create) + .service(create_email_sync) + .service(remove) + .service(send_custom_email); } #[derive(Deserialize)] @@ -56,6 +67,152 @@ pub async fn create( Ok(HttpResponse::Accepted().finish()) } +/// Inserts notifications for all users and tries to send emails immediately. +/// +/// Responds with the user IDs that could not be emailed: +/// - `200` if every recipient was emailed (empty list) +/// - `207` if some recipients could not be emailed (list of failed IDs) +#[post( + "external_notifications/email-sync", + guard = "external_notification_key_guard" +)] +pub async fn create_email_sync( + pool: web::Data, + redis: web::Data, + email_queue: web::Data, + create_notification: web::Json, +) -> Result>>, ApiError> { + let CreateNotification { body, user_ids } = + create_notification.into_inner(); + let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::>(); + + let user_ids = raw_user_ids + .iter() + .map(|x| DBUserId(*x)) + .collect::>(); + + let mut txn = pool.begin().await?; + + if !DBUser::exists_many(&user_ids, &mut txn).await? { + return Err(ApiError::InvalidInput( + "One of the specified users do not exist.".to_owned(), + )); + } + + // Skip users who already have an identical notification + let body_value = serde_json::value::to_value(&body)?; + let already_notified = sqlx::query!( + " + SELECT DISTINCT user_id + FROM notifications + WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb + ", + &raw_user_ids[..], + body_value, + ) + .fetch_all(&mut txn) + .await? + .into_iter() + .map(|row| DBUserId(row.user_id)) + .collect::>(); + + let notification_user_ids = user_ids + .clone() + .into_iter() + .filter(|id| !already_notified.contains(id)) + .collect::>(); + + NotificationBuilder { body: body.clone() } + .insert_many_without_delivery(notification_user_ids, &mut txn, &redis) + .await?; + + txn.commit().await?; + + let mut email_txn = pool.begin().await?; + + let mut failed = Vec::new(); + for user_id in &user_ids { + let Some(user) = + DBUser::get_id(*user_id, &mut email_txn, &redis).await? + else { + failed.push(UserId(user_id.0 as u64)); + continue; + }; + + let delivered = match user + .email + .and_then(|email| email.parse::().ok()) + { + Some(mailbox) => { + email_queue + .send_one(&mut email_txn, body.clone(), *user_id, mailbox) + .await? + == NotificationDeliveryStatus::Delivered + } + None => false, + }; + + if !delivered { + failed.push(UserId(user_id.0 as u64)); + } + } + + let status = if failed.is_empty() { + StatusCode::OK + } else { + StatusCode::MULTI_STATUS + }; + + Ok(web::Json(failed).customize().with_status(status)) +} + +#[derive(Deserialize)] +struct NotificationFilter { + pub user_ids: Vec, + #[serde(flatten)] + pub body: serde_json::Map, +} + +#[delete("external_notifications", guard = "external_notification_key_guard")] +pub async fn remove( + pool: web::Data, + redis: web::Data, + notification_filter: web::Json, +) -> Result { + let NotificationFilter { user_ids, body } = + notification_filter.into_inner(); + + if user_ids.is_empty() { + return Err(ApiError::Request(eyre!( + "at least one user must be provided to remove notifications from" + ))); + } + + if body.is_empty() { + return Err(ApiError::Request(eyre!( + "at least one `body` field must be provided to match notifications" + ))); + } + + let filters = serde_json::Value::Object(body); + + let user_ids = user_ids + .into_iter() + .map(|x| DBUserId(x.0 as i64)) + .collect::>(); + + let mut txn = pool.begin().await?; + + DBNotification::remove_many_matching_body( + &filters, &user_ids, &mut txn, &redis, + ) + .await?; + + txn.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} + #[derive(Deserialize)] struct SendEmail { pub users: Vec,