summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormdecimus <mauro@stalw.art>2024-09-27 12:26:20 +0200
committermdecimus <mauro@stalw.art>2024-09-27 12:26:20 +0200
commit6d2c1521c5c15e63fe24a82a68454645c62fd83e (patch)
tree417ba27b9fe8d75c56ffe80430ea6631d12249d0
parenta45fea86ed5b5c6c7742b8d435301ef9f6bc034f (diff)
Contact form support
-rw-r--r--crates/common/src/config/network.rs90
-rw-r--r--crates/common/src/lib.rs10
-rw-r--r--crates/jmap/src/api/form.rs248
-rw-r--r--crates/jmap/src/api/http.rs22
-rw-r--r--crates/jmap/src/api/mod.rs1
-rw-r--r--crates/jmap/src/auth/oauth/mod.rs32
-rw-r--r--crates/utils/src/map/vec_map.rs5
7 files changed, 374 insertions, 34 deletions
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<ContactForm>,
+ pub http_response_url: IfBlock,
+ pub http_allowed_endpoint: IfBlock,
+}
+
+#[derive(Clone)]
+pub struct ContactForm {
+ pub rcpt_to: Vec<String>,
+ pub max_size: usize,
+ pub rate: Option<Rate>,
+ pub validate_domain: bool,
+ pub from_email: FieldOrDefault,
+ pub from_subject: FieldOrDefault,
+ pub from_name: FieldOrDefault,
+ pub field_honey_pot: Option<String>,
+}
+
+#[derive(Clone)]
+pub struct FieldOrDefault {
+ pub field: Option<String>,
+ 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<Self> {
+ if !config
+ .property_or_default::<bool>("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::<bool>("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::<Option<Rate>>("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<enterprise::Enterprise>,
}
-#[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 <hello@stalw.art>
+ *
+ * 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<Output = trc::Result<HttpResponse>> + Send;
+}
+
+impl FormHandler for Server {
+ async fn handle_contact_form(
+ &self,
+ session: &HttpSessionData,
+ form: &ContactForm,
+ form_data: FormData,
+ ) -> trc::Result<HttpResponse> {
+ // 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<String, Vec<u8>>,
+ fields: VecMap<String, String>,
}
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<String> {
- 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<Vec<u8>> {
- self.fields.remove(key)
+ pub fn fields(&self) -> impl Iterator<Item = (&String, &String)> {
+ 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<K: Eq + PartialEq, V> VecMap<K, V> {
}
#[inline(always)]
- pub fn remove(&mut self, key: &K) -> Option<V> {
+ pub fn remove<Q: ?Sized>(&mut self, key: &Q) -> Option<V>
+ where
+ K: Borrow<Q> + PartialEq<Q>,
+ {
self.inner
.iter()
.position(|kv| kv.key == *key)