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:
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user