From 0a992e29002c0c5eba2c1b4be21e1b8b96f50424 Mon Sep 17 00:00:00 2001 From: nobody Date: Mon, 5 Aug 2024 04:45:34 +0200 Subject: [PATCH] split config & move email out into own file In preparation for separate programs, the config spec is now built in main; each module defines its own group and main combines them all. --- src/{config_check.rs => config_spec.rs} | 78 +++------------ src/email.rs | 111 +++++++++++++++++++++ src/hedgedoc.rs | 24 ++++- src/main.rs | 126 +++++++++++------------- src/mediawiki.rs | 37 ++++++- 5 files changed, 234 insertions(+), 142 deletions(-) rename src/{config_check.rs => config_spec.rs} (75%) create mode 100644 src/email.rs diff --git a/src/config_check.rs b/src/config_spec.rs similarity index 75% rename from src/config_check.rs rename to src/config_spec.rs index 51bab17..6487df6 100644 --- a/src/config_check.rs +++ b/src/config_spec.rs @@ -13,65 +13,9 @@ const ANSI_FIELD: &str = "\x1b[1;33m"; const ANSI_NOTICE: &str = "\x1b[33m"; const ANSI_RESET: &str = "\x1b[0m"; -const CONFIG_SPEC: CfgSpec<'static> = CfgSpec { - groups: &[ - CfgGroup { name: "config", - description: "(internal values)", - fields: &[ - CfgField::Silent { key: "version", default: "1" }, - ], - }, - CfgGroup { name: "hedgedoc", - description: "HedgeDoc markdown pad server settings", - fields: &[ - CfgField::Default { key: "server-url", default: "https://md.berlin.ccc.de", description: "Hedgedoc server storing the pads." }, - CfgField::Default { key: "template-name", default: "plenum-template", description: "Name of the pad containing the template to use." }, - // TODO: make these generators? - CfgField::Optional { key: "last-id", description: "ID of last plenum's pad." }, - CfgField::Optional { key: "next-id", description: "ID of next plenum's pad." }, - ], - }, - CfgGroup { name: "email", - description: "Sending emails.", - fields: &[ - CfgField::Default { key: "server", default: "mail.berlin.ccc.de", description: "SMTP server used for sending emails." }, - CfgField::Default { key: "user", default: "plenum-bot@berlin.ccc.de", description: "User name used for authenticating with the mail server." }, - CfgField::Password { key: "password", description: "Password for authenticating with the mail server." }, - CfgField::Default { key: "sender", default: "Plenumsbot ", description: "Email address to use for \"From:\"." }, - CfgField::Default { key: "to", default: "CCCB Intern ", description: "Recipient of the emails sent." }, - CfgField::Optional { key: "last-message-id", description: "Message-Id of last initial announcement to send In-Reply-To (if applicable)." }, - ], - }, - CfgGroup { name: "wiki", - description: "API Settings for Mediawiki", - fields: &[ - CfgField::Default { key: "server-url", default: "https://wiki.berlin.ccc.de/" , description: "Server running the wiki." }, - CfgField::Default { key: "http-user", default: "cccb-wiki", description: "HTTP basic auth user name." }, - CfgField::Password { key: "http-pass", description: "HTTP basic auth password." }, - CfgField::Default { key: "api-user", default: "PlenumBot@PlenumBot-PW1", description: "API Username associated with the bot account used for edits." }, - CfgField::Password { key: "api-secret", description: "API secret / \"password\" used for authenticating as the bot." }, - ], - }, - // TODO: Matrix, - CfgGroup { name: "text", - description: "Various strings used.", - fields: &[ - CfgField::Default { key: "email-greeting", - default: "Hallo liebe Mitreisende,", - description: "\"Hello\"-greeting added at the start of every email." - }, - CfgField::Default { key: "email-signature", - default: "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]", - description: "Text added at the bottom of every email." - }, - ], - }, - ], -}; - /// Ensure all fields with known default values exist. -pub fn populate_defaults(config: &KV) { - for group in CONFIG_SPEC.groups { +pub fn populate_defaults(spec: &CfgSpec, config: &KV) { + for group in spec.groups { for field in group.fields { match field { CfgField::Silent { default, .. } => { @@ -89,9 +33,9 @@ pub fn populate_defaults(config: &KV) { /// Check all groups / fields and interactively edit as needed. /// /// Will report if the config is fine or has missing values. -pub fn interactive_check(config: KV) -> Result<(), Box> { +pub fn interactive_check(spec: &CfgSpec, config: KV) -> Result<(), Box> { let mut all_valid: bool = true; - for group in CONFIG_SPEC.groups { + for group in spec.groups { group_header(group.name, group.description); let todo = needs_info(&config, group); // TODO: add distinction between edit all / edit necessary only @@ -306,7 +250,7 @@ fn prompt_password() -> String { /* ***** config structure definition ***** */ /// Describes a field in the configuration. -enum CfgField<'a> { +pub enum CfgField<'a> { /// Silently writes a default to the DB if absent, never prompts for changes. Silent { key: &'a str, default: &'a str }, /// Writes a default, asks for changes. @@ -326,17 +270,17 @@ enum CfgField<'a> { /// A group of related config fields. The final key of an inner value will be /// `{group.name}-{key}`. -struct CfgGroup<'a> { - name: &'a str, - description: &'a str, - fields: &'a [CfgField<'a>], +pub struct CfgGroup<'a> { + pub name: &'a str, + pub description: &'a str, + pub fields: &'a [CfgField<'a>], } /// A description of the entire config, as a collection of named groups. /// /// Used both to populate defaults and to interactively adjust the configuration. -struct CfgSpec<'a> { - groups: &'a [CfgGroup<'a>], +pub struct CfgSpec<'a> { + pub groups: &'a [CfgGroup<'a>], } impl<'a> CfgField<'a> { diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 0000000..8863fa9 --- /dev/null +++ b/src/email.rs @@ -0,0 +1,111 @@ +use crate::config_spec::{CfgField, CfgGroup}; +use lettre::message::{header, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; +use std::error::Error; +use uuid::Uuid; + +pub const CONFIG: CfgGroup<'static> = CfgGroup { + name: "email", + description: "Sending emails.", + fields: &[ + CfgField::Default { + key: "server", + default: "mail.berlin.ccc.de", + description: "SMTP server used for sending emails.", + }, + CfgField::Default { + key: "user", + default: "plenum-bot@berlin.ccc.de", + description: "User name used for authenticating with the mail server.", + }, + CfgField::Password { + key: "password", + description: "Password for authenticating with the mail server.", + }, + CfgField::Default { + key: "sender", + default: "Plenumsbot ", + description: "Email address to use for \"From:\".", + }, + CfgField::Default { + key: "to", + default: "CCCB Intern ", + description: "Recipient of the emails sent.", + }, + CfgField::Optional { + key: "last-message-id", + description: + "Message-Id of last initial announcement to send In-Reply-To (if applicable).", + }, + ], +}; + +#[derive(Clone)] +pub struct Email { + server: String, + credentials: Credentials, + message_id_suffix: String, + is_dry_run: bool, +} + +pub struct SimpleEmail { + base: Email, + from: String, + to: String, + in_reply_to: Option, +} + +impl SimpleEmail { + pub fn new(base: Email, from: &str, to: &str, in_reply_to: Option) -> Self { + Self { base: base.clone(), from: from.to_string(), to: to.to_string(), in_reply_to } + } + pub fn send_email(&self, subject: String, body: String) -> Result> { + self.base.send_email( + self.from.clone(), + self.to.clone(), + subject, + body, + self.in_reply_to.clone(), + ) + } +} + +impl Email { + pub fn new(server: &str, user: &str, pass: &str, is_dry_run: bool) -> Self { + let message_id_suffix = user.split('@').next_back().unwrap_or(&server).to_string(); + let credentials = Credentials::new(user.to_string(), pass.to_string()); + Self { server: server.to_string(), credentials, message_id_suffix, is_dry_run } + } + + pub fn send_email( + &self, from: String, to: String, subject: String, body: String, in_reply_to: Option, + ) -> Result> { + let message_id = Uuid::new_v4().to_string() + &self.message_id_suffix; + let email = Message::builder() + .from(from.parse().unwrap()) + .to(to.parse().unwrap()) + .message_id(Some(message_id.clone())); + let email = + if in_reply_to.is_some() { email.in_reply_to(in_reply_to.unwrap()) } else { email } + .subject(subject) + .singlepart( + SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(body), + ) + .unwrap(); + + if !self.is_dry_run { + let mailer = SmtpTransport::starttls_relay(&self.server)? + .credentials(self.credentials.clone()) + .build(); + mailer.send(&email)?; + Ok(message_id) + } else { + println!( + "[DRY RUN - NOT sending email]\n(raw message:)\n{}", + std::str::from_utf8(&email.formatted()).unwrap_or("((UTF-8 error))") + ); + Ok("dummy-message-id@localhost".to_string()) + } + } +} diff --git a/src/hedgedoc.rs b/src/hedgedoc.rs index 9c9949f..1ff94aa 100644 --- a/src/hedgedoc.rs +++ b/src/hedgedoc.rs @@ -1,8 +1,28 @@ +use crate::config_spec::{CfgField, CfgGroup}; use reqwest::blocking::Client; use reqwest::blocking::Response; use std::error::Error; -// TODO: implement dry-run logic +pub const CONFIG: CfgGroup<'static> = CfgGroup { + name: "hedgedoc", + description: "HedgeDoc markdown pad server settings", + fields: &[ + CfgField::Default { + key: "server-url", + default: "https://md.berlin.ccc.de", + description: "Hedgedoc server storing the pads.", + }, + CfgField::Default { + key: "template-name", + default: "plenum-template", + description: "Name of the pad containing the template to use.", + }, + // TODO: make these generators? + CfgField::Optional { key: "last-id", description: "ID of last plenum's pad." }, + CfgField::Optional { key: "next-id", description: "ID of next plenum's pad." }, + ], +}; + pub struct HedgeDoc { server_url: String, is_dry_run: bool, @@ -35,6 +55,7 @@ impl HedgeDoc { } pub fn create_pad(&self) -> Result> { + if self.is_dry_run { todo!("NYI: sane dry-run behavior") } let res = self.do_request(&format!("{}/new", self.server_url)).unwrap(); if res.status().is_success() { Ok(self.get_id_from_response(res)) @@ -44,6 +65,7 @@ impl HedgeDoc { } pub fn import_note(&self, id: Option<&str>, content: String) -> Result> { + if self.is_dry_run { todo!("NYI: sane dry-run behavior") } let url = match id { Some(id) => self.format_url(&format!("new/{id}")), None => self.format_url("new"), diff --git a/src/main.rs b/src/main.rs index f18e62c..a2205ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,33 +34,58 @@ future improvements: */ // Import other .rs files as modules mod key_value; +use email::{Email, SimpleEmail}; use key_value::KeyValueStore as KV; +mod config_spec; +use config_spec::{CfgField, CfgGroup, CfgSpec}; +mod email; mod hedgedoc; use hedgedoc::HedgeDoc; mod mediawiki; -mod config_check; pub mod variables_and_settings; use std::borrow::Cow; use std::env; use std::error::Error; -use std::fs::File; -use std::io::Read; -// use std::future::Future; use chrono::{Datelike, Local, NaiveDate, Weekday}; use clap::{Arg, Command}; use regex::Regex; -use uuid::Uuid; -use lettre::message::{header, SinglePart}; -use lettre::transport::smtp::authentication::Credentials; -use lettre::{Message, SmtpTransport, Transport}; use reqwest::blocking::Client; const FALLBACK_TEMPLATE: &str = variables_and_settings::FALLBACK_TEMPLATE; +/* ***** Config Spec ***** */ +const CONFIG_SPEC: CfgSpec<'static> = CfgSpec { + groups: &[ + CfgGroup { name: "config", + description: "(internal values)", + fields: &[ + CfgField::Silent { key: "version", default: "1" }, + ], + }, + hedgedoc::CONFIG, + mediawiki::CONFIG, + email::CONFIG, + // TODO: Matrix, …? + CfgGroup { name: "text", + description: "Various strings used.", + fields: &[ + CfgField::Default { key: "email-greeting", + default: "Hallo liebe Mitreisende,", + description: "\"Hello\"-greeting added at the start of every email." + }, + CfgField::Default { key: "email-signature", + default: "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]", + description: "Text added at the bottom of every email." + }, + ], + }, + ], +}; + /* ***** Runtime Configuration (Arguments & Environment Variables) ***** */ /// Checks environment variable `DRY_RUN` to see if any external operations @@ -109,14 +134,26 @@ fn main() -> Result<(), Box> { let args = parse_args(); let config_file = args.config_file.as_str(); let config = KV::new(config_file).unwrap(); - config_check::populate_defaults(&config); + config_spec::populate_defaults(&CONFIG_SPEC, &config); if args.check_mode { - return config_check::interactive_check(config); + return config_spec::interactive_check(&CONFIG_SPEC, config); } // config let hedgedoc = HedgeDoc::new(&config["hedgedoc-server-url"], is_dry_run()); + let email_ = Email::new( + &config["email-server"], + &config["email-user"], + &config["email-password"], + is_dry_run(), + ); + let email = SimpleEmail::new( + email_, + &config["email-from"], + &config["email-to"], + config.get("email-in-reply-to").ok(), + ); println!("[START]\nAktueller Zustand der DB:"); - config.dump_redacting(&["email-password", "wiki-password", "matrix-password"]).ok(); + config.dump_redacting(&["email-password", "wiki-http-password", "wiki-api-secret", "matrix-password"]).ok(); // Dienstage diesen Monat let all_tuesdays: Vec = get_all_weekdays(0, Weekday::Tue); @@ -213,9 +250,8 @@ Bis morgen, 20 Uhr! {email_signature}"# ); // ADJ_TIMEYWIMEY - println!("---E-Mail:---\n{}\n-----------", message); - // XXX option x expect - message_id = Some(mail_versenden(&config, betreff, message).expect("Plenum findet statt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!")) + message_id = email.send_email(betreff, message).ok() + // .expect("Plenum findet statt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!") } else { // Mail an alle senden und absagen let betreff = format!("Plenum am {} fällt mangels Themen aus", nächster_plenumtermin); @@ -232,9 +268,8 @@ Bis zum nächsten Plenum. {email_signature}"#, nächster_plenumtermin ); - println!("---E-Mail:---\n{}\n-----------", message); - // XXX option x expect - message_id = Some(mail_versenden(&config, betreff, message).expect("Plenum wird abgesagt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!")) + message_id = email.send_email(betreff, message).ok() + // .expect("Plenum wird abgesagt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!")) } } else if in_3_days_is_plenum { println!("In 3 Tagen ist Plenum, deshalb wird eine Erinnerung raus geschickt!"); @@ -251,9 +286,8 @@ Hier ist der Link zum Pad, wo ihr noch Themen eintragen könnt: {email_signature}"# ); - println!("---E-Mail:---\n{}\n-----------", message); - // XXX option x expect - message_id = Some(mail_versenden(&config, betreff, message).expect("Noch nicht genug Themen. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!")) + message_id = email.send_email(betreff, message).ok() + // .expect("Noch nicht genug Themen. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!")) } } else if yesterday_was_plenum { // This logic breaks on 02/2034, but on every other month it works @@ -290,15 +324,15 @@ Und hier ist das Protokoll des letzten Plenums: ); let betreff: String = format!("Plenumsprotokoll vom {}: Es gab {} TOPs", nächster_plenumtermin, top_anzahl); - println!("---E-Mail:---\n{}\n-----------", message); // XXX option x expect - message_id = Some(mail_versenden(&config, betreff, message).expect("Mail mit Plenumsprotokoll wurde versucht zu senden, konnte aber nicht gesendet werden!")); + message_id = email.send_email(betreff, message).ok(); + // .expect("Mail mit Plenumsprotokoll wurde versucht zu senden, konnte aber nicht gesendet werden!")); mediawiki::pad_ins_wiki(old_pad_content_without_top_instructions); } println!("message id: {:?}", message_id); println!("[ENDE]\nAktueller Zustand der DB:"); - config.dump_redacting(&["email-password", "matrix-password", "wiki-password"]).ok(); + config.dump_redacting(&["email-password", "wiki-http-password", "wiki-api-secret", "matrix-password"]).ok(); if config.has_errors() { Err("There were errors.".into()) @@ -350,52 +384,6 @@ fn create_tldr(pad_content: &String) -> Vec<&str> { //tldr_vec.append() = m.iter() } -fn mail_versenden( - config: &KV, betreff: String, inhalt: String, -) -> std::result::Result> { - // Define the email - let message_id: String = Uuid::new_v4().to_string() + &String::from("@berlin.ccc.de"); - - let mail_to: &str = &config["email-to"]; - - let email = Message::builder() - // Set the sender's name and email address - .from(config["email-sender"].parse().unwrap()) - // Set the recipient's name and email address - .to(mail_to.parse().unwrap()) - .message_id(Option::from(message_id.clone())) - .in_reply_to("19de3985-05b4-42f3-9a6a-e941479be2ed@berlin.ccc.de".to_string()) - // Set the subject of the email - .subject(betreff) - .singlepart(SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(inhalt)) - .unwrap(); - - if !is_dry_run() { - // Set up the SMTP client - let creds = Credentials::new( - config["email-user"].to_string(), - config["email-password"].to_string(), - ); - // Open a remote connection to gmail - - let mailer = - SmtpTransport::starttls_relay(&config["email-server"])?.credentials(creds).build(); - - // Send the email - match mailer.send(&email) { - Ok(_) => println!("Email sent successfully!"), - Err(e) => eprintln!("Could not send email: {:?}", e), - } - Ok(message_id) - } else { - println!( - "[DRY RUN - NOT] sending email\n(raw message:)\n{}", - std::str::from_utf8(&email.formatted()).unwrap_or("((UTF-8 error))") - ); - Ok("dummy-message-id@localhost".to_string()) - } -} - fn generate_new_pad_for_following_date( config: KV, hedgedoc: HedgeDoc, übernächster_plenumtermin: &String, überübernächster_plenumtermin: &String, kv: &KV, diff --git a/src/mediawiki.rs b/src/mediawiki.rs index fdc95f2..b5c0087 100644 --- a/src/mediawiki.rs +++ b/src/mediawiki.rs @@ -1,12 +1,39 @@ -use std::error::Error; -use std::fs::File; -use std::io::Read; -use clap::builder::Str; +use crate::config_spec::{CfgField, CfgGroup}; use pandoc::{PandocError, PandocOutput}; use reqwest; use reqwest::blocking::Client; use reqwest::tls; use serde::Deserialize; +use std::error::Error; +use std::fs::File; +use std::io::Read; + +pub const CONFIG: CfgGroup<'static> = CfgGroup { + name: "wiki", + description: "API Settings for Mediawiki", + fields: &[ + CfgField::Default { + key: "server-url", + default: "https://wiki.berlin.ccc.de/", + description: "Server running the wiki.", + }, + CfgField::Default { + key: "http-user", + default: "cccb-wiki", + description: "HTTP basic auth user name.", + }, + CfgField::Password { key: "http-password", description: "HTTP basic auth password." }, + CfgField::Default { + key: "api-user", + default: "PlenumBot@PlenumBot-PW1", + description: "API Username associated with the bot account used for edits.", + }, + CfgField::Password { + key: "api-secret", + description: "API secret / \"password\" used for authenticating as the bot.", + }, + ], +}; #[derive(Deserialize)] struct QueryResponse { @@ -76,4 +103,4 @@ pub fn get_login_token(client: &Client, http_user: &str, http_pass: &String) -> .text()?; let response_deserialized: QueryResponse = serde_json::from_str(&resp)?; Ok(response_deserialized.query.tokens.logintoken) -} \ No newline at end of file +}