mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2025-09-13 15:34:15 +08:00
461 lines
15 KiB
Rust
461 lines
15 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: LicenseRef-SEL
|
|
*
|
|
* This file is subject to the Stalwart Enterprise License Agreement (SEL) and
|
|
* is NOT open source software.
|
|
*
|
|
*/
|
|
|
|
use std::{sync::Arc, time::Duration};
|
|
|
|
use ahash::AHashMap;
|
|
use directory::{backend::internal::manage::ManageDirectory, Type};
|
|
use store::{Store, Stores};
|
|
use trc::{EventType, MetricType, TOTAL_EVENT_COUNT};
|
|
use utils::config::{
|
|
cron::SimpleCron,
|
|
utils::{AsKey, ParseValue},
|
|
Config, ConfigKey,
|
|
};
|
|
|
|
use crate::{
|
|
expr::{tokenizer::TokenMap, Expression},
|
|
manager::config::ConfigManager,
|
|
};
|
|
|
|
use super::{
|
|
license::LicenseKey, llm::AiApiConfig, AlertContent, AlertContentToken, AlertMethod,
|
|
Enterprise, MetricAlert, MetricStore, SpamFilterLlmConfig, TraceStore, Undelete,
|
|
};
|
|
|
|
impl Enterprise {
|
|
pub async fn parse(
|
|
config: &mut Config,
|
|
config_manager: &ConfigManager,
|
|
stores: &Stores,
|
|
data: &Store,
|
|
) -> Option<Self> {
|
|
let server_hostname = config
|
|
.value("server.hostname")
|
|
.or_else(|| config.value("lookup.default.hostname"))?;
|
|
let mut update_license = None;
|
|
|
|
let license_result = match (
|
|
config.value("enterprise.license-key"),
|
|
config.value("enterprise.api-key"),
|
|
) {
|
|
(Some(license_key), maybe_api_key) => {
|
|
match (LicenseKey::new(license_key, server_hostname), maybe_api_key) {
|
|
(Ok(license), Some(api_key)) if license.is_near_expiration() => Ok(license
|
|
.try_renew(api_key)
|
|
.await
|
|
.map(|result| {
|
|
update_license = Some(result.encoded_key);
|
|
result.key
|
|
})
|
|
.unwrap_or(license)),
|
|
(Ok(license), None) => Ok(license),
|
|
(Err(_), Some(api_key)) => LicenseKey::invalid(server_hostname)
|
|
.try_renew(api_key)
|
|
.await
|
|
.map(|result| {
|
|
update_license = Some(result.encoded_key);
|
|
result.key
|
|
}),
|
|
(maybe_license, _) => maybe_license,
|
|
}
|
|
}
|
|
(None, Some(api_key)) => LicenseKey::invalid(server_hostname)
|
|
.try_renew(api_key)
|
|
.await
|
|
.map(|result| {
|
|
update_license = Some(result.encoded_key);
|
|
result.key
|
|
}),
|
|
(None, None) => {
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// Report error
|
|
let license = match license_result {
|
|
Ok(license) => license,
|
|
Err(err) => {
|
|
config.new_build_warning("enterprise.license-key", err.to_string());
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// Update the license if a new one was obtained
|
|
if let Some(license) = update_license {
|
|
config
|
|
.keys
|
|
.insert("enterprise.license-key".to_string(), license.clone());
|
|
if let Err(err) = config_manager
|
|
.set(
|
|
[ConfigKey {
|
|
key: "enterprise.license-key".to_string(),
|
|
value: license.to_string(),
|
|
}],
|
|
true,
|
|
)
|
|
.await
|
|
{
|
|
trc::error!(err
|
|
.caused_by(trc::location!())
|
|
.details("Failed to update license key"));
|
|
}
|
|
}
|
|
|
|
match data
|
|
.count_principals(None, Type::Individual.into(), None)
|
|
.await
|
|
{
|
|
Ok(total) if total > license.accounts as u64 => {
|
|
config.new_build_warning(
|
|
"enterprise.license-key",
|
|
format!(
|
|
"License key is valid but only allows {} accounts, found {}.",
|
|
license.accounts, total
|
|
),
|
|
);
|
|
return None;
|
|
}
|
|
Err(e) => {
|
|
if !matches!(data, Store::None) {
|
|
config.new_build_error("enterprise.license-key", e.to_string());
|
|
}
|
|
return None;
|
|
}
|
|
_ => (),
|
|
}
|
|
|
|
let trace_store = if config
|
|
.property_or_default("tracing.history.enable", "false")
|
|
.unwrap_or(false)
|
|
{
|
|
if let Some(store) = config
|
|
.value("tracing.history.store")
|
|
.and_then(|name| stores.stores.get(name))
|
|
.cloned()
|
|
{
|
|
TraceStore {
|
|
retention: config
|
|
.property_or_default::<Option<Duration>>("tracing.history.retention", "30d")
|
|
.unwrap_or(Some(Duration::from_secs(30 * 24 * 60 * 60))),
|
|
store,
|
|
}
|
|
.into()
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let metrics_store = if config
|
|
.property_or_default("metrics.history.enable", "false")
|
|
.unwrap_or(false)
|
|
{
|
|
if let Some(store) = config
|
|
.value("metrics.history.store")
|
|
.and_then(|name| stores.stores.get(name))
|
|
.cloned()
|
|
{
|
|
MetricStore {
|
|
retention: config
|
|
.property_or_default::<Option<Duration>>("metrics.history.retention", "90d")
|
|
.unwrap_or(Some(Duration::from_secs(90 * 24 * 60 * 60))),
|
|
store,
|
|
interval: config
|
|
.property_or_default::<SimpleCron>("metrics.history.interval", "0 * *")
|
|
.unwrap_or_else(|| SimpleCron::parse_value("0 * *").unwrap()),
|
|
}
|
|
.into()
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Parse AI APIs
|
|
let mut ai_apis = AHashMap::new();
|
|
for id in config
|
|
.sub_keys("enterprise.ai", ".url")
|
|
.map(|s| s.to_string())
|
|
.collect::<Vec<_>>()
|
|
{
|
|
if let Some(api) = AiApiConfig::parse(config, &id) {
|
|
ai_apis.insert(id, api.into());
|
|
}
|
|
}
|
|
|
|
Some(Enterprise {
|
|
license,
|
|
undelete: config
|
|
.property_or_default::<Option<Duration>>("storage.undelete.retention", "false")
|
|
.unwrap_or_default()
|
|
.map(|retention| Undelete { retention }),
|
|
logo_url: config.value("enterprise.logo-url").map(|s| s.to_string()),
|
|
trace_store,
|
|
metrics_store,
|
|
metrics_alerts: parse_metric_alerts(config),
|
|
spam_filter_llm: SpamFilterLlmConfig::parse(config, &ai_apis),
|
|
ai_apis,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SpamFilterLlmConfig {
|
|
pub fn parse(config: &mut Config, models: &AHashMap<String, Arc<AiApiConfig>>) -> Option<Self> {
|
|
if !config
|
|
.property_or_default::<bool>("spam-filter.llm.enable", "false")
|
|
.unwrap_or_default()
|
|
{
|
|
return None;
|
|
}
|
|
let model = config.value_require_non_empty("spam-filter.llm.model")?;
|
|
let model = if let Some(model) = models.get(model) {
|
|
model.clone()
|
|
} else {
|
|
let message = format!("Model {model:?} not found in AI API configuration");
|
|
config.new_build_error("spam-filter.llm.model", message);
|
|
return None;
|
|
};
|
|
|
|
let llm = SpamFilterLlmConfig {
|
|
model,
|
|
temperature: config
|
|
.property_or_default("spam-filter.llm.temperature", "0.5")
|
|
.unwrap_or(0.5),
|
|
prompt: config
|
|
.value_require_non_empty("spam-filter.llm.prompt")?
|
|
.to_string(),
|
|
separator: config
|
|
.value_require_non_empty("spam-filter.llm.separator")
|
|
.unwrap_or_default()
|
|
.chars()
|
|
.next()
|
|
.unwrap_or(','),
|
|
index_category: config
|
|
.property("spam-filter.llm.index.category")
|
|
.unwrap_or_default(),
|
|
index_confidence: config.property("spam-filter.llm.index.confidence"),
|
|
index_explanation: config.property("spam-filter.llm.index.explanation"),
|
|
categories: config
|
|
.values("spam-filter.llm.categories")
|
|
.map(|(_, v)| v.trim().to_uppercase())
|
|
.collect(),
|
|
confidence: config
|
|
.values("spam-filter.llm.confidence")
|
|
.map(|(_, v)| v.trim().to_uppercase())
|
|
.collect(),
|
|
};
|
|
|
|
if llm.categories.is_empty() {
|
|
config.new_build_error("spam-filter.llm.categories", "No categories defined");
|
|
return None;
|
|
}
|
|
if llm.index_confidence.is_some() && llm.confidence.is_empty() {
|
|
config.new_build_error(
|
|
"spam-filter.llm.confidence",
|
|
"Confidence index is defined but no confidence values are provided",
|
|
);
|
|
return None;
|
|
}
|
|
|
|
llm.into()
|
|
}
|
|
}
|
|
|
|
pub fn parse_metric_alerts(config: &mut Config) -> Vec<MetricAlert> {
|
|
let mut alerts = Vec::new();
|
|
|
|
for metric_id in config
|
|
.sub_keys("metrics.alerts", ".enable")
|
|
.map(|s| s.to_string())
|
|
.collect::<Vec<_>>()
|
|
{
|
|
if let Some(alert) = parse_metric_alert(config, metric_id) {
|
|
alerts.push(alert);
|
|
}
|
|
}
|
|
|
|
alerts
|
|
}
|
|
|
|
fn parse_metric_alert(config: &mut Config, id: String) -> Option<MetricAlert> {
|
|
if !config.property_or_default::<bool>(("metrics.alerts", id.as_str(), "enable"), "false")? {
|
|
return None;
|
|
}
|
|
|
|
let mut alert = MetricAlert {
|
|
condition: Expression::try_parse(
|
|
config,
|
|
("metrics.alerts", id.as_str(), "condition"),
|
|
&TokenMap::default().with_variables_map(
|
|
EventType::variants()
|
|
.into_iter()
|
|
.map(|e| (sanitize_metric_name(e.name()), e.id() as u32))
|
|
.chain(MetricType::variants().iter().map(|m| {
|
|
(
|
|
sanitize_metric_name(m.name()),
|
|
m.code() as u32 + TOTAL_EVENT_COUNT as u32,
|
|
)
|
|
})),
|
|
),
|
|
)?,
|
|
method: Vec::new(),
|
|
id,
|
|
};
|
|
let id_str = alert.id.as_str();
|
|
|
|
if config
|
|
.property_or_default::<bool>(("metrics.alerts", id_str, "notify.event.enable"), "false")
|
|
.unwrap_or_default()
|
|
{
|
|
alert.method.push(AlertMethod::Event {
|
|
message: parse_alert_content(
|
|
("metrics.alerts", id_str, "notify.event.message"),
|
|
config,
|
|
),
|
|
});
|
|
}
|
|
|
|
if config
|
|
.property_or_default::<bool>(("metrics.alerts", id_str, "notify.email.enable"), "false")
|
|
.unwrap_or_default()
|
|
{
|
|
let from_addr = config
|
|
.value_require(("metrics.alerts", id_str, "notify.email.from-addr"))?
|
|
.trim()
|
|
.to_string();
|
|
let from_name = config
|
|
.value(("metrics.alerts", id_str, "notify.email.from-name"))
|
|
.map(|s| s.to_string());
|
|
let to = config
|
|
.values(("metrics.alerts", id_str, "notify.email.to"))
|
|
.filter_map(|(_, s)| {
|
|
if s.contains('@') {
|
|
s.trim().to_string().into()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let subject =
|
|
parse_alert_content(("metrics.alerts", id_str, "notify.email.subject"), config)?;
|
|
let body = parse_alert_content(("metrics.alerts", id_str, "notify.email.body"), config)?;
|
|
|
|
if !from_addr.contains('@') {
|
|
config.new_build_error(
|
|
("metrics.alerts", id_str, "notify.email.from-addr"),
|
|
"Invalid from email address",
|
|
);
|
|
}
|
|
if to.is_empty() {
|
|
config.new_build_error(
|
|
("metrics.alerts", id_str, "notify.email.to"),
|
|
"Missing recipient address(es)",
|
|
);
|
|
}
|
|
if subject.0.is_empty() {
|
|
config.new_build_error(
|
|
("metrics.alerts", id_str, "notify.email.subject"),
|
|
"Missing email subject",
|
|
);
|
|
}
|
|
if body.0.is_empty() {
|
|
config.new_build_error(
|
|
("metrics.alerts", id_str, "notify.email.body"),
|
|
"Missing email body",
|
|
);
|
|
}
|
|
|
|
alert.method.push(AlertMethod::Email {
|
|
from_name,
|
|
from_addr,
|
|
to,
|
|
subject,
|
|
body,
|
|
});
|
|
}
|
|
|
|
if alert.method.is_empty() {
|
|
config.new_build_error(
|
|
("metrics.alerts", id_str),
|
|
"No notification method enabled for alert",
|
|
);
|
|
}
|
|
|
|
alert.into()
|
|
}
|
|
|
|
fn parse_alert_content(key: impl AsKey, config: &mut Config) -> Option<AlertContent> {
|
|
let mut tokens = Vec::new();
|
|
let mut value = config.value(key)?.chars().peekable();
|
|
let mut buf = String::new();
|
|
|
|
while let Some(ch) = value.next() {
|
|
if ch == '%' && value.peek() == Some(&'{') {
|
|
value.next();
|
|
|
|
let mut var_name = String::new();
|
|
let mut found_curly = false;
|
|
|
|
for ch in value.by_ref() {
|
|
if ch == '}' {
|
|
found_curly = true;
|
|
break;
|
|
}
|
|
var_name.push(ch);
|
|
}
|
|
|
|
if found_curly && value.peek() == Some(&'%') {
|
|
value.next();
|
|
if let Some(event_type) = EventType::try_parse(&var_name)
|
|
.map(AlertContentToken::Event)
|
|
.or_else(|| MetricType::try_parse(&var_name).map(AlertContentToken::Metric))
|
|
{
|
|
if !buf.is_empty() {
|
|
tokens.push(AlertContentToken::Text(std::mem::take(&mut buf)));
|
|
}
|
|
tokens.push(event_type);
|
|
} else {
|
|
buf.push('%');
|
|
buf.push('{');
|
|
buf.push_str(&var_name);
|
|
buf.push('}');
|
|
buf.push('%');
|
|
}
|
|
} else {
|
|
buf.push('%');
|
|
buf.push('{');
|
|
buf.push_str(&var_name);
|
|
}
|
|
} else {
|
|
buf.push(ch);
|
|
}
|
|
}
|
|
|
|
if !buf.is_empty() {
|
|
tokens.push(AlertContentToken::Text(buf));
|
|
}
|
|
|
|
AlertContent(tokens).into()
|
|
}
|
|
|
|
fn sanitize_metric_name(name: &str) -> String {
|
|
let mut result = String::with_capacity(name.len());
|
|
for ch in name.chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
result.push(ch);
|
|
} else {
|
|
result.push('_');
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|