Compare commits

...
Author SHA1 Message Date
sychic 838ae9a16c feat(labrinth): return more detailed failure information for external
notifications
2026-07-01 20:48:46 -04:00
sychic 7f1a3f7140 style(labrinth): derive default 2026-07-01 11:47:17 -04:00
sychic c09b8cc4e0 refactor(labrinth): combine logic for external notification endpoints
also introduce new notification that doesn't send an email
2026-07-01 11:47:16 -04:00
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::auth::get_user_from_headers; use crate::auth::get_user_from_headers;
use crate::database::PgPool; use crate::database::PgPool;
use crate::database::models::ids::{DBNotificationId, DBUserId}; use crate::database::models::ids::{DBNotificationId, DBUserId};
@@ -5,26 +7,26 @@ use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser; use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::notifications::NotificationDeliveryStatus;
use crate::models::users::Role; use crate::models::users::Role;
use crate::models::v3::notifications::{ use crate::models::v3::notifications::{Notification, NotificationBody};
Notification, NotificationBody, NotificationDeliveryStatus,
};
use crate::models::v3::pats::Scopes; use crate::models::v3::pats::Scopes;
use crate::queue::email::EmailQueue; use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::routes::ApiError; use crate::routes::ApiError;
use crate::routes::internal::external_notifications::EmailFailure::{
FailedToSend, MailboxNotFound, UserNotFound,
};
use crate::routes::internal::statuses::broadcast_friends_message; use crate::routes::internal::statuses::broadcast_friends_message;
use crate::sync::friends::RedisFriendsMessage; use crate::sync::friends::RedisFriendsMessage;
use crate::util::guards::external_notification_key_guard; use crate::util::guards::external_notification_key_guard;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::web; use actix_web::web;
use actix_web::{ use actix_web::{HttpRequest, HttpResponse, delete, post};
CustomizeResponder, HttpRequest, HttpResponse, Responder, delete, post,
};
use ariadne::ids::UserId; use ariadne::ids::UserId;
use eyre::eyre; use eyre::eyre;
use lettre::message::Mailbox; use lettre::message::Mailbox;
use serde::Deserialize; use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(create) cfg.service(create)
@@ -33,66 +35,72 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(send_custom_email); .service(send_custom_email);
} }
#[derive(Deserialize, PartialEq, Default)]
enum EmailStrategy {
#[default]
Async,
Sync,
None,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateNotification { struct CreateNotification {
pub body: NotificationBody, pub body: NotificationBody,
pub user_ids: Vec<UserId>, pub user_ids: Vec<UserId>,
#[serde(default)]
pub email: EmailStrategy,
}
#[derive(thiserror::Error, Debug, Serialize)]
#[serde(tag = "type", content = "data")]
enum EmailFailure {
#[error("user not found")]
UserNotFound,
#[error("mailbox not found")]
MailboxNotFound,
#[error("failed to send: {0:?}")]
FailedToSend(NotificationDeliveryStatus),
#[error("api error: {0}")]
ApiError(
#[serde(serialize_with = "serialize_api_error")]
#[from]
crate::routes::ApiError,
),
}
fn serialize_api_error<S>(
error: &ApiError,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
error.as_api_error().serialize(serializer)
} }
#[post("external_notifications", guard = "external_notification_key_guard")] #[post("external_notifications", guard = "external_notification_key_guard")]
pub async fn create( pub async fn create(
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>, create_notification: web::Json<CreateNotification>,
) -> Result<HttpResponse, ApiError> { ) -> Result<(web::Json<HashMap<UserId, EmailFailure>>, StatusCode), ApiError> {
let CreateNotification { body, user_ids } = create_impl(pool, redis, email_queue, create_notification.into_inner())
create_notification.into_inner(); .await
let user_ids = user_ids
.into_iter()
.map(|x| DBUserId(x.0 as i64))
.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(),
));
}
let notification_ids = NotificationBuilder { body }
.insert_many(user_ids, &mut txn, &redis)
.await?;
let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?;
txn.commit().await?;
broadcast_notifications(&redis, notifications).await;
Ok(HttpResponse::Accepted().finish())
} }
/// Inserts notifications for all users and tries to send emails immediately. async fn create_impl(
///
/// 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>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>, email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>, data: CreateNotification,
) -> Result<CustomizeResponder<web::Json<Vec<UserId>>>, ApiError> { ) -> Result<(web::Json<HashMap<UserId, EmailFailure>>, StatusCode), ApiError> {
let CreateNotification { body, user_ids } = let CreateNotification {
create_notification.into_inner(); body,
user_ids,
email: email_strategy,
} = data;
let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>(); let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>();
let user_ids = raw_user_ids let user_ids = raw_user_ids
.iter() .iter()
.map(|x| DBUserId(*x)) .map(|x| DBUserId(*x))
@@ -129,9 +137,21 @@ pub async fn create_email_sync(
.filter(|id| !already_notified.contains(id)) .filter(|id| !already_notified.contains(id))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let notification_ids = NotificationBuilder { body: body.clone() } let notification_builder = NotificationBuilder { body: body.clone() };
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
.await?; let notification_ids = if email_strategy == EmailStrategy::Async {
notification_builder
.insert_many(notification_user_ids, &mut txn, &redis)
.await?
} else {
notification_builder
.insert_many_without_delivery(
notification_user_ids,
&mut txn,
&redis,
)
.await?
};
let notifications = let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?; get_site_exposed_notifications(&notification_ids, &mut txn).await?;
@@ -140,42 +160,98 @@ pub async fn create_email_sync(
broadcast_notifications(&redis, notifications).await; broadcast_notifications(&redis, notifications).await;
let mut email_txn = pool.begin().await?; if email_strategy == EmailStrategy::Sync {
let mut email_txn = pool.begin().await?;
let mut failed = Vec::new(); let mut failed = HashMap::new();
for user_id in &user_ids { let users = DBUser::get_many_ids(&user_ids, &mut email_txn, &redis)
let Some(user) = .await?
DBUser::get_id(*user_id, &mut email_txn, &redis).await? .into_iter()
else { .map(|user| (user.id, user))
failed.push(UserId(user_id.0 as u64)); .collect::<HashMap<_, _>>();
continue;
};
let delivered = match user for db_user_id in &user_ids {
.email let user_id = UserId(db_user_id.0 as u64);
.and_then(|email| email.parse::<Mailbox>().ok()) let Some(user) = users.get(db_user_id) else {
{ failed.insert(user_id, UserNotFound);
Some(mailbox) => { continue;
email_queue };
.send_one(&mut email_txn, body.clone(), *user_id, mailbox)
.await?
== NotificationDeliveryStatus::Delivered
}
None => false,
};
if !delivered { let Some(mailbox) = user
failed.push(UserId(user_id.0 as u64)); .email
.as_ref()
.and_then(|email| email.parse::<Mailbox>().ok())
else {
failed.insert(user_id, MailboxNotFound);
continue;
};
match email_queue
.send_one(&mut email_txn, body.clone(), *db_user_id, mailbox)
.await
{
Ok(status) => {
if status != NotificationDeliveryStatus::Delivered {
failed.insert(user_id, FailedToSend(status));
}
}
Err(error) => {
if matches!(
error,
ApiError::SqlxDatabase(_) | ApiError::Database(_)
) {
return Err(error);
};
failed.insert(user_id, error.into());
}
};
} }
email_txn.commit().await?;
let status = if failed
.values()
.any(|x| matches!(x, EmailFailure::ApiError(_)))
{
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::OK
};
return Ok((web::Json(failed), status));
} }
let status = if failed.is_empty() { Ok((web::Json(HashMap::new()), StatusCode::ACCEPTED))
StatusCode::OK }
} else {
StatusCode::MULTI_STATUS
};
Ok(web::Json(failed).customize().with_status(status)) /// Inserts notifications for all users and tries to send emails immediately.
///
/// Responds with the user IDs that could not be emailed and a reason why
#[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>,
data: web::Json<CreateNotification>,
) -> Result<(web::Json<Vec<UserId>>, StatusCode), ApiError> {
let data = data.into_inner();
create_impl(
pool,
redis,
email_queue,
CreateNotification {
body: data.body,
user_ids: data.user_ids,
email: EmailStrategy::Sync,
},
)
.await
.map(|(res, code)| {
(web::Json(res.into_inner().into_keys().collect()), code)
})
} }
#[derive(Deserialize)] #[derive(Deserialize)]