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",
]
[[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"

View file

@ -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"

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 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<Mutex<Db>>,
classifier: Arc<Mutex<Classifier>>,
forge: Arc<Forgejo>,
mailer: Arc<SmtpConfig>,
}
#[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 _ = {