Initial mail support.

Deletion and verification mails are now sent via sendmail
This commit is contained in:
seu 2018-11-02 11:50:57 +01:00
parent 49f15fd5e0
commit a7937158ff
8 changed files with 215 additions and 55 deletions

View File

@ -20,6 +20,13 @@ parking_lot = "0.6"
structopt = "0.2"
url = "1.6"
hex = "0.3"
lettre_email = "0.8"
[dependencies.lettre]
version = "0.8"
default-features = false
# smtp-transport doesn't build (openssl problem)
features = ["file-transport", "sendmail-transport"]
[dependencies.rocket_contrib]
version = "0"

65
src/mail.rs Normal file
View File

@ -0,0 +1,65 @@
use rocket_contrib::Template;
use lettre::{SendmailTransport, EmailTransport};
use lettre_email::EmailBuilder;
use serde::Serialize;
use Result;
use types::Email;
#[derive(Serialize, Clone)]
pub struct Context{
pub token: String,
pub userid: String,
pub domain: String,
}
fn send_mail<T>(to: &Email, subject: &str, template_dir: &str,
template_base: &str, domain: &str, ctx: T)
-> Result<()> where T: Serialize + Clone
{
let html = Template::show(template_dir, format!("{}-html", template_base), ctx.clone());
let txt = Template::show(template_dir, format!("{}-txt", template_base), ctx);
let email = EmailBuilder::new()
.to(to.to_string())
.from(format!("noreply@{}", domain))
.subject(subject)
.alternative(
html.ok_or("Email template failed to render")?,
txt.ok_or("Email template failed to render")?)
.build().unwrap();
let mut sender = SendmailTransport::new();
sender.send(&email)?;
Ok(())
}
pub fn send_verification_mail(userid: &Email, token: &str, template_dir: &str,
domain: &str)
-> Result<()>
{
let ctx = Context{
token: token.to_string(),
userid: userid.to_string(),
domain: domain.to_string(),
};
send_mail(userid, "Please verify your email address", template_dir,
"verify-email", domain, ctx)
}
pub fn send_confirmation_mail(userid: &Email, token: &str, template_dir: &str,
domain: &str)
-> Result<()>
{
let ctx = Context{
token: token.to_string(),
userid: userid.to_string(),
domain: domain.to_string(),
};
send_mail(userid, "Please confirm deletion of your key", template_dir,
"confirm-email", domain, ctx)
}

View File

@ -23,10 +23,13 @@ extern crate rand;
extern crate tempfile;
extern crate parking_lot;
extern crate structopt;
extern crate lettre;
extern crate lettre_email;
mod web;
mod database;
mod types;
mod mail;
mod errors {
error_chain!{
@ -39,6 +42,7 @@ mod errors {
StringUtf8Error(::std::string::FromUtf8Error);
StrUtf8Error(::std::str::Utf8Error);
HexError(::hex::FromHexError);
SendmailError(::lettre::sendmail::error::Error);
}
}
}
@ -62,6 +66,11 @@ pub struct Opt {
/// Port and address to listen on.
#[structopt(short = "l", long = "listen", default_value = "0.0.0.0:8080")]
listen: String,
/// FQDN of the server. Used in templates.
#[structopt(short = "D", long = "domain", default_value = "localhost")]
domain: String,
}
fn main() {

View File

@ -50,6 +50,8 @@ mod templates {
}
struct StaticDir(String);
pub struct MailTemplateDir(String);
pub struct Domain(String);
impl<'a, 'r> FromRequest<'a, 'r> for queries::Hkp {
type Error = ();
@ -165,10 +167,13 @@ fn verify(db: rocket::State<Polymorphic>, token: String)
}
}
#[get("/delete/<fpr>")]
fn delete(db: rocket::State<Polymorphic>, fpr: String)
#[get("/vks/delete/<fpr>")]
fn delete(db: rocket::State<Polymorphic>, fpr: String,
tmpl: State<MailTemplateDir>, domain: State<Domain>)
-> result::Result<Template, Custom<String>>
{
use mail::send_confirmation_mail;
let fpr = match Fingerprint::from_str(&fpr) {
Ok(fpr) => fpr,
Err(_) => {
@ -286,6 +291,7 @@ pub fn serve(opt: &Opt, db: Polymorphic) -> Result<()> {
.root(opt.base.clone())
.extra("template_dir", format!("{}/templates", opt.base.display()))
.extra("static_dir", format!("{}/public", opt.base.display()))
.extra("domain", opt.domain.clone())
.finalize()?;
let routes = routes![
// infra
@ -313,6 +319,23 @@ pub fn serve(opt: &Opt, db: Polymorphic) -> Result<()> {
Ok(rocket.manage(StaticDir(static_dir)))
}))
.attach(AdHoc::on_attach(|rocket| {
let static_dir = rocket.config()
.get_str("template_dir")
.unwrap()
.to_string();
Ok(rocket.manage(MailTemplateDir(static_dir)))
}))
.attach(AdHoc::on_attach(|rocket| {
let static_dir = rocket.config()
.get_str("domain")
.unwrap()
.to_string();
Ok(rocket.manage(Domain(static_dir)))
}))
.mount("/", routes)
.manage(db)
.launch();

View File

@ -7,8 +7,14 @@ use rocket::http::{ContentType, Status};
use rocket::response::status::Custom;
use rocket_contrib::Template;
use types::Email;
use mail::send_verification_mail;
use web::{Domain, MailTemplateDir};
use database::{Database, Polymorphic};
use std::io::Read;
use std::str::FromStr;
mod template {
#[derive(Serialize)]
pub struct Token {
@ -17,82 +23,132 @@ mod template {
}
#[derive(Serialize)]
pub struct Context {
pub struct Verify {
pub tokens: Vec<Token>,
}
}
#[post("/keys", data = "<data>")]
#[post("/pks/add", data = "<data>")]
// signature requires the request to have a `Content-Type`
pub fn multipart_upload(db: State<Polymorphic>, cont_type: &ContentType, data: Data) -> Result<Template, Custom<String>> {
// this and the next check can be implemented as a request guard but it seems like just
// more boilerplate than necessary
if !cont_type.is_form_data() {
return Err(Custom(
Status::BadRequest,
"Content-Type not multipart/form-data".into()
));
}
let (_, boundary) = cont_type.params().find(|&(k, _)| k == "boundary").ok_or_else(
pub fn multipart_upload(db: State<Polymorphic>, cont_type: &ContentType,
data: Data, tmpl: State<MailTemplateDir>,
domain: State<Domain>)
-> Result<Template, Custom<String>>
{
if cont_type.is_form_data() {
// multipart/form-data
let (_, boundary) = cont_type.params().find(|&(k, _)| k == "boundary").ok_or_else(
|| Custom(
Status::BadRequest,
"`Content-Type: multipart/form-data` boundary param not provided".into()
)
)?;
)
)?;
process_upload(boundary, data, db.inner())
process_upload(boundary, data, db.inner(), &tmpl.0, &domain.0)
} else if cont_type.is_form() {
use rocket::request::FormItems;
use std::io::Cursor;
// application/x-www-form-urlencoded
let mut buf = Vec::default();
data.stream_to(&mut buf).or_else(|_| {
Err(Custom(Status::BadRequest,
"`Content-Type: application/x-www-form-urlencoded` not valid".into()))
})?;
for (key, value) in FormItems::from(&*String::from_utf8_lossy(&buf)) {
let decoded_value = value.url_decode().or_else(|_| {
Err(Custom(Status::BadRequest,
"`Content-Type: application/x-www-form-urlencoded` not valid".into()))
})?;
match key.as_str() {
"keytext" => {
return process_key(Cursor::new(decoded_value.as_bytes()),
&db, &tmpl.0, &domain.0);
}
_ => { /* skip */ }
}
}
Err(Custom(Status::BadRequest, "Not a PGP public key".into()))
} else {
Err(Custom(Status::BadRequest, "Content-Type not a form".into()))
}
}
fn process_upload(boundary: &str, data: Data, db: &Polymorphic) -> Result<Template, Custom<String>> {
fn process_upload(boundary: &str, data: Data, db: &Polymorphic, tmpl: &str,
domain: &str)
-> Result<Template, Custom<String>>
{
// saves all fields, any field longer than 10kB goes to a temporary directory
// Entries could implement FromData though that would give zero control over
// how the files are saved; Multipart would be a good impl candidate though
match Multipart::with_body(data.open(), boundary).save().temp() {
Full(entries) => process_entries(entries, db),
Partial(partial, _) => {
process_entries(partial.entries, db)
},
Full(entries) => process_multipart(entries, db, tmpl, domain),
Partial(partial, _) => process_multipart(partial.entries, db, tmpl, domain),
Error(err) => Err(Custom(Status::InternalServerError, err.to_string())),
}
}
// having a streaming output would be nice; there's one for returning a `Read` impl
// but not one that you can `write()` to
fn process_entries(entries: Entries, db: &Polymorphic) -> Result<Template, Custom<String>> {
use openpgp::TPK;
match entries.fields.get(&"key".to_string()) {
fn process_multipart(entries: Entries, db: &Polymorphic, tmpl: &str,
domain: &str)
-> Result<Template, Custom<String>>
{
match entries.fields.get(&"keytext".to_string()) {
Some(ent) if ent.len() == 1 => {
let reader = ent[0].data.readable().map_err(|err| {
Custom(Status::InternalServerError, err.to_string())
})?;
match TPK::from_reader(reader) {
Ok(tpk) => {
match db.merge_or_publish(tpk) {
Ok(tokens) => {
let tokens = tokens
.into_iter().map(|(uid,tok)| {
template::Token{ userid: uid.to_string(), token: tok }
}).collect::<Vec<_>>();
let context = template::Context{
tokens: tokens
};
Ok(Template::render("upload", context))
}
Err(err) =>
Err(Custom(Status::InternalServerError,
format!("{:?}", err))),
}
}
Err(_) => Err(Custom(Status::BadRequest,
"Not a PGP public key".into())),
}
process_key(reader, db, tmpl, domain)
}
Some(_) | None =>
Err(Custom(Status::BadRequest, "Not a PGP public key".into())),
}
}
fn process_key<R>(reader: R, db: &Polymorphic, tmpl: &str, domain: &str)
-> Result<Template, Custom<String>> where R: Read
{
use openpgp::{Reader, TPK};
let reader = Reader::from_reader(reader).or_else(|_| {
Err(Custom(Status::BadRequest,
"`Content-Type: application/x-www-form-urlencoded` not valid".into()))
})?;
match TPK::from_reader(reader) {
Ok(tpk) => {
match db.merge_or_publish(tpk) {
Ok(tokens) => {
let tokens = tokens
.into_iter().map(|(uid,tok)| {
template::Token{ userid: uid.to_string(), token: tok }
}).collect::<Vec<_>>();
// send out emails
for tok in tokens.iter() {
let &template::Token{ ref userid, ref token } = tok;
Email::from_str(userid).and_then(|email| {
send_verification_mail(&email, token, tmpl, domain)
}).map_err(|err| {
Custom(Status::InternalServerError, format!("{:?}", err))
})?;
}
let context = template::Verify{
tokens: tokens
};
Ok(Template::render("upload", context))
}
Err(err) =>
Err(Custom(Status::InternalServerError,
format!("{:?}", err))),
}
}
Err(_) => Err(Custom(Status::BadRequest, "Not a PGP public key".into())),
}
}

View File

@ -10,8 +10,8 @@
</form>
<h2>Upload your key</h2>
<form action="/keys" method=POST enctype=multipart/form-data>
<input type="file" id="key" name="key" placeholder="Your public key">
<form action="/pks/add" method=POST enctype=multipart/form-data>
<input type="file" id="keytext" name="keytext" placeholder="Your public key">
<input type="submit" value="Upload">
</form>
{{/layout}}

View File

@ -3,7 +3,7 @@
{{#if tokens}}
<ul>
{{#each tokens}}
<li>{{userid}}: <a href="/verify/{{token}}"><tt>{{token}}</tt></a></li>
<li>{{userid}}: <a href="/vks/verify/{{token}}"><tt>{{token}}</tt></a></li>
{{/each}}
</ul>
{{else}}

View File

@ -2,7 +2,7 @@
{{#if verified }}
<h1>Email verified</h1>
<p>You've verified <em>{{ userid }}</em> successfully. Everybody who knows
your email address is no able to find your key. You can <a href="/delete/{{
your email address is no able to find your key. You can <a href="/vks/delete/{{
fpr }}">delete</a> your key any time you want.</p>
{{else}}
<h1>Email verification failed</h1>