mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2024-11-13 04:39:02 +08:00
This commit is contained in:
parent
a45fea86ed
commit
6d2c1521c5
7 changed files with 377 additions and 37 deletions
|
@ -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);
|
||||
|
|
|
@ -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
248
crates/jmap/src/api/form.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue