You've already forked AstralRinth
forked from didirus/AstralRinth
[DO NOT MERGE] Email notification system (#4338)
* Migration * Fixup db models * Redis * Stuff * Switch PKs to BIGSERIALs, insert to notifications_deliveries when inserting notifications * Queue, templates * Query cache * Fixes, fixtures * Perf, cache template data & HTML bodies * Notification type configuration, ResetPassword notification type * Reset password * Query cache * Clippy + fmt * Traces, fix typo, fix user email in ResetPassword * send_email * Models, db * Remove dead code, adjust notification settings in migration * Clippy fmt * Delete dead code, fixes * Fmt * Update apps/labrinth/src/queue/email.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com> * Remove old fixtures * Unify email retry delay * Fix type * External notifications * Remove `notifications_types_preference_restrictions`, as user notification preferences is out of scope for this PR * Query cache, fmt, clippy * Fix join in get_many_user_exposed_on_site * Remove migration comment * Query cache * Update html body urls * Remove comment * Add paymentfailed.service variable to PaymentFailed notification variant * Fix compile error * Fix deleting notifications * Update apps/labrinth/src/database/models/user_item.rs Co-authored-by: Josiah Glosson <soujournme@gmail.com> Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com> * Update apps/labrinth/src/database/models/user_item.rs Co-authored-by: Josiah Glosson <soujournme@gmail.com> Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com> * Update Cargo.toml Co-authored-by: Josiah Glosson <soujournme@gmail.com> Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com> * Update apps/labrinth/migrations/20250902133943_notification-extension.sql Co-authored-by: Josiah Glosson <soujournme@gmail.com> Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com> * Address review comments * Fix compliation * Update apps/labrinth/src/database/models/users_notifications_preferences_item.rs Co-authored-by: Josiah Glosson <soujournme@gmail.com> Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com> * Use strfmt to format emails * Configurable Reply-To * Configurable Reply-To * Refactor for email background task * Send some emails inline * Fix account creation email check * Revert "Use strfmt to format emails" This reverts commit e0d6614afe51fa6349918377e953ba294c34ae0b. * Reintroduce fill_template * Set password reset email inline * Process more emails per index * clippy fmt * Query cache --------- Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Josiah Glosson <soujournme@gmail.com>
This commit is contained in:
committed by
GitHub
parent
1491642209
commit
902d749293
339
apps/labrinth/src/queue/email.rs
Normal file
339
apps/labrinth/src/queue/email.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use crate::database::models::ids::*;
|
||||
use crate::database::models::notification_item::DBNotification;
|
||||
use crate::database::models::notifications_deliveries_item::DBNotificationDelivery;
|
||||
use crate::database::models::notifications_template_item::NotificationTemplate;
|
||||
use crate::database::models::user_item::DBUser;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::models::v3::notifications::{
|
||||
NotificationChannel, NotificationDeliveryStatus,
|
||||
};
|
||||
use crate::routes::ApiError;
|
||||
use chrono::Utc;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use reqwest::Client;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
|
||||
const EMAIL_RETRY_DELAY_SECONDS: i64 = 10;
|
||||
|
||||
pub enum Mailer {
|
||||
Uninitialized,
|
||||
Initialized(Arc<AsyncSmtpTransport<Tokio1Executor>>),
|
||||
}
|
||||
|
||||
impl Mailer {
|
||||
pub async fn to_transport(
|
||||
&mut self,
|
||||
) -> Result<Arc<AsyncSmtpTransport<Tokio1Executor>>, MailError> {
|
||||
let maybe_transport = match self {
|
||||
Mailer::Uninitialized => {
|
||||
let username = dotenvy::var("SMTP_USERNAME")?;
|
||||
let password = dotenvy::var("SMTP_PASSWORD")?;
|
||||
let host = dotenvy::var("SMTP_HOST")?;
|
||||
let port =
|
||||
dotenvy::var("SMTP_PORT")?.parse::<u16>().unwrap_or(465);
|
||||
|
||||
let creds = (!username.is_empty())
|
||||
.then(|| Credentials::new(username, password));
|
||||
|
||||
let tls_setting = match dotenvy::var("SMTP_TLS")?.as_str() {
|
||||
"none" => Tls::None,
|
||||
"opportunistic_start_tls" => Tls::Opportunistic(
|
||||
TlsParameters::new(host.to_string())?,
|
||||
),
|
||||
"requires_start_tls" => {
|
||||
Tls::Required(TlsParameters::new(host.to_string())?)
|
||||
}
|
||||
"tls" => {
|
||||
Tls::Wrapper(TlsParameters::new(host.to_string())?)
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
"Unrecognized SMTP TLS setting. Defaulting to TLS."
|
||||
);
|
||||
Tls::Wrapper(TlsParameters::new(host.to_string())?)
|
||||
}
|
||||
};
|
||||
|
||||
let mut mailer =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
|
||||
.port(port)
|
||||
.tls(tls_setting);
|
||||
|
||||
if let Some(creds) = creds {
|
||||
mailer = mailer.credentials(creds);
|
||||
}
|
||||
|
||||
let mailer = mailer.build();
|
||||
|
||||
let result = mailer.test_connection().await;
|
||||
|
||||
match &result {
|
||||
Ok(true) => Some(Arc::new(mailer)),
|
||||
Ok(false) => {
|
||||
error!("SMTP NOOP failed, disabling mailer");
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, "Failed to test SMTP connection, disabling mailer");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Mailer::Initialized(transport) => Some(Arc::clone(transport)),
|
||||
};
|
||||
|
||||
let transport =
|
||||
maybe_transport.ok_or_else(|| MailError::Uninitialized)?;
|
||||
*self = Mailer::Initialized(Arc::clone(&transport));
|
||||
Ok(transport)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MailError {
|
||||
#[error("Environment Error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("Mail Error: {0}")]
|
||||
Mail(#[from] lettre::error::Error),
|
||||
#[error("Address Parse Error: {0}")]
|
||||
Address(#[from] lettre::address::AddressError),
|
||||
#[error("SMTP Error: {0}")]
|
||||
Smtp(#[from] lettre::transport::smtp::Error),
|
||||
#[error("Couldn't initialize SMTP transport")]
|
||||
Uninitialized,
|
||||
#[error("HTTP error fetching template: {0}")]
|
||||
HttpTemplate(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EmailQueue {
|
||||
pg: PgPool,
|
||||
client: reqwest::Client,
|
||||
redis: RedisPool,
|
||||
mailer: Arc<TokioMutex<Mailer>>,
|
||||
identity: templates::MailingIdentity,
|
||||
}
|
||||
|
||||
impl EmailQueue {
|
||||
/// Initializes the email queue from environment variables, and tests the SMTP connection.
|
||||
///
|
||||
/// # Panic
|
||||
///
|
||||
/// Panics if a TLS backend cannot be initialized by [`reqwest::ClientBuilder`].
|
||||
pub fn init(pg: PgPool, redis: RedisPool) -> Result<Self, MailError> {
|
||||
Ok(Self {
|
||||
pg,
|
||||
redis,
|
||||
mailer: Arc::new(TokioMutex::new(Mailer::Uninitialized)),
|
||||
identity: templates::MailingIdentity::from_env()?,
|
||||
client: Client::builder()
|
||||
.user_agent("Modrinth")
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(name = "EmailQueue::index", skip_all)]
|
||||
pub async fn index(&self) -> Result<(), ApiError> {
|
||||
let transport = self.mailer.lock().await.to_transport().await?;
|
||||
|
||||
let begin = std::time::Instant::now();
|
||||
|
||||
let mut deliveries = DBNotificationDelivery::lock_channel_processable(
|
||||
NotificationChannel::Email,
|
||||
50,
|
||||
&self.pg,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if deliveries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let n_to_process = deliveries.len();
|
||||
|
||||
// Auto-fail deliveries which have been attempted over 3 times to avoid
|
||||
// ballooning the error rate.
|
||||
for d in deliveries.iter_mut().filter(|d| d.attempt_count >= 3) {
|
||||
d.status = NotificationDeliveryStatus::PermanentlyFailed;
|
||||
d.update(&self.pg).await?;
|
||||
}
|
||||
|
||||
// We hold a FOR UPDATE lock on the rows here, so no other workers are accessing them
|
||||
// at the same time.
|
||||
|
||||
let notification_ids = deliveries
|
||||
.iter()
|
||||
.filter(|d| d.attempt_count < 3)
|
||||
.map(|d| d.notification_id)
|
||||
.collect::<Vec<_>>();
|
||||
let notifications =
|
||||
DBNotification::get_many(¬ification_ids, &self.pg).await?;
|
||||
|
||||
// For all notifications we collected, fill out the template
|
||||
// and send it via SMTP in parallel.
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
for notification in notifications {
|
||||
let this = self.clone();
|
||||
let transport = Arc::clone(&transport);
|
||||
|
||||
futures.push(async move {
|
||||
let mut txn = this.pg.begin().await?;
|
||||
|
||||
let maybe_user = DBUser::get_id(
|
||||
notification.user_id,
|
||||
&mut *txn,
|
||||
&this.redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(mailbox) = maybe_user
|
||||
.and_then(|user| user.email)
|
||||
.and_then(|email| email.parse().ok())
|
||||
else {
|
||||
return Ok((
|
||||
notification.id,
|
||||
NotificationDeliveryStatus::SkippedPreferences,
|
||||
));
|
||||
};
|
||||
|
||||
this.send_one_with_transport(
|
||||
&mut txn,
|
||||
transport,
|
||||
notification.body,
|
||||
notification.user_id,
|
||||
mailbox,
|
||||
)
|
||||
.await
|
||||
.map(|status| (notification.id, status))
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = futures.next().await {
|
||||
match result {
|
||||
Ok((notification_id, status)) => {
|
||||
if let Some(idx) = deliveries
|
||||
.iter()
|
||||
.position(|d| d.notification_id == notification_id)
|
||||
{
|
||||
let update_next_attempt =
|
||||
status == NotificationDeliveryStatus::Pending;
|
||||
|
||||
let mut delivery = deliveries.remove(idx);
|
||||
delivery.status = status;
|
||||
delivery.next_attempt += if update_next_attempt {
|
||||
chrono::Duration::seconds(EMAIL_RETRY_DELAY_SECONDS)
|
||||
} else {
|
||||
chrono::Duration::seconds(0)
|
||||
};
|
||||
|
||||
delivery.attempt_count += 1;
|
||||
delivery.update(&self.pg).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Err(error) => error!(%error, "Error building email"),
|
||||
}
|
||||
}
|
||||
|
||||
for mut delivery in deliveries {
|
||||
// For these, there was an error building the email, like a
|
||||
// database error. Retry them after a delay.
|
||||
|
||||
delivery.next_attempt = Utc::now()
|
||||
+ chrono::Duration::seconds(EMAIL_RETRY_DELAY_SECONDS);
|
||||
|
||||
delivery.update(&self.pg).await?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Processed {} email deliveries in {}ms",
|
||||
n_to_process,
|
||||
begin.elapsed().as_millis()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_one(
|
||||
&self,
|
||||
txn: &mut sqlx::PgTransaction<'_>,
|
||||
notification: NotificationBody,
|
||||
user_id: DBUserId,
|
||||
address: Mailbox,
|
||||
) -> Result<NotificationDeliveryStatus, ApiError> {
|
||||
let transport = self.mailer.lock().await.to_transport().await?;
|
||||
self.send_one_with_transport(
|
||||
txn,
|
||||
transport,
|
||||
notification,
|
||||
user_id,
|
||||
address,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_one_with_transport(
|
||||
&self,
|
||||
txn: &mut sqlx::PgTransaction<'_>,
|
||||
transport: Arc<AsyncSmtpTransport<Tokio1Executor>>,
|
||||
notification: NotificationBody,
|
||||
user_id: DBUserId,
|
||||
address: Mailbox,
|
||||
) -> Result<NotificationDeliveryStatus, ApiError> {
|
||||
// If there isn't any template present in the database for the
|
||||
// notification type, skip it.
|
||||
|
||||
let Some(template) = NotificationTemplate::list_channel(
|
||||
NotificationChannel::Email,
|
||||
&mut **txn,
|
||||
&self.redis,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|t| t.notification_type == notification.notification_type()) else {
|
||||
return Ok(NotificationDeliveryStatus::SkippedDefault);
|
||||
};
|
||||
|
||||
let message = templates::build_email(
|
||||
&mut **txn,
|
||||
&self.redis,
|
||||
&self.client,
|
||||
user_id,
|
||||
¬ification,
|
||||
&template,
|
||||
self.identity.clone(),
|
||||
address,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let send_result = transport.send(message).await;
|
||||
|
||||
Ok(send_result.map_or_else(|error| {
|
||||
error!(%error, smtp.code = ?extract_smtp_code(&error), "Error sending email");
|
||||
|
||||
if error.is_permanent() {
|
||||
NotificationDeliveryStatus::PermanentlyFailed
|
||||
} else {
|
||||
NotificationDeliveryStatus::Pending
|
||||
}
|
||||
}, |_| NotificationDeliveryStatus::Delivered))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_smtp_code(e: &lettre::transport::smtp::Error) -> Option<u16> {
|
||||
e.status().map(|x| x.into())
|
||||
}
|
||||
|
||||
mod templates;
|
||||
403
apps/labrinth/src/queue/email/templates.rs
Normal file
403
apps/labrinth/src/queue/email/templates.rs
Normal file
@@ -0,0 +1,403 @@
|
||||
use super::MailError;
|
||||
use crate::database::models::DBUser;
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::models::ids::*;
|
||||
use crate::database::models::notifications_template_item::NotificationTemplate;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::v3::notifications::NotificationBody;
|
||||
use crate::routes::ApiError;
|
||||
use futures::TryFutureExt;
|
||||
use lettre::Message;
|
||||
use lettre::message::{Mailbox, MultiPart, SinglePart};
|
||||
use sqlx::query;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, warn};
|
||||
|
||||
const USER_NAME: &str = "user.name";
|
||||
const USER_EMAIL: &str = "user.email";
|
||||
|
||||
const RESETPASSWORD_URL: &str = "resetpassword.url";
|
||||
const VERIFYEMAIL_URL: &str = "verifyemail.url";
|
||||
const AUTHPROVIDER_NAME: &str = "authprovider.name";
|
||||
const EMAILCHANGED_NEW_EMAIL: &str = "emailchanged.new_email";
|
||||
const BILLING_URL: &str = "billing.url";
|
||||
|
||||
const PAYMENTFAILED_AMOUNT: &str = "paymentfailed.amount";
|
||||
const PAYMENTFAILED_SERVICE: &str = "paymentfailed.service";
|
||||
|
||||
const TEAMINVITE_INVITER_NAME: &str = "teaminvite.inviter.name";
|
||||
const TEAMINVITE_PROJECT_NAME: &str = "teaminvite.project.name";
|
||||
const TEAMINVITE_ROLE_NAME: &str = "teaminvite.role.name";
|
||||
|
||||
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 STATUSCHANGE_PROJECT_NAME: &str = "statuschange.project.name";
|
||||
const STATUSCHANGE_OLD_STATUS: &str = "statuschange.old.status";
|
||||
const STATUSCHANGE_NEW_STATUS: &str = "statuschange.new.status";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MailingIdentity {
|
||||
from_name: String,
|
||||
from_address: String,
|
||||
reply_name: Option<String>,
|
||||
reply_address: Option<String>,
|
||||
}
|
||||
|
||||
impl MailingIdentity {
|
||||
pub fn from_env() -> dotenvy::Result<Self> {
|
||||
Ok(Self {
|
||||
from_name: dotenvy::var("SMTP_FROM_NAME")?,
|
||||
from_address: dotenvy::var("SMTP_FROM_ADDRESS")?,
|
||||
reply_name: dotenvy::var("SMTP_REPLY_TO_NAME").ok(),
|
||||
reply_address: dotenvy::var("SMTP_REPLY_TO_ADDRESS").ok(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_email(
|
||||
exec: impl sqlx::PgExecutor<'_>,
|
||||
redis: &RedisPool,
|
||||
client: &reqwest::Client,
|
||||
user_id: DBUserId,
|
||||
body: &NotificationBody,
|
||||
template: &NotificationTemplate,
|
||||
from: MailingIdentity,
|
||||
to: Mailbox,
|
||||
) -> Result<Message, ApiError> {
|
||||
let get_html_body = async {
|
||||
let result: Result<Result<String, reqwest::Error>, ApiError> =
|
||||
match template.get_cached_html_data(redis).await? {
|
||||
Some(html_body) => Ok(Ok(html_body)),
|
||||
None => {
|
||||
let result = client
|
||||
.get(&template.body_fetch_url)
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.and_then(|res| async move { res.error_for_status() })
|
||||
.and_then(|res| res.text())
|
||||
.await;
|
||||
|
||||
if let Ok(ref body) = result {
|
||||
template
|
||||
.set_cached_html_data(body.clone(), redis)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
};
|
||||
|
||||
result
|
||||
};
|
||||
|
||||
let MailingIdentity {
|
||||
from_name,
|
||||
from_address,
|
||||
reply_name,
|
||||
reply_address,
|
||||
} = from;
|
||||
|
||||
let (html_body_result, mut variables) = futures::try_join!(
|
||||
get_html_body,
|
||||
collect_template_variables(exec, redis, user_id, body)
|
||||
)?;
|
||||
|
||||
variables.insert(USER_EMAIL, to.email.to_string());
|
||||
|
||||
let mut message_builder = Message::builder().from(Mailbox::new(
|
||||
Some(from_name),
|
||||
from_address.parse().map_err(MailError::from)?,
|
||||
));
|
||||
|
||||
if let Some((name, address)) = reply_name.zip(reply_address) {
|
||||
message_builder = message_builder.reply_to(Mailbox::new(
|
||||
Some(name),
|
||||
address.parse().map_err(MailError::from)?,
|
||||
));
|
||||
}
|
||||
|
||||
message_builder = message_builder.to(to).subject(&template.subject_line);
|
||||
|
||||
let plaintext_filled_body =
|
||||
fill_template(&template.plaintext_fallback, &variables);
|
||||
|
||||
let email_message = match html_body_result {
|
||||
Ok(html_body) => {
|
||||
let html_filled_body = fill_template(&html_body, &variables);
|
||||
message_builder
|
||||
.multipart(MultiPart::alternative_plain_html(
|
||||
plaintext_filled_body,
|
||||
html_filled_body,
|
||||
))
|
||||
.map_err(MailError::from)?
|
||||
}
|
||||
|
||||
Err(error) => {
|
||||
error!(%error, "Failed to fetch template body");
|
||||
message_builder
|
||||
.singlepart(SinglePart::plain(plaintext_filled_body))
|
||||
.map_err(MailError::from)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(email_message)
|
||||
}
|
||||
|
||||
fn fill_template(
|
||||
mut text: &str,
|
||||
variables: &HashMap<&'static str, String>,
|
||||
) -> String {
|
||||
let mut buffer = String::with_capacity(text.len());
|
||||
|
||||
loop {
|
||||
if let Some((previous, start_variable)) = text.split_once('{') {
|
||||
buffer.push_str(previous);
|
||||
|
||||
if let Some((variable_name, rest)) = start_variable.split_once('}')
|
||||
{
|
||||
// Replace variable with an empty string if it isn't matched
|
||||
buffer.push_str(
|
||||
variables
|
||||
.get(variable_name)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
text = rest;
|
||||
} else {
|
||||
warn!("Unmatched open brace in template");
|
||||
text = start_variable;
|
||||
}
|
||||
} else {
|
||||
buffer.push_str(text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
}
|
||||
|
||||
async fn collect_template_variables(
|
||||
exec: impl sqlx::PgExecutor<'_>,
|
||||
redis: &RedisPool,
|
||||
user_id: DBUserId,
|
||||
n: &NotificationBody,
|
||||
) -> Result<HashMap<&'static str, String>, ApiError> {
|
||||
async fn only_select_default_variables(
|
||||
exec: impl sqlx::PgExecutor<'_>,
|
||||
redis: &RedisPool,
|
||||
user_id: DBUserId,
|
||||
) -> Result<HashMap<&'static str, String>, ApiError> {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
let user = DBUser::get_id(user_id, exec, redis)
|
||||
.await?
|
||||
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
|
||||
|
||||
map.insert(USER_NAME, user.username);
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
match &n {
|
||||
NotificationBody::TeamInvite {
|
||||
team_id: _,
|
||||
project_id,
|
||||
invited_by,
|
||||
role,
|
||||
} => {
|
||||
let result = query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.username "user_name!",
|
||||
users.email "user_email",
|
||||
inviter.username "inviter_name!",
|
||||
project.name "project_name!"
|
||||
FROM users
|
||||
INNER JOIN users inviter ON inviter.id = $1
|
||||
INNER JOIN mods project ON project.id = $2
|
||||
WHERE users.id = $3
|
||||
"#,
|
||||
invited_by.0 as i64,
|
||||
project_id.0 as i64,
|
||||
user_id.0 as i64
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(USER_NAME, result.user_name);
|
||||
map.insert(TEAMINVITE_INVITER_NAME, result.inviter_name);
|
||||
map.insert(TEAMINVITE_PROJECT_NAME, result.project_name);
|
||||
map.insert(TEAMINVITE_ROLE_NAME, role.clone());
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::OrganizationInvite {
|
||||
organization_id,
|
||||
invited_by,
|
||||
team_id: _,
|
||||
role,
|
||||
} => {
|
||||
let result = query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.username "user_name!",
|
||||
users.email "user_email",
|
||||
inviter.username "inviter_name!",
|
||||
organization.name "organization_name!"
|
||||
FROM users
|
||||
INNER JOIN users inviter ON inviter.id = $1
|
||||
INNER JOIN organizations organization ON organization.id = $2
|
||||
WHERE users.id = $3
|
||||
"#,
|
||||
invited_by.0 as i64,
|
||||
organization_id.0 as i64,
|
||||
user_id.0 as i64
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(USER_NAME, result.user_name);
|
||||
map.insert(ORGINVITE_INVITER_NAME, result.inviter_name);
|
||||
map.insert(ORGINVITE_ORG_NAME, result.organization_name);
|
||||
map.insert(ORGINVITE_ROLE_NAME, role.clone());
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::StatusChange {
|
||||
project_id,
|
||||
old_status,
|
||||
new_status,
|
||||
} => {
|
||||
let result = query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.username "user_name!",
|
||||
users.email "user_email",
|
||||
project.name "project_name!"
|
||||
FROM users
|
||||
INNER JOIN mods project ON project.id = $1
|
||||
WHERE users.id = $2
|
||||
"#,
|
||||
project_id.0 as i64,
|
||||
user_id.0 as i64,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(USER_NAME, result.user_name);
|
||||
map.insert(STATUSCHANGE_PROJECT_NAME, result.project_name);
|
||||
map.insert(STATUSCHANGE_OLD_STATUS, old_status.as_str().to_owned());
|
||||
map.insert(STATUSCHANGE_NEW_STATUS, new_status.as_str().to_owned());
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::ResetPassword { flow } => {
|
||||
let url = format!(
|
||||
"{}/{}?flow={}",
|
||||
dotenvy::var("SITE_URL")?,
|
||||
dotenvy::var("SITE_RESET_PASSWORD_PATH")?,
|
||||
flow
|
||||
);
|
||||
|
||||
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|
||||
|| DatabaseError::Database(sqlx::Error::RowNotFound),
|
||||
)?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(RESETPASSWORD_URL, url);
|
||||
map.insert(USER_NAME, user.username);
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::VerifyEmail { flow } => {
|
||||
let url = format!(
|
||||
"{}/{}?flow={}",
|
||||
dotenvy::var("SITE_URL")?,
|
||||
dotenvy::var("SITE_VERIFY_EMAIL_PATH")?,
|
||||
flow
|
||||
);
|
||||
|
||||
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|
||||
|| DatabaseError::Database(sqlx::Error::RowNotFound),
|
||||
)?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(VERIFYEMAIL_URL, url);
|
||||
map.insert(USER_NAME, user.username);
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::AuthProviderAdded { provider }
|
||||
| NotificationBody::AuthProviderRemoved { provider } => {
|
||||
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|
||||
|| DatabaseError::Database(sqlx::Error::RowNotFound),
|
||||
)?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(USER_NAME, user.username);
|
||||
map.insert(AUTHPROVIDER_NAME, provider.clone());
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::TwoFactorEnabled
|
||||
| NotificationBody::TwoFactorRemoved
|
||||
| NotificationBody::PasswordChanged
|
||||
| NotificationBody::PasswordRemoved => {
|
||||
only_select_default_variables(exec, redis, user_id).await
|
||||
}
|
||||
|
||||
NotificationBody::EmailChanged {
|
||||
new_email,
|
||||
to_email: _,
|
||||
} => {
|
||||
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|
||||
|| DatabaseError::Database(sqlx::Error::RowNotFound),
|
||||
)?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(USER_NAME, user.username);
|
||||
map.insert(EMAILCHANGED_NEW_EMAIL, new_email.clone());
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::PaymentFailed { amount, service } => {
|
||||
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|
||||
|| DatabaseError::Database(sqlx::Error::RowNotFound),
|
||||
)?;
|
||||
|
||||
let url = format!(
|
||||
"{}/{}",
|
||||
dotenvy::var("SITE_URL")?,
|
||||
dotenvy::var("SITE_BILLING_PATH")?,
|
||||
);
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(USER_NAME, user.username);
|
||||
map.insert(PAYMENTFAILED_AMOUNT, amount.clone());
|
||||
map.insert(PAYMENTFAILED_SERVICE, service.clone());
|
||||
map.insert(BILLING_URL, url);
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::ProjectUpdate { .. }
|
||||
| NotificationBody::LegacyMarkdown { .. }
|
||||
| NotificationBody::ModeratorMessage { .. }
|
||||
| NotificationBody::Unknown => {
|
||||
only_select_default_variables(exec, redis, user_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analytics;
|
||||
pub mod email;
|
||||
pub mod maxmind;
|
||||
pub mod moderation;
|
||||
pub mod payouts;
|
||||
|
||||
Reference in New Issue
Block a user