API and mail sending implementation
commit
e2f5f10cf7
@ -0,0 +1,2 @@
|
||||
/target
|
||||
config.toml
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "contact-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rocket = { version = "0.5.0", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0.114"
|
||||
toml = "0.8.9"
|
||||
governor = "0.6.3"
|
||||
mail-send = "0.4.7"
|
||||
mail-builder = "0.3.1"
|
||||
|
@ -0,0 +1,56 @@
|
||||
use std::{num::NonZeroU32, path::Path};
|
||||
use std::{fs, io};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(rename="form")]
|
||||
pub forms: Vec<Form>,
|
||||
pub mailer: EmailConfig,
|
||||
pub rate_limit: RateConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Form {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub recipient_email: String,
|
||||
pub subject: String,
|
||||
pub fields: Vec<Field>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Field {
|
||||
pub name: String,
|
||||
pub pattern: Option<String>,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EmailConfig {
|
||||
pub from_address: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub implicit_tls: bool,
|
||||
pub username: String,
|
||||
pub password: String
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RateConfig {
|
||||
pub burst_max: NonZeroU32,
|
||||
pub replenish_seconds: u32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn parse(path: &Path) -> Result<Self, io::Error> {
|
||||
let config_str = fs::read_to_string(path)?;
|
||||
|
||||
match toml::from_str(&config_str) {
|
||||
Ok(config) => return Ok(config),
|
||||
// TODO: Print warning about parse error
|
||||
Err(_err) => Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid syntax for config file")),
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
use std::convert::Infallible;
|
||||
use std::net::IpAddr;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rocket::request::{self, FromRequest};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::http::Status;
|
||||
use rocket::Request;
|
||||
use mail_builder::MessageBuilder;
|
||||
|
||||
pub struct ClientIp(Option<IpAddr>);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for ClientIp {
|
||||
type Error = Infallible;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
||||
request::Outcome::Success(ClientIp(request.client_ip()))
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/contact/<slug>", data = "<fields>")]
|
||||
pub async fn handler(state: &rocket::State<crate::State>, client_ip: ClientIp, slug: &str, fields: Json<HashMap<&str, &str>>) -> Result<String, (Status, String)> {
|
||||
{
|
||||
let rate_limit = &state.rate_limit;
|
||||
|
||||
if client_ip.0.is_none() {
|
||||
return Err((Status::InternalServerError, "Could not identify client IP".to_string()))
|
||||
}
|
||||
|
||||
let client_ip = client_ip.0.unwrap();
|
||||
|
||||
let rate_limit_outcome = rate_limit.check_key(&client_ip);
|
||||
|
||||
if let Err(_not_until) = rate_limit_outcome {
|
||||
return Err((Status::TooManyRequests, "Rate limit error".to_string()));
|
||||
}
|
||||
|
||||
println!("Passed rate limit");
|
||||
};
|
||||
|
||||
{
|
||||
let email_client = &state.smtp_builder;
|
||||
let mail_conf = &state.config.mailer;
|
||||
let form_conf = &state.config.forms.iter().find(|form| {
|
||||
form.slug == slug
|
||||
});
|
||||
|
||||
if form_conf.is_none() {
|
||||
return Err((Status::NotFound, "Not found".to_string()));
|
||||
}
|
||||
|
||||
let form_conf = form_conf.unwrap();
|
||||
|
||||
let mut message_text = format!(
|
||||
"You have recieved a message on {}:
|
||||
|
||||
Form fields:", form_conf.name);
|
||||
|
||||
for field in &form_conf.fields {
|
||||
let value_opt = fields.get(field.name.as_str());
|
||||
let required = field.required;
|
||||
|
||||
if value_opt.is_none() && required {
|
||||
return Err((Status::BadRequest, format!("Missing field: {}", field.name)))
|
||||
}
|
||||
|
||||
message_text.push_str(&format!(" - {}: {}\n", field.name, value_opt.unwrap_or(&"[empty]")));
|
||||
}
|
||||
|
||||
let message = MessageBuilder::new()
|
||||
.from(mail_conf.from_address.clone())
|
||||
.to(form_conf.recipient_email.clone())
|
||||
.subject(form_conf.subject.clone())
|
||||
.text_body(message_text);
|
||||
|
||||
println!("Message composed");
|
||||
|
||||
let connection_result = email_client.connect().await;
|
||||
|
||||
if let Err(err) = connection_result {
|
||||
println!("Error connecting to server: {}", err.to_string());
|
||||
|
||||
return Err((Status::InternalServerError, "Could not send message".to_string()));
|
||||
} else {
|
||||
let mut connection = connection_result.unwrap();
|
||||
println!("Connected to mail server");
|
||||
|
||||
if let Err(err) = connection.send(message).await {
|
||||
println!("Error sending email: {}", err.to_string());
|
||||
|
||||
return Err((Status::InternalServerError, "Could not send message".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
println!("Message sent");
|
||||
return Ok("Message sent".to_string());
|
||||
};
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
#[macro_use] extern crate rocket;
|
||||
|
||||
use std::{net::IpAddr, path::Path, time::Duration};
|
||||
use config::Config;
|
||||
use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter};
|
||||
use mail_send::{SmtpClientBuilder, Credentials};
|
||||
use rocket::data::{Limits, ToByteUnit};
|
||||
|
||||
mod config;
|
||||
mod endpoints;
|
||||
|
||||
pub struct State {
|
||||
pub rate_limit: RateLimiter<IpAddr,DashMapStateStore<IpAddr>,DefaultClock>,
|
||||
pub smtp_builder: SmtpClientBuilder<String>,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
#[launch]
|
||||
fn rocket() -> _ {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() - 1 != 1 {
|
||||
panic!("Expected 1 argument, got {}", args.len() - 1)
|
||||
}
|
||||
|
||||
let path = Path::new(&args[1]);
|
||||
let config = crate::config::Config::parse(path).unwrap();
|
||||
|
||||
let smtp_builder = SmtpClientBuilder::new(config.mailer.host.clone(), config.mailer.port)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.implicit_tls(config.mailer.implicit_tls)
|
||||
.credentials(Credentials::Plain {
|
||||
username: config.mailer.username.clone(),
|
||||
secret: config.mailer.password.clone()
|
||||
});
|
||||
|
||||
let quota = Quota::with_period(
|
||||
Duration::from_secs(config.rate_limit.replenish_seconds.into())
|
||||
).unwrap().allow_burst(config.rate_limit.burst_max);
|
||||
|
||||
let rate_limit : RateLimiter<IpAddr, _, _> = RateLimiter::dashmap(quota);
|
||||
|
||||
let state = State {
|
||||
config,
|
||||
rate_limit,
|
||||
smtp_builder
|
||||
};
|
||||
|
||||
let rocket_conf = rocket::config::Config {
|
||||
limits: Limits::default()
|
||||
.limit("json", 2.kibibytes()),
|
||||
..rocket::config::Config::default()
|
||||
};
|
||||
|
||||
rocket::custom(rocket_conf)
|
||||
.manage(state)
|
||||
.mount("/", routes![
|
||||
endpoints::handler
|
||||
])
|
||||
}
|
Loading…
Reference in New Issue