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>
This commit is contained in:
Calum H.
2026-06-02 17:34:04 +01:00
committed by GitHub
parent d61397097c
commit 6ee5e4df19
11 changed files with 550 additions and 8 deletions
@@ -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<string, () => Promise<{ default: Component }>>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Button, Column, Heading, Link as VLink, Row, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You've been invited to a server"
:manual-links="[
{ link: 'https://modrinth.com/dashboard/notifications', label: 'Notification dashboard' },
{ link: 'https://support.modrinth.com', label: 'Support Portal' },
]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a server</Heading>
<Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base">
Modrinth user
<b
><VLink href="https://modrinth.com/user/{inviter.name}" class="text-green underline">
{inviter.name}
</VLink></b
>
has invited you to help manage
<b>{server.name}</b>
on Modrinth Hosting.
</Text>
<Section class="bg-bg-super mb-4 mt-4 rounded-lg border border-divider pb-4 pl-4 pr-4 pt-4">
<Text class="m-0 text-base">
You have been invited with the <b>{server.role}</b> role permission.
</Text>
</Section>
<Button
href="https://modrinth.com/dashboard/notifications"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Review invitation
</Button>
<VLink href="https://modrinth.com/dashboard/notifications">
<Text class="text-muted mt-2 break-words text-xs font-bold"
>https://modrinth.com/dashboard/notifications</Text
>
</VLink>
<Text class="text-base">
To accept or reject this invitation, open your Modrinth notifications and review the invite.
If you were not expecting this invitation, contact the server owner or reach out to Modrinth
Support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>.
</Text>
<Section class="border-0 border-t border-solid border-divider pt-4">
<Text class="mb-3 mt-0 text-base font-bold">What does my role mean?</Text>
<Row>
<Column class="pr-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Editor</Text>
<Text class="mb-0 mt-2 text-base">
Manage instance content, files, backups, and other settings.
</Text>
</Section>
</Column>
<Column class="pl-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Viewer</Text>
<Text class="mb-0 mt-2 text-base">
Start, stop, and view the server without making changes.
</Text>
</Section>
</Column>
</Row>
</Section>
</StyledEmail>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Button, Column, Heading, Link as VLink, Row, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You've been invited to a server"
:manual-links="[
{ link: '{serverinvite.url}', label: 'Create account and review invitation' },
{ link: 'https://support.modrinth.com', label: 'Support Portal' },
]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a server</Heading>
<Text class="text-base">Hi,</Text>
<Text class="text-base">
Modrinth user
<b
><VLink href="https://modrinth.com/user/{inviter.name}" class="text-green underline">
{inviter.name}
</VLink></b
>
has invited you to help manage
<b>{server.name}</b>
on Modrinth Hosting.
</Text>
<Section class="bg-bg-super mb-4 mt-4 rounded-lg border border-divider pb-4 pl-4 pr-4 pt-4">
<Text class="m-0 text-base">
You have been invited with the <b>{server.role}</b> role permission.
</Text>
</Section>
<Button
href="{serverinvite.url}"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Create account
</Button>
<VLink href="{serverinvite.url}">
<Text class="text-muted mt-2 break-words text-xs font-bold">{serverinvite.url}</Text>
</VLink>
<Text class="text-base">
To accept or reject this invitation, create a Modrinth account and review the invite from your
notifications dashboard. If you were not expecting this invitation, contact the server owner
or reach out to Modrinth Support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>.
</Text>
<Section class="border-0 border-t border-solid border-divider pt-4">
<Text class="mb-3 mt-0 text-base font-bold">What does my role let me do?</Text>
<Row>
<Column class="pr-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Editor</Text>
<Text class="mb-0 mt-2 text-base">
Manage instance content, files, backups, and other settings.
</Text>
</Section>
</Column>
<Column class="pl-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Viewer</Text>
<Text class="mb-0 mt-2 text-base">
Start, stop, and view the server without making changes.
</Text>
</Section>
</Column>
</Row>
</Section>
</StyledEmail>
</template>
@@ -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"
}
@@ -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"
}
@@ -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'
)
);
@@ -129,12 +129,11 @@ impl NotificationBuilder {
Ok(())
}
pub async fn insert_many(
async fn insert_many_records(
&self,
users: Vec<DBUserId>,
users: &[DBUserId],
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
) -> Result<Vec<i64>, 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<DBUserId>,
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::<Vec<_>>();
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<DBUserId>,
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<usize, DatabaseError> {
let user_ids = users.iter().map(|x| x.0).collect::<Vec<i64>>();
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::<Vec<_>>()
.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<Item = &DBUserId>,
redis: &RedisPool,
@@ -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,
@@ -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<DBNotification> 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,
@@ -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 { .. }
@@ -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<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>,
) -> Result<CustomizeResponder<web::Json<Vec<UserId>>>, ApiError> {
let CreateNotification { body, user_ids } =
create_notification.into_inner();
let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>();
let user_ids = raw_user_ids
.iter()
.map(|x| DBUserId(*x))
.collect::<Vec<_>>();
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::<std::collections::HashSet<_>>();
let notification_user_ids = user_ids
.clone()
.into_iter()
.filter(|id| !already_notified.contains(id))
.collect::<Vec<_>>();
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::<Mailbox>().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<UserId>,
#[serde(flatten)]
pub body: serde_json::Map<String, serde_json::Value>,
}
#[delete("external_notifications", guard = "external_notification_key_guard")]
pub async fn remove(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
notification_filter: web::Json<NotificationFilter>,
) -> Result<HttpResponse, ApiError> {
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::<Vec<_>>();
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<UserId>,