From cd7dced0a689ce8080cf5bdf9c1fb7ef634c4bce Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Tue, 26 May 2026 12:38:46 -0400 Subject: [PATCH] feat: direct email notification endpoint --- .../src/database/models/notification_item.rs | 34 +++++- .../routes/internal/external_notifications.rs | 110 +++++++++++++++++- 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs index c25858ef29..0e73465377 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, diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs index def435071a..9c870cf4f0 100644 --- a/apps/labrinth/src/routes/internal/external_notifications.rs +++ b/apps/labrinth/src/routes/internal/external_notifications.rs @@ -5,19 +5,28 @@ 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, 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_direct_email) + .service(send_custom_email); } #[derive(Deserialize)] @@ -56,6 +65,99 @@ pub async fn create( Ok(HttpResponse::Accepted().finish()) } +/// Directly sends emails to users and inserts notifications when emails are +/// delivered successfully. +/// +/// Responds with the user IDs that could not be emailed: +/// - `200` if every recipient was delivered (empty list) +/// - `207` if some recipients failed (list of failed IDs) +/// - `500` if no recipient was delivered +#[post( + "external_notifications/direct-email", + guard = "external_notification_key_guard" +)] +pub async fn create_direct_email( + 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 user_ids = user_ids + .into_iter() + .map(|x| DBUserId(x.0 as i64)) + .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(), + )); + } + + let mut results: Vec> = + Vec::with_capacity(user_ids.len()); + + for user_id in &user_ids { + let user = DBUser::get_id(*user_id, &mut txn, &redis).await?.ok_or( + ApiError::Internal(eyre!( + "user `{}` disappeared while sending notification email", + user_id.0 + )), + )?; + + let delivered = + match user.email.and_then(|email| email.parse::().ok()) { + Some(mailbox) => { + email_queue + .send_one(&mut txn, body.clone(), *user_id, mailbox) + .await? + == NotificationDeliveryStatus::Delivered + } + None => false, + }; + + results.push(if delivered { + Ok(*user_id) + } else { + Err(*user_id) + }); + } + + let delivered = results + .iter() + .filter_map(|result| result.as_ref().ok().copied()) + .collect::>(); + + if delivered.is_empty() { + return Err(ApiError::Internal(eyre!( + "failed to deliver notification email to any of {} recipients", + user_ids.len(), + ))); + } + + NotificationBuilder { body } + .insert_many_without_delivery(delivered, &mut txn, &redis) + .await?; + + txn.commit().await?; + + let failed = results + .into_iter() + .filter_map(|result| result.err().map(|id| UserId(id.0 as u64))) + .collect::>(); + + let status = if failed.is_empty() { + StatusCode::OK + } else { + StatusCode::MULTI_STATUS + }; + + Ok(web::Json(failed).customize().with_status(status)) +} + #[derive(Deserialize)] struct SendEmail { pub users: Vec,