From d46e959c42b5cb3cf81e76064db1a157b1213e8d Mon Sep 17 00:00:00 2001 From: cos Date: Tue, 28 Jun 2022 13:03:36 +0200 Subject: Initial addition of invitation-mailer --- .gitignore | 2 + _invitation-mailer/Cargo.toml | 16 ++ _invitation-mailer/email_body-2022june30.j2 | 11 ++ _invitation-mailer/src/main.rs | 283 ++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 _invitation-mailer/Cargo.toml create mode 100644 _invitation-mailer/email_body-2022june30.j2 create mode 100644 _invitation-mailer/src/main.rs diff --git a/.gitignore b/.gitignore index badbc02..e5b175e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ _site .sass-cache +_invitation-mailer/target +_invitation-mailer/Cargo.lock diff --git a/_invitation-mailer/Cargo.toml b/_invitation-mailer/Cargo.toml new file mode 100644 index 0000000..471d1d9 --- /dev/null +++ b/_invitation-mailer/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "invitation-mailer" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +august = "2.4" +chrono = "0.4" +emailmessage = "0.2" +getopts = "0.2" +ics = "0.5" +lettre = { version = "0.10.0-rc.7", default-features = false, features = [ "smtp-transport", "native-tls" ]} +minijinja = "0.16" +nos = "0.1" +uuid = { version = "0.8", features = ["v4"] } diff --git a/_invitation-mailer/email_body-2022june30.j2 b/_invitation-mailer/email_body-2022june30.j2 new file mode 100644 index 0000000..a588e1c --- /dev/null +++ b/_invitation-mailer/email_body-2022june30.j2 @@ -0,0 +1,11 @@ +Hey everyone, + +{{ body }} + +The website is also with the information(https://cph.rs/). +If you plan to participate, please give us a heads up so we have an +approximate idea of how many we will be! By sending a mail to +{{ organizer_email }} + +In excitement--until then, +Simon, Christian, and Frederik diff --git a/_invitation-mailer/src/main.rs b/_invitation-mailer/src/main.rs new file mode 100644 index 0000000..6913970 --- /dev/null +++ b/_invitation-mailer/src/main.rs @@ -0,0 +1,283 @@ +use { + anyhow::{ + anyhow, + Context, + Result, + }, + chrono::{ + DateTime, + Local, + TimeZone, + Utc, + }, + getopts::Options, + ics::{ + escape_text, + Event, + ICalendar, + properties::{ + Description, + DtEnd, + DtStart, + Location, + Organizer, + Summary, + }, + }, + emailmessage::{ + header, + Message, + MultiPart, + SinglePart, + }, + lettre::{ + address::Envelope, + SmtpTransport, + Transport, + transport::smtp::authentication::Credentials, + }, + minijinja::{ + Environment, + context as jinja_context, + }, + nos::Document, + std::{ + env::args, + fs::File, + io::Read, + }, + uuid::Uuid, +}; + +fn print_usage(program: &str, opts: Options) { + let brief = format!("Usage: {} [options] 'Recipient name '", program); + print!("{}", opts.usage(&brief)); +} + +fn to_iso8601(dt: DateTime) -> String { + // https://github.com/chronotope/chrono/issues/244 + DateTime::::from(dt).format("%Y%m%dT%H%M%SZ").to_string() +} + +fn month_number(month: &str) -> Result { + match month { + "January" => Ok(1), + "February" => Ok(2), + "March" => Ok(3), + "April" => Ok(4), + "May" => Ok(5), + "June" => Ok(6), + "July" => Ok(7), + "August" => Ok(8), + "September" => Ok(9), + "October" => Ok(10), + "November" => Ok(11), + "December" => Ok(12), + invalid => Err(anyhow!("Invalid month name: {invalid}")), + } +} + +struct Desc { + _html: String, + plain: String, +} + +struct HackNight { + title: String, + desc: Desc, + start: DateTime, + end: DateTime, + location: String, +} + +impl HackNight { + fn new( + title: String, + desc: Desc, + start: DateTime, + end: DateTime, + location: String, + ) -> Self { + Self { + title, + desc, + start, + end, + location, + } + } + + fn from_html(filename: &str) -> Result { + let mut file = File::open(filename)?; + let mut html = String::new(); + File::read_to_string(&mut file, &mut html)?; + let document = Document::from(&html); + let event = document.select("article.event").iter().next() + .ok_or_else(|| anyhow!("Could not find event in html file"))?; + + let year = event.select("p.event-year").text().to_string().parse::()?; + let month = month_number(event.select("p.event-month").text().to_string().as_str())?; + let day = event.select("p.event-day").text().to_string().parse::()?; + + let timespan = event.select("dl.dl-horizontal > dd").iter().last() + .ok_or_else(|| anyhow!("No venue timespan found"))?.text().to_string(); + let (start_str, end_str) = timespan.split_once('—') + .ok_or_else(|| anyhow!("Couldn't split timespan"))?; + let start_hh = start_str[0..=1].parse::()?; + let start_mm = start_str[3..=4].parse::()?; + let end_hh: u32 = end_str[0..=1].parse()?; + let end_mm: u32 = end_str[3..=4].parse()?; + + let description = event.select("div.event-desc"); + let title = event.select("h3.event-desc-header").text().to_string().trim().to_owned(); + + let mut title_elem = event.select("h3.event-desc-header"); + title_elem.remove(); + + let location = event.select("dl.dl-horizontal > dd > strong").iter().next() + .ok_or_else(|| anyhow!("No venue location found"))?.text().to_string(); + let mut horizontals = event.select("dl.dl-horizontal"); + horizontals.remove(); + + let desc = Desc { + _html: description.html().to_string(), + plain: august::convert_unstyled(&description.html().to_string(), 79), + }; + + let start = Local.ymd(year, month, day).and_hms(start_hh, start_mm, 0); + let end = Local.ymd(year, month, day).and_hms(end_hh, end_mm, 0); + + Ok(HackNight::new(title, desc, start, end, location)) + } + + fn ics<'a>(&'a self, organizer: &str) -> Result> { + let mut calendar = ICalendar::new("2.0", "-//cph.rs//Hack Night inviter//EN"); + let uuid = Uuid::new_v4().to_string(); + let timestamp = to_iso8601(Local::now()); + + let mut event = Event::new(uuid, timestamp); + + event.push(Organizer::new(format!("mailto:{organizer}"))); + event.push(DtStart::new(to_iso8601(self.start))); + event.push(DtEnd::new(to_iso8601(self.end))); + event.push(Summary::new(format!("Rust {}", self.title))); + event.push(Location::new(self.location.clone())); + event.push(Description::new(escape_text(self.desc.plain.clone()))); + + calendar.add_event(event); + + Ok(calendar) + } + + fn mail_body(&self, fields: &Input) -> Result { + let mut file = File::open(fields.template.clone())?; + let mut contents = String::new(); + File::read_to_string(&mut file, &mut contents)?; + let mut env = Environment::new(); + env.add_template("plain/text", &contents)?; + let template = env.get_template("plain/text")?; + + template.render(jinja_context! { + body => self.desc.plain, + organizer_email => fields.organizer_email, + }).context("jinja") + } + + fn mime_message(&self, input: &Input, body: &str, ics: &ICalendar) + -> Result>> + { + Ok(Message::builder() + .from(format!("{} <{}>", input.sender_name, input.sender_email).parse()?) + .to(format!("{} <{}>", input.recipient_name, input.recipient_email).parse()?) + .subject(format!("Rust {}", &self.title)) + .mime_body( + MultiPart::mixed() + .singlepart( + SinglePart::quoted_printable() + .header(header::ContentType("text/plain; charset=utf8".parse()?)) + .body(String::from(body)) + ) + .singlepart( + SinglePart::quoted_printable() + .header(header::ContentType("text/calendar; charset=utf8".parse()?)) + .body(format!("{}", ics)) + ) + ) + ) + } +} + +struct Input { + sender_name: String, + sender_email: String, + recipient_name: String, + recipient_email: String, + _organizer_name: String, + organizer_email: String, + template: String, +} + +fn main() -> Result<()> { + let args: Vec = args().collect(); + let program = args[0].clone(); + + let mut opts = Options::new(); + opts.optopt("", "smtp-username", "", ""); + opts.optopt("", "smtp-password", "", ""); + opts.optopt("", "smtp-server", "", ""); + opts.optopt("", "sender-name", "", "Remember to quote spaces"); + opts.optopt("", "sender-email", "", ""); + opts.optopt("", "organizer-name", "", "(Never actually used)"); + opts.optopt("", "organizer-email", "", ""); + opts.optopt("", "jekyll-input-file", "../index.html", ""); + opts.optopt("", "email-template", "email_body.j2", ""); + opts.optflag("h", "help", "Print this help text"); + let matches = opts.parse(&args[1..])?; + if matches.opt_present("h") { + print_usage(&program, opts); + return Ok(()); + } + let (recipient_name, recipient_email) = if !matches.free.is_empty() { + matches.free[0].strip_suffix('>') + .ok_or_else(|| anyhow!("Could not understand recipient argument"))?.split_once(" <") + .ok_or_else(|| anyhow!("Could not understand recipient argument"))? + } else { + print_usage(&program, opts); + return Ok(()); + }; + + let input = Input { + sender_name: matches.opt_str("sender-name").ok_or_else(|| anyhow!("Missing sender name"))?, + sender_email: matches.opt_str("sender-email") + .ok_or_else(|| anyhow!("Missing sender email"))?, + _organizer_name: matches.opt_str("organizer-name").unwrap_or_default(), + organizer_email: matches.opt_str("organizer-email") + .ok_or_else(|| anyhow!("Missing organizer email"))?, + recipient_name: String::from(recipient_name), + recipient_email: String::from(recipient_email), + template: matches.opt_str("email-template") + .unwrap_or_else(|| String::from("email_body.j2")), + }; + let hacknight = HackNight::from_html(&matches.opt_str("jekyll-input-file") + .unwrap_or_else(|| String::from("../index.html")))?; + + let calendar = hacknight.ics(&input.organizer_email)?; + let body = hacknight.mail_body(&input)?; + + let email = hacknight.mime_message(&input, &body, &calendar)?; + + let creds = Credentials::new( + matches.opt_str("smtp-username").ok_or_else(|| anyhow!("Missing smtp username"))?, + matches.opt_str("smtp-password").ok_or_else(|| anyhow!("Missing smtp password"))?, + ); + + // let mailer = SmtpTransport::relay( + let mailer = SmtpTransport::starttls_relay( + &matches.opt_str("smtp-server").ok_or_else(|| anyhow!("Missing smtp server"))? + )?.credentials(creds).build(); + + let envelope = Envelope::new(Some(input.sender_email.parse()?), + vec![input.recipient_email.parse()?])?; + mailer.send_raw(&envelope, format!("{}", &email).as_bytes()).map(|_| ()) + .context("Failed to send email message") +} -- cgit v1.2.3