Everything clients, users and operators meet now carries the fork's name, with no aliases (SPEC.md §2.4, changed here from "protocol identifiers stay"): - JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap. - WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once. - Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in; a unit test fails if Cargo.lock ever moves past the vendored copy. The trusted runtime now names itself too, rather than answering sieve-rs's default. - The web interface's OAuth client is inbuxa-webui. On every start the old stalwart-webui client is removed and any application naming it is moved over. - The spam filter's blobs are INBUXA_SPAM_*; every start moves any left under the old keys, so a trained model survives. - SQL stores and log files default to inbuxa, in the code and in the schema served to the admin (checksum regenerated). - Settings are INBUXA_* only. A STALWART_* variable that's set where its INBUXA_* one isn't stops the server at startup, naming it. - The version-upgrade messages link docs.inbuxa.org's migration page, and the OpenAPI description, smtp crate metadata and web-push test fixtures lose the name. Kept on purpose, allowlisted with reasons: the OAuth key-derivation contexts (renaming them would end every session and invalidate every sealed client id) and the hashed application prefix. Also fixes a latent start-up failure: ensure_client updated an existing first-party client with a revision of 0, which the registry's assertion never matches, so adding a redirect URI or changing the webmail secret failed start-up. And the principal session test now expects legacyProtocols (C-1, added 2026-09-21), which it had missed. Tested: the server builds without warnings; common's 106 unit tests, including the vendoring check; a new integration test for the two start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
304 lines
12 KiB
Rust
304 lines
12 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
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", types::brand_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())
|
|
// inbuxa: without it, `environment "name"` answers sieve-rs's default
|
|
.with_env_variable("name", types::brand_server!());
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use sieve::compiler::grammar::Capability;
|
|
|
|
// inbuxa: sieve-rs is vendored (vendor/sieve-rs) to carry the fork's
|
|
// name in its Sieve extensions. If Cargo.lock moves sieve-rs past the
|
|
// vendored version, Cargo drops the patch with only a warning and
|
|
// upstream's spelling comes back; this fails instead.
|
|
#[test]
|
|
fn sieve_extensions_carry_the_fork_name() {
|
|
for (capability, name) in [
|
|
(Capability::While, "vnd.inbuxa.while"),
|
|
(Capability::Expressions, "vnd.inbuxa.expressions"),
|
|
] {
|
|
assert_eq!(capability.to_string(), name);
|
|
assert_eq!(Capability::parse(name), capability);
|
|
}
|
|
}
|
|
}
|