Files
AstralRinth/apps/labrinth/src/queue/email/templates.rs
T
Josiah Glosson b23d3e674f Update Rust & Java dependencies (#4540)
* Update Java dependencies

* Baselint lint fixes

* Update Rust version

* Update actix-files 0.6.6 -> 0.6.8

* Update actix-http 3.11.0 -> 3.11.2

* Update actix-rt 2.10.0 -> 2.11.0

* Update async_zip 0.0.17 -> 0.0.18

* Update async-compression 0.4.27 -> 0.4.32

* Update async-trait 0.1.88 -> 0.1.89

* Update async-tungstenite 0.30.0 -> 0.31.0

* Update const_format 0.2.34 -> 0.2.35

* Update bitflags 2.9.1 -> 2.9.4

* Update bytemuck 1.23.1 -> 1.24.0

* Update typed-path 0.11.0 -> 0.12.0

* Update chrono 0.4.41 -> 0.4.42

* Update cidre 0.11.2 -> 0.11.3

* Update clap 4.5.43 -> 4.5.48

* Update data-url 0.3.1 -> 0.3.2

* Update discord-rich-presence 0.2.5 -> 1.0.0

* Update enumset 1.1.7 -> 1.1.10

* Update flate2 1.1.2 -> 1.1.4

* Update hyper 1.6.0 -> 1.7.0

* Update hyper-util 0.1.16 -> 0.1.17

* Update iana-time-zone 0.1.63 -> 0.1.64

* Update image 0.25.6 -> 0.25.8

* Update indexmap 2.10.0 -> 2.11.4

* Update json-patch 4.0.0 -> 4.1.0

* Update meilisearch-sdk 0.29.1 -> 0.30.0

* Update clickhouse 0.13.3 -> 0.14.0

* Fix some prettier things

* Update lettre 0.11.18 -> 0.11.19

* Update phf 0.12.1 -> 0.13.1

* Update png 0.17.16 -> 0.18.0

* Update quick-xml 0.38.1 -> 0.38.3

* Update redis 0.32.4 -> 0.32.7

* Update regex 1.11.1 -> 1.11.3

* Update reqwest 0.12.22 -> 0.12.23

* Update rust_decimal 1.37.2 -> 1.38.0

* Update rust-s3 0.35.1 -> 0.37.0

* Update serde 1.0.219 -> 1.0.228

* Update serde_bytes 0.11.17 -> 0.11.19

* Update serde_json 1.0.142 -> 1.0.145

* Update serde_with 3.14.0 -> 3.15.0

* Update sentry 0.42.0 -> 0.45.0 and sentry-actix 0.42.0 -> 0.45.0

* Update spdx 0.10.9 -> 0.12.0

* Update sysinfo 0.36.1 -> 0.37.2

* Update tauri 2.7.0 -> 2.8.5

* Update tauri-build 2.3.1 -> 2.4.1

* Update tauri-plugin-deep-link 2.4.1 -> 2.4.3

* Update tauri-plugin-dialog 2.3.2 -> 2.4.0

* Update tauri-plugin-http 2.5.1 -> 2.5.2

* Update tauri-plugin-opener 2.4.0 -> 2.5.0

* Update tauri-plugin-os 2.3.0 -> 2.3.1

* Update tauri-plugin-single-instance 2.3.2 -> 2.3.4

* Update tempfile 3.20.0 -> 3.23.0

* Update thiserror 2.0.12 -> 2.0.17

* Update tracing-subscriber 0.3.19 -> 0.3.20

* Update url 2.5.4 -> 2.5.7

* Update uuid 1.17.0 -> 1.18.1

* Update webp 0.3.0 -> 0.3.1

* Update whoami 1.6.0 -> 1.6.1

* Note that windows and windows-core can't be updated yet

* Update zbus 5.9.0 -> 5.11.0

* Update zip 4.3.0 -> 6.0.0

* Fix build

* Enforce rustls crypto provider

* Refresh Cargo.lock

* Update transitive dependencies

* Bump Gradle usage to Java 17

* Use ubuntu-latest consistently across workflows

* Fix lint

* Fix lint in Rust

* Update native-dialog 0.9.0 -> 0.9.2

* Update regex 1.11.3 -> 1.12.2

* Update reqwest 0.12.23 -> 0.12.24

* Update rust_decimal 1.38.0 -> 1.39.0

* Remaining lock-only updates

* chore: move TLS impl of some other dependencies to aws-lc-rs

The AWS bloatware "virus" expands by sheer force of widespread adoption
by the ecosystem... 🫣

* chore(fmt): run Tombi

---------

Co-authored-by: Alejandro González <me@alegon.dev>
2025-10-15 20:45:47 +00:00

740 lines
24 KiB
Rust

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 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<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: &mut sqlx::PgTransaction<'_>,
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 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<String>,
html: Option<String>,
}
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<EmailTemplate, ApiError> {
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!("USD${:.2}", *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::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<String, ApiError> {
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<chrono::Utc>) -> 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()
}