From 6d2c1521c5c15e63fe24a82a68454645c62fd83e Mon Sep 17 00:00:00 2001 From: mdecimus Date: Fri, 27 Sep 2024 12:26:20 +0200 Subject: Contact form support --- crates/common/src/config/network.rs | 90 ++++++++++++- crates/common/src/lib.rs | 10 +- crates/jmap/src/api/form.rs | 248 ++++++++++++++++++++++++++++++++++++ crates/jmap/src/api/http.rs | 22 +++- crates/jmap/src/api/mod.rs | 1 + crates/jmap/src/auth/oauth/mod.rs | 32 ++--- crates/utils/src/map/vec_map.rs | 5 +- 7 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 crates/jmap/src/api/form.rs diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index b7c3faa5..d4c6d026 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -4,14 +4,38 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{ - expr::{if_block::IfBlock, tokenizer::TokenMap}, - Network, -}; -use utils::config::Config; +use crate::expr::{if_block::IfBlock, tokenizer::TokenMap}; +use utils::config::{Config, Rate}; use super::*; +#[derive(Clone)] +pub struct Network { + pub node_id: u64, + pub security: Security, + pub contact_form: Option, + pub http_response_url: IfBlock, + pub http_allowed_endpoint: IfBlock, +} + +#[derive(Clone)] +pub struct ContactForm { + pub rcpt_to: Vec, + pub max_size: usize, + pub rate: Option, + pub validate_domain: bool, + pub from_email: FieldOrDefault, + pub from_subject: FieldOrDefault, + pub from_name: FieldOrDefault, + pub field_honey_pot: Option, +} + +#[derive(Clone)] +pub struct FieldOrDefault { + pub field: Option, + pub default: String, +} + pub(crate) const HTTP_VARS: &[u32; 11] = &[ V_LISTENER, V_REMOTE_IP, @@ -30,6 +54,7 @@ impl Default for Network { fn default() -> Self { Self { security: Default::default(), + contact_form: None, node_id: 0, http_response_url: IfBlock::new::<()>( "server.http.url", @@ -41,11 +66,66 @@ impl Default for Network { } } +impl ContactForm { + pub fn parse(config: &mut Config) -> Option { + if !config + .property_or_default::("form.enable", "false") + .unwrap_or_default() + { + return None; + } + + let form = ContactForm { + rcpt_to: config + .values("form.deliver-to") + .filter_map(|(_, addr)| { + if addr.contains('@') && addr.contains('.') { + Some(addr.trim().to_lowercase()) + } else { + None + } + }) + .collect(), + max_size: config.property("form.max-size").unwrap_or(100 * 1024), + validate_domain: config + .property_or_default::("form.validate-domain", "true") + .unwrap_or(true), + from_email: FieldOrDefault::parse(config, "form.email", "postmaster@localhost"), + from_subject: FieldOrDefault::parse(config, "form.subject", "Contact Form"), + from_name: FieldOrDefault::parse(config, "form.name", "Contact Form"), + field_honey_pot: config.value("form.honey-pot.field").map(|v| v.to_string()), + rate: config + .property_or_default::>("form.rate-limit", "5/1h") + .unwrap_or_default(), + }; + + if !form.rcpt_to.is_empty() { + Some(form) + } else { + config.new_build_error("form.deliver-to", "No valid email addresses found"); + None + } + } +} + +impl FieldOrDefault { + pub fn parse(config: &mut Config, key: &str, default: &str) -> Self { + FieldOrDefault { + field: config.value((key, "field")).map(|s| s.to_string()), + default: config + .value((key, "default")) + .unwrap_or(default) + .to_string(), + } + } +} + impl Network { pub fn parse(config: &mut Config) -> Self { let mut network = Network { node_id: config.property("cluster.node-id").unwrap_or_default(), security: Security::parse(config), + contact_form: ContactForm::parse(config), ..Default::default() }; let token_map = &TokenMap::default().with_variables(HTTP_VARS); diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b71976b9..cda2f96f 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -17,6 +17,7 @@ use auth::{roles::RolePermissions, AccessToken}; use config::{ imap::ImapConfig, jmap::settings::JmapConfig, + network::Network, scripts::{RemoteList, Scripting}, smtp::SmtpConfig, storage::Storage, @@ -24,7 +25,6 @@ use config::{ }; use dashmap::DashMap; -use expr::if_block::IfBlock; use futures::StreamExt; use imap_proto::protocol::list::Attribute; use ipc::{DeliveryEvent, HousekeeperEvent, QueueEvent, ReportingEvent, StateEvent}; @@ -210,14 +210,6 @@ pub struct Core { pub enterprise: Option, } -#[derive(Clone)] -pub struct Network { - pub node_id: u64, - pub security: Security, - pub http_response_url: IfBlock, - pub http_allowed_endpoint: IfBlock, -} - pub trait IntoString: Sized { fn into_string(self) -> String; } diff --git a/crates/jmap/src/api/form.rs b/crates/jmap/src/api/form.rs new file mode 100644 index 00000000..2d0025f6 --- /dev/null +++ b/crates/jmap/src/api/form.rs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, fmt::Write, future::Future}; + +use chrono::Utc; +use common::{ + config::network::{ContactForm, FieldOrDefault}, + ipc::{DeliveryResult, IngestMessage}, + psl, Server, +}; +use hyper::StatusCode; +use mail_builder::{ + headers::{ + address::{Address, EmailAddress}, + HeaderType, + }, + mime::make_boundary, + MessageBuilder, +}; +use serde_json::json; +use store::{ + write::{now, BatchBuilder, BlobOp}, + Serialize, +}; +use trc::AddContext; +use utils::BlobHash; +use x509_parser::nom::AsBytes; + +use crate::{auth::oauth::FormData, services::ingest::MailDelivery}; + +use super::{ + http::{HttpSessionData, ToHttpResponse}, + HttpResponse, JsonResponse, +}; + +pub trait FormHandler: Sync + Send { + fn handle_contact_form( + &self, + session: &HttpSessionData, + form: &ContactForm, + form_data: FormData, + ) -> impl Future> + Send; +} + +impl FormHandler for Server { + async fn handle_contact_form( + &self, + session: &HttpSessionData, + form: &ContactForm, + form_data: FormData, + ) -> trc::Result { + // Validate rate + if let Some(rate) = &form.rate { + if !session.remote_ip.is_loopback() + && self + .core + .storage + .lookup + .is_rate_allowed( + format!("contact:{}", session.remote_ip).as_bytes(), + rate, + false, + ) + .await + .caused_by(trc::location!())? + .is_some() + { + return Err(trc::LimitEvent::TooManyRequests.into_err()); + } + } + + // Validate honeypot + if form + .field_honey_pot + .as_ref() + .map_or(false, |field| form_data.has_field(field)) + { + return Err(trc::ResourceEvent::BadParameters + .into_err() + .details("Honey pot field present")); + } + + // Obtain fields + let from_email = form_data + .get_or_default(&form.from_email) + .trim() + .to_lowercase(); + let from_subject = form_data.get_or_default(&form.from_subject).trim(); + let from_name = form_data.get_or_default(&form.from_name).trim(); + + // Validate email + let mut failure = None; + let mut has_success = false; + if form.validate_domain && from_email != form.from_email.default { + if let Some(domain) = from_email.rsplit_once('@').and_then(|(local, domain)| { + if !local.is_empty() + && domain.contains('.') + && psl::suffix(domain.as_bytes()).is_some() + { + Some(domain) + } else { + None + } + }) { + if self + .core + .smtp + .resolvers + .dns + .mx_lookup(domain) + .await + .is_err() + { + failure = Some(format!("No MX records found for domain {domain:?}. Please enter a valid email address.", ).into()); + } + } else { + failure = Some(Cow::Borrowed("Please enter a valid email address.")); + } + } + + if failure.is_none() { + // Build body + let mut body = String::with_capacity(1024); + for (field, value) in form_data.fields() { + if !value.is_empty() { + body.push_str(field); + body.push_str(": "); + body.push_str(value); + body.push_str("\r\n"); + } + } + let _ = write!( + &mut body, + "Date: {}\r\n", + Utc::now().format("%a, %d %b %Y %T %z") + ); + let _ = write!( + &mut body, + "IP: {}:{}\r\n", + session.remote_ip, session.remote_port + ); + + // Build message + let message = MessageBuilder::new() + .from((from_name, from_email.as_str())) + .header( + "To", + HeaderType::Address(Address::List( + form.rcpt_to + .iter() + .map(|rcpt| { + Address::Address(EmailAddress { + name: None, + email: rcpt.into(), + }) + }) + .collect(), + )), + ) + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .message_id(format!( + "<{}@{}.{}>", + make_boundary("."), + session.remote_ip, + session.remote_port + )) + .subject(from_subject) + .text_body(body) + .write_to_vec() + .unwrap_or_default(); + + // Reserve and write blob + let message_blob = BlobHash::from(message.as_bytes()); + let message_size = message.len(); + let mut batch = BatchBuilder::new(); + batch.set( + BlobOp::Reserve { + hash: message_blob.clone(), + until: now() + 120, + }, + 0u32.serialize(), + ); + self.store() + .write(batch.build()) + .await + .caused_by(trc::location!())?; + self.blob_store() + .put_blob(message_blob.as_slice(), message.as_ref()) + .await + .caused_by(trc::location!())?; + + for result in self + .deliver_message(IngestMessage { + sender_address: from_email, + recipients: form.rcpt_to.clone(), + message_blob, + message_size, + session_id: session.session_id, + }) + .await + { + match result { + DeliveryResult::Success => { + has_success = true; + } + DeliveryResult::TemporaryFailure { reason } + | DeliveryResult::PermanentFailure { reason, .. } => failure = Some(reason), + } + } + + // Suppress errors if there is at least one success + if has_success { + failure = None; + } + } + + Ok(JsonResponse::with_status( + if has_success { + StatusCode::OK + } else { + StatusCode::BAD_REQUEST + }, + json!({ + "data": { + "success": has_success, + "details": failure, + }, + }), + ) + .into_http_response()) + } +} + +impl FormData { + pub fn get_or_default<'x>(&'x self, field: &'x FieldOrDefault) -> &'x str { + if let Some(field_name) = &field.field { + self.get(field_name) + .filter(|f| !f.is_empty()) + .unwrap_or(field.default.as_str()) + } else { + field.default.as_str() + } + } +} diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 4252edcb..d3dd0938 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -37,7 +37,7 @@ use crate::{ api::management::enterprise::telemetry::TelemetryApi, auth::{ authenticate::{Authenticator, HttpHeaders}, - oauth::{auth::OAuthApiHandler, token::TokenHandler, OAuthMetadata}, + oauth::{auth::OAuthApiHandler, token::TokenHandler, FormData, OAuthMetadata}, rate_limit::RateLimiter, }, blob::{download::BlobDownload, upload::BlobUpload, DownloadResponse, UploadResponse}, @@ -47,6 +47,7 @@ use crate::{ use super::{ autoconfig::Autoconfig, event_source::EventSourceHandler, + form::FormHandler, management::{ManagementApi, ManagementApiError}, request::RequestHandler, session::SessionHandler, @@ -450,6 +451,25 @@ impl ParseHttp for Server { // SPDX-SnippetEnd } + "form" => { + if let Some(form) = &self.core.network.contact_form { + match *req.method() { + Method::POST => { + self.is_anonymous_allowed(&session.remote_ip).await?; + + let form_data = + FormData::from_request(&mut req, form.max_size, session.session_id) + .await?; + + return self.handle_contact_form(&session, form, form_data).await; + } + Method::OPTIONS => { + return Ok(StatusCode::NO_CONTENT.into_http_response()); + } + _ => {} + } + } + } _ => { let path = req.uri().path(); let resource = self diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 0ee7ef52..6a56a471 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -14,6 +14,7 @@ use utils::map::vec_map::VecMap; pub mod autoconfig; pub mod event_source; +pub mod form; pub mod http; pub mod management; pub mod request; diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index a7bb162d..f56585eb 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -4,10 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::collections::HashMap; - use hyper::header::CONTENT_TYPE; use serde::{Deserialize, Serialize}; +use utils::map::vec_map::VecMap; use crate::api::{http::fetch_body, HttpRequest}; @@ -191,11 +190,11 @@ impl TokenResponse { #[derive(Debug)] pub struct FormData { - fields: HashMap>, + fields: VecMap, } impl FormData { - async fn from_request( + pub async fn from_request( req: &mut HttpRequest, max_len: usize, session_id: u64, @@ -208,17 +207,18 @@ impl FormData { fetch_body(req, max_len, session_id).await, ) { (Some(content_type), Some(body)) => { - let mut fields = HashMap::new(); + let mut fields = VecMap::new(); if let Some(boundary) = content_type.get_param(mime::BOUNDARY) { for mut field in form_data::FormData::new(&body[..], boundary.as_str()).flatten() { - let value = field.bytes().unwrap_or_default().to_vec(); - fields.insert(field.name, value); + let value = String::from_utf8_lossy(&field.bytes().unwrap_or_default()) + .into_owned(); + fields.append(field.name, value); } } else { for (key, value) in form_urlencoded::parse(&body) { - fields.insert(key.into_owned(), value.into_owned().into_bytes()); + fields.append(key.into_owned(), value.into_owned()); } } Ok(FormData { fields }) @@ -230,22 +230,18 @@ impl FormData { } pub fn get(&self, key: &str) -> Option<&str> { - self.fields - .get(key) - .and_then(|v| std::str::from_utf8(v).ok()) + self.fields.get(key).map(|v| v.as_str()) } pub fn remove(&mut self, key: &str) -> Option { - self.fields - .remove(key) - .and_then(|v| String::from_utf8(v).ok()) + self.fields.remove(key) } - pub fn get_bytes(&self, key: &str) -> Option<&[u8]> { - self.fields.get(key).map(|v| v.as_slice()) + pub fn has_field(&self, key: &str) -> bool { + self.fields.get(key).map_or(false, |v| !v.is_empty()) } - pub fn remove_bytes(&mut self, key: &str) -> Option> { - self.fields.remove(key) + pub fn fields(&self) -> impl Iterator { + self.fields.iter() } } diff --git a/crates/utils/src/map/vec_map.rs b/crates/utils/src/map/vec_map.rs index 32d8803b..b2b7c9b5 100644 --- a/crates/utils/src/map/vec_map.rs +++ b/crates/utils/src/map/vec_map.rs @@ -92,7 +92,10 @@ impl VecMap { } #[inline(always)] - pub fn remove(&mut self, key: &K) -> Option { + pub fn remove(&mut self, key: &Q) -> Option + where + K: Borrow + PartialEq, + { self.inner .iter() .position(|kv| kv.key == *key) -- cgit v1.2.3-70-g09d2