use std::path::{PathBuf, Path}; use anyhow; use handlebars::Handlebars; use lettre::{Transport as LettreTransport, SendmailTransport, file::FileTransport}; use lettre::builder::{EmailBuilder, PartBuilder, Mailbox, MimeMultipartType}; use url; use serde::Serialize; use uuid::Uuid; use crate::counters; use rocket_i18n::I18n; use gettext_macros::i18n; use rfc2047::rfc2047_encode; use crate::template_helpers; use crate::database::types::Email; use crate::Result; mod context { #[derive(Serialize, Clone)] pub struct Verification { pub lang: String, pub primary_fp: String, pub uri: String, pub userid: String, pub base_uri: String, pub domain: String, } #[derive(Serialize, Clone)] pub struct Manage { pub lang: String, pub primary_fp: String, pub uri: String, pub base_uri: String, pub domain: String, } #[derive(Serialize, Clone)] pub struct Welcome { pub lang: String, pub primary_fp: String, pub uri: String, pub base_uri: String, pub domain: String, } } pub struct Service { from: Mailbox, domain: String, templates: Handlebars, transport: Transport, } enum Transport { Sendmail, Filemail(PathBuf), } impl Service { /// Sends mail via sendmail. pub fn sendmail(from: &str, base_uri: &str, template_dir: &Path) -> Result { Self::new(from, base_uri, template_dir, Transport::Sendmail) } /// Sends mail by storing it in the given directory. pub fn filemail(from: &str, base_uri: &str, template_dir: &Path, path: &Path) -> Result { Self::new(from, base_uri, template_dir, Transport::Filemail(path.to_owned())) } fn new(from: &str, base_uri: &str, template_dir: &Path, transport: Transport) -> Result { let templates = template_helpers::load_handlebars(template_dir)?; let domain = url::Url::parse(base_uri) ?.host_str().ok_or_else(|| anyhow!("No host in base-URI")) ?.to_string(); Ok(Self { from: from.into(), domain, templates, transport }) } pub fn send_verification( &self, i18n: &I18n, base_uri: &str, tpk_name: String, userid: &Email, token: &str ) -> Result<()> { let ctx = context::Verification { lang: i18n.lang.to_string(), primary_fp: tpk_name, uri: format!("{}/verify/{}", base_uri, token), userid: userid.to_string(), base_uri: base_uri.to_owned(), domain: self.domain.clone(), }; counters::inc_mail_sent("verify", userid); self.send( &vec![userid], &i18n!( i18n.catalog, context = "Subject for verification email, {0} = userid, {1} = keyserver domain", "Verify {0} for your key on {1}"; userid, &self.domain, ), "verify", i18n.lang, ctx, ) } pub fn send_manage_token( &self, i18n: &I18n, base_uri: &str, tpk_name: String, recipient: &Email, link_path: &str, ) -> Result<()> { let ctx = context::Manage { lang: i18n.lang.to_string(), primary_fp: tpk_name, uri: format!("{}{}", base_uri, link_path), base_uri: base_uri.to_owned(), domain: self.domain.clone(), }; counters::inc_mail_sent("manage", recipient); self.send( &[recipient], &i18n!( i18n.catalog, context = "Subject for manage email, {} = keyserver domain", "Manage your key on {}"; &self.domain ), "manage", i18n.lang, ctx, ) } pub fn send_welcome( &self, base_uri: &str, tpk_name: String, userid: &Email, token: &str ) -> Result<()> { let ctx = context::Welcome { lang: "en".to_owned(), primary_fp: tpk_name, uri: format!("{}/upload/{}", base_uri, token), base_uri: base_uri.to_owned(), domain: self.domain.clone(), }; counters::inc_mail_sent("welcome", userid); self.send( &vec![userid], &format!("Your key upload on {domain}", domain = self.domain), "welcome", "en", ctx, ) } fn render_template( &self, template: &str, locale: &str, ctx: impl Serialize ) -> Result<(String, String)> { let html = self.templates.render(&format!("{}/{}.htm", locale, template), &ctx) .or_else(|_| self.templates.render(&format!("{}.htm", template), &ctx)) .map_err(|_| anyhow!("Email template failed to render"))?; let txt = self.templates.render(&format!("{}/{}.txt", locale, template), &ctx) .or_else(|_| self.templates.render(&format!("{}.txt", template), &ctx)) .map_err(|_| anyhow!("Email template failed to render"))?; Ok((html, txt)) } fn send( &self, to: &[&Email], subject: &str, template: &str, locale: &str, ctx: impl Serialize ) -> Result<()> { let (html, txt) = self.render_template(template, locale, ctx)?; if cfg!(debug_assertions) { for recipient in to.iter() { println!("To: {}", recipient.to_string()); } println!("{}", &txt); } // build this ourselves, as a temporary workaround for https://github.com/lettre/lettre/issues/400 let text = PartBuilder::new() .body(txt) .header(("Content-Type", "text/plain; charset=utf-8")) .header(("Content-Transfer-Encoding", "8bit")) .build(); let html = PartBuilder::new() .body(html) .header(("Content-Type", "text/html; charset=utf-8")) .header(("Content-Transfer-Encoding", "8bit")) .build(); let email = EmailBuilder::new() .from(self.from.clone()) .subject(rfc2047_encode(subject)) .message_id(format!("<{}@{}>", Uuid::new_v4(), self.domain)) .message_type(MimeMultipartType::Alternative) .header(("Content-Transfer-Encoding", "8bit")) .child(text) .child(html); let email = to.iter().fold(email, |email, to| email.to(to.to_string())); let email = email.build()?; match self.transport { Transport::Sendmail => { let mut transport = SendmailTransport::new(); transport.send(email)?; }, Transport::Filemail(ref path) => { let mut transport = FileTransport::new(path); transport.send(email)?; }, } Ok(()) } } // for some reason, this is no longer public in lettre itself // FIXME replace with builtin struct on lettre update // see https://github.com/lettre/lettre/blob/master/lettre/src/file/mod.rs#L41 #[cfg(test)] #[derive(Deserialize)] struct SerializableEmail { #[serde(alias = "envelope")] _envelope: lettre::Envelope, #[serde(alias = "message_id")] _message_id: String, message: Vec, } /// Returns and removes the first mail it finds from the given /// directory. #[cfg(test)] pub fn pop_mail(dir: &Path) -> Result> { use std::fs; for entry in fs::read_dir(dir)? { let entry = entry?; if entry.file_type()?.is_file() { let fh = fs::File::open(entry.path())?; fs::remove_file(entry.path())?; let mail: SerializableEmail = ::serde_json::from_reader(fh)?; let body = String::from_utf8_lossy(&mail.message).to_string(); return Ok(Some(body)); } } Ok(None) } #[cfg(test)] mod test { use super::*; use tempfile::{tempdir, TempDir}; use gettext_macros::{include_i18n}; use std::str::FromStr; const BASEDIR: &str = "http://localhost/"; const FROM: &str = "test@localhost"; const TO: &str = "recipient@example.org"; fn configure_i18n(lang: &'static str) -> I18n { let langs = include_i18n!(); let catalog = langs.clone().into_iter().find(|(l, _)| *l == lang).unwrap().1; rocket_i18n::I18n { catalog, lang } } fn configure_mail() -> (Service, TempDir) { let template_dir: PathBuf = ::std::env::current_dir().unwrap().join("dist/email-templates").to_str().unwrap().into(); let tempdir = tempdir().unwrap(); let service = Service::filemail(FROM, BASEDIR, &template_dir, tempdir.path()).unwrap(); (service, tempdir) } fn assert_header(headers: &[(&str, &str)], name: &str, pred: impl Fn(&str) -> bool) { if let Some((_, v)) = headers.iter().find(|(h, _)| *h == name) { assert!(pred(v)); } else { panic!("Missing header: {}", name); } } fn check_headers(mail_content: &str) { // this naively assumes that all lines colons are headers, and that all headers fit in // a single line. that's not accurate, but ok for our testing. let headers: Vec<_> = mail_content .lines() .filter(|line| line.contains(": ")) .map(|line| { let mut it = line.splitn(2, ": "); let h = it.next().unwrap(); let v = it.next().unwrap(); (h, v) }) .collect(); assert!(headers.contains(&("Content-Transfer-Encoding", "8bit"))); assert!(headers.contains(&("Content-Type", "text/plain; charset=utf-8"))); assert!(headers.contains(&("Content-Type", "text/html; charset=utf-8"))); assert!(headers.contains(&("From", ""))); assert!(headers.contains(&("To", ""))); assert_header(&headers, "Content-Type", |v| v.starts_with("multipart/alternative")); assert_header(&headers, "Date", |v| v.contains("+0000")); assert_header(&headers, "Message-ID", |v| v.contains("@localhost>")); } #[test] fn pop_mail_empty() { let (_mail, tempdir) = configure_mail(); assert!(pop_mail(tempdir.path()).unwrap().is_none()); } #[test] fn check_verification_mail_en() { let (mail, tempdir) = configure_mail(); let i18n = configure_i18n("en"); let recipient = Email::from_str(TO).unwrap(); mail.send_verification(&i18n, "test", "fingerprintoo".to_owned(), &recipient, "token").unwrap(); let mail_content = pop_mail(tempdir.path()).unwrap().unwrap(); check_headers(&mail_content); assert!(mail_content.contains("lang=\"en\"")); assert!(mail_content.contains("Hi,")); assert!(mail_content.contains("fingerprintoo")); assert!(mail_content.contains("test/verify/token")); assert!(mail_content.contains("test/about")); assert!(mail_content.contains("To let others find this key")); } #[test] fn check_verification_mail_ja() { let (mail, tempdir) = configure_mail(); let i18n = configure_i18n("ja"); let recipient = Email::from_str(TO).unwrap(); mail.send_verification(&i18n, "test", "fingerprintoo".to_owned(), &recipient, "token").unwrap(); let mail_content = pop_mail(tempdir.path()).unwrap().unwrap(); check_headers(&mail_content); assert!(mail_content.contains("lang=\"ja\"")); assert!(mail_content.contains("どうも、")); assert!(mail_content.contains("fingerprintoo")); assert!(mail_content.contains("test/verify/token")); assert!(mail_content.contains("test/about")); assert!(mail_content.contains("あなたのメールアド")); assert!(mail_content.contains("Subject: =?utf-8?q?localhost=E3=81=AE=E3=81=82=E3=81=AA=E3=81=9F=E3=81=AE?=")); } #[test] fn check_manage_mail_en() { let (mail, tempdir) = configure_mail(); let i18n = configure_i18n("en"); let recipient = Email::from_str(TO).unwrap(); mail.send_manage_token(&i18n, "test", "fingerprintoo".to_owned(), &recipient, "token").unwrap(); let mail_content = pop_mail(tempdir.path()).unwrap().unwrap(); check_headers(&mail_content); assert!(mail_content.contains("lang=\"en\"")); assert!(mail_content.contains("Hi,")); assert!(mail_content.contains("fingerprintoo")); assert!(mail_content.contains("testtoken")); assert!(mail_content.contains("test/about")); assert!(mail_content.contains("manage and delete")); } #[test] fn check_manage_mail_ja() { let (mail, tempdir) = configure_mail(); let i18n = configure_i18n("ja"); let recipient = Email::from_str(TO).unwrap(); mail.send_manage_token(&i18n, "test", "fingerprintoo".to_owned(), &recipient, "token").unwrap(); let mail_content = pop_mail(tempdir.path()).unwrap().unwrap(); check_headers(&mail_content); print!("{}", mail_content); assert!(mail_content.contains("lang=\"ja\"")); assert!(mail_content.contains("どうも、")); assert!(mail_content.contains("fingerprintoo")); assert!(mail_content.contains("testtoken")); assert!(mail_content.contains("test/about")); assert!(mail_content.contains("この鍵の掲示されたア")); assert!(mail_content.contains("Subject: =?utf-8?q?localhost=E3=81=AE=E9=8D=B5=E3=82=92=E7=AE=A1=E7=90=86?=")); } #[test] fn check_welcome_mail() { let (mail, tempdir) = configure_mail(); let recipient = Email::from_str(TO).unwrap(); mail.send_welcome("test", "fingerprintoo".to_owned(), &recipient, "token").unwrap(); let mail_content = pop_mail(tempdir.path()).unwrap().unwrap(); check_headers(&mail_content); assert!(mail_content.contains("lang=\"en\"")); assert!(mail_content.contains("Hi,")); assert!(mail_content.contains("fingerprintoo")); assert!(mail_content.contains("test/upload/token")); assert!(mail_content.contains("test/about")); assert!(mail_content.contains("first time")); } }