Contact form support
Some checks failed
trivy / Check (push) Failing after -9m33s

This commit is contained in:
mdecimus 2024-09-27 12:26:20 +02:00
parent a45fea86ed
commit 6d2c1521c5
7 changed files with 377 additions and 37 deletions

View file

@ -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);

View file

@ -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;
}

248
crates/jmap/src/api/form.rs Normal file
View file

@ -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()
}
}
}

View file

@ -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

View file

@ -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;

View file

@ -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())
}
pub fn get_bytes(&self, key: &str) -> Option<&[u8]> {
self.fields.get(key).map(|v| v.as_slice())
}
pub fn remove_bytes(&mut self, key: &str) -> Option<Vec<u8>> {
self.fields.remove(key)
}
pub fn has_field(&self, key: &str) -> bool {
self.fields.get(key).map_or(false, |v| !v.is_empty())
}
pub fn fields(&self) -> impl Iterator<Item = (&String, &String)> {
self.fields.iter()
}
}

View file

@ -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)