Notify users by email when locking their account (wip: error handling)

This commit is contained in:
Armaël Guéneau 2024-12-19 16:21:56 +01:00
parent d4af61fb35
commit a3c9105caa
4 changed files with 225 additions and 9 deletions

126
Cargo.lock generated
View file

@ -260,6 +260,12 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@ -456,6 +462,16 @@ dependencies = [
"phf_codegen", "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]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@ -592,6 +608,22 @@ dependencies = [
"syn", "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]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -663,6 +695,7 @@ dependencies = [
"anyhow", "anyhow",
"forgejo-api", "forgejo-api",
"lazy_static", "lazy_static",
"lettre",
"rand", "rand",
"reqwest 0.12.9", "reqwest 0.12.9",
"serde", "serde",
@ -829,6 +862,16 @@ dependencies = [
"tracing", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.1" version = "0.15.1"
@ -1224,7 +1267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.15.1",
] ]
[[package]] [[package]]
@ -1269,6 +1312,32 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 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]] [[package]]
name = "libc" name = "libc"
version = "0.2.164" version = "0.2.164"
@ -1348,6 +1417,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.0" version = "0.8.0"
@ -1387,6 +1462,16 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@ -1630,6 +1715,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psm"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.37" version = "1.0.37"
@ -1639,6 +1733,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.5"
@ -1846,7 +1946,9 @@ version = "0.23.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f1a745511c54ba6d4465e8d5dfbd81b45791756de28d4981af70d6dca128f1e" checksum = "7f1a745511c54ba6d4465e8d5dfbd81b45791756de28d4981af70d6dca128f1e"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@ -2087,6 +2189,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -2593,6 +2708,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.9" version = "0.1.9"

View file

@ -19,6 +19,7 @@ tera = "1"
lazy_static = "1" lazy_static = "1"
actix-files = "0.6" actix-files = "0.6"
unicode-segmentation = "1" unicode-segmentation = "1"
lettre = { version = "0.11", features = ["builder", "smtp-transport", "rustls-tls"], default-features = false }
[profile.profiling] [profile.profiling]
inherits = "dev" inherits = "dev"

79
src/email.rs Normal file
View file

@ -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<SmtpTransport, lettre::transport::smtp::Error> {
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<SmtpConfig> {
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 \
<prod-sysadmin@deuxfleurs.fr>.
-- 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 <prod-sysadmin@deuxfleurs.fr>.
-- the Deuxfleurs admins"))
.unwrap();
smtp.mailer()?.send(&email)?;
Ok(())
}

View file

@ -13,12 +13,14 @@ use tera::Tera;
mod classifier; mod classifier;
mod data; mod data;
mod db; mod db;
mod email;
mod scrape; mod scrape;
mod workers; mod workers;
use classifier::Classifier; use classifier::Classifier;
use data::*; use data::*;
use db::{Db, IsSpam}; use db::{Db, IsSpam};
use email::SmtpConfig;
// Fetch user data from forgejo from time to time // Fetch user data from forgejo from time to time
const FORGEJO_POLL_DELAY: Duration = Duration::from_secs(11 * 3600); // 11 hours 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( async fn apply_classification(
forge: &Forgejo, forge: &Forgejo,
mailer: &SmtpConfig,
db: &mut Db, db: &mut Db,
classifier: &mut Classifier, classifier: &mut Classifier,
ids: &[(UserId, bool)], ids: &[(UserId, bool)],
@ -199,10 +202,10 @@ async fn apply_classification(
let spammers = set_spam(db, classifier, ids, overwrite); let spammers = set_spam(db, classifier, ids, overwrite);
for user in spammers { for user in spammers {
// TODO: send email (what do we do if sending the email didn't work?) let user = &db.users.get(&user).unwrap();
// TODO: batch the email sending? (only open one smtp connection) lock_user_account(forge, &user.login).await?;
lock_user_account(forge, &db.users.get(&user).unwrap().login).await?; email::send_locked_account_notice(&mailer, &user.login, &user.email).await?;
// TODO: better error handling: retries, ..? // TODO: better and more robust error handling: retries, or a worker to send the emails ..?
} }
Ok(()) Ok(())
@ -224,6 +227,7 @@ struct AppState {
db: Arc<Mutex<Db>>, db: Arc<Mutex<Db>>,
classifier: Arc<Mutex<Classifier>>, classifier: Arc<Mutex<Classifier>>,
forge: Arc<Forgejo>, forge: Arc<Forgejo>,
mailer: Arc<SmtpConfig>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -323,14 +327,20 @@ async fn post_classified(
let db = &mut data.db.lock().unwrap(); let db = &mut data.db.lock().unwrap();
let classifier = &mut data.classifier.lock().unwrap(); let classifier = &mut data.classifier.lock().unwrap();
let forge = &data.forge;
let updates: Vec<(UserId, bool)> = form let updates: Vec<(UserId, bool)> = form
.iter() .iter()
.map(|(id, classification)| (UserId(*id), classification == "spam")) .map(|(id, classification)| (UserId(*id), classification == "spam"))
.collect(); .collect();
apply_classification(forge, db, classifier, &updates, overwrite) apply_classification(
&data.forge,
&data.mailer,
db,
classifier,
&updates,
overwrite,
)
.await .await
.unwrap(); // FIXME .unwrap(); // FIXME
@ -401,6 +411,7 @@ async fn main() -> std::io::Result<()> {
eprintln!("Load users and repos"); eprintln!("Load users and repos");
let forge = Arc::new(forge().unwrap() /* FIXME */); 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, classifier) = load_db(&forge).await.unwrap(); // FIXME
let db = Arc::new(Mutex::new(db)); let db = Arc::new(Mutex::new(db));
let classifier = Arc::new(Mutex::new(classifier)); let classifier = Arc::new(Mutex::new(classifier));
@ -409,6 +420,7 @@ async fn main() -> std::io::Result<()> {
db: db.clone(), db: db.clone(),
classifier: classifier.clone(), classifier: classifier.clone(),
forge: forge.clone(), forge: forge.clone(),
mailer: mailer.clone(),
}); });
let _ = { let _ = {