summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcos <cos>2022-06-28 13:03:36 +0200
committercos <cos>2022-07-22 08:06:01 +0200
commitd46e959c42b5cb3cf81e76064db1a157b1213e8d (patch)
treeb922641fb1c8569965f532ead4501a4d390fd294
parent22b43c8344cfa467ff03ece29a0ea2d57da270fc (diff)
downloadcph.rs-d46e959c42b5cb3cf81e76064db1a157b1213e8d.zip
Initial addition of invitation-mailer
-rw-r--r--.gitignore2
-rw-r--r--_invitation-mailer/Cargo.toml16
-rw-r--r--_invitation-mailer/email_body-2022june30.j211
-rw-r--r--_invitation-mailer/src/main.rs283
4 files changed, 312 insertions, 0 deletions
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 <recipient@email.address>'", program);
+ print!("{}", opts.usage(&brief));
+}
+
+fn to_iso8601(dt: DateTime<Local>) -> String {
+ // https://github.com/chronotope/chrono/issues/244
+ DateTime::<Utc>::from(dt).format("%Y%m%dT%H%M%SZ").to_string()
+}
+
+fn month_number(month: &str) -> Result<u32> {
+ 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<Local>,
+ end: DateTime<Local>,
+ location: String,
+}
+
+impl HackNight {
+ fn new(
+ title: String,
+ desc: Desc,
+ start: DateTime<Local>,
+ end: DateTime<Local>,
+ location: String,
+ ) -> Self {
+ Self {
+ title,
+ desc,
+ start,
+ end,
+ location,
+ }
+ }
+
+ fn from_html(filename: &str) -> Result<HackNight> {
+ 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::<i32>()?;
+ 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::<u32>()?;
+
+ 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::<u32>()?;
+ let start_mm = start_str[3..=4].parse::<u32>()?;
+ 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<ICalendar<'a>> {
+ 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<String> {
+ 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<Message<MultiPart<String>>>
+ {
+ 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<String> = 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")
+}