Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
@@ -0,0 +1,323 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::config::mailstore::jmap::JmapConfig;
use ahash::AHashSet;
use calcard::icalendar::ICalendarDuration;
use jmap_proto::{
object::{email::EmailComparator, file_node::FileNodeComparator},
request::capability::{
BlobCapabilities, CalendarCapabilities, Capabilities, Capability, ContactsCapabilities,
CoreCapabilities, EmptyCapabilities, FileNodeCapabilities, MailCapabilities,
PrincipalAvailabilityCapabilities, PrincipalCapabilities, SieveAccountCapabilities,
SieveSessionCapabilities, SubmissionCapabilities, WebPushCapabilities,
},
types::date::UTCDate,
};
use registry::{
schema::structs::{Calendar, Email, SieveUserInterpreter},
types::EnumImpl,
};
use store::registry::bootstrap::Bootstrap;
use types::type_state::DataType;
use utils::map::vec_map::VecMap;
impl JmapConfig {
pub async fn add_capabilities(&mut self, bp: &mut Bootstrap) {
// Add core capabilities
self.capabilities.session.append(
Capability::Core,
Capabilities::Core(CoreCapabilities {
max_size_upload: self.upload_max_size as u64,
max_concurrent_upload: self.upload_max_concurrent.unwrap_or(u32::MAX as u64),
max_size_request: self.request_max_size as u64,
max_concurrent_requests: self.request_max_concurrent.unwrap_or(u32::MAX as u64),
max_calls_in_request: self.request_max_calls as u64,
max_objects_in_get: self.get_max_objects as u64,
max_objects_in_set: self.set_max_objects as u64,
collation_algorithms: vec![
"i;ascii-numeric".to_string(),
"i;ascii-casemap".to_string(),
"i;unicode-casemap".to_string(),
],
}),
);
// Add email capabilities
let email = bp.setting_infallible::<Email>().await;
self.capabilities.session.append(
Capability::Mail,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Mail,
Capabilities::Mail(MailCapabilities {
max_mailboxes_per_email: None,
max_mailbox_depth: email.max_mailbox_depth,
max_size_mailbox_name: email.max_mailbox_name_length,
max_size_attachments_per_email: email.max_attachment_size,
email_query_sort_options: vec![
EmailComparator::ReceivedAt,
EmailComparator::Size,
EmailComparator::From,
EmailComparator::To,
EmailComparator::Subject,
EmailComparator::SentAt,
EmailComparator::HasKeyword(Default::default()),
EmailComparator::AllInThreadHaveKeyword(Default::default()),
EmailComparator::SomeInThreadHaveKeyword(Default::default()),
],
may_create_top_level_mailbox: true,
}),
);
// Add calendar capabilities
self.capabilities.session.append(
Capability::Calendars,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Calendars,
Capabilities::Calendar(CalendarCapabilities {
max_calendars_per_event: None,
min_date_time: UTCDate {
year: 1,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
tz_before_gmt: false,
tz_hour: 0,
tz_minute: 0,
},
max_date_time: UTCDate {
year: 9999,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 59,
tz_before_gmt: false,
tz_hour: 0,
tz_minute: 0,
},
max_expanded_query_duration: ICalendarDuration::from_seconds(86400 * 365)
.to_string(),
max_participants_per_event: bp
.setting_infallible::<Calendar>()
.await
.max_attendees
.into(),
may_create_calendar: true,
}),
);
self.capabilities.session.append(
Capability::CalendarsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::CalendarsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add contacts capabilities
self.capabilities.session.append(
Capability::Contacts,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Contacts,
Capabilities::Contacts(ContactsCapabilities {
max_address_books_per_card: None,
may_create_address_book: true,
}),
);
self.capabilities.session.append(
Capability::ContactsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::ContactsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add file node capabilities
self.capabilities.session.append(
Capability::FileNode,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::FileNode,
Capabilities::FileNode(FileNodeCapabilities {
max_file_node_depth: None,
max_size_file_node_name: 255,
forbidden_name_chars: Some("/<>:\"\\|?*".to_string()),
forbidden_node_names: Some(
[
".", "..", "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3",
"COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2",
"LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
]
.into_iter()
.map(str::to_string)
.collect(),
),
file_node_query_sort_options: vec![
FileNodeComparator::Name,
FileNodeComparator::Size,
FileNodeComparator::NodeType,
],
may_create_top_level_file_node: true,
case_insensitive_names: false,
web_trash_url: None,
web_url_template: None,
web_write_url_template: None,
}),
);
// Add principal capabilities
self.capabilities.session.append(
Capability::Principals,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Principals,
Capabilities::Principals(PrincipalCapabilities {
current_user_principal_id: None,
}),
);
self.capabilities.session.append(
Capability::PrincipalsAvailability,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::PrincipalsAvailability,
Capabilities::PrincipalsAvailability(PrincipalAvailabilityCapabilities {
max_availability_duration: ICalendarDuration::from_seconds(86400 * 365).to_string(),
}),
);
// Add submission capabilities
self.capabilities.session.append(
Capability::Submission,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Submission,
Capabilities::Submission(SubmissionCapabilities {
max_delayed_send: 86400 * 30,
submission_extensions: VecMap::from_iter([
("FUTURERELEASE".to_string(), Vec::new()),
("SIZE".to_string(), Vec::new()),
("DSN".to_string(), Vec::new()),
("DELIVERYBY".to_string(), Vec::new()),
("MT-PRIORITY".to_string(), vec!["MIXER".to_string()]),
("REQUIRETLS".to_string(), vec![]),
]),
}),
);
// Add vacation response capabilities
self.capabilities.session.append(
Capability::VacationResponse,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::VacationResponse,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add Sieve capabilities
let sieve = bp.setting_infallible::<SieveUserInterpreter>().await;
let disabled_capabilities = sieve
.disable_capabilities
.into_iter()
.map(|v| v.as_str())
.collect::<AHashSet<&str>>();
let mut extensions = sieve::compiler::grammar::Capability::all()
.iter()
.map(|c| c.to_string())
.filter(|c| !disabled_capabilities.contains(c.as_str()))
.collect::<Vec<String>>();
extensions.sort_unstable();
self.capabilities.session.append(
Capability::Sieve,
Capabilities::SieveSession(SieveSessionCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Sieve,
Capabilities::SieveAccount(SieveAccountCapabilities {
max_script_name: sieve.max_script_name_length as u64,
max_script_size: sieve.max_script_size,
max_scripts: sieve.max_scripts.unwrap_or(u32::MAX as u64),
max_redirects: sieve.max_redirects,
extensions,
notification_methods: if !sieve.allowed_notify_uris.is_empty() {
sieve.allowed_notify_uris.into_inner().into()
} else {
None
},
ext_lists: None,
}),
);
// Add Blob capabilities
self.capabilities.session.append(
Capability::Blob,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Blob,
Capabilities::Blob(BlobCapabilities {
max_size_blob_set: (self.request_max_size as u64 * 3 / 4) - 512,
max_data_sources: self.request_max_calls as u64,
supported_type_names: vec![
DataType::Email,
DataType::Thread,
DataType::SieveScript,
],
supported_digest_algorithms: vec!["sha", "sha-256", "sha-512"],
}),
);
// Add Quota capabilities
self.capabilities.session.append(
Capability::Quota,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Quota,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add Email Delivery Push capabilities
self.capabilities.session.append(
Capability::EmailPush,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::EmailPush,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add Web Push VAPID capabilities
if let Some(application_server_key) = self
.vapid
.as_ref()
.map(|vapid| vapid.public_key().to_string())
{
self.capabilities.session.append(
Capability::WebPushVapid,
Capabilities::WebPush(WebPushCapabilities {
application_server_key,
}),
);
}
}
}
+298
View File
@@ -0,0 +1,298 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ahash::{AHashMap, AHashSet};
use nlp::language::Language;
use registry::{
schema::{
enums::{
CompressionAlgo, SearchCalendarField, SearchContactField, SearchEmailField,
StorageQuota,
},
prelude::ObjectType,
structs::{
AddressBook, Authentication, Calendar, DataRetention, Domain, Email, FileStorage, Jmap,
Search, SieveUserInterpreter, SystemSettings,
},
},
types::EnumImpl,
};
use std::time::Duration;
use store::{
registry::bootstrap::Bootstrap,
search::{CalendarSearchField, ContactSearchField, EmailSearchField, SearchField},
write::SearchIndex,
};
use types::special_use::SpecialUse;
use utils::cron::SimpleCron;
use crate::storage::ObjectQuota;
#[derive(Clone)]
pub struct EmailConfig {
pub default_language: Language,
pub default_domain_id: u32,
pub default_domain_name: String,
pub mailbox_max_depth: usize,
pub mailbox_name_max_len: usize,
pub mail_attachments_max_size: usize,
pub mail_max_size: usize,
pub mail_autoexpunge_after: Option<u64>,
pub email_submission_autoexpunge_after: Option<u64>,
pub changes_max_history: Option<usize>,
pub share_notification_max_history: Option<Duration>,
pub sieve_max_script_name: usize,
pub default_folders: Vec<DefaultFolder>,
pub shared_folder: String,
pub encrypt: bool,
pub encrypt_append: bool,
pub index_batch_size: usize,
pub index_fields: AHashMap<SearchIndex, AHashSet<SearchField>>,
pub max_objects: ObjectQuota,
pub compression: CompressionAlgo,
pub account_purge_frequency: SimpleCron,
pub data_purge_frequency: SimpleCron,
pub blob_purge_frequency: SimpleCron,
}
#[derive(Clone, Debug)]
pub struct DefaultFolder {
pub name: String,
pub aliases: Vec<String>,
pub special_use: SpecialUse,
pub subscribe: bool,
pub create: bool,
}
impl EmailConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let email = bp.setting_infallible::<Email>().await;
let dr = bp.setting_infallible::<DataRetention>().await;
let sieve = bp.setting_infallible::<SieveUserInterpreter>().await;
let search = bp.setting_infallible::<Search>().await;
let jmap = bp.setting_infallible::<Jmap>().await;
let file = bp.setting_infallible::<FileStorage>().await;
let calendar = bp.setting_infallible::<Calendar>().await;
let address_book = bp.setting_infallible::<AddressBook>().await;
let system = bp.setting_infallible::<SystemSettings>().await;
let auth = bp.setting_infallible::<Authentication>().await;
// Obtain default domain name
let default_domain_name = if system.default_domain_id.is_valid()
&& let Some(default_domain) =
bp.get_infallible::<Domain>(system.default_domain_id).await
{
default_domain.name
} else {
if system.default_domain_id.is_valid() {
bp.build_error(
ObjectType::SystemSettings.singleton(),
format!(
"Default domain with ID {} not found",
system.default_domain_id
),
);
}
"localhost.local".to_string()
};
// Parse default object quotas
let mut max_objects = ObjectQuota::default();
for (item, max) in [
(StorageQuota::MaxEmails, email.max_messages),
(StorageQuota::MaxMailboxes, email.max_mailboxes),
(StorageQuota::MaxSieveScripts, sieve.max_scripts),
(StorageQuota::MaxEmailIdentities, email.max_identities),
(StorageQuota::MaxEmailSubmissions, email.max_submissions),
(StorageQuota::MaxMaskedAddresses, email.max_masked_addresses),
(StorageQuota::MaxAppPasswords, auth.max_app_passwords),
(StorageQuota::MaxApiKeys, auth.max_api_keys),
(StorageQuota::MaxPublicKeys, email.max_public_keys),
(StorageQuota::MaxPushSubscriptions, jmap.max_subscriptions),
(StorageQuota::MaxCalendars, calendar.max_calendars),
(StorageQuota::MaxCalendarEvents, calendar.max_events),
(
StorageQuota::MaxParticipantIdentities,
calendar.max_participant_identities,
),
(
StorageQuota::MaxCalendarEventNotifications,
calendar.max_event_notifications,
),
(
StorageQuota::MaxAddressBooks,
address_book.max_address_books,
),
(StorageQuota::MaxContactCards, address_book.max_contacts),
(StorageQuota::MaxFiles, file.max_files),
(StorageQuota::MaxFolders, file.max_folders),
] {
if let Some(max) = max {
max_objects.set(item, max as u32);
}
}
// Parse default folders
let mut default_folders = Vec::new();
let mut shared_folder = "Shared Folders".to_string();
for (special_use, folder) in email.default_folders {
let special_use = match special_use {
registry::schema::enums::SpecialUse::Inbox => SpecialUse::Inbox,
registry::schema::enums::SpecialUse::Trash => SpecialUse::Trash,
registry::schema::enums::SpecialUse::Junk => SpecialUse::Junk,
registry::schema::enums::SpecialUse::Drafts => SpecialUse::Drafts,
registry::schema::enums::SpecialUse::Archive => SpecialUse::Archive,
registry::schema::enums::SpecialUse::Sent => SpecialUse::Sent,
registry::schema::enums::SpecialUse::Important => SpecialUse::Important,
registry::schema::enums::SpecialUse::Memos => SpecialUse::Memos,
registry::schema::enums::SpecialUse::Scheduled => SpecialUse::Scheduled,
registry::schema::enums::SpecialUse::Snoozed => SpecialUse::Snoozed,
registry::schema::enums::SpecialUse::Shared => {
shared_folder = folder.name;
continue;
}
};
default_folders.push(DefaultFolder {
name: folder.name,
aliases: folder.aliases.into_inner(),
special_use,
subscribe: folder.subscribe,
create: folder.create
|| matches!(
special_use,
SpecialUse::Inbox | SpecialUse::Trash | SpecialUse::Junk
),
});
}
for (special_use, name) in [
(SpecialUse::Inbox, "Inbox"),
(SpecialUse::Trash, "Deleted Items"),
(SpecialUse::Junk, "Junk Mail"),
(SpecialUse::Drafts, "Drafts"),
(SpecialUse::Sent, "Sent Items"),
] {
if !default_folders.iter().any(|f| f.special_use == special_use) {
default_folders.push(DefaultFolder {
name: name.to_string(),
aliases: Vec::new(),
special_use,
subscribe: true,
create: true,
});
}
}
// Search Index settings
let mut index_fields = AHashMap::new();
if search.index_email {
index_fields.insert(
SearchIndex::Email,
search
.index_email_fields
.into_iter()
.map(|field| {
SearchField::Email(match field {
SearchEmailField::From => EmailSearchField::From,
SearchEmailField::To => EmailSearchField::To,
SearchEmailField::Cc => EmailSearchField::Cc,
SearchEmailField::Bcc => EmailSearchField::Bcc,
SearchEmailField::Subject => EmailSearchField::Subject,
SearchEmailField::Body => EmailSearchField::Body,
SearchEmailField::Attachment => EmailSearchField::Attachment,
SearchEmailField::ReceivedAt => EmailSearchField::ReceivedAt,
SearchEmailField::SentAt => EmailSearchField::SentAt,
SearchEmailField::Size => EmailSearchField::Size,
SearchEmailField::HasAttachment => EmailSearchField::HasAttachment,
SearchEmailField::Headers => EmailSearchField::Headers,
})
})
.collect(),
);
}
if search.index_contacts {
index_fields.insert(
SearchIndex::Contacts,
search
.index_contact_fields
.into_iter()
.map(|field| {
SearchField::Contact(match field {
SearchContactField::Member => ContactSearchField::Member,
SearchContactField::Kind => ContactSearchField::Kind,
SearchContactField::Name => ContactSearchField::Name,
SearchContactField::Nickname => ContactSearchField::Nickname,
SearchContactField::Organization => ContactSearchField::Organization,
SearchContactField::Email => ContactSearchField::Email,
SearchContactField::Phone => ContactSearchField::Phone,
SearchContactField::OnlineService => ContactSearchField::OnlineService,
SearchContactField::Address => ContactSearchField::Address,
SearchContactField::Note => ContactSearchField::Note,
SearchContactField::Uid => ContactSearchField::Uid,
})
})
.collect(),
);
}
if search.index_calendar {
index_fields.insert(
SearchIndex::Calendar,
search
.index_calendar_fields
.into_iter()
.map(|field| {
SearchField::Calendar(match field {
SearchCalendarField::Title => CalendarSearchField::Title,
SearchCalendarField::Description => CalendarSearchField::Description,
SearchCalendarField::Location => CalendarSearchField::Location,
SearchCalendarField::Owner => CalendarSearchField::Owner,
SearchCalendarField::Attendee => CalendarSearchField::Attendee,
SearchCalendarField::Start => CalendarSearchField::Start,
SearchCalendarField::Uid => CalendarSearchField::Uid,
})
})
.collect(),
);
}
EmailConfig {
default_language: Language::from_iso_639(search.default_language.as_str())
.unwrap_or(Language::English),
mailbox_max_depth: email.max_mailbox_depth as usize,
mailbox_name_max_len: email.max_mailbox_name_length as usize,
mail_attachments_max_size: email.max_attachment_size as usize,
mail_max_size: email.max_message_size as usize,
mail_autoexpunge_after: dr.expunge_trash_after.map(|d| d.into_inner().as_secs()),
email_submission_autoexpunge_after: dr
.expunge_submissions_after
.map(|d| d.into_inner().as_secs()),
changes_max_history: dr.max_changes_history.map(|v| v as usize),
share_notification_max_history: dr.expunge_share_notify_after.map(|v| v.into_inner()),
sieve_max_script_name: sieve.max_script_name_length as usize,
encrypt: email.encrypt_at_rest,
encrypt_append: email.encrypt_on_append,
index_batch_size: search.index_batch_size as usize,
index_fields,
max_objects,
default_folders,
shared_folder,
account_purge_frequency: dr.expunge_schedule.into(),
data_purge_frequency: dr.data_cleanup_schedule.into(),
blob_purge_frequency: dr.blob_cleanup_schedule.into(),
compression: email.compression_algorithm,
default_domain_id: system.default_domain_id.id() as u32,
default_domain_name,
}
}
}
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use registry::schema::structs::{Imap, Rate};
use std::time::Duration;
use store::registry::bootstrap::Bootstrap;
#[derive(Default, Clone)]
pub struct ImapConfig {
pub max_request_size: usize,
pub max_auth_failures: u32,
pub allow_plain_auth: bool,
pub timeout_auth: Duration,
pub timeout_unauth: Duration,
pub timeout_idle: Duration,
pub rate_requests: Option<Rate>,
pub rate_concurrent: Option<u64>,
pub max_messages_per_command: u32,
pub max_messages_per_save: u32,
pub min_uid_batch_size: u32,
pub max_uid_batches: u32,
}
impl ImapConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let imap = bp.setting_infallible::<Imap>().await;
ImapConfig {
max_request_size: imap.max_request_size as usize,
max_auth_failures: imap.max_auth_failures as u32,
timeout_auth: imap.timeout_authenticated.into_inner(),
timeout_unauth: imap.timeout_anonymous.into_inner(),
timeout_idle: imap.timeout_idle.into_inner(),
rate_requests: imap.max_request_rate,
rate_concurrent: imap.max_concurrent,
allow_plain_auth: imap.allow_plain_text_auth,
max_messages_per_command: imap.max_messages_per_command.min(u32::MAX as u64) as u32,
max_messages_per_save: imap.max_messages_per_save.min(u32::MAX as u64) as u32,
min_uid_batch_size: imap.min_uid_batch_size.min(u32::MAX as u64) as u32,
max_uid_batches: imap.max_uid_batches.min(u32::MAX as u64) as u32,
}
}
}
+134
View File
@@ -0,0 +1,134 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::network::webpush::{Vapid, VapidKey};
use jmap_proto::request::capability::BaseCapabilities;
use registry::schema::{prelude::ObjectType, structs::Jmap};
use std::time::Duration;
use store::registry::bootstrap::Bootstrap;
#[derive(Default, Clone)]
pub struct JmapConfig {
pub query_max_results: usize,
pub snippet_max_results: usize,
pub changes_max_results: usize,
pub request_max_size: usize,
pub request_max_calls: usize,
pub request_max_concurrent: Option<u64>,
pub get_max_objects: usize,
pub set_max_objects: usize,
pub upload_max_size: usize,
pub upload_max_concurrent: Option<u64>,
pub upload_tmp_quota_size: usize,
pub upload_tmp_quota_amount: usize,
pub upload_tmp_ttl: u64,
pub mail_parse_max_items: usize,
pub contact_parse_max_items: usize,
pub calendar_parse_max_items: usize,
pub event_source_throttle: Duration,
pub push_attempt_interval: Duration,
pub push_attempts_max: u32,
pub push_retry_interval: Duration,
pub push_timeout: Duration,
pub push_verify_timeout: Duration,
pub push_throttle: Duration,
pub push_total_shards: u32,
pub push_max_size: usize,
pub web_socket_throttle: Duration,
pub web_socket_timeout: Duration,
pub web_socket_heartbeat: Duration,
pub vapid: Option<Vapid>,
pub capabilities: BaseCapabilities,
}
impl JmapConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let jmap = bp.setting_infallible::<Jmap>().await;
let web_push_key = jmap
.web_push_key
.secret()
.await
.map_err(|err| {
bp.build_error(
ObjectType::Jmap.singleton(),
format!("Unable to retrieve Web Push key: {err}"),
);
})
.unwrap_or_default()
.map(|k| k.into_owned());
let web_push_contact = jmap
.web_push_contact
.as_deref()
.and_then(crate::network::webpush::normalize_contact)
.or_else(|| {
let hostname = bp.registry.local_hostname();
(!hostname.is_empty()).then(|| format!("mailto:postmaster@{hostname}"))
});
let mut jmap = JmapConfig {
query_max_results: jmap.query_max_results as usize,
changes_max_results: jmap.changes_max_results as usize,
snippet_max_results: jmap.snippet_max_results as usize,
request_max_size: jmap.max_request_size as usize,
request_max_calls: jmap.max_method_calls as usize,
request_max_concurrent: jmap.max_concurrent_requests,
get_max_objects: jmap.get_max_results as usize,
set_max_objects: jmap.set_max_objects as usize,
upload_max_size: jmap.max_upload_size as usize,
upload_max_concurrent: jmap.max_concurrent_uploads,
upload_tmp_quota_size: jmap.upload_quota as usize,
upload_tmp_quota_amount: jmap.max_upload_count as usize,
upload_tmp_ttl: jmap.upload_ttl.into_inner().as_secs().max(1),
mail_parse_max_items: jmap.parse_limit_email as usize,
contact_parse_max_items: jmap.parse_limit_contact as usize,
calendar_parse_max_items: jmap.parse_limit_event as usize,
event_source_throttle: jmap.event_source_throttle.into_inner(),
web_socket_throttle: jmap.websocket_throttle.into_inner(),
web_socket_timeout: jmap.websocket_timeout.into_inner(),
web_socket_heartbeat: jmap.websocket_heartbeat.into_inner(),
push_attempt_interval: jmap.push_attempt_wait.into_inner(),
push_attempts_max: jmap.push_max_attempts as u32,
push_retry_interval: jmap.push_retry_wait.into_inner(),
push_timeout: jmap.push_request_timeout.into_inner(),
push_verify_timeout: jmap.push_verify_timeout.into_inner(),
push_throttle: jmap.push_throttle.into_inner(),
push_total_shards: jmap.push_shards_total as u32,
push_max_size: jmap.max_push_size as usize,
vapid: None,
capabilities: BaseCapabilities::default(),
};
// Enable Web Push VAPID only when a signing key is configured
jmap.vapid = web_push_key
.as_deref()
.map(str::trim)
.filter(|pem| !pem.is_empty())
.and_then(|pem| match VapidKey::from_pkcs8_pem(pem) {
Ok(key) => Some(key),
Err(err) => {
bp.build_error(
ObjectType::Jmap.singleton(),
format!("Invalid Web Push VAPID key: {err}"),
);
None
}
})
.map(|key| Vapid::new(key, web_push_contact));
// Add capabilities
jmap.add_capabilities(bp).await;
jmap
}
}
+12
View File
@@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod capabilities;
pub mod email;
pub mod imap;
pub mod jmap;
pub mod scripts;
pub mod spamfilter;
@@ -0,0 +1,279 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
VERSION_PUBLIC,
expr::if_block::{BootstrapExprExt, IfBlock},
scripts::{
functions::{register_functions_trusted, register_functions_untrusted},
plugins::RegisterSievePlugins,
},
};
use ahash::AHashMap;
use registry::{
schema::{
prelude::ObjectType,
structs::{
SieveSystemInterpreter, SieveSystemScript, SieveUserInterpreter, SieveUserScript,
SystemSettings,
},
},
types::EnumImpl,
};
use sieve::{Compiler, Runtime, Sieve, compiler::grammar::Capability};
use std::{collections::hash_map::Entry, sync::Arc};
use store::registry::bootstrap::Bootstrap;
pub struct Scripting {
pub untrusted_compiler: Compiler,
pub untrusted_runtime: Runtime,
pub trusted_runtime: Runtime,
pub trusted_compiler: Compiler,
pub max_received_headers: usize,
pub from_addr: IfBlock,
pub from_name: IfBlock,
pub return_path: IfBlock,
pub sign: IfBlock,
pub untrusted_sign: IfBlock,
pub trusted_scripts: AHashMap<String, Arc<Sieve>>,
pub untrusted_scripts: AHashMap<String, Arc<Sieve>>,
pub http_client: reqwest::Client,
}
impl Scripting {
pub async fn parse(bp: &mut Bootstrap) -> Self {
// Parse untrusted compiler
let untrusted = bp.setting_infallible::<SieveUserInterpreter>().await;
let untrusted_sign = bp.compile_expr(
ObjectType::SieveUserInterpreter.singleton(),
&untrusted.ctx_dkim_sign_domain(),
);
let mut fnc_map_untrusted = register_functions_untrusted().register_plugins_untrusted();
let untrusted_compiler = Compiler::new()
.with_max_script_size(untrusted.max_script_size as usize)
.with_max_string_size(untrusted.max_string_length as usize)
.with_max_variable_name_size(untrusted.max_var_name_length as usize)
.with_max_nested_blocks(untrusted.max_nested_blocks as usize)
.with_max_nested_tests(untrusted.max_nested_tests as usize)
.with_max_nested_foreverypart(untrusted.max_nested_for_every as usize)
.with_max_match_variables(untrusted.max_match_vars as usize)
.with_max_local_variables(untrusted.max_local_vars as usize)
.with_max_header_size(untrusted.max_header_size as usize)
.with_max_includes(untrusted.max_includes as usize)
.register_functions(&mut fnc_map_untrusted);
// Parse untrusted runtime
let mut untrusted_runtime = Runtime::new()
.with_functions(&mut fnc_map_untrusted)
.with_max_nested_includes(untrusted.max_nested_includes as usize)
.with_cpu_limit(untrusted.max_cpu_cycles as usize)
.with_max_variable_size(untrusted.max_var_size as usize)
.with_max_redirects(untrusted.max_redirects as usize)
.with_max_received_headers(usize::MAX) // This is set to usize::MAX here, but the actual limit is enforced during ingestion.
.with_max_header_size(untrusted.max_header_size as usize)
.with_max_out_messages(untrusted.max_out_messages as usize)
.with_default_vacation_expiry(untrusted.default_expiry_vacation.into_inner().as_secs())
.with_default_duplicate_expiry(
untrusted.default_expiry_duplicate.into_inner().as_secs(),
)
.with_capability(Capability::Expressions)
.without_capabilities(
untrusted
.disable_capabilities
.iter()
.map(|cap| cap.as_str()),
)
.with_valid_notification_uris(untrusted.allowed_notify_uris)
.with_protected_headers(untrusted.protected_headers)
.with_vacation_default_subject(untrusted.default_subject)
.with_vacation_subject_prefix(untrusted.default_subject_prefix)
.with_env_variable("name", "Stalwart Server")
.with_env_variable("version", VERSION_PUBLIC)
.with_env_variable("location", "MS")
.with_env_variable("phase", "during");
// Parse trusted compiler and runtime
let mut fnc_map_trusted = register_functions_trusted().register_plugins_trusted();
// Allocate compiler and runtime
let trusted = bp.setting_infallible::<SieveSystemInterpreter>().await;
let system = bp.setting_infallible::<SystemSettings>().await;
let local_hostname = if !system.default_hostname.is_empty() {
system.default_hostname.clone()
} else {
bp.registry.local_hostname().to_string()
};
let trusted_compiler = Compiler::new()
.with_max_string_size(52428800)
.with_max_variable_name_size(100)
.with_max_nested_blocks(50)
.with_max_nested_tests(50)
.with_max_nested_foreverypart(10)
.with_max_local_variables(8192)
.with_max_header_size(10240)
.with_max_includes(10)
.with_no_capability_check(trusted.no_capability_check)
.register_functions(&mut fnc_map_trusted);
let mut trusted_runtime = Runtime::new()
.without_capabilities([
Capability::FileInto,
Capability::Vacation,
Capability::VacationSeconds,
Capability::Fcc,
Capability::Mailbox,
Capability::MailboxId,
Capability::MboxMetadata,
Capability::ServerMetadata,
Capability::ImapSieve,
Capability::Duplicate,
])
.with_capability(Capability::Expressions)
.with_capability(Capability::While)
.with_max_variable_size(trusted.max_var_size as usize)
.with_max_header_size(10240)
.with_valid_notification_uri("mailto")
.with_functions(&mut fnc_map_trusted)
.with_max_redirects(trusted.max_redirects as usize)
.with_max_out_messages(trusted.max_out_messages as usize)
.with_cpu_limit(trusted.max_cpu_cycles as usize)
.with_max_nested_includes(trusted.max_nested_includes as usize)
.with_max_received_headers(trusted.max_received_headers as usize)
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs());
trusted_runtime.set_local_hostname(local_hostname.clone());
untrusted_runtime.set_local_hostname(local_hostname);
// Parse trusted scripts
let mut trusted_scripts: AHashMap<String, Arc<Sieve>> = AHashMap::new();
for script in bp.list_infallible::<SieveSystemScript>().await {
if !script.object.is_active {
continue;
}
match trusted_compiler.compile(script.object.contents.as_bytes()) {
Ok(compiled) => match trusted_scripts.entry(script.object.name.to_lowercase()) {
Entry::Vacant(entry) => {
entry.insert(compiled.into());
}
Entry::Occupied(_) => {
bp.build_error(
script.id,
format!(
"Another active system Sieve script is already named {:?}, script names are case insensitive",
script.object.name
),
);
}
},
Err(err) => {
bp.build_error(
script.id,
format!("Failed to compile system Sieve script: {err}"),
);
}
}
}
// Parse untrusted scripts
let mut untrusted_scripts: AHashMap<String, Arc<Sieve>> = AHashMap::new();
for script in bp.list_infallible::<SieveUserScript>().await {
if !script.object.is_active {
continue;
}
match untrusted_compiler.compile(script.object.contents.as_bytes()) {
Ok(compiled) => match untrusted_scripts.entry(script.object.name.to_lowercase()) {
Entry::Vacant(entry) => {
entry.insert(compiled.into());
}
Entry::Occupied(_) => {
bp.build_error(
script.id,
format!(
"Another active user global Sieve script is already named {:?}, script names are case insensitive",
script.object.name
),
);
}
},
Err(err) => {
bp.build_error(
script.id,
format!("Failed to compile user global Sieve script: {err}"),
);
}
}
}
Scripting {
untrusted_compiler,
untrusted_runtime,
trusted_runtime,
trusted_compiler,
untrusted_scripts,
trusted_scripts,
http_client: utils::http::http_client_builder(cfg!(feature = "test_mode"))
.pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_default(),
max_received_headers: untrusted.max_received_headers as usize,
from_addr: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_default_from_address(),
),
from_name: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_default_from_name(),
),
return_path: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_default_return_path(),
),
sign: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_dkim_sign_domain(),
),
untrusted_sign,
}
}
pub fn trusted_script(&self, name: &str) -> Option<&Arc<Sieve>> {
script_by_name(&self.trusted_scripts, name)
}
pub fn untrusted_script(&self, name: &str) -> Option<&Arc<Sieve>> {
script_by_name(&self.untrusted_scripts, name)
}
}
fn script_by_name<'x>(
scripts: &'x AHashMap<String, Arc<Sieve>>,
name: &str,
) -> Option<&'x Arc<Sieve>> {
scripts
.get(name)
.or_else(|| scripts.get(name.to_lowercase().as_str()))
}
impl Clone for Scripting {
fn clone(&self) -> Self {
Self {
untrusted_compiler: self.untrusted_compiler.clone(),
untrusted_runtime: self.untrusted_runtime.clone(),
trusted_runtime: self.trusted_runtime.clone(),
from_addr: self.from_addr.clone(),
from_name: self.from_name.clone(),
return_path: self.return_path.clone(),
max_received_headers: self.max_received_headers,
sign: self.sign.clone(),
untrusted_sign: self.untrusted_sign.clone(),
trusted_scripts: self.trusted_scripts.clone(),
untrusted_scripts: self.untrusted_scripts.clone(),
trusted_compiler: self.trusted_compiler.clone(),
http_client: self.http_client.clone(),
}
}
}
@@ -0,0 +1,742 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::expr::{
Variable,
functions::ResolveVariable,
if_block::{BootstrapExprExt, IfBlock},
};
use ahash::AHashSet;
use mail_auth::common::resolver::ToReverseName;
use nlp::classifier::model::{CcfhClassifier, FhClassifier};
use registry::schema::{
enums::{ExpressionVariable, ModelSize},
prelude::ObjectType,
structs::{
self, SpamDnsblServer, SpamDnsblSettings, SpamFileExtension, SpamPyzor, SpamRule,
SpamSettings, SpamTag,
},
};
use sieve::SpamStatus;
use std::{
net::{IpAddr, SocketAddr},
time::Duration,
};
use store::registry::{RegistryObject, bootstrap::Bootstrap};
use tokio::net::lookup_host;
use utils::{cache::CacheItemWeight, glob::GlobMap};
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)]
pub enum SpamClassifier {
FhClassifier {
classifier: FhClassifier,
last_trained_at: u64,
},
CcfhClassifier {
classifier: CcfhClassifier,
last_trained_at: u64,
},
#[default]
Disabled,
}
#[derive(Debug, Clone, Default)]
pub struct SpamFilterConfig {
pub enabled: bool,
pub card_is_ham: bool,
pub trusted_reply: bool,
pub grey_list_expiry: Option<u64>,
pub dnsbl: DnsBlConfig,
pub rules: SpamFilterRules,
pub lists: SpamFilterLists,
pub pyzor: Option<PyzorConfig>,
pub classifier: Option<ClassifierConfig>,
pub scores: SpamFilterScoreConfig,
pub spam_rules_url: Option<String>,
pub url_client: reqwest::Client,
}
#[derive(Debug, Clone, Default)]
pub struct SpamFilterScoreConfig {
pub reject_threshold: f32,
pub discard_threshold: f32,
pub spam_threshold: f32,
}
impl SpamFilterScoreConfig {
pub fn spam_percentage(&self, score: f32) -> u8 {
let spam_threshold = self.spam_threshold;
if spam_threshold <= 0.0 {
return if score >= spam_threshold { 100 } else { 0 };
}
let max_threshold = [self.reject_threshold, self.discard_threshold]
.into_iter()
.filter(|threshold| *threshold > spam_threshold)
.min_by(f32::total_cmp)
.unwrap_or(spam_threshold * 2.0);
if score <= 0.0 {
0
} else if score < spam_threshold {
((50.0 * score / spam_threshold) as u8).min(49)
} else {
((50.0 + 50.0 * (score - spam_threshold) / (max_threshold - spam_threshold)) as u8)
.min(100)
}
}
pub fn is_spam(&self, score: f32) -> bool {
score >= self.spam_threshold
}
}
pub fn spam_status(percentage: Option<u8>) -> SpamStatus {
match percentage {
Some(0) => SpamStatus::Ham,
Some(100) => SpamStatus::Spam,
Some(percentage) => SpamStatus::MaybeSpam(percentage as f64 / 100.0),
None => SpamStatus::Unknown,
}
}
#[derive(Debug, Clone, Default)]
pub struct DnsBlConfig {
pub max_ip_checks: usize,
pub max_domain_checks: usize,
pub max_email_checks: usize,
pub max_url_checks: usize,
pub servers: Vec<DnsBlServer>,
}
#[derive(Debug, Clone, Default)]
pub struct SpamFilterLists {
pub file_extensions: GlobMap<FileExtension>,
pub scores: GlobMap<SpamFilterAction<f32>>,
}
#[derive(Debug, Clone)]
pub enum SpamFilterAction<T> {
Allow(T),
Discard,
Reject,
Disabled,
}
#[derive(Debug, Clone, Default)]
pub struct ClassifierConfig {
pub w_params: FtrlParameters,
pub i_params: Option<FtrlParameters>,
pub reservoir_capacity: usize,
pub min_ham_samples: u64,
pub min_spam_samples: u64,
pub auto_learn_reply_ham: bool,
pub auto_learn_card_is_ham: bool,
pub auto_learn_spam_trap: bool,
pub auto_learn_spam_rbl_count: u32,
pub hold_samples_for: u64,
pub train_frequency: Option<u64>,
pub log_scale: bool,
pub l2_normalize: bool,
}
#[derive(Debug, Clone, Default)]
pub struct FtrlParameters {
pub feature_hash_size: usize,
pub alpha: f64,
pub beta: f64,
pub l1_ratio: f64,
pub l2_ratio: f64,
}
#[derive(Debug, Clone)]
pub struct PyzorConfig {
pub address: SocketAddr,
pub timeout: Duration,
pub min_count: u64,
pub min_wl_count: u64,
pub ratio: f64,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SpamFilterRules {
pub url: Vec<IfBlock>,
pub domain: Vec<IfBlock>,
pub email: Vec<IfBlock>,
pub ip: Vec<IfBlock>,
pub header: Vec<IfBlock>,
pub body: Vec<IfBlock>,
pub any: Vec<IfBlock>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileExtension {
pub known_types: AHashSet<String>,
pub is_bad: bool,
pub is_archive: bool,
pub is_nz: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Element {
Url,
Domain,
Email,
Ip,
Header,
Body,
#[default]
Any,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Location {
EnvelopeFrom,
EnvelopeTo,
HeaderDkimPass,
HeaderReceived,
HeaderFrom,
HeaderReplyTo,
HeaderSubject,
HeaderTo,
HeaderCc,
HeaderBcc,
HeaderMid,
HeaderDnt,
Ehlo,
BodyText,
BodyHtml,
Attachment,
Tcp,
}
#[derive(Debug, Clone)]
pub struct DnsBlServer {
pub id: String,
pub zone: IfBlock,
pub scope: Element,
pub tags: IfBlock,
}
impl SpamFilterConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let spam = bp.setting_infallible::<SpamSettings>().await;
SpamFilterConfig {
enabled: spam.enable,
card_is_ham: spam.trust_contacts,
trusted_reply: spam.trust_replies,
dnsbl: DnsBlConfig::parse(bp).await,
rules: SpamFilterRules::parse(bp).await,
lists: SpamFilterLists::parse(bp).await,
pyzor: PyzorConfig::parse(bp).await,
classifier: ClassifierConfig::parse(bp).await,
scores: SpamFilterScoreConfig {
reject_threshold: spam.score_reject.into_inner() as f32,
discard_threshold: spam.score_discard.into_inner() as f32,
spam_threshold: spam.score_spam.into_inner() as f32,
},
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
spam_rules_url: spam.spam_filter_rules_url,
url_client: utils::http::http_client_builder(true)
.pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none())
.user_agent("Mozilla/5.0 (X11; Linux i686; rv:109.0) Gecko/20100101 Firefox/118.0")
.build()
.unwrap_or_default(),
}
}
}
impl SpamFilterRules {
pub async fn parse(bp: &mut Bootstrap) -> SpamFilterRules {
let mut rules = vec![];
for rule in bp.list_infallible::<SpamRule>().await {
if let Some(rule) = SpamFilterRule::parse(bp, rule) {
rules.push(rule);
}
}
rules.sort_by_key(|a| a.priority);
let mut result = SpamFilterRules::default();
for rule in rules {
match rule.scope {
Element::Url => result.url.push(rule.rule),
Element::Domain => result.domain.push(rule.rule),
Element::Email => result.email.push(rule.rule),
Element::Ip => result.ip.push(rule.rule),
Element::Header => result.header.push(rule.rule),
Element::Body => result.body.push(rule.rule),
Element::Any => result.any.push(rule.rule),
}
}
result
}
}
struct SpamFilterRule {
rule: IfBlock,
priority: i32,
scope: Element,
}
impl SpamFilterRule {
pub fn parse(bp: &mut Bootstrap, obj: RegistryObject<SpamRule>) -> Option<Self> {
match obj.object {
SpamRule::Any(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Any,
priority: rule.priority as i32,
}
.into(),
SpamRule::Url(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Url,
priority: rule.priority as i32,
}
.into(),
SpamRule::Domain(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Domain,
priority: rule.priority as i32,
}
.into(),
SpamRule::Email(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Email,
priority: rule.priority as i32,
}
.into(),
SpamRule::Ip(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Ip,
priority: rule.priority as i32,
}
.into(),
SpamRule::Header(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Header,
priority: rule.priority as i32,
}
.into(),
SpamRule::Body(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Body,
priority: rule.priority as i32,
}
.into(),
_ => None,
}
}
}
impl DnsBlConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut servers = vec![];
for server in bp.list_infallible::<SpamDnsblServer>().await {
if let Some(server) = DnsBlServer::parse(bp, server) {
servers.push(server);
}
}
let dnsbl = bp.setting_infallible::<SpamDnsblSettings>().await;
DnsBlConfig {
max_ip_checks: dnsbl.ip_limit as usize,
max_domain_checks: dnsbl.domain_limit as usize,
max_email_checks: dnsbl.email_limit as usize,
max_url_checks: dnsbl.url_limit as usize,
servers,
}
}
}
impl DnsBlServer {
pub fn parse(bp: &mut Bootstrap, obj: RegistryObject<SpamDnsblServer>) -> Option<Self> {
match obj.object {
SpamDnsblServer::Any(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Any,
id: server.name,
}
.into(),
SpamDnsblServer::Url(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Url,
id: server.name,
}
.into(),
SpamDnsblServer::Domain(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Domain,
id: server.name,
}
.into(),
SpamDnsblServer::Email(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Email,
id: server.name,
}
.into(),
SpamDnsblServer::Ip(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Ip,
id: server.name,
}
.into(),
SpamDnsblServer::Header(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Header,
id: server.name,
}
.into(),
SpamDnsblServer::Body(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Body,
id: server.name,
}
.into(),
_ => None,
}
}
}
impl SpamFilterLists {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut lists = SpamFilterLists {
file_extensions: GlobMap::default(),
scores: GlobMap::default(),
};
for tag in bp.list_infallible::<SpamTag>().await {
match tag.object {
SpamTag::Score(tag) => lists.scores.insert_pattern(
&tag.tag,
SpamFilterAction::Allow(tag.score.into_inner() as f32),
),
SpamTag::Discard(tag) => lists
.scores
.insert_pattern(&tag.tag, SpamFilterAction::Discard),
SpamTag::Reject(tag) => lists
.scores
.insert_pattern(&tag.tag, SpamFilterAction::Reject),
}
}
for ext in bp.list_infallible::<SpamFileExtension>().await {
let ext = ext.object;
lists.file_extensions.insert_pattern(
&ext.extension,
FileExtension {
known_types: ext.content_types.into_iter().collect(),
is_bad: ext.is_bad,
is_archive: ext.is_archive,
is_nz: ext.is_nz,
},
);
}
lists
}
}
impl PyzorConfig {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
let pyzor = bp.setting_infallible::<SpamPyzor>().await;
if !pyzor.enable {
return None;
}
let port = pyzor.port;
let host = pyzor.host;
let address = match lookup_host(format!("{host}:{port}"))
.await
.map(|mut a| a.next())
{
Ok(Some(address)) => address,
Ok(None) => {
bp.build_error(
ObjectType::SpamPyzor.singleton(),
"Invalid address: No addresses found.",
);
return None;
}
Err(err) => {
bp.build_error(
ObjectType::SpamPyzor.singleton(),
format!("Invalid address: {}", err),
);
return None;
}
};
PyzorConfig {
address,
timeout: pyzor.timeout.into_inner(),
min_count: pyzor.block_count,
min_wl_count: pyzor.allow_count,
ratio: pyzor.ratio.into_inner(),
}
.into()
}
}
impl ClassifierConfig {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
let classifier = bp.setting_infallible::<structs::SpamClassifier>().await;
let (log_scale, l2_normalize, w_params, i_params) = match classifier.model {
structs::SpamClassifierModel::FtrlFh(model) => (
model.feature_log_scale,
model.feature_l2_normalize,
FtrlParameters::parse(&model.parameters),
None,
),
structs::SpamClassifierModel::FtrlCcfh(model) => (
model.feature_log_scale,
model.feature_l2_normalize,
FtrlParameters::parse(&model.parameters),
Some(FtrlParameters::parse(&model.indicator_parameters)),
),
structs::SpamClassifierModel::Disabled => return None,
};
ClassifierConfig {
w_params,
i_params,
reservoir_capacity: classifier.reservoir_capacity as usize,
auto_learn_card_is_ham: classifier.learn_ham_from_card,
auto_learn_reply_ham: classifier.learn_ham_from_reply,
auto_learn_spam_trap: classifier.learn_spam_from_traps,
auto_learn_spam_rbl_count: classifier.learn_spam_from_rbl_hits as u32,
hold_samples_for: classifier.hold_samples_for.into_inner().as_secs(),
min_ham_samples: classifier.min_ham_samples,
min_spam_samples: classifier.min_spam_samples,
train_frequency: classifier.train_frequency.map(|d| d.into_inner().as_secs()),
log_scale,
l2_normalize,
}
.into()
}
}
impl FtrlParameters {
pub fn parse(params: &structs::FtrlParameters) -> Self {
let hash_size = match params.num_features {
ModelSize::V16 => 16,
ModelSize::V17 => 17,
ModelSize::V18 => 18,
ModelSize::V19 => 19,
ModelSize::V20 => 20,
ModelSize::V21 => 21,
ModelSize::V22 => 22,
ModelSize::V23 => 23,
ModelSize::V24 => 24,
ModelSize::V25 => 25,
ModelSize::V26 => 26,
ModelSize::V27 => 27,
ModelSize::V28 => 28,
};
FtrlParameters {
feature_hash_size: 1 << hash_size,
alpha: params.alpha.into_inner(),
beta: params.beta.into_inner(),
l1_ratio: params.l1_ratio.into_inner(),
l2_ratio: params.l2_ratio.into_inner(),
}
}
}
impl SpamClassifier {
pub fn is_active(&self) -> bool {
!matches!(self, SpamClassifier::Disabled)
}
}
impl Location {
pub fn as_str(&self) -> &'static str {
match self {
Location::EnvelopeFrom => "env_from",
Location::EnvelopeTo => "env_to",
Location::HeaderDkimPass => "dkim_pass",
Location::HeaderReceived => "received",
Location::HeaderFrom => "from",
Location::HeaderReplyTo => "reply_to",
Location::HeaderSubject => "subject",
Location::HeaderTo => "to",
Location::HeaderCc => "cc",
Location::HeaderBcc => "bcc",
Location::HeaderMid => "message_id",
Location::HeaderDnt => "dnt",
Location::Ehlo => "ehlo",
Location::BodyText => "body_text",
Location::BodyHtml => "body_html",
Location::Attachment => "attachment",
Location::Tcp => "tcp",
}
}
}
impl Element {
pub fn as_str(&self) -> &'static str {
match self {
Element::Url => "url",
Element::Domain => "domain",
Element::Email => "email",
Element::Ip => "ip",
Element::Header => "header",
Element::Body => "body",
Element::Any => "any",
}
}
}
pub struct IpResolver {
ip: IpAddr,
ip_string: String,
reverse: String,
octets: Variable<'static>,
}
impl ResolveVariable for IpResolver {
fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> {
match variable {
ExpressionVariable::Ip | ExpressionVariable::Value => self.ip_string.as_str().into(),
ExpressionVariable::IpReverse => self.reverse.as_str().into(),
ExpressionVariable::Octets => self.octets.clone(),
ExpressionVariable::IsV4 => Variable::Integer(self.ip.is_ipv4() as _),
ExpressionVariable::IsV6 => Variable::Integer(self.ip.is_ipv6() as _),
_ => Variable::Integer(0),
}
}
fn resolve_global(&self, _: &str) -> Variable<'_> {
Variable::Integer(0)
}
}
impl IpResolver {
pub fn new(ip: IpAddr) -> Self {
Self {
ip_string: ip.to_string(),
reverse: ip.to_reverse_name(),
octets: Variable::Array(match ip {
IpAddr::V4(ipv4_addr) => ipv4_addr
.octets()
.iter()
.map(|o| Variable::Integer(*o as _))
.collect(),
IpAddr::V6(ipv6_addr) => ipv6_addr
.octets()
.iter()
.map(|o| Variable::Integer(*o as _))
.collect(),
}),
ip,
}
}
}
impl CacheItemWeight for IpResolver {
fn weight(&self) -> u64 {
(std::mem::size_of::<IpResolver>() + self.ip_string.len() + self.reverse.len()) as u64
}
}
impl<T> SpamFilterAction<T> {
pub fn as_score(&self) -> Option<&T> {
match self {
SpamFilterAction::Allow(value) => Some(value),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(spam: f32, discard: f32, reject: f32) -> SpamFilterScoreConfig {
SpamFilterScoreConfig {
reject_threshold: reject,
discard_threshold: discard,
spam_threshold: spam,
}
}
#[test]
fn spam_percentage_defaults() {
let config = config(5.0, 0.0, 0.0);
for (score, expected) in [
(-10.0, 0),
(0.0, 0),
(0.5, 5),
(2.5, 25),
(4.9, 49),
(4.999, 49),
(5.0, 50),
(7.5, 75),
(9.9, 99),
(10.0, 100),
(50.0, 100),
] {
assert_eq!(config.spam_percentage(score), expected, "score {score}");
}
}
#[test]
fn spam_percentage_matches_is_spam() {
for config in [
config(5.0, 0.0, 0.0),
config(5.0, 20.0, 15.0),
config(1.0, 0.0, 3.0),
config(12.5, 25.0, 0.0),
config(0.0, 0.0, 0.0),
] {
for score in (-2000..=4000).map(|score| score as f32 / 100.0) {
assert_eq!(
config.spam_percentage(score) >= 50,
config.is_spam(score),
"score {score} with {config:?}"
);
}
}
}
#[test]
fn spam_percentage_ceiling_is_lowest_enabled_threshold() {
let reject_lowest = config(5.0, 20.0, 15.0);
assert_eq!(reject_lowest.spam_percentage(10.0), 75);
assert_eq!(reject_lowest.spam_percentage(15.0), 100);
let discard_only = config(5.0, 15.0, 0.0);
assert_eq!(discard_only.spam_percentage(10.0), 75);
let below_spam_threshold = config(5.0, 3.0, 0.0);
assert_eq!(below_spam_threshold.spam_percentage(7.5), 75);
}
#[test]
fn spam_status_from_percentage() {
assert!(matches!(spam_status(None), SpamStatus::Unknown));
assert!(matches!(spam_status(Some(0)), SpamStatus::Ham));
assert!(matches!(spam_status(Some(100)), SpamStatus::Spam));
assert!(matches!(
spam_status(Some(50)),
SpamStatus::MaybeSpam(fraction) if fraction == 0.5
));
}
}