use super::MailError; use crate::database::models::ids::*; use crate::database::models::notifications_template_item::{ NotificationTemplate, get_or_set_cached_dynamic_html, }; use crate::database::models::{ DBOrganization, DBProject, DBUser, DatabaseError, }; use crate::database::redis::RedisPool; use crate::models::v3::notifications::NotificationBody; use crate::routes::ApiError; use crate::util::error::Context; use ariadne::ids::base62_impl::to_base62; 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 SUBSCRIPTION_ID: &str = "subscription.id"; const TAXNOTIFICATION_OLD_AMOUNT: &str = "taxnotification.old_amount"; const TAXNOTIFICATION_OLD_TAX_AMOUNT: &str = "taxnotification.old_tax_amount"; const TAXNOTIFICATION_OLD_TOTAL_AMOUNT: &str = "taxnotification.old_total_amount"; const TAXNOTIFICATION_NEW_AMOUNT: &str = "taxnotification.new_amount"; const TAXNOTIFICATION_NEW_TAX_AMOUNT: &str = "taxnotification.new_tax_amount"; const TAXNOTIFICATION_NEW_TOTAL_AMOUNT: &str = "taxnotification.new_total_amount"; const TAXNOTIFICATION_BILLING_INTERVAL: &str = "taxnotification.billing_interval"; const TAXNOTIFICATION_DUE: &str = "taxnotification.due"; const TAXNOTIFICATION_SERVICE: &str = "taxnotification.service"; const CREDIT_DAYS: &str = "credit.days_formatted"; const CREDIT_PREVIOUS_DUE: &str = "credit.previous_due"; const CREDIT_NEXT_DUE: &str = "credit.next_due"; const CREDIT_HEADER_MESSAGE: &str = "credit.header_message"; const CREDIT_SUBSCRIPTION_TYPE: &str = "credit.subscription.type"; 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"; const NEWPAT_TOKEN_NAME: &str = "newpat.token_name"; const PROJECT_ID: &str = "project.id"; const PROJECT_NAME: &str = "project.name"; const PROJECT_ICON_URL: &str = "project.icon_url"; const REPORT_ID: &str = "report.id"; const REPORT_TITLE: &str = "report.title"; const REPORT_DATE: &str = "report.date"; const NEWREPORT_ID: &str = "newreport.id"; const PROJECT_OLD_STATUS: &str = "project.oldstatus"; const PROJECT_NEW_STATUS: &str = "project.newstatus"; const NEWOWNER_TYPE: &str = "new_owner.type"; const NEWOWNER_TYPE_CAPITALIZED: &str = "new_owner.type_capitalized"; const NEWOWNER_NAME: &str = "new_owner.name"; const PAYOUTAVAILABLE_AMOUNT: &str = "payout.amount"; const PAYOUTAVAILABLE_PERIOD: &str = "payout.period"; #[derive(Clone)] pub struct MailingIdentity { from_name: String, from_address: String, reply_name: Option, reply_address: Option, } impl MailingIdentity { pub fn from_env() -> dotenvy::Result { 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: &mut sqlx::PgTransaction<'_>, redis: &RedisPool, client: &reqwest::Client, user_id: DBUserId, body: &NotificationBody, template: &NotificationTemplate, from: MailingIdentity, to: Mailbox, ) -> Result { let get_html_body = async { let result: Result, 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 db_user = DBUser::get_id(user_id, &mut **exec, redis) .await? .ok_or(DatabaseError::Database(sqlx::Error::RowNotFound))?; let map = [ (USER_NAME, db_user.username), (USER_EMAIL, to.email.to_string()), ] .into_iter() .collect(); let (html_body_result, either) = futures::try_join!( get_html_body, collect_template_variables(exec, redis, user_id, body, map) )?; 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)?, )); } struct Body { plaintext: Option, html: Option, } let (body, subject) = match either { EmailTemplate::Static(variables) => { let plaintext_filled_body = fill_template(&template.plaintext_fallback, &variables); let email_body = Body { plaintext: Some(plaintext_filled_body), html: match html_body_result { Ok(html_body) => { Some(fill_template(&html_body, &variables)) } Err(error) => { error!(%error, "Failed to fetch template body"); None } }, }; let subject = fill_template(&template.subject_line, &variables); (email_body, subject) } EmailTemplate::Dynamic { variables, body, title, } => { let body = Body { plaintext: None, html: Some(fill_template(&body, &variables)), }; (body, fill_template(&title, &variables)) } }; message_builder = message_builder.to(to).subject(subject); let email_message = match body { Body { plaintext: Some(plaintext), html: Some(html), } => message_builder .multipart(MultiPart::alternative_plain_html(plaintext, html)) .map_err(MailError::from)?, Body { plaintext: Some(plaintext), html: None, } => message_builder .singlepart(SinglePart::plain(plaintext)) .map_err(MailError::from)?, Body { plaintext: None, html: Some(html), } => message_builder .singlepart(SinglePart::html(html)) .map_err(MailError::from)?, Body { plaintext: None, html: None, } => { return Err(ApiError::Internal(eyre::eyre!( "Neither HTML or plaintext could be generated" ))); } }; 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 } enum EmailTemplate { Static(HashMap<&'static str, String>), Dynamic { variables: HashMap<&'static str, String>, body: String, title: String, }, } async fn collect_template_variables( exec: &mut sqlx::PgTransaction<'_>, redis: &RedisPool, user_id: DBUserId, n: &NotificationBody, mut map: HashMap<&'static str, String>, ) -> Result { match &n { NotificationBody::PatCreated { token_name } => { map.insert(NEWPAT_TOKEN_NAME, token_name.clone()); Ok(EmailTemplate::Static(map)) } NotificationBody::ModerationMessageReceived { project_id, .. } => { let result = DBProject::get_id( DBProjectId(project_id.0 as i64), exec, redis, ) .await? .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))? .inner; map.insert(PROJECT_ID, to_base62(project_id.0)); map.insert(PROJECT_NAME, result.name); map.insert(PROJECT_ICON_URL, result.icon_url.unwrap_or_default()); Ok(EmailTemplate::Static(map)) } NotificationBody::ReportStatusUpdated { report_id } => { let result = query!( r#" SELECT r.created, COALESCE(m.name, v.version_number, u.username, 'unknown') "title!" FROM reports r LEFT JOIN mods m ON r.mod_id = m.id LEFT JOIN versions v ON r.version_id = v.id LEFT JOIN users u ON r.user_id = u.id WHERE r.id = $1 "#, report_id.0 as i64 ) .fetch_one(&mut **exec) .await?; map.insert(REPORT_ID, to_base62(report_id.0)); map.insert(REPORT_TITLE, result.title); map.insert(REPORT_DATE, date_human_readable(result.created)); Ok(EmailTemplate::Static(map)) } NotificationBody::ReportSubmitted { report_id } => { let result = query!( r#" SELECT COALESCE(m.name, v.version_number, u.username, 'unknown') "title!" FROM reports r LEFT JOIN mods m ON r.mod_id = m.id LEFT JOIN versions v ON r.version_id = v.id LEFT JOIN users u ON r.user_id = u.id WHERE r.id = $1 "#, report_id.0 as i64 ) .fetch_one(&mut **exec) .await?; map.insert(REPORT_TITLE, result.title); map.insert(NEWREPORT_ID, to_base62(report_id.0)); Ok(EmailTemplate::Static(map)) } NotificationBody::ProjectStatusApproved { project_id } => { let result = query!( r#" SELECT name, icon_url FROM mods WHERE id = $1 "#, project_id.0 as i64 ) .fetch_one(&mut **exec) .await?; map.insert(PROJECT_ID, to_base62(project_id.0)); map.insert(PROJECT_NAME, result.name); map.insert(PROJECT_ICON_URL, result.icon_url.unwrap_or_default()); Ok(EmailTemplate::Static(map)) } NotificationBody::ProjectStatusNeutral { project_id, old_status, new_status, } => { let result = DBProject::get_id( DBProjectId(project_id.0 as i64), exec, redis, ) .await? .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))? .inner; map.insert(PROJECT_ID, to_base62(project_id.0)); map.insert(PROJECT_NAME, result.name); map.insert(PROJECT_ICON_URL, result.icon_url.unwrap_or_default()); map.insert(PROJECT_OLD_STATUS, old_status.as_str().to_string()); map.insert(PROJECT_NEW_STATUS, new_status.as_str().to_string()); Ok(EmailTemplate::Static(map)) } NotificationBody::ProjectTransferred { project_id, new_owner_user_id, new_owner_organization_id, } => { let project = DBProject::get_id( DBProjectId(project_id.0 as i64), &mut **exec, redis, ) .await? .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))? .inner; map.insert(PROJECT_ID, to_base62(project_id.0)); map.insert(PROJECT_NAME, project.name); map.insert(PROJECT_ICON_URL, project.icon_url.unwrap_or_default()); if let Some(new_owner_user_id) = new_owner_user_id { let user = DBUser::get_id( DBUserId(new_owner_user_id.0 as i64), &mut **exec, redis, ) .await? .ok_or_else(|| { DatabaseError::Database(sqlx::Error::RowNotFound) })?; map.insert(NEWOWNER_TYPE, "user".to_string()); map.insert(NEWOWNER_TYPE_CAPITALIZED, "User".to_string()); map.insert(NEWOWNER_NAME, user.username); } else if let Some(new_owner_organization_id) = new_owner_organization_id { let org = DBOrganization::get_id( DBOrganizationId(new_owner_organization_id.0 as i64), &mut **exec, redis, ) .await? .ok_or_else(|| { DatabaseError::Database(sqlx::Error::RowNotFound) })?; map.insert(NEWOWNER_TYPE, "organization".to_string()); map.insert( NEWOWNER_TYPE_CAPITALIZED, "Organization".to_string(), ); map.insert(NEWOWNER_NAME, org.name); } Ok(EmailTemplate::Static(map)) } 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(&mut **exec) .await?; map.insert(TEAMINVITE_INVITER_NAME, result.inviter_name); map.insert(TEAMINVITE_PROJECT_NAME, result.project_name); map.insert(TEAMINVITE_ROLE_NAME, role.clone()); Ok(EmailTemplate::Static(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(&mut **exec) .await?; map.insert(ORGINVITE_INVITER_NAME, result.inviter_name); map.insert(ORGINVITE_ORG_NAME, result.organization_name); map.insert(ORGINVITE_ROLE_NAME, role.clone()); Ok(EmailTemplate::Static(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(&mut **exec) .await?; 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(EmailTemplate::Static(map)) } NotificationBody::ResetPassword { flow } => { let url = format!( "{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_RESET_PASSWORD_PATH")?, flow ); map.insert(RESETPASSWORD_URL, url); Ok(EmailTemplate::Static(map)) } NotificationBody::VerifyEmail { flow } => { let url = format!( "{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_VERIFY_EMAIL_PATH")?, flow ); map.insert(VERIFYEMAIL_URL, url); Ok(EmailTemplate::Static(map)) } NotificationBody::AuthProviderAdded { provider } | NotificationBody::AuthProviderRemoved { provider } => { map.insert(AUTHPROVIDER_NAME, provider.clone()); Ok(EmailTemplate::Static(map)) } NotificationBody::TwoFactorEnabled | NotificationBody::TwoFactorRemoved | NotificationBody::PasswordChanged | NotificationBody::PasswordRemoved => Ok(EmailTemplate::Static(map)), NotificationBody::EmailChanged { new_email, to_email: _, } => { map.insert(EMAILCHANGED_NEW_EMAIL, new_email.clone()); Ok(EmailTemplate::Static(map)) } NotificationBody::PaymentFailed { amount, service } => { let url = format!( "{}/{}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_BILLING_PATH")?, ); let mut map = HashMap::new(); map.insert(PAYMENTFAILED_AMOUNT, amount.clone()); map.insert(PAYMENTFAILED_SERVICE, service.clone()); map.insert(BILLING_URL, url); Ok(EmailTemplate::Static(map)) } NotificationBody::PayoutAvailable { amount, date_available, } => { if let Some(period_month) = date_available.checked_sub_months(chrono::Months::new(2)) { map.insert( PAYOUTAVAILABLE_PERIOD, period_month.format("%B %Y").to_string(), ); } map.insert( PAYOUTAVAILABLE_AMOUNT, format!("${:.2} USD", *amount as f64 / 100.0), ); Ok(EmailTemplate::Static(map)) } NotificationBody::TaxNotification { subscription_id, old_amount, old_tax_amount, new_amount, new_tax_amount, billing_interval, currency, due, service, } => { map.insert( TAXNOTIFICATION_OLD_AMOUNT, fmt_money(*old_amount, currency), ); map.insert( TAXNOTIFICATION_OLD_TAX_AMOUNT, fmt_money(*old_tax_amount, currency), ); map.insert( TAXNOTIFICATION_OLD_TOTAL_AMOUNT, fmt_money(*old_amount + *old_tax_amount, currency), ); map.insert( TAXNOTIFICATION_NEW_AMOUNT, fmt_money(*new_amount, currency), ); map.insert( TAXNOTIFICATION_NEW_TAX_AMOUNT, fmt_money(*new_tax_amount, currency), ); map.insert( TAXNOTIFICATION_NEW_TOTAL_AMOUNT, fmt_money(*new_amount + *new_tax_amount, currency), ); map.insert( TAXNOTIFICATION_BILLING_INTERVAL, billing_interval.as_str().to_owned(), ); map.insert(TAXNOTIFICATION_DUE, date_human_readable(*due)); map.insert(TAXNOTIFICATION_SERVICE, service.clone()); map.insert(SUBSCRIPTION_ID, to_base62(subscription_id.0)); Ok(EmailTemplate::Static(map)) } NotificationBody::SubscriptionCredited { subscription_id, days, previous_due, next_due, header_message, } => { map.insert( CREDIT_DAYS, format!("{days} day{}", if *days == 1 { "" } else { "s" }), ); map.insert(CREDIT_PREVIOUS_DUE, date_human_readable(*previous_due)); map.insert(CREDIT_NEXT_DUE, date_human_readable(*next_due)); map.insert(SUBSCRIPTION_ID, to_base62(subscription_id.0)); // Only insert header message if provided; frontend sets default fallback if let Some(h) = header_message.clone() { map.insert(CREDIT_HEADER_MESSAGE, h); } // Derive subscription type label for templates // Resolve product metadata via price_id join if let Some(info) = crate::database::models::user_subscription_item::DBUserSubscription::get( (*subscription_id).into(), &mut **exec, ) .await .ok() .flatten() && let Ok(Some(pinfo)) = crate::database::models::products_tax_identifier_item::product_info_by_product_price_id(info.price_id, &mut **exec).await { let label = match pinfo.product_metadata { crate::models::billing::ProductMetadata::Pyro { .. } => "server".to_string(), crate::models::billing::ProductMetadata::Medal { .. } => "server".to_string(), crate::models::billing::ProductMetadata::Midas => "Modrinth+".to_string(), }; map.insert(CREDIT_SUBSCRIPTION_TYPE, label); } Ok(EmailTemplate::Static(map)) } NotificationBody::Custom { title, body_md, key, } => Ok(EmailTemplate::Dynamic { variables: map, body: dynamic_email_body(redis, title, body_md, key).await?, title: title.to_string(), }), NotificationBody::ProjectUpdate { .. } | NotificationBody::ModeratorMessage { .. } | NotificationBody::LegacyMarkdown { .. } | NotificationBody::Unknown => Ok(EmailTemplate::Static(map)), } } async fn dynamic_email_body( redis: &RedisPool, title: &str, body_md: &str, key: &str, ) -> Result { get_or_set_cached_dynamic_html(redis, key, || async { let site_url = dotenvy::var("SITE_URL") .wrap_internal_err("SITE_URL is not set")?; let site_url = site_url.trim_end_matches('/'); let url = format!("{site_url}/_internal/templates/email/dynamic"); std::str::from_utf8( reqwest::Client::new() .post(url) .json(&serde_json::json!({ "title": title, "body": body_md, })) .send() .await .and_then(|res| res.error_for_status())? .bytes() .await? .as_ref(), ) .wrap_internal_err("email body is not valid UTF-8") .map(ToOwned::to_owned) }) .await } fn date_human_readable(date: chrono::DateTime) -> String { date.format("%B %d, %Y").to_string() } fn fmt_money(amount: i64, currency: &str) -> String { rusty_money::Money::from_minor( amount, rusty_money::iso::find(currency).unwrap_or(rusty_money::iso::USD), ) .to_string() }