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
@@ -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>,