Files
stoatchat/crates/core/database/src/util/email.rs
Zomatree d27917b824 chore: migrate authifier into codebase (#658)
Co-authored-by: izzy <me@insrt.uk>
Signed-off-by: Zomatree <me@zomatree.live>
Signed-off-by: izzy <me@insrt.uk>
2026-06-21 00:50:06 +01:00

272 lines
8.8 KiB
Rust

use std::{collections::HashSet, sync::LazyLock};
use lettre::{
transport::smtp::{authentication::Credentials, client::Tls},
SmtpTransport,
};
use regex::Regex;
use revolt_config::{config, ApiSmtp};
use revolt_result::Result;
static SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new("([^@]+)(@.+)").unwrap());
static SYMBOL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\+.+|\\.").unwrap());
static HANDLEBARS: LazyLock<handlebars::Handlebars<'static>> =
LazyLock::new(handlebars::Handlebars::new);
static REVOLT_SOURCE_LIST: LazyLock<HashSet<String>> = LazyLock::new(|| {
include_str!("../../assets/revolt_source_list.txt")
.split('\n')
.map(|x| x.into())
.collect()
});
/// Strip special characters and aliases from emails
pub fn normalise_email(original: String) -> String {
let split = SPLIT.captures(&original).unwrap();
let mut clean = SYMBOL_RE
.replace_all(split.get(1).unwrap().as_str(), "")
.to_string();
clean.push_str(split.get(2).unwrap().as_str());
clean.to_lowercase()
}
/// Email template
#[derive(Clone)]
pub struct Template {
/// Title of the email
pub title: String,
/// Plain text version of this email
pub text: String,
/// HTML version of this email
pub html: Option<String>,
/// URL to redirect people to from the email
///
/// Use `{{url}}` to fill this field.
///
/// Any given URL will be suffixed with a unique token if applicable.
///
/// e.g. `https://example.com?t=` becomes `https://example.com?t=UNIQUE_CODE`
pub url: String,
}
/// Email templates
#[derive(Clone)]
pub struct Templates {
/// Template for email verification
pub verify: Template,
/// Template for password reset
pub reset: Template,
/// Template for password reset when the account already exists on creation
pub reset_existing: Template,
/// Template for account deletion
pub deletion: Template,
/// Template for suspention
pub suspension: Template,
}
pub async fn email_templates() -> Templates {
let config = config().await;
if std::env::var("TEST_DB").is_ok() {
Templates {
verify: Template {
title: "verify".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
reset: Template {
title: "reset".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
reset_existing: Template {
title: "reset_existing".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
deletion: Template {
title: "deletion".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
suspension: Template {
title: "suspension".into(),
text: "[[dummy]]".into(),
url: "".into(),
html: None,
},
}
} else if config.production {
Templates {
verify: Template {
title: "Verify your Stoat account.".into(),
text: include_str!("../../templates/verify.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: Some(include_str!("../../templates/verify.html").into()),
},
reset: Template {
title: "Reset your Stoat password.".into(),
text: include_str!("../../templates/reset.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset.html").into()),
},
reset_existing: Template {
title: "You already have a Stoat account, reset your password.".into(),
text: include_str!("../../templates/reset-existing.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset-existing.html").into()),
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: Some(include_str!("../../templates/deletion.html").into()),
},
suspension: Template {
title: "Account Suspension".to_string(),
html: Some(include_str!("../../templates/suspension.html").to_owned()),
text: include_str!("../../templates/suspension.txt").to_owned(),
url: Default::default(),
},
}
} else {
Templates {
verify: Template {
title: "Verify your account.".into(),
text: include_str!("../../templates/verify.whitelabel.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: None,
},
reset: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
reset_existing: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.whitelabel.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: None,
},
suspension: Template {
title: "Account Suspension".to_string(),
text: include_str!("../../templates/suspension.whitelabel.txt").to_owned(),
url: Default::default(),
html: None,
},
}
}
}
/// Create SMTP transport
pub fn create_transport(smtp: &ApiSmtp) -> SmtpTransport {
let relay = if smtp.use_starttls == Some(true) {
SmtpTransport::starttls_relay(&smtp.host).unwrap()
} else {
SmtpTransport::relay(&smtp.host).unwrap()
};
let relay = if let Some(port) = smtp.port {
relay.port(port.try_into().unwrap())
} else {
relay
};
let relay = if smtp.use_tls == Some(false) {
relay.tls(Tls::None)
} else {
relay
};
relay
.credentials(Credentials::new(
smtp.username.clone(),
smtp.password.clone(),
))
.build()
}
/// Render an email template
fn render_template(text: &str, variables: &handlebars::JsonValue) -> Result<String> {
HANDLEBARS
.render_template(text, variables)
.map_err(|_| create_error!(RenderFail))
}
/// Send an email
pub fn send_email(
smtp: &ApiSmtp,
address: String,
template: &Template,
variables: handlebars::JsonValue,
) -> Result<()> {
let m = lettre::Message::builder()
.from(smtp.from_address.parse().expect("valid `smtp_from`"))
.to(address.parse().expect("valid `smtp_to`"))
.subject(template.title.clone());
let m = if let Some(reply_to) = &smtp.reply_to {
m.reply_to(reply_to.parse().expect("valid `smtp_reply_to`"))
} else {
m
};
let text = render_template(&template.text, &variables).expect("valid `template`");
let m = if let Some(html) = &template.html {
m.multipart(lettre::message::MultiPart::alternative_plain_html(
text,
render_template(html, &variables).expect("valid `template`"),
))
} else {
m.body(text)
}
.expect("valid `message`");
use lettre::Transport;
let sender = create_transport(smtp);
match sender.send(&m) {
Ok(_) => Ok(()),
Err(error) => {
error!(
"Failed to send email to {}!\nlettre error: {}",
address, error
);
revolt_config::capture_error(&error);
Err(create_error!(EmailFailed))
}
}
}
pub fn validate_email(email: &str) -> Result<()> {
// Make sure this is an actual email
if !validator::validate_email(email) {
return Err(create_error!(IncorrectData {
with: "email".to_string()
}));
}
// Check if the email is blacklisted
if let Some(domain) = email.split('@').next_back() {
if REVOLT_SOURCE_LIST.contains(&domain.to_string()) {
return Err(create_error!(Blacklisted));
}
}
Ok(())
}