From a3c9105caaf79b7fc7a4db335c5c1d816e6bd870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arma=C3=ABl=20Gu=C3=A9neau?= Date: Thu, 19 Dec 2024 16:21:56 +0100 Subject: [PATCH] Notify users by email when locking their account (wip: error handling) --- Cargo.lock | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/email.rs | 79 ++++++++++++++++++++++++++++++++ src/main.rs | 28 ++++++++---- 4 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 src/email.rs diff --git a/Cargo.lock b/Cargo.lock index 95b6a80..ec2351a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -456,6 +462,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -592,6 +608,22 @@ dependencies = [ "syn", ] +[[package]] +name = "email-encoding" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3d894bbbab314476b265f9b2d46bf24b123a36dd0e96b06a1b49545b9d9dcc" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -663,6 +695,7 @@ dependencies = [ "anyhow", "forgejo-api", "lazy_static", + "lettre", "rand", "reqwest 0.12.9", "serde", @@ -829,6 +862,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.1" @@ -1224,7 +1267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.1", ] [[package]] @@ -1269,6 +1312,32 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4c9a167ff73df98a5ecc07e8bf5ce90b583665da3d1762eb1f775ad4d0d6f5" +dependencies = [ + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "socket2", + "tokio", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.164" @@ -1348,6 +1417,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1387,6 +1462,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1630,6 +1715,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "quote" version = "1.0.37" @@ -1639,6 +1733,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "rand" version = "0.8.5" @@ -1846,7 +1946,9 @@ version = "0.23.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f1a745511c54ba6d4465e8d5dfbd81b45791756de28d4981af70d6dca128f1e" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2087,6 +2189,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2593,6 +2708,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index 82be74d..0bb0530 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ tera = "1" lazy_static = "1" actix-files = "0.6" unicode-segmentation = "1" +lettre = { version = "0.11", features = ["builder", "smtp-transport", "rustls-tls"], default-features = false } [profile.profiling] inherits = "dev" diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 0000000..31ac513 --- /dev/null +++ b/src/email.rs @@ -0,0 +1,79 @@ +use anyhow::anyhow; +use lettre::{Message, SmtpTransport, Transport}; +use std::env; + +pub struct SmtpConfig { + address: String, + username: String, + password: String, +} + +impl SmtpConfig { + pub fn mailer(&self) -> Result { + use lettre::transport::smtp::authentication::Credentials; + Ok(SmtpTransport::relay(&self.address)? + .credentials(Credentials::new( + self.username.to_owned(), + self.password.to_owned(), + )) + .build()) + } + + pub async fn from_env() -> anyhow::Result { + let address = env::var("SMTP_ADDRESS")?; + let username = env::var("SMTP_USERNAME")?; + let password = env::var("SMTP_PASSWORD")?; + let smtp = SmtpConfig { + address, + username, + password, + }; + if !smtp.mailer()?.test_connection()? { + return Err(anyhow!("Unable to contact the SMTP relay")); + } + Ok(smtp) + } +} + +pub async fn send_locked_account_notice( + smtp: &SmtpConfig, + login: &str, + email: &str, +) -> anyhow::Result<()> { + let grace_period_days = crate::GRACE_PERIOD.as_secs() / (24 * 3600); + + let email = Message::builder() + .from(smtp.username.parse().unwrap()) + .to(email.parse()?) + .subject(format!( + "[Forgejo Deuxfleurs] Your account was marked as spam and will be deleted in {} days", + grace_period_days + )) + .body(format!( + "\ +(English version below) + +Ceci est un email pour vous informer que votre compte utilisateur \"{login}\" sur \ +https://git.deuxfleurs.fr a été considéré comme spam par un administrateur. + +Par défaut, ce compte sera par conséquent supprimé dans {grace_period_days} jours. + +S'il s'agit d'une erreur de notre part, merci de nous contacter rapidement par email via \ +. + +-- les administrateurs de Deuxfleurs + +-------- + +This is an email to inform you that your user account \"{login}\" at https://git.deuxfleurs.fr \ +was considered as spam by an administrator. + +Without further action on your part, the account will be deleted in {grace_period_days} days. + +If you believe this is a mistake, please reach out quickly by email at . + +-- the Deuxfleurs admins")) + .unwrap(); + smtp.mailer()?.send(&email)?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index c613a1a..4586693 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,12 +13,14 @@ use tera::Tera; mod classifier; mod data; mod db; +mod email; mod scrape; mod workers; use classifier::Classifier; use data::*; use db::{Db, IsSpam}; +use email::SmtpConfig; // Fetch user data from forgejo from time to time const FORGEJO_POLL_DELAY: Duration = Duration::from_secs(11 * 3600); // 11 hours @@ -191,6 +193,7 @@ async fn lock_user_account(forge: &Forgejo, username: &str) -> anyhow::Result<() async fn apply_classification( forge: &Forgejo, + mailer: &SmtpConfig, db: &mut Db, classifier: &mut Classifier, ids: &[(UserId, bool)], @@ -199,10 +202,10 @@ async fn apply_classification( let spammers = set_spam(db, classifier, ids, overwrite); for user in spammers { - // TODO: send email (what do we do if sending the email didn't work?) - // TODO: batch the email sending? (only open one smtp connection) - lock_user_account(forge, &db.users.get(&user).unwrap().login).await?; - // TODO: better error handling: retries, ..? + let user = &db.users.get(&user).unwrap(); + lock_user_account(forge, &user.login).await?; + email::send_locked_account_notice(&mailer, &user.login, &user.email).await?; + // TODO: better and more robust error handling: retries, or a worker to send the emails ..? } Ok(()) @@ -224,6 +227,7 @@ struct AppState { db: Arc>, classifier: Arc>, forge: Arc, + mailer: Arc, } #[derive(Debug, Deserialize)] @@ -323,16 +327,22 @@ async fn post_classified( let db = &mut data.db.lock().unwrap(); let classifier = &mut data.classifier.lock().unwrap(); - let forge = &data.forge; let updates: Vec<(UserId, bool)> = form .iter() .map(|(id, classification)| (UserId(*id), classification == "spam")) .collect(); - apply_classification(forge, db, classifier, &updates, overwrite) - .await - .unwrap(); // FIXME + apply_classification( + &data.forge, + &data.mailer, + db, + classifier, + &updates, + overwrite, + ) + .await + .unwrap(); // FIXME db.store_to_path(Path::new("db.json")).unwrap(); // FIXME classifier @@ -401,6 +411,7 @@ async fn main() -> std::io::Result<()> { eprintln!("Load users and repos"); let forge = Arc::new(forge().unwrap() /* FIXME */); + let mailer = Arc::new(SmtpConfig::from_env().await.unwrap() /* FIXME */); let (db, classifier) = load_db(&forge).await.unwrap(); // FIXME let db = Arc::new(Mutex::new(db)); let classifier = Arc::new(Mutex::new(classifier)); @@ -409,6 +420,7 @@ async fn main() -> std::io::Result<()> { db: db.clone(), classifier: classifier.clone(), forge: forge.clone(), + mailer: mailer.clone(), }); let _ = {