Files
inbuxa-server/crates/common/src/config/mailstore/scripts.rs
T
jcoffey-dev cc6f1eb298
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m53s
Rename the identifiers that carried the upstream name
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.
2026-09-22 19:33:02 -07:00

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);
}
}
}