diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 6780804e..8e527546 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -496,6 +496,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner, } .assert_is_valid(remote_ip) @@ -505,6 +506,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner, } } @@ -529,6 +531,7 @@ impl AccessToken { scope_idx, inner, origin: None, + expires_at: None, }) .and_then(|token| token.assert_is_valid(remote_ip)) } @@ -544,6 +547,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner, } .assert_is_valid(remote_ip) @@ -747,6 +751,7 @@ impl AccessToken { access_token = AccessToken { scope_idx: access_token.scope_idx, origin: access_token.origin.clone(), + expires_at: access_token.expires_at, inner: Arc::new(inner), }; } @@ -990,10 +995,30 @@ impl AccessToken { self } + /// inbuxa: when the bearer token this session signed in with expires, + /// in seconds since the Unix epoch. None when it signed in otherwise. + pub fn expires_at(&self) -> Option { + self.expires_at + } + + /// inbuxa: records when the bearer token this session signed in with + /// expires. + pub fn with_expires_at(mut self, expires_at: Option) -> Self { + self.expires_at = expires_at; + self + } + + /// inbuxa: the version of the account's password this session's + /// credentials go with; it changes with the password. + pub fn credential_version(&self) -> u64 { + self.inner.credential_version + } + pub fn new_admin() -> AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner: Arc::new(AccessTokenInner::new_admin()), } } @@ -1009,6 +1034,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner: Arc::new(AccessTokenInner { account_id, tenant_id: Default::default(), diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 3e160bcf..90df12be 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -415,6 +415,8 @@ impl Server { } _ => false, }; + // inbuxa: the sign-in lasts no longer than the token + let expires_at = JwtClaims::expires_at(token); let token = self .build_directory_token(directory, result, req.remote_ip) .await?; @@ -426,7 +428,9 @@ impl Server { .reason("Authenticated using an email alias but account does not have AuthenticateAlias permission")); } // inbuxa: AU-5 - return Ok(token.with_origin(Via::Directory)); + return Ok(token + .with_origin(Via::Directory) + .with_expires_at(expires_at)); } Err(err) => { external_error = Some(err); @@ -445,16 +449,19 @@ impl Server { .and_then(|token| AccessToken::new(token, req.remote_ip)) // inbuxa: AU-5 .map(|token| { - token.with_origin(Via::OAuth { - client: token_info - .claims - .as_deref() - .filter(|claims| !claims.is_empty()) - .unwrap_or("unknown") - .chars() - .take(200) - .collect(), - }) + token + .with_origin(Via::OAuth { + client: token_info + .claims + .as_deref() + .filter(|claims| !claims.is_empty()) + .unwrap_or("unknown") + .chars() + .take(200) + .collect(), + }) + // inbuxa: the sign-in lasts no longer than the token + .with_expires_at(Some(token_info.expiry)) }), Err(err) => { if let Some(external_error) = external_error { @@ -747,6 +754,17 @@ impl<'x> JwtClaims<'x> { serde_json::from_slice(payload).ok() } + /// inbuxa: when a JWT expires, from its `exp` claim. Read only after the + /// directory has verified the token. + fn expires_at(token: &str) -> Option { + serde_json::from_slice::(&Self::decode_payload(token)?) + .ok()? + .get("exp")? + .as_f64() + .filter(|exp| *exp > 0.0) + .map(|exp| exp as u64) + } + fn domain(&self) -> Option<&str> { [&self.email, &self.preferred_username, &self.upn] .into_iter() diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 648d5d6f..6f9e5c2f 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -134,6 +134,8 @@ pub struct AccessToken { inner: Arc, // inbuxa: how this session signed in, for the audit log (AU-5) origin: Option>, + // inbuxa: when the bearer token this session signed in with expires + expires_at: Option, } #[derive(Debug, Default, Clone)] @@ -320,6 +322,7 @@ impl BuildAccessToken for Arc { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner: self, } } diff --git a/crates/common/src/cache/reload.rs b/crates/common/src/cache/reload.rs index 6e20d490..fa2038cb 100644 --- a/crates/common/src/cache/reload.rs +++ b/crates/common/src/cache/reload.rs @@ -54,8 +54,8 @@ impl Server { if expires_at > now() { let mut ips = self.inner.data.blocked_ips.write(); if let Some(ip) = ip.address.try_to_ip() { - ips.blocked_ip_addresses - .insert(IpWithTtl::new(ip, expires_at)); + // inbuxa: also when an earlier ban is remembered + ips.block(IpWithTtl::new(ip, expires_at)); } else { ips.blocked_ip_networks .push(IpWithTtl::new(ip.address, expires_at)); diff --git a/crates/common/src/config/mailstore/scripts.rs b/crates/common/src/config/mailstore/scripts.rs index 4067603f..7a34e0f1 100644 --- a/crates/common/src/config/mailstore/scripts.rs +++ b/crates/common/src/config/mailstore/scripts.rs @@ -300,4 +300,52 @@ mod tests { assert_eq!(Capability::parse(name), capability); } } + + // inbuxa: a :regex key compiles to at most 1 MiB; it could take ten + #[test] + fn sieve_regexes_are_size_limited() { + let compile = |pattern: &str| { + sieve::Compiler::new().compile( + format!( + "require \"regex\";\nif header :regex \"subject\" \"{pattern}\" {{ keep; }}\n" + ) + .as_bytes(), + ) + }; + assert!(compile(r"^\\w{20}$").is_ok()); + let err = compile(r"^\\w{200}$").unwrap_err().to_string(); + assert!(err.contains("Invalid regular expression"), "{err}"); + } + + // inbuxa: a pattern built from a variable is compiled once per run, not + // once for every header it's tested against + #[test] + fn sieve_variable_patterns_compile_once() { + let script = std::sync::Arc::new( + sieve::Compiler::new() + .compile( + br#"require ["regex", "variables"]; +set "p" "^\\w{20}x$"; +if header :regex "x-h" "${p}" { discard; } +"#, + ) + .unwrap(), + ); + let runtime = sieve::Runtime::new(); + let run = |headers: usize| { + let mut message = "X-H: value\r\n".repeat(headers); + message.push_str("Subject: test\r\n\r\nbody\r\n"); + let start = std::time::Instant::now(); + let mut ctx = runtime.filter(message.as_bytes()); + let mut input = sieve::Input::script("test", script.clone()); + while let Some(result) = ctx.run(input) { + result.unwrap(); + input = sieve::Input::True; + } + start.elapsed() + }; + let one = run(1); + let many = run(1000); + assert!(many < one * 10, "1 header: {one:?}, 1000 headers: {many:?}"); + } } diff --git a/crates/common/src/config/mailstore/spamfilter.rs b/crates/common/src/config/mailstore/spamfilter.rs index 01c1989a..e8194acc 100644 --- a/crates/common/src/config/mailstore/spamfilter.rs +++ b/crates/common/src/config/mailstore/spamfilter.rs @@ -11,6 +11,7 @@ use crate::expr::{ functions::ResolveVariable, if_block::{BootstrapExprExt, IfBlock}, }; +use crate::network::public_only::PublicOnly; use ahash::AHashSet; use mail_auth::common::resolver::ToReverseName; use nlp::classifier::model::{CcfhClassifier, FhClassifier}; @@ -248,9 +249,13 @@ impl SpamFilterConfig { grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()), // inbuxa: unset, empty or upstream's old default means the bundled rules spam_rules_url: crate::manager::spam_rules::rules_url(spam.spam_filter_rules_url), - url_client: utils::http::http_client_builder(true) + // inbuxa: redirectors are looked up over verified https, at + // public addresses only + url_client: utils::http::http_client_builder(false) .pool_max_idle_per_host(0) .redirect(reqwest::redirect::Policy::none()) + .https_only(true) + .public_only() .user_agent("Mozilla/5.0 (X11; Linux i686; rv:109.0) Gecko/20100101 Firefox/118.0") .build() .unwrap_or_default(), diff --git a/crates/common/src/config/smtp/mod.rs b/crates/common/src/config/smtp/mod.rs index 3268d084..78673614 100644 --- a/crates/common/src/config/smtp/mod.rs +++ b/crates/common/src/config/smtp/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ pub mod auth; @@ -14,7 +16,9 @@ use self::{ auth::MailAuthConfig, queue::QueueConfig, report::ReportConfig, resolver::Resolvers, session::SessionConfig, }; -use crate::{config::smtp::queue::RequireOptional, expr::if_block::IfBlock}; +use crate::{ + config::smtp::queue::RequireOptional, expr::if_block::IfBlock, network::public_only::PublicOnly, +}; use registry::{ schema::{properties::ObjectType, structs::Rate}, types::id::ObjectId, @@ -60,13 +64,20 @@ impl SmtpConfig { resolvers: Resolvers::parse(bp).await, mail_auth: MailAuthConfig::parse(bp).await, report: ReportConfig::parse(bp).await, + // Both fetch from hosts the remote domain names in DNS mta_sts_client: utils::http::http_client_builder(false) .pool_max_idle_per_host(0) .user_agent(crate::USER_AGENT) .redirect(reqwest::redirect::Policy::none()) + .public_only() + .build() + .unwrap_or_default(), + tls_report_client: utils::http::http_client_builder(false) + .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::none()) + .public_only() .build() .unwrap_or_default(), - tls_report_client: utils::http::unpooled_http_client(false), }; if !config.resolvers.dnssec_available diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f31cc37b..ee34a61a 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -303,6 +303,8 @@ pub struct HttpAuthCache { pub expires: Instant, // inbuxa: how the cached credentials signed in (AU-5) pub origin: Option>, + // inbuxa: when the cached bearer token expires + pub expires_at: Option, } pub struct Ipc { @@ -378,6 +380,8 @@ pub enum DavResourceMetadata { names: TinyVec<[DavName; 2]>, start: i64, duration: u32, + // inbuxa: EVENT_VIEW_* flags + view: u8, }, CalendarEventNotification { names: TinyVec<[DavName; 2]>, @@ -391,6 +395,11 @@ pub enum DavResourceMetadata { }, } +// inbuxa: what a sharee may see of a calendar event: the time only of a +// private or confidential one, and not the attendees of one that hides them +pub const EVENT_VIEW_PRIVATE: u8 = 1; +pub const EVENT_VIEW_HIDE_ATTENDEES: u8 = 1 << 1; + #[derive(Debug, Clone, Default)] pub struct TinyCalendarPreferences { pub account_id: u32, diff --git a/crates/common/src/network/mod.rs b/crates/common/src/network/mod.rs index 1d61319e..23194074 100644 --- a/crates/common/src/network/mod.rs +++ b/crates/common/src/network/mod.rs @@ -42,6 +42,7 @@ pub mod legacy; pub mod limiter; pub mod listen; pub mod mta; +pub mod public_only; pub mod security; pub mod stream; pub mod tls; diff --git a/crates/common/src/network/public_only.rs b/crates/common/src/network/public_only.rs new file mode 100644 index 00000000..567be0a7 --- /dev/null +++ b/crates/common/src/network/public_only.rs @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! HTTP requests to URLs that outsiders choose (push subscription endpoints, +//! TLS-RPT `rua=` URIs, MTA-STS policy hosts, URL shorteners found in mail) +//! may only reach public addresses. A host name is checked when it resolves, +//! so a name that answers with a loopback, private or link-local address +//! can't reach inside the network, and neither can a redirect or a later +//! re-resolution. IP literals don't resolve, so callers check them with +//! [`has_public_host`] before sending. + +use super::is_global_ip; +use reqwest::{ + ClientBuilder, Url, + dns::{Addrs, Name, Resolve, Resolving}, +}; +use std::net::IpAddr; + +pub trait PublicOnly { + /// Connect only to public addresses, and never through a proxy (which + /// would resolve the name itself, out of our sight). + fn public_only(self) -> Self; +} + +impl PublicOnly for ClientBuilder { + fn public_only(self) -> Self { + self.no_proxy().dns_resolver(PublicResolver) + } +} + +struct PublicResolver; + +impl Resolve for PublicResolver { + fn resolve(&self, name: Name) -> Resolving { + Box::pin(async move { + let addrs = tokio::net::lookup_host((name.as_str(), 0)) + .await? + .filter(|addr| is_global_ip(&addr.ip())) + .collect::>(); + + if !addrs.is_empty() { + Ok(Box::new(addrs.into_iter()) as Addrs) + } else { + Err(format!("{} has no public address", name.as_str()).into()) + } + }) + } +} + +/// False when the URL's host is an IP literal that isn't public, or is +/// missing. Host names pass here and are checked when they resolve. +pub fn has_public_host(url: &str) -> bool { + let Some(host) = Url::parse(url) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + else { + return false; + }; + let host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(&host); + + match host.parse::() { + // The test harness serves push and reports on 127.0.0.1; + // loopback host names are still refused when they resolve. + #[cfg(feature = "test_mode")] + Ok(ip) if ip.is_loopback() => true, + Ok(ip) => is_global_ip(&ip), + Err(_) => true, + } +} + +#[cfg(test)] +mod tests { + use super::{PublicOnly, has_public_host}; + use std::time::Duration; + + #[test] + fn ip_literal_hosts() { + for url in [ + "https://127.0.0.1/push", + "https://10.1.2.3:8443/", + "https://169.254.169.254/latest/meta-data/", + "https://[::1]/", + "https://[::ffff:192.168.0.1]/", + "https://[fd00::1]/", + "not a url", + ] { + assert!(!has_public_host(url), "{url}"); + } + + for url in [ + "https://8.8.8.8/", + "https://[2606:4700::1111]/", + "https://push.example.com/", + "https://localhost/", + ] { + assert!(has_public_host(url), "{url}"); + } + } + + #[tokio::test] + async fn names_resolving_to_local_addresses_are_not_reached() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let client = reqwest::Client::builder().public_only().build().unwrap(); + + let result = client + .get(format!("http://localhost:{port}/")) + .timeout(Duration::from_secs(5)) + .send() + .await; + assert!(result.is_err(), "{result:?}"); + assert!( + tokio::time::timeout(Duration::from_millis(200), listener.accept()) + .await + .is_err(), + "a connection reached the loopback listener" + ); + } +} diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index 86e0eebf..b710d9b6 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -333,27 +333,56 @@ impl Server { .data .blocked_ips .write() - .blocked_ip_addresses - .insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX))); + .block(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX))); // Write blocked IP to config // inbuxa: AU-1.10: recorded as the server's automatic ban - let RegistryWriteResult::Success(id) = inbuxa_features::audit::scope::system( + let blocked_ip = Object::from(BlockedIp { + address: IpAddrOrMask::from_ip(ip), + created_at: UTCDateTime::from_timestamp(now as i64), + expires_at: expires_at.map(|ts| UTCDateTime::from_timestamp(ts as i64)), + reason, + }); + let id = match inbuxa_features::audit::scope::system( "auto-ban", - self.registry().write(RegistryWrite::insert( - &BlockedIp { - address: IpAddrOrMask::from_ip(ip), - created_at: UTCDateTime::from_timestamp(now as i64), - expires_at: expires_at.map(|ts| UTCDateTime::from_timestamp(ts as i64)), - reason, - } - .into(), - )), + self.registry().write(RegistryWrite::insert(&blocked_ip)), ) .await .caused_by(trc::location!())? - else { - return Ok(()); + { + RegistryWriteResult::Success(id) => id, + // inbuxa: the address's earlier ban is still on record (it is + // removed some time after it ends): the new ban replaces it + // unless that one lasts longer + RegistryWriteResult::PrimaryKeyConflict { existing_id, .. } + if existing_id.object() == ObjectType::BlockedIp => + { + let Some(existing) = self.registry().get(existing_id).await? else { + return Ok(()); + }; + let existing_expires_at = BlockedIp::from(existing.clone()) + .expires_at + .map(|dt| dt.timestamp() as u64) + .unwrap_or(u64::MAX); + if existing_expires_at >= expires_at.unwrap_or(u64::MAX) { + return Ok(()); + } + let RegistryWriteResult::Success(id) = inbuxa_features::audit::scope::system( + "auto-ban", + self.registry().write(RegistryWrite::update( + existing_id.id(), + &blocked_ip, + &existing, + )), + ) + .await + .caused_by(trc::location!())? + else { + return Ok(()); + }; + id + } + _ => return Ok(()), }; // Increment version @@ -530,6 +559,21 @@ impl BlockedIps { } } +impl BlockedIps { + /// inbuxa: bans an address. A set keeps the entry it already has, so an + /// address whose earlier ban has ended would otherwise stay unbanned; + /// the later of the two bans is kept. + pub fn block(&mut self, ip: IpWithTtl) { + if self + .blocked_ip_addresses + .get(&ip) + .is_none_or(|existing| existing.expires_at < ip.expires_at) + { + self.blocked_ip_addresses.replace(ip); + } + } +} + impl Hash for IpWithTtl { fn hash(&self, state: &mut H) { self.ip.hash(state); diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs index 74ee8fee..78ab0960 100644 --- a/crates/common/src/sharing/resources.rs +++ b/crates/common/src/sharing/resources.rs @@ -49,8 +49,14 @@ impl DavResources { if !shared_containers.is_empty() { let mut document_ids = RoaringBitmap::new(); + // inbuxa: only files carry their own ACLs and share an id space with + // their folders. Calendar events and contacts are shared through + // their containers alone. for resource in &self.resources { - if !resource.is_container() && shared_containers.contains(resource.document_id) { + if !resource.is_container() + && resource.acls().is_some() + && shared_containers.contains(resource.document_id) + { document_ids.insert(resource.document_id); } } @@ -69,6 +75,31 @@ impl DavResources { } } + /// inbuxa: the shared items a sharee may see, without the calendar events + /// whose view has any of `hidden` (EVENT_VIEW_*). + pub fn shared_events( + &self, + access_token: &AccessToken, + check_acls: impl IntoIterator, + match_any: bool, + hidden: u8, + ) -> RoaringBitmap { + let mut document_ids = self.shared_items(access_token, check_acls, match_any); + if !document_ids.is_empty() { + document_ids -= self.events_with_view(hidden); + } + document_ids + } + + /// inbuxa: the calendar events whose view has any of `view` (EVENT_VIEW_*) + pub fn events_with_view(&self, view: u8) -> RoaringBitmap { + self.resources + .iter() + .filter(|resource| resource.event_view() & view != 0) + .map(|resource| resource.document_id) + .collect() + } + pub fn shared_documents( &self, access_token: &AccessToken, diff --git a/crates/common/src/storage/dav.rs b/crates/common/src/storage/dav.rs index c36eeed5..86673314 100644 --- a/crates/common/src/storage/dav.rs +++ b/crates/common/src/storage/dav.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -309,6 +311,14 @@ impl DavResource { } } + // inbuxa: EVENT_VIEW_* flags of a calendar event + pub fn event_view(&self) -> u8 { + match &self.data { + DavResourceMetadata::CalendarEvent { view, .. } => *view, + _ => 0, + } + } + pub fn calendar_preferences(&self, account_id: u32) -> Option<&TinyCalendarPreferences> { match &self.data { DavResourceMetadata::Calendar { preferences, .. } => preferences diff --git a/crates/common/src/storage/quota.rs b/crates/common/src/storage/quota.rs index 6ae7b209..577adf25 100644 --- a/crates/common/src/storage/quota.rs +++ b/crates/common/src/storage/quota.rs @@ -15,9 +15,28 @@ use registry::{ schema::enums::{StorageQuota, TenantStorageQuota}, types::EnumImpl, }; +use std::sync::Arc; use store::{ValueKey, write::ValueClass}; use trc::AddContext; +// inbuxa: the disk quota of the account a request writes into, with the bytes +// the request has already added. A request commits all its items at once, so +// each item is checked together with the ones before it, not against the +// stored usage alone. +pub struct QuotaBudget { + account: Arc, + reserved: u64, +} + +impl QuotaBudget { + pub fn new(account: Arc) -> Self { + Self { + account, + reserved: 0, + } + } +} + impl Server { pub async fn get_used_quota_account(&self, account_id: u32) -> trc::Result { self.core @@ -73,6 +92,17 @@ impl Server { Ok(()) } + // inbuxa: checks an item against the account's quota and, when it fits, + // counts it towards the rest of the request + pub async fn reserve_quota(&self, budget: &mut QuotaBudget, item_size: u64) -> trc::Result<()> { + if item_size > 0 { + self.has_available_quota(&budget.account, budget.reserved + item_size) + .await?; + budget.reserved += item_size; + } + Ok(()) + } + #[inline(always)] pub fn object_quota(&self, user_quotas: Option<&ObjectQuota>, object: StorageQuota) -> u32 { user_quotas.unwrap_or(&self.core.email.max_objects).0[object as usize] diff --git a/crates/dav-proto/src/requests/mod.rs b/crates/dav-proto/src/requests/mod.rs index e00526c5..269dc547 100644 --- a/crates/dav-proto/src/requests/mod.rs +++ b/crates/dav-proto/src/requests/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -75,10 +77,18 @@ impl From<&RawElement<'_>> for DeadElementTag { .unwrap_or("invalid-utf8") .trim_ascii() .to_string(); + let name = if is_xml_name(&name) { + name + } else { + "invalid-name".to_string() + }; let mut attrs = String::with_capacity(raw.element.attributes_raw().len()); if let Some(namespace) = &raw.namespace { attrs.push_str("xmlns=\""); - attrs.push_str(std::str::from_utf8(namespace).unwrap_or("invalid-utf8")); + push_attribute_value( + &mut attrs, + std::str::from_utf8(namespace).unwrap_or("invalid-utf8"), + ); attrs.push('"'); } @@ -90,14 +100,15 @@ impl From<&RawElement<'_>> for DeadElementTag { if let (Ok(key), Ok(value)) = ( std::str::from_utf8(attr.key.as_ref()), std::str::from_utf8(attr.value.as_ref()), - ) { + ) && is_xml_name(key) + { if !attrs.is_empty() { attrs.push(' '); } attrs.push_str(key); attrs.push('='); attrs.push('"'); - attrs.push_str(value); + push_attribute_value(&mut attrs, value); attrs.push('"'); } } @@ -109,6 +120,31 @@ impl From<&RawElement<'_>> for DeadElementTag { } } +/// inbuxa: element and attribute names are echoed back as they came in, so +/// nothing that could end the tag or start markup may be in them +pub(crate) fn is_xml_name(name: &str) -> bool { + !name.is_empty() + && !name + .chars() + .any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | '&' | '=' | '/')) +} + +/// inbuxa: a stored attribute value is always written inside double quotes, +/// so it's unescaped as received (it may have been quoted with ') and escaped +/// again for that +fn push_attribute_value(out: &mut String, raw: &str) { + let value = quick_xml::escape::unescape(raw).unwrap_or(std::borrow::Cow::Borrowed(raw)); + for c in value.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(c), + } + } +} + #[cfg(test)] mod tests { use calcard::vcard::VCardVersion; @@ -121,6 +157,68 @@ mod tests { }, }; + // inbuxa: dead properties are stored from one user's PROPPATCH or LOCK + // and shown to everyone who can read the resource: what comes back out + // must be the same well-formed XML, whatever quoting came in + #[test] + fn dead_property_round_trip_is_well_formed() { + let xml = r#" + + + + text + + + "#; + + let update = PropertyUpdate::parse(&mut Tokenizer::new(xml.as_bytes())).unwrap(); + let output = update + .set + .iter() + .map(|property| property.to_string()) + .collect::(); + let wrapped = format!("{output}"); + + let mut reader = quick_xml::Reader::from_str(&wrapped); + let mut elements = Vec::new(); + let mut attributes = Vec::new(); + loop { + match reader.read_event() { + Ok(quick_xml::events::Event::Start(element)) + | Ok(quick_xml::events::Event::Empty(element)) => { + elements + .push(String::from_utf8(element.local_name().as_ref().to_vec()).unwrap()); + for attribute in element.attributes() { + let attribute = attribute.unwrap(); + attributes.push(( + String::from_utf8(attribute.key.as_ref().to_vec()).unwrap(), + quick_xml::escape::unescape( + std::str::from_utf8(&attribute.value).unwrap(), + ) + .unwrap() + .into_owned(), + )); + } + } + Ok(quick_xml::events::Event::Eof) => break, + Ok(_) => {} + Err(err) => panic!("malformed output {output:?}: {err}"), + } + } + + assert_eq!( + elements, + ["root", "note", "outer", "inner", "empty"], + "{output}" + ); + for (name, value) in [("a", r#"x" >"#), ("b", r#"&""#), ("c", "")] { + assert!( + attributes.contains(&(name.to_string(), value.to_string())), + "{name}={value:?} not in {attributes:?}" + ); + } + } + #[test] fn parse_address_data_version() { let xml = r#" diff --git a/crates/dav-proto/src/responses/mod.rs b/crates/dav-proto/src/responses/mod.rs index 1ce07a87..d2ae4582 100644 --- a/crates/dav-proto/src/responses/mod.rs +++ b/crates/dav-proto/src/responses/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ pub mod acl; @@ -13,6 +15,7 @@ pub mod property; pub mod propstat; pub mod schedule; +use crate::requests::is_xml_name; use crate::schema::{ Namespaces, property::{Comp, ResourceType, SupportedCollation}, @@ -174,21 +177,20 @@ pub trait DeadPropertyFormat { impl DeadPropertyFormat for DeadProperty { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut last_tag = ""; + // inbuxa: each end tag closes the element opened last + let mut open_tags = Vec::new(); for item in &self.0 { match item { DeadPropertyTag::ElementStart(tag) => { - let name = &tag.name; - if let Some(attrs) = &tag.attrs { - write!(f, "<{name} {attrs}>")?; - } else { - write!(f, "<{name}>")?; - } - last_tag = name; + let name = dead_element_name(&tag.name); + write!(f, "<{name}{}>", DeadAttributes(tag.attrs.as_deref()))?; + open_tags.push(name); } DeadPropertyTag::ElementEnd => { - write!(f, "", last_tag)?; + if let Some(name) = open_tags.pop() { + write!(f, "")?; + } } DeadPropertyTag::Text(text) => { text.write_escaped_to(f)?; @@ -196,6 +198,47 @@ impl DeadPropertyFormat for DeadProperty { } } + while let Some(name) = open_tags.pop() { + write!(f, "")?; + } + + Ok(()) + } +} + +/// inbuxa: a stored element name that couldn't be written back as one +pub(crate) fn dead_element_name(name: &str) -> &str { + if is_xml_name(name) { + name + } else { + "invalid-name" + } +} + +/// inbuxa: writes a dead property's stored attributes as ` name="value"` +/// pairs. Values stored before they were escaped on the way in may hold a +/// quote or markup, so writing stops at the first part that isn't a plain +/// pair. +pub(crate) struct DeadAttributes<'x>(pub Option<&'x str>); + +impl Display for DeadAttributes<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Some(mut attrs) = self.0 else { + return Ok(()); + }; + + loop { + attrs = attrs.trim_start(); + let Some((name, value, rest)) = attrs.split_once('=').and_then(|(name, rest)| { + let (value, rest) = rest.strip_prefix('"')?.split_once('"')?; + (is_xml_name(name) && !value.contains('<')).then_some((name, value, rest)) + }) else { + break; + }; + write!(f, " {name}=\"{value}\"")?; + attrs = rest; + } + Ok(()) } } @@ -247,6 +290,37 @@ mod tests { } } + // inbuxa: attributes stored before they were escaped on the way in are + // written only up to the first part that isn't a plain name="value" pair + #[test] + fn stored_dead_property_attributes_stay_inside_the_tag() { + struct Format(DeadProperty); + impl Display for Format { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + super::DeadPropertyFormat::fmt(&self.0, f) + } + } + + let property = DeadProperty(vec![ + DeadPropertyTag::ElementStart(DeadElementTag { + name: "note".to_string(), + attrs: Some(r#"xmlns="urn:example:z" a="x" >" b="y""#.to_string()), + }), + DeadPropertyTag::ElementStart(DeadElementTag { + name: "inner".to_string(), + attrs: Some(r#"c="""#.to_string()), + }), + DeadPropertyTag::Text("text".to_string()), + DeadPropertyTag::ElementEnd, + DeadPropertyTag::ElementEnd, + ]); + + assert_eq!( + Format(property).to_string(), + r#"text"# + ); + } + #[test] fn parse_responses() { for (num, test) in [ diff --git a/crates/dav-proto/src/responses/property.rs b/crates/dav-proto/src/responses/property.rs index 44dffde6..4acbec64 100644 --- a/crates/dav-proto/src/responses/property.rs +++ b/crates/dav-proto/src/responses/property.rs @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{XmlCdataEscape, XmlEscape}; use crate::{ - responses::DeadPropertyFormat, + responses::{DeadAttributes, DeadPropertyFormat, dead_element_name}, schema::{ Namespace, Namespaces, property::{ @@ -39,12 +41,9 @@ impl Display for PropResponse { impl Display for DavPropertyValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let (name, attrs) = self.property.tag_name(); + let name = dead_element_name(name); - write!(f, "<{}", name)?; - - if let Some(attrs) = attrs { - write!(f, " {attrs}")?; - } + write!(f, "<{name}{}", DeadAttributes(attrs))?; if !matches!(self.value, DavValue::Null) { write!(f, ">{}", self.value, name) @@ -275,11 +274,7 @@ impl Display for ReportSet { impl Display for DavProperty { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let (name, attrs) = self.tag_name(); - if let Some(attrs) = attrs { - write!(f, "<{name} {attrs}/>") - } else { - write!(f, "<{name}/>") - } + write!(f, "<{}{}/>", dead_element_name(name), DeadAttributes(attrs)) } } diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index a4d60802..fba65473 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -32,6 +32,7 @@ use groupware::{ cache::GroupwareCache, calendar::{ CalendarEvent, CalendarEventData, dates::has_oversized_rrule, itip::ItipSendStatus, + privacy::itip_messages_for, }, scheduling::{ ItipMessages, event_create::itip_create, event_update::itip_update, @@ -266,7 +267,10 @@ impl CalendarUpdateRequestHandler for Server { } } - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = Some(ItipMessages::new(itip_messages_for( + messages, + new_event.flags, + ))); } else { return Err(DavError::Condition(DavErrorCondition::new( StatusCode::PRECONDITION_FAILED, @@ -423,7 +427,8 @@ impl CalendarUpdateRequestHandler for Server { < self.core.groupware.itip_outbound_max_recipients { event.schedule_tag = Some(1); - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = + Some(ItipMessages::new(itip_messages_for(messages, event.flags))); } else { return Err(DavError::Condition(DavErrorCondition::new( StatusCode::PRECONDITION_FAILED, diff --git a/crates/dav/src/common/lock.rs b/crates/dav/src/common/lock.rs index f768ea4e..6c758541 100644 --- a/crates/dav/src/common/lock.rs +++ b/crates/dav/src/common/lock.rs @@ -385,36 +385,70 @@ impl LockRequestHandler for Server { ) { let mut base_path = None; - 'outer: for (pos, resource) in resources.iter().enumerate() { + // inbuxa: a lock token counts as submitted wherever it appears in + // the If header + let submitted_tokens = headers + .if_ + .iter() + .flat_map(|if_| if_.list.iter()) + .filter_map(|cond| match cond { + Condition::StateToken { token, .. } => Some(*token), + _ => None, + }) + .collect::>(); + + for (pos, resource) in resources.iter().enumerate() { if pos == 0 && matches!(method, DavMethod::COPY) { continue; } if let Some(idx) = locks.find_cache_pos(self, resource).await? { - let mut failed_locks = Vec::new(); + let found_locks = locks.find_locks_by_pos(idx, resource, true)?; + let mut failed_locks: Vec = Vec::new(); - for (lock_path, lock_item) in locks.find_locks_by_pos(idx, resource, true)? { - let lock_token = lock_item.urn().to_string(); - if headers.if_.iter().any(|if_| { - if_.resource - .is_none_or(|r| { - r.trim_end_matches('/').ends_with(lock_path)}) - && if_.list.iter().any(|cond| matches!(cond, Condition::StateToken { token, .. } if token == &lock_token)) + // inbuxa: the resource and every locked resource below it + // each need the token of one of the locks covering them + // (more than one only when the locks are shared) + let mut locked_paths = vec![resource.path]; + for (lock_path, _) in &found_locks { + if lock_path.len() > resource.path.len() + && !locked_paths.contains(lock_path) + { + locked_paths.push(lock_path); + } + } + for locked_path in locked_paths { + let covering = found_locks + .iter() + .filter(|(lock_path, lock_item)| { + *lock_path == locked_path + || (lock_item.depth_infinity + && locked_path + .strip_prefix(*lock_path) + .is_some_and(|rest| rest.starts_with('/'))) + }) + .collect::>(); + if covering.iter().any(|(_, lock_item)| { + submitted_tokens.contains(&lock_item.urn().to_string().as_str()) }) { - break 'outer; - } else { - let base_path = base_path.get_or_insert_with(|| { - headers.base_uri() - .unwrap_or_default() - }); - failed_locks.push(format!("{base_path}/{lock_path}").into()); + continue; + } + for (lock_path, _) in covering { + let base_path = base_path + .get_or_insert_with(|| headers.base_uri().unwrap_or_default()); + let href = format!("{base_path}/{lock_path}"); + if !failed_locks.contains(&href) { + failed_locks.push(href); + } } } if !failed_locks.is_empty() { lock_response = Err(DavErrorCondition::new( StatusCode::LOCKED, - BaseCondition::LockTokenSubmitted(List(failed_locks)), + BaseCondition::LockTokenSubmitted(List( + failed_locks.into_iter().map(Into::into).collect(), + )), ) .into()); break; diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 79dad936..cfd2d912 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ @@ -294,10 +296,14 @@ impl PropFindRequestHandler for Server { let collection_children; let sync_collection; let mut query_filter = None; - let mut limit = std::cmp::min( + // inbuxa: a limit of zero still allows one result, so the countdown + // below can't wrap around and lift the cap + let max_results = std::cmp::min( query.limit.unwrap_or(u32::MAX) as usize, self.core.groupware.max_results, - ); + ) + .max(1); + let mut limit = max_results; let mut is_sync_limited = false; let mut is_propfind = false; let mut ical_instances_limit = self.core.groupware.max_ical_instances; @@ -1115,12 +1121,7 @@ impl PropFindRequestHandler for Server { Response::new_status([query.uri], StatusCode::INSUFFICIENT_STORAGE) .with_error(BaseCondition::NumberOfMatchesWithinLimit) .with_response_description(if ical_instances_limit > 0 { - format!( - "The number of matches exceeds the limit of {}", - query - .limit - .unwrap_or(self.core.groupware.max_results as u32) - ) + format!("The number of matches exceeds the limit of {max_results}") } else { format!( "The number of recurrence instances exceeds the limit of {}", @@ -1223,6 +1224,9 @@ async fn get( })) }); + // inbuxa: the containers the caller can see, before narrowing to changes + let visible_containers = display_containers.clone(); + // Filter by changelog let is_sync = match query.sync_type { SyncType::From { id, seq } => { @@ -1307,6 +1311,26 @@ async fn get( .vanished(account_id, vanished_collection.into(), Query::Since(id)) .await .caused_by(trc::location!())?; + + // inbuxa: the log holds deletions from the whole account: keep + // those inside the synced collection, under a container the + // caller can see + let scope = resource + .resource + .filter(|path| !path.is_empty()) + .map_or_else( + || resources.base_path.clone(), + |path| resources.format_collection(path), + ); + vanished.retain(|href| { + href.starts_with(scope.as_str()) + && visible_containers.as_ref().is_none_or(|containers| { + href.strip_prefix(resources.base_path.as_str()) + .and_then(|path| path.trim_end_matches('/').rsplit_once('/')) + .and_then(|(parent, _)| resources.by_path(parent)) + .is_some_and(|parent| containers.contains(parent.document_id())) + }) + }); total_changes += vanished.len(); } diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs index 0130e4ab..520da2b0 100644 --- a/crates/dav/src/file/copy_move.rs +++ b/crates/dav/src/file/copy_move.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::FromDavResource; @@ -32,7 +34,7 @@ use store::{ }; use trc::AddContext; use types::{ - acl::Acl, + acl::{Acl, AclGrant}, collection::{Collection, SyncCollection, VanishedCollection}, }; @@ -158,21 +160,23 @@ impl FileCopyMoveRequestHandler for Server { // Validate destination ACLs if let Some(document_id) = destination.document_id { - if let Some(delete_destination) = &delete_destination - && !access_token.is_member(to_account_id) - && !to_resources.has_access_to_container( - access_token, - delete_destination.document_id.unwrap(), - Acl::Delete, - ) - { - return Err(DavError::Code(StatusCode::FORBIDDEN)); - } - - if !access_token.is_member(to_account_id) - && !to_resources.has_access_to_container(access_token, document_id, Acl::Modify) - { - return Err(DavError::Code(StatusCode::FORBIDDEN)); + if !access_token.is_member(to_account_id) { + // inbuxa: adding to a folder needs AddItems, as PUT and MKCOL + // do, and replacing what is there needs Delete on all of it, + // as DELETE does + if !to_resources.has_access_to_container(access_token, document_id, Acl::AddItems) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + if delete_destination.is_some() { + let permissions = + to_resources.shared_containers(access_token, [Acl::Delete], false); + if !to_resources + .subtree(destination_resource_name) + .all(|resource| permissions.contains(resource.document_id())) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + } } } else if !access_token.is_member(to_account_id) { return Err(DavError::Code(StatusCode::FORBIDDEN)); @@ -342,6 +346,10 @@ pub(crate) struct Destination { pub new_name: Option, pub document_id: Option, pub is_container: bool, + // inbuxa: the ACL of this node. What a COPY, or a MOVE from another + // account, puts in a folder takes the folder's ACL, as an uploaded file + // does, never the source's. + pub acls: Vec, } impl Default for Destination { @@ -351,6 +359,7 @@ impl Default for Destination { document_id: Default::default(), new_name: Default::default(), is_container: true, + acls: Default::default(), } } } @@ -501,6 +510,7 @@ async fn copy_container( }; node.modified = now; node.created = now; + node.acls = destination.acls.clone(); if let Some(new_name) = destination.new_name.take() { node.name = new_name; } @@ -612,6 +622,11 @@ async fn overwrite_and_delete_item( dest_node.inner.name.to_string() }; source_node.parent_id = dest_node.inner.parent_id.into(); + // inbuxa: a file moved from another account over a file keeps that + // file's ACL + if from_account_id != to_account_id { + source_node.acls = dest_node.inner.acls.iter().map(AclGrant::from).collect(); + } let mut batch = BatchBuilder::new(); let etag = source_node @@ -689,6 +704,8 @@ async fn overwrite_item( dest_node.inner.name.to_string() }; source_node.parent_id = dest_node.inner.parent_id.into(); + // inbuxa: a copy that replaces a file keeps that file's ACL + source_node.acls = dest_node.inner.acls.iter().map(AclGrant::from).collect(); let mut batch = BatchBuilder::new(); let etag = source_node .update( @@ -758,6 +775,7 @@ async fn move_item( .etag() } else { // Destination is in a different account: insert a new node, then delete the old one + new_node.acls = destination.acls; let to_document_id = server .store() .assign_document_ids(to_account_id, Collection::FileNode, 1) @@ -818,6 +836,7 @@ async fn copy_item( .deserialize::() .caused_by(trc::location!())?; node.parent_id = parent_id; + node.acls = destination.acls; if let Some(new_name) = destination.new_name { node.name = new_name; } @@ -902,6 +921,11 @@ impl FromDavResource for Destination { document_id: Some(item.document_id()), is_container: item.is_container(), new_name: None, + acls: item + .resource + .acls() + .map(|acls| acls.to_vec()) + .unwrap_or_default(), } } } diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 464c9dad..1f78b4fb 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -84,6 +84,7 @@ impl OpenIdDirectory { continue; } + check_token_type(&token_data.claims)?; self.validate_scopes(&token_data.claims)?; let mut claims = token_data.claims; @@ -431,6 +432,37 @@ fn extract_string_list(value: &serde_json::Value) -> Option> { } } +/// inbuxa: only an access token signs in. A token the provider marks as an +/// ID token was minted for whichever client the user signed in to, and a +/// refresh token is never presented as a bearer token. Keycloak marks its +/// tokens with `typ`, Amazon Cognito with `token_use`. +fn check_token_type(claims: &serde_json::Value) -> Result<(), OidcError> { + let marker = |claim: &str| claims.get(claim).and_then(|v| v.as_str()); + let refused = match (marker("typ"), marker("token_use")) { + (Some(typ), _) + if ["id", "refresh", "offline", "logout"] + .iter() + .any(|kind| typ.eq_ignore_ascii_case(kind)) => + { + Some(typ) + } + (_, Some(token_use)) + if ["id", "refresh"] + .iter() + .any(|kind| token_use.eq_ignore_ascii_case(kind)) => + { + Some(token_use) + } + _ => None, + }; + match refused { + Some(kind) => Err(OidcError::TokenValidation(format!( + "Not an access token (marked {kind:?})" + ))), + None => Ok(()), + } +} + #[inline(always)] fn is_claim_missing(claims: &serde_json::Value, claim: Option<&String>) -> bool { claim.is_some_and(|claim| claims.get(claim).is_none_or(serde_json::Value::is_null)) @@ -441,6 +473,120 @@ mod tests { use super::*; use serde_json::json; + // A key for these tests only + const TEST_KEY: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIGtahTxccpNyO2c/+k3fa0Opl10EO4gp0Iw8XAM2hUeL +-----END PRIVATE KEY-----"; + const TEST_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE= +-----END PUBLIC KEY-----"; + + fn test_directory() -> OpenIdDirectory { + let mut keys = AHashMap::new(); + keys.insert( + "test".to_string(), + Arc::new(CachedKey { + decoding_key: DecodingKey::from_ed_pem(TEST_PUBLIC_KEY.as_bytes()).unwrap(), + algorithm: Algorithm::EdDSA, + }), + ); + OpenIdDirectory { + config: crate::backend::oidc::OidcConfig { + issue_url: "https://idp.example.org".to_string(), + require_aud: None, + require_scopes: Vec::new(), + claim_email: "email".to_string(), + claim_name: None, + claim_groups: None, + default_domain: None, + }, + discovery: crate::backend::oidc::OidcDiscovery { + url: "https://idp.example.org".to_string(), + document: crate::backend::oidc::DiscoveryDocument { + issuer: "https://idp.example.org".to_string(), + jwks_uri: "https://idp.example.org/jwks".to_string(), + userinfo_endpoint: "https://idp.example.org/userinfo".to_string(), + token_endpoint: "https://idp.example.org/token".to_string(), + authorization_endpoint: "https://idp.example.org/auth".to_string(), + end_session_endpoint: None, + scopes_supported: None, + claims_supported: None, + code_challenge_methods_supported: None, + }, + }, + http: Client::new(), + cache: tokio::sync::RwLock::new(crate::backend::oidc::JwksCache { + keys, + last_updated: Instant::now(), + }), + } + } + + fn signed(claims: serde_json::Value) -> String { + let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA); + header.kid = Some("test".to_string()); + let mut claims = claims; + claims["iss"] = json!("https://idp.example.org"); + claims["exp"] = json!( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 300 + ); + claims["email"] = json!("john@example.org"); + jsonwebtoken::encode( + &header, + &claims, + &jsonwebtoken::EncodingKey::from_ed_pem(TEST_KEY.as_bytes()).unwrap(), + ) + .unwrap() + } + + async fn sign_in(directory: &OpenIdDirectory, token: String) -> trc::Result { + directory + .authenticate(&Credentials::Bearer { + username: None, + token, + }) + .await + } + + // inbuxa: a token the provider marks as an ID or refresh token is not an + // access token: an ID token is minted for whichever client signed in + #[tokio::test] + async fn only_access_tokens_sign_in() { + let directory = test_directory(); + + for claims in [ + json!({}), + json!({"typ": "Bearer"}), + json!({"token_use": "access"}), + ] { + assert_eq!( + sign_in(&directory, signed(claims.clone())) + .await + .map(|account| account.email) + .ok() + .as_deref(), + Some("john@example.org"), + "{claims}" + ); + } + for claims in [ + json!({"typ": "ID"}), + json!({"typ": "Refresh"}), + json!({"typ": "Offline"}), + json!({"token_use": "id"}), + json!({"token_use": "refresh"}), + ] { + assert!( + sign_in(&directory, signed(claims.clone())).await.is_err(), + "{claims} must not sign in" + ); + } + } + #[test] fn extract_string_list_distinguishes_absent_from_empty() { assert_eq!( diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index f0d2c8fb..11933b53 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use argon2::Argon2; @@ -18,7 +20,8 @@ use sha1::Digest; use sha1::Sha1; use sha2::Sha256; use sha2::Sha512; -use tokio::sync::oneshot; +use std::sync::{Arc, LazyLock}; +use tokio::sync::Semaphore; use totp_rs::Totp; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -72,17 +75,58 @@ pub async fn verify_mfa_secret_hash( } } +/// inbuxa: slow password hashes run on the blocking pool, at most one per +/// CPU at a time, and the rest wait their turn. A flood of sign-ins then +/// neither stalls the async workers every connection is served on nor +/// starts hundreds of threads each holding a memory-hard hash. +static HASHERS: LazyLock> = LazyLock::new(|| { + Arc::new(Semaphore::new( + std::thread::available_parallelism().map_or(4, |n| n.get()), + )) +}); + +async fn run_hasher( + hasher: impl FnOnce() -> T + Send + 'static, +) -> trc::Result { + let thread_error = |err: String| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .caused_by(trc::location!()) + .reason(err) + }; + let permit = HASHERS + .clone() + .acquire_owned() + .await + .map_err(|err| thread_error(err.to_string()))?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + hasher() + }) + .await + .map_err(|err| thread_error(err.to_string())) +} + +/// inbuxa: checks a crypt(3)-style hash on the hashing pool. +async fn verify_crypt( + verify: fn(&[u8], &str) -> bool, + hashed_secret: &str, + secret: &[u8], +) -> trc::Result { + let secret = secret.to_vec(); + let hashed_secret = hashed_secret.to_string(); + run_hasher(move || verify(&secret, &hashed_secret)).await +} + async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { let is_argon = hashed_secret.starts_with("$argon2"); let is_pbkdf2 = !is_argon && hashed_secret.starts_with("$pbkdf2"); let is_scrypt = !is_argon && !is_pbkdf2 && hashed_secret.starts_with("$scrypt"); if is_argon || is_pbkdf2 || is_scrypt { - let (tx, rx) = oneshot::channel(); let secret = secret.to_vec(); let hashed_secret = hashed_secret.to_string(); - tokio::task::spawn_blocking(move || match PasswordHash::new(&hashed_secret) { + run_hasher(move || match PasswordHash::new(&hashed_secret) { Ok(hash) => { let result = if is_argon { Argon2::default().verify_password(&secret, &hash) @@ -92,37 +136,26 @@ async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { - tx.send(Err(trc::AuthEvent::Error - .reason(err) - .details(hashed_secret))) - .ok(); - } - }); - - match rx.await { - Ok(result) => result, - Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) - .caused_by(trc::location!()) - .reason(err)), - } + Err(err) => Err(trc::AuthEvent::Error.reason(err).details(hashed_secret)), + }) + .await? } else if hashed_secret.starts_with("$2") { // Blowfish crypt - Ok(bcrypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| bcrypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$6$") { // SHA-512 crypt - Ok(sha512_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| sha512_crypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$5$") { // SHA-256 crypt - Ok(sha256_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| sha256_crypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$sha1") { // SHA-1 crypt - Ok(sha1_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| sha1_crypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$1") { // MD5 based hash - Ok(md5_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| md5_crypt::verify(s, h), hashed_secret, secret).await } else { Err(trc::AuthEvent::Error .into_err() @@ -135,7 +168,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu verify_hash_prefix(hashed_secret, secret).await } else if hashed_secret.starts_with('_') { // Enhanced DES-based hash - Ok(bsdi_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| bsdi_crypt::verify(s, h), hashed_secret, secret).await } else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') { if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') { match algo.to_ascii_uppercase().as_str() { @@ -222,7 +255,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu verify_hash_prefix(hashed_secret, secret).await } else { // Unix crypt - Ok(unix_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| unix_crypt::verify(s, h), hashed_secret, secret).await } } "PLAIN" | "CLEAR" => Ok(hashed_secret.as_bytes() == secret), @@ -243,9 +276,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu } pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> trc::Result { - let (tx, rx) = oneshot::channel(); - - tokio::task::spawn_blocking(move || { + run_hasher(move || { let result = match algorithm { PasswordHashAlgorithm::Argon2id => { let hasher = Argon2::default(); @@ -254,14 +285,11 @@ pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> t .map(|h| h.to_string()) } PasswordHashAlgorithm::Bcrypt => { - return tx - .send(bcrypt::hash(secret.as_slice()).map_err(|err| { - trc::AuthEvent::Error - .reason(err) - .details("Bcrypt hash failed") - })) - .ok() - .unwrap_or(()); + return bcrypt::hash(secret.as_slice()).map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details("Bcrypt hash failed") + }); } PasswordHashAlgorithm::Scrypt => Scrypt::default() .hash_password(secret.as_slice()) @@ -271,20 +299,13 @@ pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> t .map(|h| h.to_string()), }; - tx.send(result.map_err(|err| { + result.map_err(|err| { trc::AuthEvent::Error .reason(err) .details("Password hash failed") - })) - .ok(); - }); - - match rx.await { - Ok(result) => result, - Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) - .caused_by(trc::location!()) - .reason(err)), - } + }) + }) + .await? } pub fn is_password_hash(s: &str) -> bool { @@ -447,6 +468,51 @@ mod tests { String::from_utf8(Base64Encoder::new().encode(bytes).unwrap()).unwrap() } + // inbuxa: a slow hash is checked off the async workers, so a flood of + // sign-ins can't stall every other connection served on them + #[tokio::test(flavor = "current_thread")] + #[allow(deprecated)] // old schemes, as imported accounts still have them + async fn slow_hashes_do_not_block_the_executor() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + let hashes = [ + bcrypt::hash_with("$2b$04$abcdefghijklmnopqrstuu", "secret").unwrap(), + sha512_crypt::hash("secret").unwrap(), + sha256_crypt::hash("secret").unwrap(), + sha1_crypt::hash("secret").unwrap(), + md5_crypt::hash("secret").unwrap(), + bsdi_crypt::hash("secret").unwrap(), + format!("{{CRYPT}}{}", unix_crypt::hash("secret").unwrap()), + Argon2::default() + .hash_password(b"secret") + .unwrap() + .to_string(), + ]; + + let ticks = Arc::new(AtomicUsize::new(0)); + let ticker = tokio::spawn({ + let ticks = ticks.clone(); + async move { + loop { + ticks.fetch_add(1, Ordering::Relaxed); + tokio::task::yield_now().await; + } + } + }); + for hash in &hashes { + let before = ticks.load(Ordering::Relaxed); + assert!(verify_secret_hash(hash, b"secret").await.unwrap(), "{hash}"); + assert!( + ticks.load(Ordering::Relaxed) > before, + "checking {hash} blocked the executor" + ); + } + ticker.abort(); + } + #[test] fn is_password_hash_detects_phc_strings() { let argon = Argon2::default() diff --git a/crates/email/src/message/delivery.rs b/crates/email/src/message/delivery.rs index 371e38ea..164a70be 100644 --- a/crates/email/src/message/delivery.rs +++ b/crates/email/src/message/delivery.rs @@ -24,10 +24,22 @@ use types::blob_hash::BlobHash; pub const ORCPT_ADDR_TYPE: &str = "rfc822;"; +// inbuxa: which sender identity of a delivered message has been verified +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VerifiedSender { + /// Neither the envelope sender nor the From header was verified + #[default] + None, + /// The From header domain passed DMARC + FromHeader, + /// The envelope sender signed in to submit the message + Envelope, +} + #[derive(Debug)] pub struct IngestMessage { pub sender_address: String, - pub sender_authenticated: bool, + pub verified_sender: VerifiedSender, pub recipients: Vec, pub message_blob: BlobHash, pub message_size: u64, @@ -266,7 +278,8 @@ impl MailDelivery for Server { received_at: None, source: IngestSource::Smtp { deliver_to: &rcpt.address, - is_sender_authenticated: message.sender_authenticated, + envelope_from: &message.sender_address, + verified_sender: message.verified_sender, is_spam: rcpt.is_spam(), }, session_id: message.session_id, @@ -279,7 +292,7 @@ impl MailDelivery for Server { message_blob, raw_message, &message.sender_address, - message.sender_authenticated, + message.verified_sender, &rcpt, message.session_id, active_script, diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs index 2d2bc6ce..68fa6b08 100644 --- a/crates/email/src/message/ingest.rs +++ b/crates/email/src/message/ingest.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::crypto::{EncryptMessage, EncryptMessageError}; @@ -10,6 +12,7 @@ use crate::{ mailbox::{INBOX_ID, JUNK_ID, SENT_ID, TRASH_ID, UidMailbox}, message::{ crypto::EncryptionFlags, + delivery::VerifiedSender, index::{IndexMessage, extractors::VisitText}, metadata::{MessageData, MessageMetadata}, }, @@ -80,7 +83,8 @@ pub struct IngestEmail<'x> { pub enum IngestSource<'x> { Smtp { deliver_to: &'x str, - is_sender_authenticated: bool, + envelope_from: &'x str, + verified_sender: VerifiedSender, is_spam: bool, }, Jmap { @@ -244,9 +248,23 @@ impl EmailIngest for Server { let is_spam = match params.source { IngestSource::Smtp { deliver_to, - is_sender_authenticated, + envelope_from, + verified_sender, mut is_spam, } => { + // inbuxa: a signed-in sender is known by its envelope address, + // which submission checks against the account; the From header + // is trusted only when it passed DMARC + let sender = match verified_sender { + VerifiedSender::Envelope => sanitize_email(envelope_from), + VerifiedSender::FromHeader => message + .from() + .and_then(|s| s.first()) + .and_then(|s| s.address()) + .and_then(sanitize_email), + VerifiedSender::None => None, + }; + // Add delivered to header if self.core.smtp.session.data.add_delivered_to { extra_headers = format!("Delivered-To: {deliver_to}\r\n"); @@ -266,13 +284,8 @@ impl EmailIngest for Server { // sender address is present in the user's address book. if is_spam && self.core.spam.card_is_ham - && let Some(sender) = message - .from() - .and_then(|s| s.first()) - .and_then(|s| s.address()) - .and_then(sanitize_email) + && let Some(sender) = &sender && sender != deliver_to - && is_sender_authenticated && self .document_exists( account_id, @@ -363,7 +376,7 @@ impl EmailIngest for Server { // iMIP processing if self.core.groupware.itip_enabled && !is_spam - && is_sender_authenticated + && let Some(sender) = &sender && params .access_token .has_permission(Permission::CalendarSchedulingReceive) @@ -372,7 +385,6 @@ impl EmailIngest for Server { .build_account_info(account.clone()) .await .caused_by(trc::location!())?; - let mut sender = None; for part in &message.parts { if part.content_type().is_some_and(|ct| { ct.ctype().eq_ignore_ascii_case("text") @@ -383,64 +395,43 @@ impl EmailIngest for Server { }) && let Some(itip_message) = part.text_contents() { if itip_message.len() < self.core.groupware.itip_inbound_max_ical_size { - if let Some(sender) = sender.get_or_insert_with(|| { - message - .from() - .and_then(|s| s.first()) - .and_then(|s| s.address()) - .and_then(sanitize_email) - }) { - match self - .itip_ingest( - &account_info, - sender, - deliver_to, - itip_message, - ) - .await - { - Ok(message) => { - if let Some(message) = message { - itip_messages.push(message); - } + match self + .itip_ingest(&account_info, sender, deliver_to, itip_message) + .await + { + Ok(message) => { + if let Some(message) = message { + itip_messages.push(message); + } + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageReceived), + SpanId = params.session_id, + From = sender.to_string(), + AccountId = account_id, + ); + } + Err(ItipIngestError::Message(itip_error)) => match itip_error { + ItipError::NothingToSend + | ItipError::OtherSchedulingAgent => (), + err => { trc::event!( - Calendar(trc::CalendarEvent::ItipMessageReceived), + Calendar(trc::CalendarEvent::ItipMessageError), SpanId = params.session_id, From = sender.to_string(), AccountId = account_id, - ); - } - Err(ItipIngestError::Message(itip_error)) => { - match itip_error { - ItipError::NothingToSend - | ItipError::OtherSchedulingAgent => (), - err => { - trc::event!( - Calendar( - trc::CalendarEvent::ItipMessageError - ), - SpanId = params.session_id, - From = sender.to_string(), - AccountId = account_id, - Details = err.to_string(), - ) - } - } - } - Err(ItipIngestError::Internal(err)) => { - trc::error!(err.caused_by(trc::location!())); + Details = err.to_string(), + ) } + }, + Err(ItipIngestError::Internal(err)) => { + trc::error!(err.caused_by(trc::location!())); } } } else { trc::event!( Calendar(trc::CalendarEvent::ItipMessageError), SpanId = params.session_id, - From = message - .from() - .and_then(|a| a.first()) - .and_then(|a| a.address()) - .map(|a| a.to_string()), + From = sender.to_string(), AccountId = account_id, Details = "iMIP message too large", Limit = self.core.groupware.itip_inbound_max_ical_size, @@ -758,6 +749,7 @@ impl EmailIngest for Server { let key_len = IndexKeyPrefix::len() + result.thread_hash.len() + U32_LEN; let document_id_pos = key_len - U32_LEN; let mut thread_merge = ThreadMerge::new(); + let mut candidates_left = THREAD_SCAN_LIMIT; self.store() .iterate( IterateParams::new( @@ -780,9 +772,16 @@ impl EmailIngest for Server { }), }, ) - .ascending(), + .descending(), |key, value| { if key.len() == key_len { + // inbuxa: only the newest messages with this thread name + // are candidates + if candidates_left == 0 { + return Ok(false); + } + candidates_left -= 1; + // Find matching references let references = value.get(U32_LEN..).unwrap_or_default(); @@ -972,6 +971,12 @@ impl EmailIngest for Server { } } +// inbuxa: how many of the newest messages sharing a thread name (or sharing +// none) a new message is compared with. Every delivery used to read all of +// them, so mail sent with one subject over and over made each delivery to that +// account slower than the last. +pub const THREAD_SCAN_LIMIT: usize = 512; + pub fn has_message_id(a: &[CheekyHash], b: &[u8]) -> bool { let mut i = 0; let mut j = 0; diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index 0980898a..a931c3df 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -11,7 +11,7 @@ use crate::{ cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, mailbox::{INBOX_ID, TRASH_ID, manage::MailboxFnc}, message::{ - delivery::{AutogeneratedMessage, IngestRecipient}, + delivery::{AutogeneratedMessage, IngestRecipient, VerifiedSender}, ingest::{EmailIngest, IngestEmail, IngestSource, IngestedEmail}, }, }; @@ -57,7 +57,7 @@ pub trait SieveScriptIngest: Sync + Send { blob_hash: &BlobHash, raw_message: &[u8], envelope_from: &str, - envelope_from_authenticated: bool, + verified_sender: VerifiedSender, envelope_to: &IngestRecipient, session_id: u64, active_script: ActiveScript, @@ -95,7 +95,7 @@ impl SieveScriptIngest for Server { blob_hash: &BlobHash, raw_message: &[u8], envelope_from: &str, - envelope_from_authenticated: bool, + verified_sender: VerifiedSender, envelope_to: &IngestRecipient, session_id: u64, active_script: ActiveScript, @@ -587,7 +587,8 @@ impl SieveScriptIngest for Server { received_at: None, source: IngestSource::Smtp { deliver_to: envelope_to.address.as_str(), - is_sender_authenticated: envelope_from_authenticated, + envelope_from, + verified_sender, is_spam: envelope_to.is_spam() && !sieve_message.did_file_into, }, session_id, diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs index f9dbe14c..b8b598e1 100644 --- a/crates/groupware/src/cache/calcard.rs +++ b/crates/groupware/src/cache/calcard.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::GroupwareCache; @@ -9,7 +11,7 @@ use crate::{ DavResourceName, RFC_3986, calendar::{ ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent, SCHEDULE_INBOX_ID, - SCHEDULE_OUTBOX_ID, storage::ItipAutoExpunge, + SCHEDULE_OUTBOX_ID, privacy::archived_event_view, storage::ItipAutoExpunge, }, contact::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}, encode_path_segment, @@ -319,6 +321,7 @@ pub(super) fn resource_from_event(event: &ArchivedCalendarEvent, document_id: u3 .collect(), start, duration, + view: archived_event_view(event), }, } } diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs index 301c7630..55c74e6b 100644 --- a/crates/groupware/src/calendar/itip.rs +++ b/crates/groupware/src/calendar/itip.rs @@ -11,7 +11,7 @@ use crate::{ cache::GroupwareCache, calendar::{ CalendarEvent, CalendarEventData, CalendarEventNotification, ChangedBy, - EVENT_HIDE_ATTENDEES, EVENT_NOTIFICATION_IS_CHANGE, + EVENT_HIDE_ATTENDEES, EVENT_NOTIFICATION_IS_CHANGE, privacy::itip_messages_for, }, scheduling::{ InstanceId, ItipError, ItipMessage, ItipSnapshots, @@ -210,6 +210,7 @@ impl ItipIngest for Server { &itip, itip_snapshots, sender.to_string(), + self.core.groupware.max_ical_instances, )? { MergeResult::Actions(changes) => { commit_itip_merge( @@ -229,7 +230,11 @@ impl ItipIngest for Server { Ok(None) } - MergeResult::Message(itip_message) => Ok(Some(itip_message)), + MergeResult::Message(itip_message) => { + Ok(itip_messages_for(vec![itip_message], event.flags) + .into_iter() + .next()) + } MergeResult::None => Ok(None), } } else { @@ -488,6 +493,7 @@ impl ItipIngest for Server { &reply, reply_snapshots, rsvp.attendee.clone(), + self.core.groupware.max_ical_instances, ) }); let changes = match merge { diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index 424689b1..c61074b2 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ pub mod alarm; @@ -9,6 +11,7 @@ pub mod dates; pub mod expand; pub mod index; pub mod itip; +pub mod privacy; pub mod storage; use calcard::icalendar::{ @@ -437,3 +440,81 @@ impl From for ICalendarComponentType { } } } + +// inbuxa: how deep components may nest below the VCALENDAR when an event is +// converted to JSCalendar. Components the converter doesn't know (X- ones, for +// example) are carried along as nested jCal, and nesting tens of thousands +// deep built a value that overflowed the stack when it was dropped or +// serialized, which aborted the server. +pub const MAX_COMPONENT_DEPTH: usize = 32; + +/// Drops whatever is nested below `MAX_COMPONENT_DEPTH`, returning whether +/// anything was. +pub fn limit_component_depth(ical: &mut ICalendar) -> bool { + let mut level = vec![0u32]; + for _ in 0..MAX_COMPONENT_DEPTH { + level = level + .iter() + .filter_map(|id| ical.components.get(*id as usize)) + .flat_map(|component| component.component_ids.iter().copied()) + .collect(); + if level.is_empty() { + return false; + } + } + + let mut truncated = false; + for id in level { + if let Some(component) = ical.components.get_mut(id as usize) + && !component.component_ids.is_empty() + { + component.component_ids.clear(); + truncated = true; + } + } + truncated +} + +#[cfg(test)] +mod tests { + use super::{MAX_COMPONENT_DEPTH, limit_component_depth}; + use calcard::{icalendar::ICalendar, jscalendar::import::ConversionOptions}; + + fn nested(depth: usize) -> String { + let mut ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:deep\r\n".to_string(); + ical.push_str(&"BEGIN:X-DEEP\r\n".repeat(depth)); + ical.push_str(&"END:X-DEEP\r\n".repeat(depth)); + ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n"); + ical + } + + #[test] + fn deep_components_are_cut_before_conversion() { + let shallow = nested(4); + let mut ical = ICalendar::parse(&shallow).unwrap(); + assert!(!limit_component_depth(&mut ical)); + assert_eq!(ical, ICalendar::parse(&shallow).unwrap()); + + // Converting and dropping the result runs on a small stack, as the + // server's workers do + let deep = nested(200_000); + let jcal = std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut ical = ICalendar::parse(&deep).unwrap(); + assert!(limit_component_depth(&mut ical)); + let jcal = serde_json::to_string( + &ical.into_jscalendar_with_opt::(ConversionOptions::default()), + ) + .unwrap(); + jcal.matches("x-deep").count() + }) + .unwrap() + .join() + .unwrap(); + assert!( + (1..=MAX_COMPONENT_DEPTH).contains(&jcal), + "{jcal} levels kept" + ); + } +} diff --git a/crates/groupware/src/calendar/privacy.rs b/crates/groupware/src/calendar/privacy.rs new file mode 100644 index 00000000..cafac743 --- /dev/null +++ b/crates/groupware/src/calendar/privacy.rs @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What a sharee of a calendar may see of an event: the time only of a +//! private or confidential one, and only the owners and themselves among the +//! attendees of an event that hides them. + +use super::{ArchivedCalendarEvent, CalendarEvent, EVENT_HIDE_ATTENDEES}; +use crate::scheduling::{ItipField, ItipMessage, ItipSummary, ItipValue}; +use calcard::icalendar::{ + ArchivedICalendarClassification, ArchivedICalendarProperty, ArchivedICalendarValue, ICalendar, + ICalendarClassification, ICalendarComponentType, ICalendarParameterName, + ICalendarParameterValue, ICalendarParticipationRole, ICalendarProperty, ICalendarValue, +}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE}; + +pub fn event_view(event: &CalendarEvent) -> u8 { + let mut view = 0; + for entry in event + .data + .event + .components + .iter() + .filter(|comp| comp.component_type.is_scheduling_object()) + .flat_map(|comp| comp.entries.iter()) + .filter(|entry| entry.name == ICalendarProperty::Class) + { + view |= match entry.values.first() { + Some(ICalendarValue::Classification(ICalendarClassification::Public)) | None => 0, + // Confidential (what CalDAV clients use for "time only") and an + // unknown class count as private + _ => EVENT_VIEW_PRIVATE, + }; + } + if event.flags & EVENT_HIDE_ATTENDEES != 0 { + view |= EVENT_VIEW_HIDE_ATTENDEES; + } + view +} + +pub fn archived_event_view(event: &ArchivedCalendarEvent) -> u8 { + let mut view = 0; + for entry in event + .data + .event + .components + .iter() + .filter(|comp| comp.component_type.is_scheduling_object()) + .flat_map(|comp| comp.entries.iter()) + .filter(|entry| matches!(entry.name, ArchivedICalendarProperty::Class)) + { + view |= match entry.values.first() { + Some(ArchivedICalendarValue::Classification( + ArchivedICalendarClassification::Public, + )) + | None => 0, + _ => EVENT_VIEW_PRIVATE, + }; + } + if event.flags.to_native() & EVENT_HIDE_ATTENDEES != 0 { + view |= EVENT_VIEW_HIDE_ATTENDEES; + } + view +} + +/// Keeps only the time and basic metadata of a private or confidential event +/// (RFC 8984, section 4.4.3), with its recurrence so its occurrences stay right. +pub fn redact_private_event(ical: &mut ICalendar) { + let mut remove_ids = Vec::new(); + for (comp_id, comp) in ical.components.iter_mut().enumerate() { + match comp.component_type { + ICalendarComponentType::VCalendar => { + comp.entries.retain(|entry| { + matches!( + entry.name, + ICalendarProperty::Prodid + | ICalendarProperty::Version + | ICalendarProperty::Calscale + ) + }); + } + ICalendarComponentType::VTimezone + | ICalendarComponentType::Standard + | ICalendarComponentType::Daylight => {} + ref component_type if component_type.is_scheduling_object() => { + comp.entries.retain(|entry| { + matches!( + entry.name, + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Rrule + | ICalendarProperty::Rdate + | ICalendarProperty::Exdate + | ICalendarProperty::Exrule + | ICalendarProperty::RecurrenceId + | ICalendarProperty::Uid + | ICalendarProperty::Sequence + | ICalendarProperty::Dtstamp + | ICalendarProperty::Created + | ICalendarProperty::LastModified + | ICalendarProperty::Class + | ICalendarProperty::Transp + ) + }); + for entry in &mut comp.entries { + entry.params.retain(|param| { + matches!( + param.name, + ICalendarParameterName::Tzid + | ICalendarParameterName::Value + | ICalendarParameterName::Range + ) + }); + } + } + _ => remove_ids.push(comp_id as u32), + } + } + if !remove_ids.is_empty() { + ical.remove_component_ids(&remove_ids); + } +} + +/// Keeps only the attendees with the owner role and those at `addresses` +pub fn hide_attendees(ical: &mut ICalendar, addresses: &[String]) { + for component in &mut ical.components { + if component.component_type.is_scheduling_object() { + component.entries.retain(|entry| match &entry.name { + ICalendarProperty::Attendee => { + entry.parameters(&ICalendarParameterName::Role).any(|role| { + matches!( + role, + ICalendarParameterValue::Role(ICalendarParticipationRole::Owner,) + ) + }) || entry + .calendar_address() + .is_some_and(|addr| addresses.iter().any(|a| a.eq_ignore_ascii_case(addr))) + } + _ => true, + }); + } + } +} + +/// An organizer's scheduling messages for an event that hides its attendees +/// go to each recipient on its own and name no attendee but the organizer, +/// the owners and that recipient. +pub fn itip_hide_attendees(messages: Vec>) -> Vec> { + let mut result = Vec::with_capacity(messages.len()); + for message in messages { + if !message.from_organizer { + result.push(message); + continue; + } + for rcpt in &message.to { + let mut ical = message.message.clone(); + for component in &mut ical.components { + if component.component_type.is_scheduling_object() { + let organizer = component + .property(&ICalendarProperty::Organizer) + .and_then(|entry| entry.calendar_address()) + .map(|addr| addr.to_string()); + component.entries.retain(|entry| { + entry.name != ICalendarProperty::Attendee + || entry.parameters(&ICalendarParameterName::Role).any(|role| { + matches!( + role, + ICalendarParameterValue::Role( + ICalendarParticipationRole::Owner + ) + ) + }) + || entry.calendar_address().is_some_and(|addr| { + addr.eq_ignore_ascii_case(rcpt) + || organizer.as_deref().is_some_and(|organizer| { + addr.eq_ignore_ascii_case(organizer) + }) + }) + }); + } + } + let mut summary = message.summary.clone(); + match &mut summary { + ItipSummary::Invite(fields) + | ItipSummary::Cancel(fields) + | ItipSummary::Rsvp { + current: fields, .. + } => hide_summary_attendees(fields, rcpt), + ItipSummary::Update { + current, previous, .. + } => { + hide_summary_attendees(current, rcpt); + hide_summary_attendees(previous, rcpt); + } + } + result.push(ItipMessage { + from: message.from.clone(), + from_organizer: true, + to: vec![rcpt.clone()], + summary, + message: ical, + }); + } + } + result +} + +fn hide_summary_attendees(fields: &mut [ItipField], rcpt: &str) { + for field in fields { + if let ItipValue::Participants(participants) = &mut field.value { + participants.retain(|participant| { + participant.is_organizer || participant.email.eq_ignore_ascii_case(rcpt) + }); + } + } +} + +/// The scheduling messages of an event with these flags as they may be sent +pub fn itip_messages_for( + messages: Vec>, + event_flags: u16, +) -> Vec> { + if event_flags & EVENT_HIDE_ATTENDEES != 0 { + itip_hide_attendees(messages) + } else { + messages + } +} diff --git a/crates/groupware/src/calendar/storage.rs b/crates/groupware/src/calendar/storage.rs index 6b2bb104..ed8da790 100644 --- a/crates/groupware/src/calendar/storage.rs +++ b/crates/groupware/src/calendar/storage.rs @@ -14,6 +14,7 @@ use crate::{ DavResourceName, DestroyArchive, RFC_3986, calendar::{ ArchivedCalendarEventNotification, CalendarEventNotification, alarm::CalendarAlarmType, + privacy::itip_messages_for, }, scheduling::{ItipMessages, event_cancel::itip_cancel}, }; @@ -495,7 +496,7 @@ impl DestroyArchive> { .caused_by(trc::location!())?; if let Ok(messages) = itip_cancel(&event.data.event, account_info.addresses(), true) { - ItipMessages::new(vec![messages]) + ItipMessages::new(itip_messages_for(vec![messages], event.flags)) .queue(batch) .caused_by(trc::location!())?; } diff --git a/crates/groupware/src/scheduling/inbound.rs b/crates/groupware/src/scheduling/inbound.rs index 175d3829..e688ef5e 100644 --- a/crates/groupware/src/scheduling/inbound.rs +++ b/crates/groupware/src/scheduling/inbound.rs @@ -6,14 +6,21 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::scheduling::{ - InstanceId, ItipError, ItipMessage, ItipSnapshots, organizer::organizer_request_full, +use crate::{ + calendar::dates::has_oversized_rrule, + scheduling::{ + Attendee, InstanceId, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, + organizer::organizer_request_full, + }, }; use ahash::AHashSet; -use calcard::icalendar::{ - ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, - ICalendarParameter, ICalendarParameterName, ICalendarProperty, ICalendarStatus, ICalendarValue, - Uri, +use calcard::{ + common::timezone::Tz, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParameterName, ICalendarProperty, ICalendarStatus, + ICalendarValue, Uri, + }, }; #[derive(Debug)] @@ -56,6 +63,7 @@ pub fn itip_process_message( itip: &ICalendar, itip_snapshots: ItipSnapshots<'_>, sender: String, + max_instances: usize, ) -> Result { if snapshots.organizer.email != itip_snapshots.organizer.email { return Err(ItipError::OrganizerMismatch); @@ -71,9 +79,24 @@ pub fn itip_process_message( } match method { ICalendarMethod::Reply => { - handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?; + handle_reply( + ical, + &snapshots, + &itip_snapshots, + &sender, + max_instances, + &mut merge_actions, + )?; } ICalendarMethod::Refresh => { + // inbuxa: only a current attendee may ask for the event again + if !snapshots + .components + .values() + .any(|snapshot| snapshot.attendee_by_email(&sender).is_some()) + { + return Err(ItipError::SenderIsNotParticipant(sender)); + } return organizer_request_full(ical, &snapshots, None, false).and_then( |messages| { messages @@ -315,7 +338,14 @@ pub fn itip_process_message( }) }) => { - handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?; + handle_reply( + ical, + &snapshots, + &itip_snapshots, + &sender, + max_instances, + &mut merge_actions, + )?; } _ => return Err(ItipError::UnsupportedMethod(method.clone())), } @@ -351,11 +381,15 @@ pub fn itip_import_message(ical: &mut ICalendar) -> Result<(), ItipError> { } fn handle_reply( + ical: &ICalendar, snapshots: &ItipSnapshots<'_>, itip_snapshots: &ItipSnapshots<'_>, sender: &str, + max_instances: usize, merge_actions: &mut Vec, ) -> Result<(), ItipError> { + let mut occurrences = None; + for (instance_id, itip_snapshot) in &itip_snapshots.components { if let Some(snapshot) = snapshots.components.get(instance_id) { if let (Some(attendee), Some(updated_attendee)) = ( @@ -370,44 +404,8 @@ fn handle_reply( if changed_part_stat || changed_rsvp || changed_delegated_to || has_request_status { // Update participant status - let mut add_parameters = Vec::new(); - let mut remove_parameters = Vec::new(); - if changed_part_stat { - remove_parameters.push(ICalendarParameterName::Partstat); - if let Some(part_stat) = updated_attendee.part_stat { - add_parameters.push(ICalendarParameter::partstat(part_stat.clone())); - } - } - - if changed_rsvp { - remove_parameters.push(ICalendarParameterName::Rsvp); - if let Some(rsvp) = updated_attendee.rsvp { - add_parameters.push(ICalendarParameter::rsvp(rsvp)); - } - } - - if changed_delegated_to { - remove_parameters.push(ICalendarParameterName::DelegatedTo); - if !updated_attendee.delegated_to.is_empty() { - add_parameters.extend(updated_attendee.delegated_to.iter().map( - |email| { - ICalendarParameter::delegated_to(Uri::Location( - email.to_string(), - )) - }, - )); - } - } - - // RFC 6638 3.2.5: the status defaults to 2.0 when the reply carries none - remove_parameters.push(ICalendarParameterName::ScheduleStatus); - add_parameters.push(ICalendarParameter::schedule_status( - if has_request_status { - itip_snapshot.request_status.join(",") - } else { - "2.0".to_string() - }, - )); + let (remove_parameters, add_parameters) = + reply_parameters(attendee, updated_attendee, itip_snapshot); merge_actions.push(MergeAction::RemoveParameters { component_id: snapshot.comp_id, @@ -506,35 +504,81 @@ fn handle_reply( } else { return Err(ItipError::SenderIsNotParticipant(sender.to_string())); } - } else if itip_snapshot.attendee_by_email(sender).is_some() { - // Add component + } else if let (InstanceId::Recurrence(recurrence_id), Some(updated_attendee)) = + (instance_id, itip_snapshot.attendee_by_email(sender)) + { + // inbuxa: an answer for an instance the organizer hasn't changed must + // come from an attendee of the series and name one of its dates. The + // new instance keeps the organizer's own ORGANIZER, UID and SEQUENCE + // and takes only the sender's answer from the reply. + let Some(series) = snapshots.components.get(&InstanceId::Main) else { + continue; + }; + let Some(attendee) = series.attendee_by_email(sender) else { + return Err(ItipError::SenderIsNotParticipant(sender.to_string())); + }; + if !occurrences + .get_or_insert_with(|| series_occurrences(ical, series.comp_id, max_instances)) + .contains(&recurrence_id.date) + { + continue; + } + let itip_component = itip_snapshot.comp; let is_todo = itip_component.component_type == ICalendarComponentType::VTodo; + let (remove_parameters, add_parameters) = + reply_parameters(attendee, updated_attendee, itip_snapshot); + let mut attendee_entry = series.comp.entries[attendee.entry_id as usize].clone(); + attendee_entry + .params + .retain(|param| !remove_parameters.contains(¶m.name)); + attendee_entry.params.extend(add_parameters); + + let mut entries = series + .comp + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Organizer + | ICalendarProperty::Uid + | ICalendarProperty::Sequence + ) + }) + .cloned() + .chain([attendee_entry]) + .collect::>(); + entries.extend( + itip_component + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Dtstamp | ICalendarProperty::RecurrenceId + ) || (is_todo + && matches!( + entry.name, + ICalendarProperty::PercentComplete + | ICalendarProperty::Status + | ICalendarProperty::Completed + )) + }) + .cloned(), + ); + + // Add the attendees the sender delegated to + for delegated_to in &updated_attendee.delegated_to { + if let Some(itip_delegated) = itip_snapshot.attendee_by_email(&delegated_to.email) { + entries.push(itip_component.entries[itip_delegated.entry_id as usize].clone()); + } + } + merge_actions.push(MergeAction::AddComponent { component: ICalendarComponent { component_type: itip_component.component_type.clone(), - entries: itip_component - .entries - .iter() - .filter(|entry| { - matches!( - entry.name, - ICalendarProperty::Organizer - | ICalendarProperty::Attendee - | ICalendarProperty::Uid - | ICalendarProperty::Dtstamp - | ICalendarProperty::Sequence - | ICalendarProperty::RecurrenceId - ) || (is_todo - && matches!( - entry.name, - ICalendarProperty::PercentComplete - | ICalendarProperty::Status - | ICalendarProperty::Completed - )) - }) - .cloned() - .collect(), + entries, component_ids: vec![], }, }); @@ -546,6 +590,78 @@ fn handle_reply( Ok(()) } +fn reply_parameters( + attendee: &Attendee<'_>, + updated_attendee: &Attendee<'_>, + itip_snapshot: &ItipSnapshot<'_>, +) -> (Vec, Vec) { + let mut add_parameters = Vec::new(); + let mut remove_parameters = Vec::new(); + if attendee.part_stat != updated_attendee.part_stat { + remove_parameters.push(ICalendarParameterName::Partstat); + if let Some(part_stat) = updated_attendee.part_stat { + add_parameters.push(ICalendarParameter::partstat(part_stat.clone())); + } + } + + if attendee.rsvp != updated_attendee.rsvp { + remove_parameters.push(ICalendarParameterName::Rsvp); + if let Some(rsvp) = updated_attendee.rsvp { + add_parameters.push(ICalendarParameter::rsvp(rsvp)); + } + } + + if attendee.delegated_to != updated_attendee.delegated_to { + remove_parameters.push(ICalendarParameterName::DelegatedTo); + if !updated_attendee.delegated_to.is_empty() { + add_parameters.extend( + updated_attendee.delegated_to.iter().map(|email| { + ICalendarParameter::delegated_to(Uri::Location(email.to_string())) + }), + ); + } + } + + // RFC 6638 3.2.5: the status defaults to 2.0 when the reply carries none + remove_parameters.push(ICalendarParameterName::ScheduleStatus); + add_parameters.push(ICalendarParameter::schedule_status( + if !itip_snapshot.request_status.is_empty() { + itip_snapshot.request_status.join(",") + } else { + "2.0".to_string() + }, + )); + + (remove_parameters, add_parameters) +} + +// The start times of a series on its own, without the instances that override it +fn series_occurrences(ical: &ICalendar, main_comp_id: u16, max_instances: usize) -> AHashSet { + let mut series = ical.clone(); + let overrides = series + .components + .iter() + .enumerate() + .filter(|(comp_id, comp)| { + comp.component_type.is_scheduling_object() && *comp_id != main_comp_id as usize + }) + .map(|(comp_id, _)| comp_id as u32) + .collect::>(); + if !overrides.is_empty() { + series.remove_component_ids(&overrides); + } + if has_oversized_rrule(&series) { + return AHashSet::new(); + } + + series + .expand_dates(Tz::Floating, max_instances) + .events + .into_iter() + .map(|event| event.start.timestamp()) + .collect() +} + pub fn itip_merge_changes(ical: &mut ICalendar, itip: &ICalendar, changes: Vec) { let mut remove_component_ids: Vec = Vec::new(); for action in changes { diff --git a/crates/http/src/auth/authenticate.rs b/crates/http/src/auth/authenticate.rs index e15d3971..48047d4a 100644 --- a/crates/http/src/auth/authenticate.rs +++ b/crates/http/src/auth/authenticate.rs @@ -13,7 +13,7 @@ use http_proto::{HttpRequest, HttpSessionData}; use hyper::header; use mail_parser::decoders::base64::base64_decode; use std::future::Future; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub trait Authenticator: Sync + Send { fn authenticate_headers( @@ -40,7 +40,8 @@ impl Authenticator for Server { session.remote_ip, )? // inbuxa: AU-5 - .with_origin_arc(http_cache.origin.clone()); + .with_origin_arc(http_cache.origin.clone()) + .with_expires_at(http_cache.expires_at); if access_token.revision() == http_cache.revision { // Enforce authenticated rate limit @@ -95,17 +96,29 @@ impl Authenticator for Server { .await?; // Cache credentials - self.inner.cache.http_auth.insert( - token.into(), - HttpAuthCache { - account_id: access_token.account_id(), - revision: access_token.revision(), - credential_id: access_token.credential_id(), - expires: Instant::now() - + Duration::from_secs(self.core.oauth.oauth_expiry_token), - origin: access_token.origin_arc(), - }, - ); + // inbuxa: a bearer token's sign-in is remembered no longer than + // the token is valid + let lifetime = Duration::from_secs(self.core.oauth.oauth_expiry_token); + let lifetime = match access_token.expires_at() { + Some(expires_at) => (UNIX_EPOCH + Duration::from_secs(expires_at)) + .duration_since(SystemTime::now()) + .unwrap_or_default() + .min(lifetime), + None => lifetime, + }; + if !lifetime.is_zero() { + self.inner.cache.http_auth.insert( + token.into(), + HttpAuthCache { + account_id: access_token.account_id(), + revision: access_token.revision(), + credential_id: access_token.credential_id(), + expires: Instant::now() + lifetime, + origin: access_token.origin_arc(), + expires_at: access_token.expires_at(), + }, + ); + } // Enforce authenticated rate limit self.is_http_authenticated_request_allowed(&access_token, session.remote_ip) diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index be57fb9a..c2c64e3a 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -224,17 +224,6 @@ impl TokenHandler for Server { } } else if grant_type.eq_ignore_ascii_case("refresh_token") { if let Some(refresh_token) = params.get("refresh_token") { - if let Some(client_id) = client_id_cred.as_deref() - && let Some(error) = self - .verify_client_secret(client_id, client_secret_cred.as_deref()) - .await? - { - return Ok(JsonResponse::with_status( - StatusCode::BAD_REQUEST, - TokenResponse::error(error), - ) - .into_http_response()); - } response = match self .validate_access_token(GrantType::RefreshToken.into(), refresh_token) .await @@ -248,11 +237,43 @@ impl TokenHandler for Server { { TokenResponse::error(ErrorType::InvalidGrant) } - Ok(token_info) => self - .issue_token( + Ok(token_info) => { + // inbuxa: the refresh token names the client it was + // issued to (AU-5). Only that client redeems it, and a + // confidential one authenticates to do so; naming no + // client stands for the token's own. A token from + // before tokens named their client goes to the client + // that authenticates now. + let issued_to = token_info + .claims + .as_deref() + .filter(|client_id| !client_id.is_empty()); + let client_id = match (issued_to, client_id_cred.as_deref()) { + (Some(issued_to), Some(client_id)) if issued_to != client_id => { + return Ok(JsonResponse::with_status( + StatusCode::BAD_REQUEST, + TokenResponse::error(ErrorType::InvalidGrant), + ) + .into_http_response()); + } + (Some(client_id), _) | (None, Some(client_id)) => client_id, + (None, None) => "", + }; + if !client_id.is_empty() + && let Some(error) = self + .verify_client_secret(client_id, client_secret_cred.as_deref()) + .await? + { + return Ok(JsonResponse::with_status( + StatusCode::BAD_REQUEST, + TokenResponse::error(error), + ) + .into_http_response()); + } + + self.issue_token( token_info.account_id, - // inbuxa: AU-5: the client travels in the refresh token - token_info.claims.as_deref().unwrap_or_default(), + client_id, issuer, None, None, @@ -267,7 +288,8 @@ impl TokenHandler for Server { .into_err() .details(err) .caused_by(trc::location!()) - })?, + })? + } Err(err) => { trc::error!( err.caused_by(trc::location!()) diff --git a/crates/http/src/form/mod.rs b/crates/http/src/form/mod.rs index d727dfe9..cb5754a5 100644 --- a/crates/http/src/form/mod.rs +++ b/crates/http/src/form/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::auth::oauth::FormData; @@ -12,7 +14,9 @@ use common::{ network::ip_to_bytes, psl, }; -use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; +use email::message::delivery::{ + IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery, VerifiedSender, +}; use http_proto::*; use hyper::StatusCode; use mail_auth::common::cache::NoCache; @@ -176,7 +180,7 @@ impl FormHandler for Server { for result in self .deliver_message(IngestMessage { sender_address: from_email, - sender_authenticated: false, + verified_sender: VerifiedSender::None, recipients: form .rcpt_to .iter() diff --git a/crates/imap-proto/src/protocol/fetch.rs b/crates/imap-proto/src/protocol/fetch.rs index 8058b039..faff38ac 100644 --- a/crates/imap-proto/src/protocol/fetch.rs +++ b/crates/imap-proto/src/protocol/fetch.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ @@ -788,7 +790,7 @@ impl DataItem<'_> { } DataItem::Flags { flags } => { buf.extend_from_slice(b"FLAGS ("); - for (pos, flag) in flags.iter().enumerate() { + for (pos, flag) in flags.iter().filter(|f| f.is_serializable()).enumerate() { if pos > 0 { buf.push(b' '); } diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs index a31c4afe..399027f4 100644 --- a/crates/imap-proto/src/protocol/mod.rs +++ b/crates/imap-proto/src/protocol/mod.rs @@ -330,6 +330,18 @@ pub enum Flag { } impl Flag { + /// inbuxa: a keyword is only written to a response when it is an atom + /// (or a backslash and an atom), so a keyword stored through JMAP, Sieve + /// or an IMAP literal cannot break out of a FLAGS list + pub fn is_serializable(&self) -> bool { + match self { + Flag::Keyword(keyword) => { + Keyword::is_valid_name(keyword.strip_prefix('\\').unwrap_or(keyword)) + } + _ => true, + } + } + pub fn serialize(&self, buf: &mut Vec) { buf.extend_from_slice(match self { Flag::Seen => b"\\Seen", diff --git a/crates/imap-proto/src/receiver.rs b/crates/imap-proto/src/receiver.rs index 945003a5..1edd9bdb 100644 --- a/crates/imap-proto/src/receiver.rs +++ b/crates/imap-proto/src/receiver.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ResponseCode, ResponseType}; @@ -74,6 +76,11 @@ pub struct Receiver { const ARG_MAX_LEN: usize = 8000; +/// inbuxa: the largest request accepted before sign-in. Nothing sent before +/// sign-in (a login, a SASL response, a bearer token) comes near it, so the +/// configured request size only applies once the client is signed in. +pub const PRE_AUTH_MAX_REQUEST_SIZE: usize = 64 * 1024; + struct ArgumentBuffer { buf: Vec, } @@ -325,10 +332,10 @@ impl Receiver { ))); } self.state = State::LiteralSeek { size, non_sync }; + self.buf.clear(); if !oversize { self.buf.resize_buffer(size as usize); } - self.buf.clear(); } else { return Err(self.error_reset("Invalid empty literal.")); } @@ -422,8 +429,9 @@ impl ArgumentBuffer { } pub fn resize_buffer(&mut self, size: usize) { + // inbuxa: room for exactly the literal (the buffer is empty here) if self.buf.capacity() < size { - self.buf.reserve(size - self.buf.capacity()); + self.buf.reserve_exact(size - self.buf.len()); } } @@ -443,9 +451,9 @@ impl ArgumentBuffer { } pub fn take(&mut self) -> Vec { - let buf = self.buf.clone(); - self.buf.clear(); - buf + // inbuxa: hand the argument over instead of copying it, so a large + // literal is neither held twice nor kept for the rest of the session + std::mem::replace(&mut self.buf, Vec::with_capacity(10)) } #[inline(always)] @@ -1127,6 +1135,24 @@ mod tests { } } + #[test] + fn receiver_does_not_keep_literal_buffer() { + let mut receiver = Receiver::::with_max_request_size(2 * 1024 * 1024); + let literal = "A".repeat(1024 * 1024); + let frame = format!("a1 APPEND inbox {{{}+}}\r\n{literal}\r\n", literal.len()); + let request = receiver.parse(&mut frame.as_bytes().iter()).unwrap(); + let Some(Token::Argument(argument)) = request.tokens.last() else { + panic!("literal missing from {request:?}"); + }; + assert_eq!(argument, literal.as_bytes()); + assert!(argument.capacity() <= literal.len() + 16); + assert!( + receiver.buf.buf.capacity() < 1024, + "receiver kept a {} byte buffer after the literal", + receiver.buf.buf.capacity() + ); + } + #[test] fn receiver_discard_oversized_non_sync_literal() { for frames in [ diff --git a/crates/imap/src/core/client.rs b/crates/imap/src/core/client.rs index b0ef6ea1..b0c105fe 100644 --- a/crates/imap/src/core/client.rs +++ b/crates/imap/src/core/client.rs @@ -444,7 +444,24 @@ impl Session { | Command::Sort(_) | Command::Thread(_) | Command::UidBatches => match state { - State::Selected { mailbox, .. } => { + State::Selected { data, mailbox } => { + // inbuxa: a shared mailbox whose share was revoked while + // it was selected can only be closed + if !matches!( + request.command, + Command::Close | Command::Unselect | Command::Check + ) && !data + .is_mailbox_readable(&mailbox.id) + .await + .map_err(|err| err.id(request.tag.clone()))? + { + return Err(trc::ImapEvent::Error + .into_err() + .details("You no longer have access to this mailbox.") + .code(ResponseCode::NoPerm) + .id(request.tag)); + } + // RFC 9586 forbids message numbers once UIDONLY is enabled if self.is_uidonly && request.command.requires_uid() { Err(trc::ImapEvent::Error diff --git a/crates/imap/src/core/mailbox.rs b/crates/imap/src/core/mailbox.rs index 60836b90..a8e6c6af 100644 --- a/crates/imap/src/core/mailbox.rs +++ b/crates/imap/src/core/mailbox.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{Account, MailboxId, MailboxSync, Session, SessionData}; @@ -435,4 +437,21 @@ impl SessionData { .details("Mailbox no longer exists.") })?) } + + /// inbuxa: whether a selected mailbox can still be read. A mailbox in + /// another account stops being readable while selected when the share + /// is revoked (or the mailbox is deleted), and from then on none of its + /// messages, flags or counts are served. + pub async fn is_mailbox_readable(&self, mailbox: &MailboxId) -> trc::Result { + if mailbox.account_id == self.account_id { + return Ok(true); + } + match self + .check_mailbox_acl(mailbox.account_id, mailbox.mailbox_id, Acl::ReadItems) + .await + { + Err(err) if err.matches(trc::EventType::Imap(trc::ImapEvent::Error)) => Ok(false), + result => result, + } + } } diff --git a/crates/imap/src/core/session.rs b/crates/imap/src/core/session.rs index 3ba49489..e3ed12ae 100644 --- a/crates/imap/src/core/session.rs +++ b/crates/imap/src/core/session.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ImapSessionManager, Session, State}; @@ -15,7 +17,7 @@ use common::{ }; use imap_proto::{ protocol::{ProtocolVersion, SerializeResponse}, - receiver::Receiver, + receiver::{PRE_AUTH_MAX_REQUEST_SIZE, Receiver}, }; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -146,7 +148,14 @@ impl Session { let (stream_rx, stream_tx) = tokio::io::split(session.stream); Ok(Session { - receiver: Receiver::with_max_request_size(server.core.imap.max_request_size), + // inbuxa: the full request size only applies after sign-in + receiver: Receiver::with_max_request_size( + server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE), + ), version: ProtocolVersion::Rev1, state: State::NotAuthenticated { auth_failures: 0 }, is_tls, @@ -201,7 +210,16 @@ impl Session { let (stream_rx, stream_tx) = tokio::io::split(self.instance.tls_accept(stream, self.session_id).await?); let stream_tx = Arc::new(tokio::sync::Mutex::new(stream_tx)); - let receiver = Receiver::with_max_request_size(self.server.core.imap.max_request_size); + let receiver = + Receiver::with_max_request_size(if matches!(state, State::NotAuthenticated { .. }) { + self.server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE) + } else { + self.server.core.imap.max_request_size + }); Ok(Session { server: self.server, diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index 58c42b33..b540b266 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -15,7 +15,7 @@ use directory::Credentials; use imap_proto::{ Command, ResponseCode, StatusResponse, protocol::{authenticate::Mechanism, capability::Capability}, - receiver::{self, Request}, + receiver::{self, PRE_AUTH_MAX_REQUEST_SIZE, Request}, }; use mail_parser::decoders::base64::base64_decode; use registry::schema::enums::Permission; @@ -129,6 +129,8 @@ impl Session { .map_err(|err| err.id(tag.clone()))?, ), }; + // inbuxa: signed in, so the configured request size now applies + self.receiver.max_request_size = self.server.core.imap.max_request_size; self.write_bytes( StatusResponse::ok("Authentication successful") .with_code(ResponseCode::Capability { @@ -148,6 +150,12 @@ impl Session { pub async fn handle_unauthenticate(&mut self, request: Request) -> trc::Result<()> { self.state = State::NotAuthenticated { auth_failures: 0 }; + self.receiver.max_request_size = self + .server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE); self.is_condstore = false; self.is_qresync = false; self.is_utf8 = false; diff --git a/crates/imap/src/op/idle.rs b/crates/imap/src/op/idle.rs index a1a0eb7a..23b1b1b7 100644 --- a/crates/imap/src/op/idle.rs +++ b/crates/imap/src/op/idle.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -199,7 +201,14 @@ impl SessionData { // Fetch selected mailbox changes if check_emails { // Synchronize emails - if let Some(mailbox) = mailbox { + // inbuxa: nothing is reported for a shared mailbox that can no + // longer be read + if let Some(mailbox) = mailbox + && self + .is_mailbox_readable(&mailbox.id) + .await + .caused_by(trc::location!())? + { // Obtain changes since last sync let modseq = mailbox.state.lock().modseq; let new_state = self diff --git a/crates/imap/src/op/store.rs b/crates/imap/src/op/store.rs index 7491871b..56078301 100644 --- a/crates/imap/src/op/store.rs +++ b/crates/imap/src/op/store.rs @@ -14,6 +14,7 @@ use crate::{ use ahash::AHashSet; use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; use email::{ + cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, mailbox::TRASH_ID, message::{ingest::EmailIngest, metadata::MessageData}, }; @@ -126,14 +127,33 @@ impl SessionData { } // Verify that the user can modify messages in this mailbox. - if !self - .check_mailbox_acl( - mailbox.id.account_id, - mailbox.id.mailbox_id, - Acl::ModifyItems, - ) + // inbuxa: \Deleted needs the right to remove messages and every other + // flag the right to modify them (RFC 4314 "t" and "w"). Keywords + // belong to the message, so on a shared account the right has to + // hold on every mailbox the message is in, not only the selected one. + let access_token = self + .refresh_access_token() .await - .imap_ctx(&arguments.tag, trc::location!())? + .imap_ctx(&arguments.tag, trc::location!())?; + let shared_rights = if !access_token.is_member(account_id) { + let cache = self + .server + .get_cached_messages(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + Some(( + cache.shared_mailboxes(&access_token, Acl::ModifyItems), + cache.shared_mailboxes(&access_token, Acl::RemoveItems), + )) + } else { + None + }; + let is_replace = matches!(arguments.operation, Operation::Set); + let wants_modify = is_replace || arguments.keywords.iter().any(|f| *f != Flag::Deleted); + let wants_remove = is_replace || arguments.keywords.contains(&Flag::Deleted); + if let Some((modify_ids, remove_ids)) = &shared_rights + && !(wants_modify && modify_ids.contains(mailbox.id.mailbox_id) + || wants_remove && remove_ids.contains(mailbox.id.mailbox_id)) { return Err(trc::ImapEvent::Error .into_err() @@ -283,29 +303,55 @@ impl SessionData { .imap_ctx(response.tag.as_ref().unwrap(), trc::location!())?; let mut new_data = data.inner.to_builder(); + // inbuxa: the rights this message allows (see above); flags + // the user may not change are left as they are + let (may_modify, may_remove) = match &shared_rights { + Some((modify_ids, remove_ids)) => ( + new_data + .mailboxes + .iter() + .all(|m| modify_ids.contains(m.mailbox_id)), + new_data + .mailboxes + .iter() + .all(|m| remove_ids.contains(m.mailbox_id)), + ), + None => (true, true), + }; + let may_change = |keyword: &Keyword| { + if keyword == &Keyword::Deleted { + may_remove + } else { + may_modify + } + }; + // Apply changes - let mut seen_changed = false; match arguments.operation { - Operation::Set => { - seen_changed = set_keywords.contains(&Keyword::Seen) - != new_data.has_keyword(&Keyword::Seen); + Operation::Set if may_modify && may_remove => { new_data.set_keywords(set_keywords.clone()); } + Operation::Set => { + new_data.keywords.retain(|keyword| { + !may_change(keyword) || set_keywords.contains(keyword) + }); + for keyword in set_keywords.iter().filter(|k| may_change(k)) { + new_data.add_keyword(keyword.clone()); + } + } Operation::Add => { - for keyword in &set_keywords { - if new_data.add_keyword(keyword.clone()) && keyword == &Keyword::Seen { - seen_changed = true; - } + for keyword in set_keywords.iter().filter(|k| may_change(k)) { + new_data.add_keyword(keyword.clone()); } } Operation::Clear => { - for keyword in &set_keywords { - if new_data.remove_keyword(keyword) && keyword == &Keyword::Seen { - seen_changed = true; - } + for keyword in set_keywords.iter().filter(|k| may_change(k)) { + new_data.remove_keyword(keyword); } } } + let seen_changed = new_data.has_keyword(&Keyword::Seen) + != data.inner.keywords.iter().any(|k| k == &Keyword::Seen); if !new_data.has_keyword_changes(data.inner) { continue; diff --git a/crates/jmap-proto/src/method/get.rs b/crates/jmap-proto/src/method/get.rs index 942ac336..396213ec 100644 --- a/crates/jmap-proto/src/method/get.rs +++ b/crates/jmap-proto/src/method/get.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -10,6 +12,7 @@ use crate::{ MaybeInvalid, deserialize::{DeserializeArguments, deserialize_request}, reference::{MaybeIdReference, MaybeResultReference, ResultReference}, + unique_valid, }, types::state::State, }; @@ -99,20 +102,10 @@ impl Default for GetRequest { impl GetRequest { pub fn unwrap_properties(&mut self, default: &[T::Property]) -> Vec { if let Some(properties_) = self.properties.take().map(|p| p.unwrap()) { - let mut properties = Vec::with_capacity(properties_.len()); + let mut properties = unique_valid(properties_); let id_prop = T::ID_PROPERTY; - let mut has_id = false; - for prop in properties_ { - if let MaybeInvalid::Value(p) = prop { - if p == id_prop { - has_id = true; - } - properties.push(p); - } - } - - if !has_id { + if !properties.contains(&id_prop) { properties.push(id_prop); } diff --git a/crates/jmap-proto/src/object/email.rs b/crates/jmap-proto/src/object/email.rs index c4651efd..112f7735 100644 --- a/crates/jmap-proto/src/object/email.rs +++ b/crates/jmap-proto/src/object/email.rs @@ -114,7 +114,10 @@ impl Property for EmailProperty { let allow_patch = key.is_none(); if let Some(Key::Property(key)) = key { match key.patch_or_prop() { - EmailProperty::Keywords => EmailProperty::Keyword(Keyword::parse(value)).into(), + // inbuxa: a keyword that is not a valid name is not taken + EmailProperty::Keywords => Some(Keyword::parse(value)) + .filter(Keyword::is_valid) + .map(EmailProperty::Keyword), EmailProperty::MailboxIds => match parse_ref(value) { MaybeReference::Value(v) => Some(EmailProperty::IdValue(v)), MaybeReference::Reference(v) => Some(EmailProperty::IdReference(v)), diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 570a5e08..37c50211 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -42,7 +42,7 @@ use crate::{ request::{capability::CapabilityIds, reference::MaybeIdReference}, }; use jmap_tools::{Null, Value}; -use std::{collections::HashMap, fmt::Debug, str::FromStr}; +use std::{collections::HashMap, fmt::Debug, hash::Hash, str::FromStr}; use types::id::Id; use utils::map::vec_map::VecMap; @@ -342,6 +342,15 @@ pub trait IntoValid { fn into_valid(self) -> impl Iterator; } +// inbuxa: a property listed twice is returned once; a list repeating one +// (bodyValues, say) used to render it again for every repeat in every object +pub fn unique_valid(list: Vec>) -> Vec { + let mut seen = ahash::AHashSet::with_capacity(list.len()); + list.into_valid() + .filter(|item| seen.insert(item.clone())) + .collect() +} + impl IntoValid for Vec> { type Item = T; diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index a88882a1..f41d8532 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -6,7 +6,7 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use common::{Server, auth::AccessToken}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; use email::cache::MessageCacheFetch; use email::cache::email::MessageCacheAccess; use email::message::metadata::MessageMetadata; @@ -139,7 +139,16 @@ impl BlobDownload for Server { ) .await .caused_by(trc::location!())? - .shared_items(access_token, [Acl::ReadItems], true) + // inbuxa: a calendar event's blob holds all of + // it, so a sharee gets none of a private or + // confidential event or one that hides its + // attendees + .shared_events( + access_token, + [Acl::ReadItems], + true, + EVENT_VIEW_PRIVATE | EVENT_VIEW_HIDE_ATTENDEES, + ) .contains(*document_id), _ => false, } diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index a5932ae4..165d698a 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -11,8 +11,17 @@ use crate::{ changes::state::JmapCacheState, }; use calcard::jscalendar::JSCalendarProperty; -use common::{Server, auth::AccessToken}; -use groupware::{cache::GroupwareCache, calendar::CalendarEvent}; +use common::{ + EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken, + storage::quota::QuotaBudget, +}; +use groupware::{ + cache::GroupwareCache, + calendar::{ + CalendarEvent, limit_component_depth, + privacy::{event_view, hide_attendees}, + }, +}; use http_proto::HttpSessionData; use jmap_proto::{ error::set::SetError, @@ -92,11 +101,13 @@ impl JmapCalendarEventCopy for Server { ) .await .caused_by(trc::location!())?; - let from_calendar_event_ids = if access_token.reads_as_owner(from_account_id) { + let is_owner = access_token.reads_as_owner(from_account_id); + let from_calendar_event_ids = if is_owner { from_cache.document_ids(false).collect::() } else { from_cache.shared_items(access_token, [Acl::ReadItems], true) }; + let mut writable_ids = None; let can_add_calendars = if access_token.is_shared(account_id) { cache @@ -113,6 +124,9 @@ impl JmapCalendarEventCopy for Server { .account_info(access_token.account_id()) .await .caused_by(trc::location!())?; + // inbuxa: the account copied into is the one whose quota applies + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); // Prepare batch let mut batch = BatchBuilder::new(); @@ -161,9 +175,33 @@ impl JmapCalendarEventCopy for Server { continue; }; - let calendar_event = _calendar_event + let mut calendar_event = _calendar_event .deserialize::() .caused_by(trc::location!())?; + limit_component_depth(&mut calendar_event.data.event); + + // inbuxa: a sharee can't copy out what it may not see + if !is_owner { + let view = event_view(&calendar_event); + if view & EVENT_VIEW_PRIVATE != 0 { + response.not_created.append( + id, + SetError::forbidden().with_description( + "Private events can't be copied from a shared calendar.", + ), + ); + continue; + } + if view & EVENT_VIEW_HIDE_ATTENDEES != 0 + && !writable_ids + .get_or_insert_with(|| { + from_cache.shared_items(access_token, [Acl::ModifyItems], true) + }) + .contains(from_calendar_event_id) + { + hide_attendees(&mut calendar_event.data.event, account_info.addresses()); + } + } match self .create_calendar_event( @@ -172,6 +210,7 @@ impl JmapCalendarEventCopy for Server { access_token, account_id, &account_info, + &mut quota, false, &can_add_calendars, calendar_event.data.event.into_jscalendar(), diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index f866331f..ae646dc6 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -11,20 +11,22 @@ use calcard::{ common::{PartialDateTime, timezone::Tz}, icalendar::{ ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter, - ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationRole, - ICalendarProperty, ICalendarValue, + ICalendarParameterName, ICalendarProperty, ICalendarValue, }, jscalendar::{ JSCalendarDateTime, JSCalendarProperty, JSCalendarValue, import::ConversionOptions, }, }; use chrono::DateTime; -use common::{Server, auth::AccessToken}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; use groupware::{ cache::GroupwareCache, calendar::{ CalendarEvent, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF, - PREF_USE_DEFAULT_ALERTS, expand::CalendarEventExpansion, + PREF_USE_DEFAULT_ALERTS, + expand::CalendarEventExpansion, + limit_component_depth, + privacy::{event_view, hide_attendees, redact_private_event}, }, }; use jmap_proto::{ @@ -73,11 +75,13 @@ impl CalendarEventGet for Server { SyncCollection::Calendar, ) .await?; - let calendar_event_ids = if access_token.reads_as_owner(account_id) { + let is_owner = access_token.reads_as_owner(account_id); + let calendar_event_ids = if is_owner { cache.document_ids(false).collect::() } else { cache.shared_items(access_token, [Acl::ReadItems], true) }; + let mut writable_ids = None; let (mut ids, has_synthetic_ids) = if let Some(rr) = request.ids.take() { let rr = rr.unwrap(); if rr.len() > self.core.jmap.get_max_objects { @@ -234,6 +238,27 @@ impl CalendarEventGet for Server { .deserialize::() .caused_by(trc::location!())?; + // inbuxa: a sharee sees the time of a private or confidential event + // only, and only the owners and themselves among the attendees of an + // event that hides them, unless they may change every event in its + // calendar. + let mut reduce_participants = reduce_participants; + if !is_owner { + let view = event_view(&calendar_event); + if view & EVENT_VIEW_PRIVATE != 0 { + redact_private_event(&mut calendar_event.data.event); + } + if view & EVENT_VIEW_HIDE_ATTENDEES != 0 + && !writable_ids + .get_or_insert_with(|| { + cache.shared_items(access_token, [Acl::ModifyItems], true) + }) + .contains(document_id) + { + reduce_participants = true; + } + } + // Extract recurrence keys from synthetic ids let mut recurrence_keys = AHashSet::new(); let mut include_base_event = false; @@ -257,28 +282,10 @@ impl CalendarEventGet for Server { // Reduce participants if reduce_participants { - for component in &mut calendar_event.data.event.components { - if component.component_type.is_scheduling_object() { - component.entries.retain(|entry| match &entry.name { - ICalendarProperty::Attendee => { - entry.parameters(&ICalendarParameterName::Role).any(|role| { - matches!( - role, - ICalendarParameterValue::Role( - ICalendarParticipationRole::Owner, - ), - ) - }) || entry.calendar_address().is_some_and(|addr| { - current_account_info - .addresses() - .iter() - .any(|a| a.eq_ignore_ascii_case(addr)) - }) - } - _ => true, - }); - } - } + hide_attendees( + &mut calendar_event.data.event, + current_account_info.addresses(), + ); } // Expand synthetic ids @@ -491,7 +498,7 @@ impl CalendarEventGet for Server { results.push((Id::from(document_id), event, expansion)); } - for (id, ical, expansion) in results { + for (id, mut ical, expansion) in results { let is_origin = return_is_origin.as_ref().is_some_and(|account| { ical.components .iter() @@ -506,6 +513,7 @@ impl CalendarEventGet for Server { }) }); + limit_component_depth(&mut ical); let jscal = ical .into_jscalendar_with_opt::( ConversionOptions::default() diff --git a/crates/jmap/src/calendar_event/parse.rs b/crates/jmap/src/calendar_event/parse.rs index b6507aa0..0646947a 100644 --- a/crates/jmap/src/calendar_event/parse.rs +++ b/crates/jmap/src/calendar_event/parse.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::blob::download::BlobDownload; @@ -10,6 +12,7 @@ use calcard::{ jscalendar::{JSCalendarProperty, import::ConversionOptions}, }; use common::{Server, auth::AccessToken}; +use groupware::calendar::limit_component_depth; use jmap_proto::{ method::parse::{ParseRequest, ParseResponse}, object::calendar_event::CalendarEvent, @@ -58,11 +61,13 @@ impl CalendarEventParse for Server { continue; } }; - let Ok(vcard) = ICalendar::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default()) + let Ok(mut vcard) = + ICalendar::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default()) else { response.not_parsable.push(blob_id); continue; }; + limit_component_depth(&mut vcard); let mut js_calendar_entries = vcard .into_jscalendar_with_opt::(ConversionOptions::default()) .into_inner() diff --git a/crates/jmap/src/calendar_event/query.rs b/crates/jmap/src/calendar_event/query.rs index fc19452e..ab577d92 100644 --- a/crates/jmap/src/calendar_event/query.rs +++ b/crates/jmap/src/calendar_event/query.rs @@ -9,7 +9,7 @@ use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; use calcard::{common::timezone::Tz, jscalendar::JSCalendarDateTime}; use chrono::offset::TimeZone; -use common::{Server, auth::AccessToken}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; use groupware::{ cache::GroupwareCache, calendar::{CalendarEvent, expand::RecurrenceKey}, @@ -69,6 +69,8 @@ impl CalendarEventQuery for Server { .await?; let default_tz = request.arguments.time_zone.unwrap_or(Tz::UTC); let mut filter: Option = None; + let mut matches_details = false; + let mut matches_participants = false; // Extract from/to arguments for cond in &request.filter { @@ -84,6 +86,23 @@ impl CalendarEventQuery for Server { } for cond in std::mem::take(&mut request.filter) { + if let Filter::Property(cond) = &cond { + matches_details |= matches!( + cond, + CalendarEventFilter::Text(_) + | CalendarEventFilter::Title(_) + | CalendarEventFilter::Description(_) + | CalendarEventFilter::Location(_) + | CalendarEventFilter::Owner(_) + | CalendarEventFilter::Attendee(_) + ); + matches_participants |= matches!( + cond, + CalendarEventFilter::Text(_) + | CalendarEventFilter::Owner(_) + | CalendarEventFilter::Attendee(_) + ); + } match cond { Filter::Property(cond) => match cond { CalendarEventFilter::InCalendar(MaybeInvalid::Value(id)) => { @@ -243,6 +262,29 @@ impl CalendarEventQuery for Server { vec![] }; + // inbuxa: a sharee can't match on what it may not see: the details of a + // private or confidential event, or the attendees of one that hides + // them unless it may change every event in its calendar. + let mask = if access_token.reads_through_share(account_id) { + let mut mask = cache.shared_events( + access_token, + [Acl::ReadItems], + true, + if matches_details { + EVENT_VIEW_PRIVATE + } else { + 0 + }, + ); + if matches_participants { + mask -= cache.events_with_view(EVENT_VIEW_HIDE_ATTENDEES) + - cache.shared_items(access_token, [Acl::ModifyItems], true); + } + mask + } else { + cache.document_ids(false).collect() + }; + let results = self .search_store() .query_account( @@ -250,11 +292,7 @@ impl CalendarEventQuery for Server { .with_filters(filters) .with_comparators(comparators) .with_account_id(account_id) - .with_mask(if access_token.reads_through_share(account_id) { - cache.shared_items(access_token, [Acl::ReadItems], true) - } else { - cache.document_ids(false).collect() - }), + .with_mask(mask), ) .await?; diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs index ba994182..3e61c8fe 100644 --- a/crates/jmap/src/calendar_event/set.rs +++ b/crates/jmap/src/calendar_event/set.rs @@ -21,6 +21,7 @@ use chrono::DateTime; use common::{ DavName, DavResources, Server, auth::{AccessToken, AccountInfo}, + storage::quota::QuotaBudget, }; use groupware::{ DestroyArchive, @@ -32,6 +33,8 @@ use groupware::{ dates::has_oversized_rrule, expand::{CalendarEventExpansion, ComponentRecurrenceId, RecurrenceKey, resolve_local}, itip::ItipSendStatus, + limit_component_depth, + privacy::itip_messages_for, }, scheduling::{ ItipMessages, @@ -80,6 +83,7 @@ pub trait CalendarEventSet: Sync + Send { access_token: &AccessToken, account_id: u32, account_info: &AccountInfo, + quota: &mut QuotaBudget, send_scheduling_messages: bool, can_add_calendars: &Option, js_calendar_event: JSCalendar<'_, Id, BlobId>, @@ -106,6 +110,9 @@ impl CalendarEventSet for Server { .scheduling_account_info(access_token.account_id(), account_id) .await .caused_by(trc::location!())?; + // inbuxa: the account written into is the one whose quota applies + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? .with_state(cache.assert_state(false, &request.if_in_state)?); let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); @@ -139,6 +146,7 @@ impl CalendarEventSet for Server { access_token, account_id, &account_info, + &mut quota, send_scheduling_messages, &can_add_calendars, JSCalendar::default(), @@ -288,6 +296,7 @@ impl CalendarEventSet for Server { } } + limit_component_depth(&mut new_calendar_event.data.event); let mut js_calendar_group = std::mem::take(&mut new_calendar_event.data.event).into_jscalendar::(); @@ -498,7 +507,10 @@ impl CalendarEventSet for Server { } } - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = Some(ItipMessages::new(itip_messages_for( + messages, + new_calendar_event.flags, + ))); } else { update.fail( &mut response, @@ -554,20 +566,16 @@ impl CalendarEventSet for Server { } // Validate quota - let extra_bytes = (new_calendar_event.size as u64) - .saturating_sub(u32::from(calendar_event.inner.size) as u64); - if extra_bytes > 0 { - match self - .has_available_quota(account_info.account(), extra_bytes) - .await - { - Ok(_) => {} - Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { - update.fail(&mut response, SetError::over_quota()); - continue 'update; - } - Err(err) => return Err(err.caused_by(trc::location!())), + // inbuxa: counted as the stored record is, not the iCalendar alone + let extra_bytes = (new_calendar_event.size() as u64) + .saturating_sub(calendar_event.inner.size() as u64); + match self.reserve_quota(&mut quota, extra_bytes).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + update.fail(&mut response, SetError::over_quota()); + continue 'update; } + Err(err) => return Err(err.caused_by(trc::location!())), } // Update record @@ -717,6 +725,7 @@ impl CalendarEventSet for Server { access_token: &AccessToken, account_id: u32, account_info: &AccountInfo, + quota: &mut QuotaBudget, send_scheduling_messages: bool, can_add_calendars: &Option, mut js_calendar_group: JSCalendar<'_, Id, BlobId>, @@ -873,7 +882,8 @@ impl CalendarEventSet for Server { < self.core.groupware.itip_outbound_max_recipients { event.schedule_tag = Some(1); - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = + Some(ItipMessages::new(itip_messages_for(messages, event.flags))); } else { return Ok(Err(SetError::invalid_properties() .with_property(JSCalendarProperty::Participants) @@ -910,10 +920,7 @@ impl CalendarEventSet for Server { } // Validate quota - match self - .has_available_quota(account_info.account(), size as u64) - .await - { + match self.reserve_quota(quota, event.size() as u64).await { Ok(_) => {} Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { return Ok(Err(SetError::over_quota())); diff --git a/crates/jmap/src/contact/copy.rs b/crates/jmap/src/contact/copy.rs index f5d78b2b..76de3501 100644 --- a/crates/jmap/src/contact/copy.rs +++ b/crates/jmap/src/contact/copy.rs @@ -7,7 +7,7 @@ */ use crate::{changes::state::JmapCacheState, contact::set::ContactCardSet}; -use common::{Server, auth::AccessToken}; +use common::{Server, auth::AccessToken, storage::quota::QuotaBudget}; use groupware::{cache::GroupwareCache, contact::ContactCard}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -56,7 +56,8 @@ impl JmapContactCardCopy for Server { ) -> trc::Result> { let account_id = request.account_id.document_id(); let from_account_id = request.from_account_id.document_id(); - let account = self.account(account_id).await.caused_by(trc::location!())?; + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); if account_id == from_account_id { return Err(trc::JmapEvent::InvalidArguments @@ -149,7 +150,7 @@ impl JmapContactCardCopy for Server { &cache, &mut batch, access_token, - &account, + &mut quota, account_id, &can_add_address_books, contact.card.into_jscontact(), diff --git a/crates/jmap/src/contact/set.rs b/crates/jmap/src/contact/set.rs index b67b13a0..affeb57a 100644 --- a/crates/jmap/src/contact/set.rs +++ b/crates/jmap/src/contact/set.rs @@ -2,15 +2,14 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::changes::state::JmapCacheState; use crate::contact::assert_is_unique_uid; use calcard::jscontact::{JSContact, JSContactProperty, JSContactValue}; -use common::{ - DavName, DavResources, Server, - auth::{AccessToken, AccountCache}, -}; +use common::{DavName, DavResources, Server, auth::AccessToken, storage::quota::QuotaBudget}; use groupware::{DestroyArchive, cache::GroupwareCache, contact::ContactCard}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -49,7 +48,7 @@ pub trait ContactCardSet: Sync + Send { cache: &DavResources, batch: &mut BatchBuilder, access_token: &AccessToken, - account: &AccountCache, + quota: &mut QuotaBudget, account_id: u32, can_add_address_books: &Option, js_contact: JSContact<'_, Id, BlobId>, @@ -65,7 +64,8 @@ impl ContactCardSet for Server { _session: &HttpSessionData, ) -> trc::Result> { let account_id = request.account_id.document_id(); - let account = self.account(account_id).await.caused_by(trc::location!())?; + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); let cache = self .fetch_dav_resources( access_token.account_id(), @@ -103,7 +103,7 @@ impl ContactCardSet for Server { &cache, &mut batch, access_token, - &account, + &mut quota, account_id, &can_add_address_books, JSContact::default(), @@ -271,17 +271,16 @@ impl ContactCardSet for Server { ); continue 'update; } - let extra_bytes = (new_contact_card.size as u64) - .saturating_sub(u32::from(contact_card.inner.size) as u64); - if extra_bytes > 0 { - match self.has_available_quota(&account, extra_bytes).await { - Ok(_) => {} - Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { - response.not_updated.append(id, SetError::over_quota()); - continue 'update; - } - Err(err) => return Err(err.caused_by(trc::location!())), + // inbuxa: counted as the stored record is, not the vCard alone + let extra_bytes = + (new_contact_card.size() as u64).saturating_sub(contact_card.inner.size() as u64); + match self.reserve_quota(&mut quota, extra_bytes).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_updated.append(id, SetError::over_quota()); + continue 'update; } + Err(err) => return Err(err.caused_by(trc::location!())), } // Update record @@ -388,7 +387,7 @@ impl ContactCardSet for Server { cache: &DavResources, batch: &mut BatchBuilder, access_token: &AccessToken, - account: &AccountCache, + quota: &mut QuotaBudget, account_id: u32, can_add_address_books: &Option, mut js_contact: JSContact<'_, Id, BlobId>, @@ -441,7 +440,13 @@ impl ContactCardSet for Server { ), ))); } - match self.has_available_quota(account, size as u64).await { + let contact = ContactCard { + names, + size: size as u32, + card, + ..Default::default() + }; + match self.reserve_quota(quota, contact.size() as u64).await { Ok(_) => {} Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { return Ok(Err(SetError::over_quota())); @@ -455,20 +460,15 @@ impl ContactCardSet for Server { .assign_document_ids(account_id, Collection::ContactCard, 1) .await .caused_by(trc::location!())?; - ContactCard { - names, - size: size as u32, - card, - ..Default::default() - } - .insert( - access_token.account_tenant_ids(), - account_id, - document_id, - batch, - ) - .caused_by(trc::location!()) - .map(|_| Ok(document_id)) + contact + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + batch, + ) + .caused_by(trc::location!()) + .map(|_| Ok(document_id)) } } diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index ca4b07d8..8971a434 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -8,7 +8,7 @@ use crate::{ changes::state::JmapCacheState, - email::{PatchResult, handle_email_patch, ingested_into_object}, + email::{PatchResult, handle_email_patch, ingested_into_object, invalid_keyword}, }; use common::{Server, auth::AccessToken, ipc::PushNotification}; use email::{ @@ -122,6 +122,13 @@ impl JmapEmailCopy for Server { .collect(); } (Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => { + // inbuxa: a keyword that is not a valid name is refused + if keywords_.iter().any(|(keyword, _)| { + !matches!(keyword, Key::Property(EmailProperty::Keyword(_))) + }) { + response.not_created.append(id, invalid_keyword()); + continue 'create; + } keywords = keywords_ .into_expanded_boolean_set() .filter_map(|id| id.try_into_property()?.try_into_keyword()) diff --git a/crates/jmap/src/email/get.rs b/crates/jmap/src/email/get.rs index 5554c6e1..1ae4a346 100644 --- a/crates/jmap/src/email/get.rs +++ b/crates/jmap/src/email/get.rs @@ -22,7 +22,7 @@ use email::{ use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::email::{Email, EmailProperty, EmailValue, HeaderForm}, - request::IntoValid, + request::unique_valid, types::date::UTCDate, }; use jmap_tools::{Key, Map, Value}; @@ -87,7 +87,7 @@ impl EmailGet for Server { let body_properties = request .arguments .body_properties - .map(|v| v.into_valid().collect()) + .map(unique_valid) .unwrap_or_else(|| { vec![ EmailProperty::PartId, diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs index 3a6a41db..777d66ef 100644 --- a/crates/jmap/src/email/import.rs +++ b/crates/jmap/src/email/import.rs @@ -2,10 +2,14 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ - blob::download::BlobDownload, changes::state::JmapCacheState, email::ingested_into_object, + blob::download::BlobDownload, + changes::state::JmapCacheState, + email::{ingested_into_object, invalid_keyword}, }; use common::{Server, auth::AccessToken, ipc::PushNotification}; use email::{ @@ -128,6 +132,12 @@ impl EmailImport for Server { } } + // inbuxa: a keyword that is not a valid name is refused + if email.keywords.iter().any(|keyword| !keyword.is_valid()) { + response.not_created.append(id, invalid_keyword()); + continue; + } + let MaybeIdReference::Id(blob_id) = email.blob_id else { response.not_created.append( id, diff --git a/crates/jmap/src/email/mod.rs b/crates/jmap/src/email/mod.rs index ccd4b559..30b2a3e5 100644 --- a/crates/jmap/src/email/mod.rs +++ b/crates/jmap/src/email/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use email::message::ingest::IngestedEmail; @@ -39,6 +41,14 @@ pub(crate) enum PatchResult<'x> { Invalid(SetError), } +/// inbuxa: the error for a keyword that is not a valid name (RFC 8621 +/// section 4.1.1), which would otherwise reach IMAP clients unescaped +pub(crate) fn invalid_keyword() -> SetError { + SetError::invalid_properties() + .with_property(EmailProperty::Keywords) + .with_description("Keywords must be 1 to 255 printable ASCII characters without spaces or ( ) { ] % * \" \\.") +} + pub(crate) fn handle_email_patch<'x>( pointer: &'x JsonPointer, value: Value<'_, EmailProperty, EmailValue>, diff --git a/crates/jmap/src/email/parse.rs b/crates/jmap/src/email/parse.rs index 8c89bdcc..999fd01e 100644 --- a/crates/jmap/src/email/parse.rs +++ b/crates/jmap/src/email/parse.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::blob::download::BlobDownload; @@ -14,7 +16,7 @@ use email::message::{ use jmap_proto::{ method::parse::{ParseRequest, ParseResponse}, object::email::{Email, EmailProperty}, - request::{IntoValid, MaybeInvalid, reference::MaybeIdReference}, + request::{MaybeInvalid, reference::MaybeIdReference, unique_valid}, }; use jmap_tools::{Key, Map, Value}; use mail_parser::{ @@ -41,37 +43,34 @@ impl EmailParse for Server { if request.blob_ids.len() > self.core.jmap.mail_parse_max_items { return Err(trc::JmapEvent::RequestTooLarge.into_err()); } - let properties = request - .properties - .map(|v| v.into_valid().collect()) - .unwrap_or_else(|| { - vec![ - EmailProperty::BlobId, - EmailProperty::Size, - EmailProperty::ReceivedAt, - EmailProperty::MessageId, - EmailProperty::InReplyTo, - EmailProperty::References, - EmailProperty::Sender, - EmailProperty::From, - EmailProperty::To, - EmailProperty::Cc, - EmailProperty::Bcc, - EmailProperty::ReplyTo, - EmailProperty::Subject, - EmailProperty::SentAt, - EmailProperty::HasAttachment, - EmailProperty::Preview, - EmailProperty::BodyValues, - EmailProperty::TextBody, - EmailProperty::HtmlBody, - EmailProperty::Attachments, - ] - }); + let properties = request.properties.map(unique_valid).unwrap_or_else(|| { + vec![ + EmailProperty::BlobId, + EmailProperty::Size, + EmailProperty::ReceivedAt, + EmailProperty::MessageId, + EmailProperty::InReplyTo, + EmailProperty::References, + EmailProperty::Sender, + EmailProperty::From, + EmailProperty::To, + EmailProperty::Cc, + EmailProperty::Bcc, + EmailProperty::ReplyTo, + EmailProperty::Subject, + EmailProperty::SentAt, + EmailProperty::HasAttachment, + EmailProperty::Preview, + EmailProperty::BodyValues, + EmailProperty::TextBody, + EmailProperty::HtmlBody, + EmailProperty::Attachments, + ] + }); let body_properties = request .arguments .body_properties - .map(|v| v.into_valid().collect()) + .map(unique_valid) .unwrap_or_else(|| { vec![ EmailProperty::PartId, diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index 083234c9..bb84aa14 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -9,7 +9,7 @@ use crate::{ blob::download::BlobDownload, changes::state::JmapCacheState, - email::{PatchResult, handle_email_patch, ingested_into_object}, + email::{PatchResult, handle_email_patch, ingested_into_object, invalid_keyword}, }; use common::{ Server, auth::AccessToken, ipc::PushNotification, storage::index::ObjectIndexBuilder, @@ -180,6 +180,13 @@ impl EmailSet for Server { .collect(); } (EmailProperty::Keywords, Value::Object(keywords_)) => { + // inbuxa: a keyword that is not a valid name is refused + if keywords_.iter().any(|(keyword, _)| { + !matches!(keyword, Key::Property(EmailProperty::Keyword(_))) + }) { + response.not_created.append(id, invalid_keyword()); + continue 'create; + } keywords = keywords_ .into_expanded_boolean_set() .filter_map(|id| id.try_into_property()?.try_into_keyword()) @@ -876,6 +883,13 @@ impl EmailSet for Server { ); } (Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => { + // inbuxa: a keyword that is not a valid name is refused + if keywords_.iter().any(|(keyword, _)| { + !matches!(keyword, Key::Property(EmailProperty::Keyword(_))) + }) { + response.not_updated.append(id, invalid_keyword()); + continue 'update; + } new_data.set_keywords( keywords_ .into_expanded_boolean_set() diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index 1dfced31..e73e5aa2 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -15,7 +15,7 @@ use crate::{ update_file_node, validate_file_node_hierarchy, }, }; -use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl, storage::quota::QuotaBudget}; use groupware::{cache::GroupwareCache, file::FileNode}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -137,6 +137,9 @@ impl FileNodeCopy for Server { let mut implicit_destroys: AHashSet = AHashSet::new(); let mut created_folders = AHashMap::new(); let mut destroy_ids = Vec::new(); + // inbuxa: copies count towards the disk quota of the account they land in + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); 'create: for (id, create) in request.create.into_valid() { let from_document_id = id.document_id(); @@ -400,6 +403,19 @@ impl FileNodeCopy for Server { continue 'create; } + // inbuxa: Validate quota + match self + .reserve_quota(&mut quota, file_node.size() as u64) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_created.append(id, SetError::over_quota()); + continue 'create; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + if !file_node.acls.is_empty() { if let Err(err) = self.acl_validate(account_id, &file_node.acls).await { response.not_created.append(id, err.into()); diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index 380fbb85..d280f660 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -11,7 +11,10 @@ use crate::{ blob::download::BlobDownload, changes::state::JmapCacheState, }; -use common::{DavResourceMetadata, DavResources, Server, auth::AccessToken, sharing::EffectiveAcl}; +use common::{ + DavResourceMetadata, DavResources, Server, auth::AccessToken, sharing::EffectiveAcl, + storage::quota::QuotaBudget, +}; use groupware::{DestroyArchive, cache::GroupwareCache, file::FileNode}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -97,6 +100,9 @@ impl FileNodeSet for Server { .unwrap_or(false); let mut pending_names: AHashMap<(u32, String), Option> = AHashMap::new(); let mut implicit_destroys: AHashSet = AHashSet::new(); + // inbuxa: files count towards the disk quota of the account they're in + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); // Process creates let mut batch = BatchBuilder::new(); @@ -316,6 +322,19 @@ impl FileNodeSet for Server { } } + // inbuxa: Validate quota + match self + .reserve_quota(&mut quota, file_node.size() as u64) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_created.append(id, SetError::over_quota()); + continue 'create; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + // Validate ACLs if !file_node.acls.is_empty() { if let Err(err) = self.acl_validate(account_id, &file_node.acls).await { @@ -595,6 +614,19 @@ impl FileNodeSet for Server { ); continue 'update; } + + // inbuxa: Validate quota + let extra_bytes = + (new_file_node.size() as u64).saturating_sub(file_node.inner.size() as u64); + match self.reserve_quota(&mut quota, extra_bytes).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_updated.append(id, SetError::over_quota()); + continue 'update; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + if has_acl_changes { if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await { response.not_updated.append(id, err.into()); diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index 81eed3b2..420f7f41 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -31,6 +31,7 @@ use jmap_proto::{ types::{date::UTCDate, state::State}, }; use jmap_tools::{Key, Map, Value}; +use mail_parser::{HeaderName, MessageParser}; use smtp::{ core::{Session, SessionData}, queue::spool::SmtpSpool, @@ -678,7 +679,6 @@ impl EmailSubmissionSet for Server { } // Add recipients to envelope if missing - let mut bcc_header = None; if rcpt_to.is_empty() { for header in metadata.contents[0].parts[0].headers.iter() { if matches!( @@ -687,9 +687,6 @@ impl EmailSubmissionSet for Server { | ArchivedMetadataHeaderName::Cc | ArchivedMetadataHeaderName::Bcc ) { - if matches!(header.name, ArchivedMetadataHeaderName::Bcc) { - bcc_header = Some(header); - } match &header.value { ArchivedMetadataHeaderValue::AddressList(addr) => { for address in addr.iter() { @@ -742,11 +739,6 @@ impl EmailSubmissionSet for Server { return Ok(Err(SetError::new(SetErrorType::NoRecipients) .with_description("No recipients found in email."))); } - } else { - bcc_header = metadata.contents[0].parts[0] - .headers - .iter() - .find(|header| matches!(header.name, ArchivedMetadataHeaderName::Bcc)); } // Update sendAt @@ -779,14 +771,8 @@ impl EmailSubmissionSet for Server { .with_description("Blob for email not found."))); }; - // Remove BCC header if present - if let Some(bcc_header) = bcc_header { - let mut new_message = Vec::with_capacity(message.len()); - let range = bcc_header.name_value_range(); - new_message.extend_from_slice(&message[..range.start]); - new_message.extend_from_slice(&message[range.end..]); - message = new_message; - } + // Remove BCC headers if present + message = strip_bcc_headers(message); // Begin local SMTP session let mut session = Session::::local( @@ -1003,3 +989,34 @@ fn rcpt_into_static(rcpt: RcptTo>) -> RcptTo> { flags: rcpt.flags, } } + +// inbuxa: removes every Bcc header of the message root, found on a fresh parse +// so that the offsets are exact whatever the size of the header +fn strip_bcc_headers(message: Vec) -> Vec { + let ranges = MessageParser::new() + .parse_headers(&message) + .and_then(|parsed| { + parsed.parts.first().map(|root| { + root.headers + .iter() + .filter(|header| header.name == HeaderName::Bcc) + .map(|header| header.offset_field as usize..header.offset_end as usize) + .collect::>() + }) + }) + .unwrap_or_default(); + if ranges.is_empty() { + return message; + } + + let mut stripped = Vec::with_capacity(message.len()); + let mut pos = 0; + for range in ranges { + if range.start >= pos && range.start <= range.end && range.end <= message.len() { + stripped.extend_from_slice(&message[pos..range.start]); + pos = range.end; + } + } + stripped.extend_from_slice(&message[pos..]); + stripped +} diff --git a/crates/jmap/src/websocket/stream.rs b/crates/jmap/src/websocket/stream.rs index 234de7d7..8e1413b1 100644 --- a/crates/jmap/src/websocket/stream.rs +++ b/crates/jmap/src/websocket/stream.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::api::{ToRequestError, notifications_into_push_objects, request::RequestHandler}; @@ -17,7 +19,9 @@ use jmap_proto::{ }, }; use std::future::Future; +use std::net::IpAddr; use std::time::Instant; +use store::write::now; use tokio_tungstenite::WebSocketStream; use trc::JmapEvent; use tungstenite::Message; @@ -94,15 +98,44 @@ impl WebSocketHandler for Server { self.core.jmap.request_max_size, ) { Ok(WebSocketMessage::Request(request)) => { - let response = self - .handle_jmap_request( - request.request, + // inbuxa: each request is served as the + // session stands now, and counts toward + // the account's request limits + let access_token = match current_websocket_token( + self, + &access_token, + session.remote_ip, + ) + .await + { + Ok(access_token) => access_token, + Err(err) => { + end_websocket(&mut stream, err, session.session_id).await; + break; + } + }; + match self + .is_http_authenticated_request_allowed( &access_token, - &session, + session.remote_ip, ) - .await; - WebSocketResponse::from_response(response, request.id) - .to_json() + .await + { + Ok(_in_flight) => { + let response = self + .handle_jmap_request( + request.request, + &access_token, + &session, + ) + .await; + WebSocketResponse::from_response(response, request.id) + .to_json() + } + Err(err) => { + WebSocketRequestError::from(err.to_request_error()).to_json() + } + } } Ok(WebSocketMessage::PushEnable(push_enable)) => { change_types = if !push_enable.data_types.is_empty() { @@ -229,6 +262,13 @@ impl WebSocketHandler for Server { // Send any queued changes let elapsed = last_changes_sent.elapsed(); if elapsed >= throttle { + // inbuxa: only while the session is still valid + if let Err(err) = + current_websocket_token(self, &access_token, session.remote_ip).await + { + end_websocket(&mut stream, err, session.session_id).await; + break; + } for push in notifications_into_push_objects(std::mem::take(&mut notifications)) { let payload = WebSocketPushObject { @@ -267,3 +307,63 @@ impl WebSocketHandler for Server { } } } + +/// inbuxa: the WebSocket's access token as it stands now. The socket +/// outlives the request that opened it, so it ends when the bearer token it +/// signed in with expires, the account is locked or removed, the password +/// changes, or the app password or API key it used goes; and each request +/// sees the account's current permissions and shares. +async fn current_websocket_token( + server: &Server, + signed_in: &AccessToken, + remote_ip: IpAddr, +) -> trc::Result { + if signed_in + .expires_at() + .is_some_and(|expires_at| expires_at <= now()) + { + return Err(trc::AuthEvent::TokenExpired.into_err()); + } + let inner = server.access_token(signed_in.account_id()).await?; + if inner.is_locked() { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Account is locked")); + } + // A session that signed in with an app password or API key ends when + // that credential goes, which renewing it below checks + if signed_in.credential_id().is_none() + && inner.credential_version() != signed_in.credential_version() + { + return Err(trc::AuthEvent::TokenExpired + .into_err() + .details("Credentials changed")); + } + AccessToken::renew(inner, signed_in.credential_id(), remote_ip).map(|access_token| { + access_token + .with_origin_arc(signed_in.origin_arc()) + .with_expires_at(signed_in.expires_at()) + }) +} + +/// inbuxa: tells the client its session is over and closes the socket. +async fn end_websocket( + stream: &mut WebSocketStream>, + err: trc::Error, + session_id: u64, +) { + trc::event!( + Jmap(JmapEvent::WebsocketStop), + SpanId = session_id, + Reason = "Session no longer authorized", + CausedBy = err, + ); + let _ = stream + .send(Message::Text( + WebSocketRequestError::from(RequestError::unauthorized()) + .to_json() + .into(), + )) + .await; + let _ = stream.close(None).await; +} diff --git a/crates/managesieve/src/core/session.rs b/crates/managesieve/src/core/session.rs index 4de2f932..ad3fbc11 100644 --- a/crates/managesieve/src/core/session.rs +++ b/crates/managesieve/src/core/session.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ManageSieveSessionManager, Session, State}; @@ -10,7 +12,7 @@ use common::{ BuildServer, network::{SessionData, SessionManager, SessionResult, SessionStream}, }; -use imap_proto::receiver::{self, Receiver}; +use imap_proto::receiver::{self, PRE_AUTH_MAX_REQUEST_SIZE, Receiver}; use tokio_rustls::server::TlsStream; impl SessionManager for ManageSieveSessionManager { @@ -23,8 +25,15 @@ impl SessionManager for ManageSieveSessionManager { // Create session let server = self.inner.build_server(); let mut session = Session { - receiver: Receiver::with_max_request_size(server.core.imap.max_request_size) - .with_start_state(receiver::State::Command { is_uid: false }), + // inbuxa: the full request size only applies after sign-in + receiver: Receiver::with_max_request_size( + server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE), + ) + .with_start_state(receiver::State::Command { is_uid: false }), server, instance: session.instance, state: State::NotAuthenticated { auth_failures: 0 }, @@ -131,8 +140,18 @@ impl Session { } pub async fn into_tls(self) -> Result>, ()> { - let receiver = Receiver::with_max_request_size(self.server.core.imap.max_request_size) - .with_start_state(receiver::State::Command { is_uid: false }); + let receiver = Receiver::with_max_request_size( + if matches!(self.state, State::NotAuthenticated { .. }) { + self.server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE) + } else { + self.server.core.imap.max_request_size + }, + ) + .with_start_state(receiver::State::Command { is_uid: false }); Ok(Session { stream: self diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index b2f71ad8..259778c1 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -14,7 +14,7 @@ use common::{ use directory::Credentials; use imap_proto::{ protocol::authenticate::Mechanism, - receiver::{self, Request}, + receiver::{self, PRE_AUTH_MAX_REQUEST_SIZE, Request}, }; use mail_parser::decoders::base64::base64_decode; use registry::schema::enums::Permission; @@ -121,12 +121,20 @@ impl Session { access_token, in_flight, }; + // inbuxa: signed in, so the configured request size now applies + self.receiver.max_request_size = self.server.core.imap.max_request_size; Ok(StatusResponse::ok("Authentication successful").into_bytes()) } pub async fn handle_unauthenticate(&mut self) -> trc::Result> { self.state = State::NotAuthenticated { auth_failures: 0 }; + self.receiver.max_request_size = self + .server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE); trc::event!( ManageSieve(trc::ManageSieveEvent::Unauthenticate), diff --git a/crates/pop3/src/client.rs b/crates/pop3/src/client.rs index f2ae8342..4f5bef50 100644 --- a/crates/pop3/src/client.rs +++ b/crates/pop3/src/client.rs @@ -205,10 +205,9 @@ impl Session { ) -> trc::Result> { match &command { Command::Capa | Command::Quit | Command::Noop => Ok(command), - Command::Auth { - mechanism: Mechanism::Plain, - .. - } + // inbuxa: every mechanism sends a credential (a bearer token is + // one too), so all of them follow the plain-text policy + Command::Auth { .. } | Command::User { .. } | Command::Pass { .. } | Command::Apop { .. } => { @@ -232,15 +231,6 @@ impl Session { .details("Already authenticated.")) } } - Command::Auth { .. } => { - if let State::NotAuthenticated { .. } = &self.state { - Ok(command) - } else { - Err(trc::Pop3Event::Error - .into_err() - .details("Already authenticated.")) - } - } Command::Stls => { if !self.stream.is_tls() { Ok(command) diff --git a/crates/pop3/src/op/mod.rs b/crates/pop3/src/op/mod.rs index 6086f169..59d35576 100644 --- a/crates/pop3/src/op/mod.rs +++ b/crates/pop3/src/op/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::network::SessionStream; @@ -21,7 +23,8 @@ impl Session { let mechanisms = if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { vec![Mechanism::Plain, Mechanism::OAuthBearer, Mechanism::XOauth2] } else { - vec![Mechanism::OAuthBearer, Mechanism::XOauth2] + // inbuxa: no mechanism is offered where none would be accepted + vec![] }; trc::event!( diff --git a/crates/services/src/state_manager/http.rs b/crates/services/src/state_manager/http.rs index 39987ba1..d0c261c1 100644 --- a/crates/services/src/state_manager/http.rs +++ b/crates/services/src/state_manager/http.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ @@ -11,7 +13,11 @@ use super::{ }; use crate::state_manager::{PushBatch, PushRegistration}; use calcard::jscalendar::JSCalendarDateTime; -use common::{Server, ipc::PushNotification, network::webpush::Vapid}; +use common::{ + Server, + ipc::PushNotification, + network::{public_only::PublicOnly, webpush::Vapid}, +}; use email::push::{PushSubscription, Urgency}; use jmap_proto::{ object::email::{EmailProperty, EmailValue}, @@ -265,6 +271,7 @@ pub(crate) fn build_push_client() -> Client { } _ => attempt.stop(), })) + .public_only() .build() .unwrap_or_default() } diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 0e2bce08..c78f258d 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -30,8 +30,9 @@ use common::{ scripts::ScriptModification, }; use mail_auth::{ - AuthenticatedMessage, AuthenticationResults, Dkim2Result, DkimResult, DmarcResult, ReceivedSpf, - SpfOutput, SpfResult, + AuthenticatedMessage, AuthenticationResults, Dkim2Result, DkimResult, DmarcOutput, DmarcResult, + ReceivedSpf, SpfOutput, SpfResult, + common::to_a_label, common::{ crypto::Algorithm, headers::{Header, HeaderWriter}, @@ -373,15 +374,38 @@ impl Session { } }; let time = Instant::now(); - let dmarc_output = - self.server + + // inbuxa: a From header naming several domains is checked + // against each of them and the strictest result applies + // (RFC 7489, Section 6.6.1) + let author_domains = dmarc_author_domains(&auth_message); + let author_messages = if author_domains.len() > 1 { + author_domains + .iter() + .map(|domain| { + let mut message = auth_message.clone(); + message.from.retain(|address| { + address.rsplit_once('@').is_some_and(|(_, addr_domain)| { + to_a_label(addr_domain) == domain.as_str() + }) + }); + Cow::Owned(message) + }) + .collect::>() + } else { + vec![Cow::Borrowed(&auth_message)] + }; + let mut dmarc_output: Option = None; + for author_message in &author_messages { + let output = self + .server .core .smtp .resolvers .dns .verify_dmarc(self.server.inner.cache.build_auth_parameters( DmarcParameters { - message: &auth_message, + message: author_message.as_ref(), dkim_output: &dkim_output, dkim2_output: dkim2_output.as_ref(), rfc5321_mail_from_domain: if !mail_from.domain.is_empty() { @@ -393,6 +417,14 @@ impl Session { }, )) .await; + if dmarc_output + .as_ref() + .is_none_or(|current| dmarc_strictness(&output) > dmarc_strictness(current)) + { + dmarc_output = Some(output); + } + } + let dmarc_output = dmarc_output.unwrap_or_default(); let dmarc_result = dmarc_output.result(); let pass = dmarc_result == DmarcResult::Pass; @@ -1220,3 +1252,33 @@ fn parse_dkim2_dsn<'x, 'r>( None } } + +// inbuxa: the distinct author domains of the From header, as DMARC compares them +fn dmarc_author_domains(message: &AuthenticatedMessage<'_>) -> Vec { + let mut domains: Vec = Vec::new(); + for address in &message.from { + if let Some((_, domain)) = address.rsplit_once('@') { + let domain = to_a_label(domain); + if !domains.iter().any(|d| d.as_str() == domain) { + domains.push(domain.into_owned()); + } + } + } + domains +} + +// inbuxa: orders DMARC results so that a failure under the strictest policy +// wins, then a failure, then no result, then a pass +fn dmarc_strictness(output: &DmarcOutput) -> (u8, u8, u8) { + let policy = match output.policy() { + dmarc::Policy::Reject => 2, + dmarc::Policy::Quarantine => 1, + dmarc::Policy::None | dmarc::Policy::Unspecified => 0, + }; + match output.result() { + DmarcResult::Fail(_) => (2, policy, 1), + DmarcResult::TempError(_) => (2, policy, 0), + DmarcResult::PermError(_) | DmarcResult::None => (1, 0, 0), + DmarcResult::Pass => (0, 0, 0), + } +} diff --git a/crates/smtp/src/inbound/session.rs b/crates/smtp/src/inbound/session.rs index 084fba6b..cae37532 100644 --- a/crates/smtp/src/inbound/session.rs +++ b/crates/smtp/src/inbound/session.rs @@ -79,11 +79,9 @@ impl Session { } else if chunk_size.saturating_add(self.data.message.len()) < self.params.max_message_size { - if self.data.message.is_empty() { - self.data.message = Vec::with_capacity(chunk_size); - } else { - self.data.message.reserve(chunk_size); - } + // Reserve only what has already arrived, the buffer + // grows with the rest of the chunk as it is received + self.data.message.reserve(chunk_size.min(iter.len())); State::Bdat(BdatReceiver::new(chunk_size, is_last)) } else { trc::event!( diff --git a/crates/smtp/src/outbound/local.rs b/crates/smtp/src/outbound/local.rs index bd38fc62..baff88ba 100644 --- a/crates/smtp/src/outbound/local.rs +++ b/crates/smtp/src/outbound/local.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -16,7 +18,9 @@ use crate::{ }, }; use common::Server; -use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; +use email::message::delivery::{ + IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery, VerifiedSender, +}; use smtp_proto::Response; use trc::SieveEvent; @@ -45,9 +49,15 @@ impl MessageWrapper { let delivery_result = server .deliver_message(IngestMessage { sender_address: self.message.return_path.to_string(), - sender_authenticated: self.message.flags - & (FROM_UNAUTHENTICATED_DMARC | FROM_AUTHENTICATED) - != 0, + // inbuxa: a signed-in sender is known by its envelope + // address, any other only by a From header that passed DMARC + verified_sender: if self.message.flags & FROM_AUTHENTICATED != 0 { + VerifiedSender::Envelope + } else if self.message.flags & FROM_UNAUTHENTICATED_DMARC != 0 { + VerifiedSender::FromHeader + } else { + VerifiedSender::None + }, recipients, message_blob: self.message.blob_hash.clone(), message_size: self.message.size, diff --git a/crates/smtp/src/outbound/mta_sts/lookup.rs b/crates/smtp/src/outbound/mta_sts/lookup.rs index 73503d64..e9f3fd15 100644 --- a/crates/smtp/src/outbound/mta_sts/lookup.rs +++ b/crates/smtp/src/outbound/mta_sts/lookup.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::{fmt::Display, sync::Arc, time::Duration}; @@ -59,48 +61,64 @@ impl MtaStsLookup for Server { }; // Check if the policy has been cached - if let Some(value) = self.inner.cache.dns_mta_sts.get(domain) + let cached = self.inner.cache.dns_mta_sts.get(domain); + if let Some(value) = &cached && value.id == record.id { - return Ok(value); + return Ok(value.clone()); } - // Fetch policy - #[cfg(not(feature = "test_mode"))] - let bytes = self - .core - .smtp - .mta_sts_client - .get(format!("https://mta-sts.{domain}/.well-known/mta-sts.txt")) - .timeout(timeout) - .send() - .await? - .bytes_with_limit(MAX_POLICY_SIZE) - .await? - .ok_or_else(|| Error::InvalidPolicy("Policy too large".to_string()))?; - #[cfg(feature = "test_mode")] - let bytes = STS_TEST_POLICY.lock().clone(); - - // Parse policy - let policy = Arc::new(Policy::parse( - std::str::from_utf8(&bytes).map_err(|err| Error::InvalidPolicy(err.to_string()))?, - record.id.clone(), - )?); - - self.inner.cache.dns_mta_sts.insert( - domain.into(), - policy.clone(), - Duration::from_secs(if (3600..31557600).contains(&policy.max_age) { - policy.max_age - } else { - 86400 - }), - ); - - Ok(policy) + // A new policy id whose policy can't be fetched keeps the cached + // policy in force until it expires (RFC 8461, Section 5.1) + match fetch_policy(self, domain, &record, timeout).await { + Ok(policy) => Ok(policy), + Err(err) => cached.ok_or(err), + } } } +#[allow(unused_variables)] +async fn fetch_policy( + server: &Server, + domain: &str, + record: &MtaSts, + timeout: Duration, +) -> Result, Error> { + // Fetch policy + #[cfg(not(feature = "test_mode"))] + let bytes = server + .core + .smtp + .mta_sts_client + .get(format!("https://mta-sts.{domain}/.well-known/mta-sts.txt")) + .timeout(timeout) + .send() + .await? + .bytes_with_limit(MAX_POLICY_SIZE) + .await? + .ok_or_else(|| Error::InvalidPolicy("Policy too large".to_string()))?; + #[cfg(feature = "test_mode")] + let bytes = STS_TEST_POLICY.lock().clone(); + + // Parse policy + let policy = Arc::new(Policy::parse( + std::str::from_utf8(&bytes).map_err(|err| Error::InvalidPolicy(err.to_string()))?, + record.id.clone(), + )?); + + server.inner.cache.dns_mta_sts.insert( + domain.into(), + policy.clone(), + Duration::from_secs(if (3600..31557600).contains(&policy.max_age) { + policy.max_age + } else { + 86400 + }), + ); + + Ok(policy) +} + impl From<&Error> for ResultType { fn from(err: &Error) -> Self { match &err { diff --git a/crates/smtp/src/reporting/tls.rs b/crates/smtp/src/reporting/tls.rs index f489e438..1cb4b102 100644 --- a/crates/smtp/src/reporting/tls.rs +++ b/crates/smtp/src/reporting/tls.rs @@ -19,6 +19,7 @@ use common::{ resolver::{Mode, MxPattern, TlsaMatching}, }, ipc::{TlsEvent, ToHash}, + network::public_only::has_public_host, }; use mail_auth::{ flate2::{Compression, write::GzEncoder}, @@ -151,6 +152,17 @@ impl TlsReporting for Server { return Ok(()); } + // inbuxa: host names are checked when they resolve + if !has_public_host(uri) { + trc::event!( + OutgoingReport(OutgoingReportEvent::SubmissionError), + SpanId = span_id, + Url = uri.to_string(), + Details = "Report URI is not a public address" + ); + continue; + } + match self .core .smtp diff --git a/crates/spam-filter/src/analysis/url.rs b/crates/spam-filter/src/analysis/url.rs index b55e7c60..8d65f9ca 100644 --- a/crates/spam-filter/src/analysis/url.rs +++ b/crates/spam-filter/src/analysis/url.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ElementLocation, is_trusted_domain, is_url_redirector}; @@ -14,6 +16,7 @@ use crate::{ }; use common::Server; use common::config::mailstore::spamfilter::{Element, IpResolver, Location}; +use common::network::public_only::has_public_host; use common::scripts::IsMixedCharset; use common::scripts::functions::unicode::CharUtils; use hyper::{Uri, header::LOCATION}; @@ -24,6 +27,9 @@ use std::{borrow::Cow, future::Future, time::Duration}; const HTTPS_SCHEME: &str = "https://"; +// inbuxa: the most redirector lookups one message can cause +const MAX_REDIRECT_LOOKUPS: usize = 10; + pub trait SpamFilterAnalyzeUrl: Sync + Send { fn spam_filter_analyze_url( &self, @@ -148,6 +154,7 @@ impl SpamFilterAnalyzeUrl for Server { if !ctx.input.is_train { let mut redirected_urls = HashSet::new(); let mut trusted_domains: HashSet = HashSet::new(); + let mut redirect_lookups = 0; { let mut checked_domains: HashSet<&str> = HashSet::new(); @@ -205,10 +212,11 @@ impl SpamFilterAnalyzeUrl for Server { let mut redirect_count = 1; let mut url_redirect = url.element.request_url(); - while redirect_count <= 3 { + while redirect_count <= 3 && redirect_lookups < MAX_REDIRECT_LOOKUPS { + redirect_lookups += 1; match http_get_header( self, - url_redirect.as_ref(), + &https_url(&url_redirect), LOCATION, Duration::from_secs(5), ) @@ -333,12 +341,24 @@ async fn http_get_header( ) -> trc::Result> { #[cfg(feature = "test_mode")] { + // Like the real client, which only speaks https + if !url.starts_with(HTTPS_SCHEME) { + return Err(trc::SieveEvent::RuntimeError + .into_err() + .details("Redirector lookups are https only")); + } + return if url.contains("redirect.") { Ok(url.split_once("/?").unwrap().1.to_string().into()) } else { Ok(None) }; } + // inbuxa: host names are checked when they resolve + if !has_public_host(url) { + return Ok(None); + } + server .core .spam @@ -362,6 +382,17 @@ async fn http_get_header( }) } +/// Redirectors are asked over https only, so a plain http link is looked up +/// at its https address. +fn https_url(url: &str) -> Cow<'_, str> { + match url.get(..7) { + Some(scheme) if scheme.eq_ignore_ascii_case("http://") => { + Cow::Owned([HTTPS_SCHEME, &url[7..]].concat()) + } + _ => Cow::Borrowed(url), + } +} + fn collect_url( urls: &mut HashSet>>, inferred_urls: &mut HashSet>>, diff --git a/crates/types/src/branding.rs b/crates/types/src/branding.rs index 98f473e7..362dbfc2 100644 --- a/crates/types/src/branding.rs +++ b/crates/types/src/branding.rs @@ -87,7 +87,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option { #[macro_export] macro_rules! brand_version { () => { - "2026.10.28" + "2026.10.29" }; } diff --git a/crates/types/src/keyword.rs b/crates/types/src/keyword.rs index 88427e97..0acfc7c6 100644 --- a/crates/types/src/keyword.rs +++ b/crates/types/src/keyword.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use jmap_tools::{Element, Property, Value}; @@ -120,6 +122,25 @@ pub enum Keyword { impl Keyword { pub const MAX_LENGTH: usize = 128; + /// inbuxa: RFC 8621 section 4.1.1: 1 to 255 characters from %x21-%x7E + /// except ( ) { ] % * " \, which also makes the keyword an IMAP atom. + /// Anything else could break out of an IMAP FLAGS list. + pub fn is_valid_name(name: &str) -> bool { + (1..=255).contains(&name.len()) + && name.bytes().all(|ch| { + matches!(ch, 0x21..=0x7e) + && !matches!(ch, b'(' | b')' | b'{' | b']' | b'%' | b'*' | b'"' | b'\\') + }) + } + + /// inbuxa: whether the keyword may be stored from JMAP or written to IMAP + pub fn is_valid(&self) -> bool { + match self { + Keyword::Other(name) => Self::is_valid_name(name), + _ => true, + } + } + pub fn parse(value: &str) -> Self { Self::try_parse(value) .unwrap_or_else(|| Keyword::Other(value.chars().take(Keyword::MAX_LENGTH).collect())) @@ -531,6 +552,43 @@ impl<'de> serde::Deserialize<'de> for Keyword { } } +#[cfg(test)] +mod tests { + use super::Keyword; + + #[test] + fn keyword_names() { + for name in [ + "$seen", + "$Junk", + "custom", + "x-y.z_1", + "a!#&'+-/:;<=>?@[^`|}~", + ] { + assert!(Keyword::is_valid_name(name), "{name:?} should be valid"); + } + for name in [ + "", + "two words", + "x)", + "x)\r\n* 1 EXPUNGE", + "tab\there", + "{5}", + "100%", + "a*", + "quo\"te", + "back\\slash", + "close]", + "caf\u{e9}", + &"a".repeat(256), + ] { + assert!(!Keyword::is_valid_name(name), "{name:?} should be invalid"); + } + assert!(Keyword::Seen.is_valid()); + assert!(!Keyword::parse("x y").is_valid()); + } +} + impl<'x, P: Property, E: Element + From> From for Value<'x, P, E> { fn from(id: Keyword) -> Self { Value::Element(E::from(id)) diff --git a/tests/resources/itip/itip_attendee_checks.txt b/tests/resources/itip/itip_attendee_checks.txt new file mode 100644 index 00000000..51d5691e --- /dev/null +++ b/tests/resources/itip/itip_attendee_checks.txt @@ -0,0 +1,167 @@ +# iTIP: REFRESH and REPLY are accepted only from the event's attendees + +# Organizer invites two attendees to a three-day series (1) +> put a@example.com attendee-checks@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:0 +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE:mailto:b@example.com +ATTENDEE;RSVP=TRUE:mailto:c@example.com +SUMMARY:Standup +DTSTART:20270104T180000Z +DTEND:20270104T183000Z +RRULE:FREQ=DAILY;COUNT=3 +DTSTAMP:20261027T120000Z +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR + +# Organizer invites two attendees to a three-day series (2) +> send + +# Someone who was never invited asks for the event +> itip x@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REFRESH +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +ORGANIZER:mailto:a@example.com +ATTENDEE:mailto:x@example.com +DTSTAMP:20261027T130000Z +END:VEVENT +END:VCALENDAR + +> send +SenderIsNotParticipant("x@example.com") + +# Organizer removes the second attendee (1) +> put a@example.com attendee-checks@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE:mailto:b@example.com +SUMMARY:Standup +DTSTART:20270104T180000Z +DTEND:20270104T183000Z +RRULE:FREQ=DAILY;COUNT=3 +DTSTAMP:20261027T130000Z +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR + +# Organizer removes the second attendee (2) +> send + +# The removed attendee asks for the event again +> itip c@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REFRESH +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +ORGANIZER:mailto:a@example.com +ATTENDEE:mailto:c@example.com +DTSTAMP:20261027T140000Z +END:VEVENT +END:VCALENDAR + +> send +SenderIsNotParticipant("c@example.com") + +# The removed attendee replies to an instance the organizer hasn't changed +> itip c@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +RECURRENCE-ID:20270105T180000Z +ORGANIZER:mailto:a@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:c@example.com +DTSTAMP:20261027T150000Z +END:VEVENT +END:VCALENDAR + +> send +SenderIsNotParticipant("c@example.com") + +# An attendee replies to one instance and adds a stranger to it +> itip b@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +RECURRENCE-ID:20270105T180000Z +ORGANIZER;CN="Not A":mailto:a@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:b@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:mallory@example.org +DTSTAMP:20261027T160000Z +END:VEVENT +END:VCALENDAR + +> send + +# An attendee replies to a date the series doesn't have +> itip b@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +RECURRENCE-ID:20270110T180000Z +ORGANIZER:mailto:a@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:b@example.com +DTSTAMP:20261027T170000Z +END:VEVENT +END:VCALENDAR + +> send + +# Only the attendee's own answer reaches the organizer's copy +> get a@example.com attendee-checks@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:b@example.com +ORGANIZER:mailto:a@example.com +RECURRENCE-ID:20270105T180000Z +UID:attendee-checks@example.com +DTSTAMP:1 +SEQUENCE:2 +END:VEVENT +BEGIN:VEVENT +STATUS:CONFIRMED +SUMMARY:Standup +DTEND:20270104T183000Z +DTSTART:20270104T180000Z +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE:mailto:b@example.com +ORGANIZER:mailto:a@example.com +UID:attendee-checks@example.com +RRULE:FREQ=DAILY;COUNT=3 +DTSTAMP:0 +SEQUENCE:2 +END:VEVENT +END:VCALENDAR + diff --git a/tests/resources/itip/rfc5546_todo.txt b/tests/resources/itip/rfc5546_todo.txt index 29b747de..4b6a60e7 100644 --- a/tests/resources/itip/rfc5546_todo.txt +++ b/tests/resources/itip/rfc5546_todo.txt @@ -436,7 +436,7 @@ ATTENDEE;PARTSTAT=IN-PROCESS:mailto:b@example.com PERCENT-COMPLETE:75 UID:calsrv.example.com-873970198738777-00@example.com DTSTAMP:19970717T233000Z -RECURRENCE-ID:19980101T170000Z +RECURRENCE-ID:19980102T100000Z SEQUENCE:1 END:VTODO END:VCALENDAR @@ -457,7 +457,7 @@ BEGIN:VTODO PERCENT-COMPLETE:75 ATTENDEE;PARTSTAT=IN-PROCESS:mailto:b@example.com ORGANIZER:mailto:a@example.com -RECURRENCE-ID:19980101T170000Z +RECURRENCE-ID:19980102T100000Z UID:calsrv.example.com-873970198738777-00@example.com DTSTAMP:0 SEQUENCE:1 @@ -489,9 +489,9 @@ SEQUENCE:1 END:VTODO BEGIN:VTODO PERCENT-COMPLETE:75 -ATTENDEE;PARTSTAT=IN-PROCESS:mailto:b@example.com +ATTENDEE;CUTYPE=INDIVIDUAL;PARTSTAT=IN-PROCESS;SCHEDULE-STATUS=2.0:mailto:b@example.com ORGANIZER:mailto:a@example.com -RECURRENCE-ID:19980101T170000Z +RECURRENCE-ID:19980102T100000Z UID:calsrv.example.com-873970198738777-00@example.com DTSTAMP:0 SEQUENCE:1 diff --git a/tests/resources/itip/rfc6638_recurring.txt b/tests/resources/itip/rfc6638_recurring.txt index cfdfac35..b3bc644c 100644 --- a/tests/resources/itip/rfc6638_recurring.txt +++ b/tests/resources/itip/rfc6638_recurring.txt @@ -240,6 +240,15 @@ BEGIN:VCALENDAR PRODID:-//Example Corp.//CalDAV Client//EN VERSION:2.0 BEGIN:VEVENT +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT; + RSVP=TRUE;PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +UID:9263504FD3AD +DTSTAMP:0 +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT SUMMARY:Review Internet-Draft DTEND;TZID=America/Montreal:20090601T160000 DTSTART;TZID=America/Montreal:20090601T150000 @@ -254,15 +263,6 @@ RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 DTSTAMP:1 SEQUENCE:0 END:VEVENT -BEGIN:VEVENT -ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE - =REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net -ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com -RECURRENCE-ID;TZID=America/Montreal:20090602T150000 -UID:9263504FD3AD -DTSTAMP:0 -SEQUENCE:1 -END:VEVENT BEGIN:VTIMEZONE TZID:America/Montreal BEGIN:STANDARD @@ -384,12 +384,22 @@ BEGIN:VCALENDAR PRODID:-//Example Corp.//CalDAV Client//EN VERSION:2.0 BEGIN:VEVENT -ATTENDEE;PARTSTAT=DECLINED:mailto:bernard@example.net -ORGANIZER:mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT; + PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com RECURRENCE-ID;TZID=America/Montreal:20090603T150000 UID:9263504FD3AD DTSTAMP:0 -SEQUENCE:1 +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT; + RSVP=TRUE;PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +UID:9263504FD3AD +DTSTAMP:0 +SEQUENCE:0 END:VEVENT BEGIN:VEVENT SUMMARY:Review Internet-Draft @@ -406,15 +416,6 @@ RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 DTSTAMP:1 SEQUENCE:0 END:VEVENT -BEGIN:VEVENT -ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE - =REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net -ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com -RECURRENCE-ID;TZID=America/Montreal:20090602T150000 -UID:9263504FD3AD -DTSTAMP:0 -SEQUENCE:1 -END:VEVENT BEGIN:VTIMEZONE TZID:America/Montreal BEGIN:STANDARD diff --git a/tests/resources/smtp/antispam/url.test b/tests/resources/smtp/antispam/url.test index 9aa2dc2b..b9b0b8c0 100644 --- a/tests/resources/smtp/antispam/url.test +++ b/tests/resources/smtp/antispam/url.test @@ -46,6 +46,33 @@ Subject: redirect to omograph login to https://www.redirect.com/?https://xn--twiter-507b.com +expect REDIRECTOR_URL HOMOGRAPH_URL + +Subject: plain http redirect to omograph + +login to http://www.redirect.com/?https://xn--twiter-507b.com + +expect REDIRECTOR_URL +expect_urls 25 + +Subject: many redirects + +https://redirect.com/?https://site1.org/ +https://redirect.com/?https://site2.org/ +https://redirect.com/?https://site3.org/ +https://redirect.com/?https://site4.org/ +https://redirect.com/?https://site5.org/ +https://redirect.com/?https://site6.org/ +https://redirect.com/?https://site7.org/ +https://redirect.com/?https://site8.org/ +https://redirect.com/?https://site9.org/ +https://redirect.com/?https://site10.org/ +https://redirect.com/?https://site11.org/ +https://redirect.com/?https://site12.org/ +https://redirect.com/?https://site13.org/ +https://redirect.com/?https://site14.org/ +https://redirect.com/?https://site15.org/ + expect HAS_ONION_URI HAS_ANON_DOMAIN Subject: url in title darkweb.onion/login diff --git a/tests/src/imap/acl.rs b/tests/src/imap/acl.rs index 9675cf87..18ca57f4 100644 --- a/tests/src/imap/acl.rs +++ b/tests/src/imap/acl.rs @@ -185,6 +185,26 @@ pub async fn test( imap_john.send("UID STORE 1 +FLAGS (\\Deleted)").await; imap_john.assert_read(Type::Tagged, ResponseType::No).await; + // inbuxa: the right to modify messages ("w") does not cover \Deleted, + // which needs the right to remove them ("t"); the other flags in the + // same STORE still apply + imap_jane.send("SETACL INBOX jdoe@example.com +w").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_john.send("UID STORE 1 +FLAGS (\\Deleted)").await; + imap_john.assert_read(Type::Tagged, ResponseType::No).await; + imap_john + .send("UID STORE 1 +FLAGS (\\Deleted \\Flagged)") + .await; + imap_john.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_john.send("UID FETCH 1 (FLAGS)").await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\\Flagged") + .assert_count("\\Deleted", 0); + imap_jane.send("SETACL INBOX jdoe@example.com -w").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_bill.send("UID STORE 1 +FLAGS (\\Deleted)").await; imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; @@ -386,6 +406,17 @@ pub async fn test( .await .assert_count("dedup test", 0); + // inbuxa: Bill has Jane's Inbox selected when she stops sharing it + imap_bill + .send("SELECT \"Shared Folders/jane.smith@example.com/INBOX\"") + .await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_bill.send("UID FETCH 1:* (PREVIEW)").await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("dedup test"); + // Jane stops sharing with Bill, and removes Insert access to John imap_jane.send("DELETEACL INBOX foobar@example.com").await; imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; @@ -400,6 +431,18 @@ pub async fn test( .assert_contains("\"jdoe@example.com\" rl") .assert_count("foobar@example.com", 0); + // inbuxa: from then on Bill gets nothing more from the selected mailbox + for command in ["UID FETCH 1:* (PREVIEW)", "UID SEARCH ALL"] { + imap_bill.send(command).await; + imap_bill + .assert_read(Type::Tagged, ResponseType::No) + .await + .assert_contains("NOPERM") + .assert_count("dedup test", 0); + } + imap_bill.send("UNSELECT").await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + // Bill should not have access to Jane's Inbox anymore imap_bill.send("LIST \"\" \"*\"").await; imap_bill diff --git a/tests/src/imap/basic.rs b/tests/src/imap/basic.rs index d56a1527..014431b7 100644 --- a/tests/src/imap/basic.rs +++ b/tests/src/imap/basic.rs @@ -58,4 +58,10 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) { imap.assert_read(Type::Continuation, ResponseType::Ok).await; imap.send_untagged("AGJvYXR5AG1jYm9hdGZhY2U=").await; imap.assert_read(Type::Tagged, ResponseType::No).await; + + // inbuxa: before sign-in a literal may only be as large as a sign-in needs + imap.send("AUTHENTICATE PLAIN {100000}").await; + imap.assert_read(Type::Tagged, ResponseType::Bad) + .await + .assert_contains("maximum request size"); } diff --git a/tests/src/imap/managesieve.rs b/tests/src/imap/managesieve.rs index 88ef3c26..6e46d34a 100644 --- a/tests/src/imap/managesieve.rs +++ b/tests/src/imap/managesieve.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::AssertResult; @@ -11,6 +13,18 @@ use imap_proto::ResponseType; pub async fn test(test: &TestServer) { println!("Running ManageSieve tests..."); + // inbuxa: before sign-in a literal may only be as large as a sign-in needs + let mut sieve = SieveConnection::connect().await; + sieve.assert_read(ResponseType::Ok).await; + sieve + .send_literal("AUTHENTICATE \"PLAIN\" ", &"A".repeat(100000)) + .await; + let line = sieve.read_line().await; + assert!( + line.contains("maximum request size"), + "oversized pre-auth literal was read: {line}" + ); + // Connect to ManageSieve let mut sieve = SieveConnection::connect().await; sieve diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index 5652bb62..32065a8c 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ pub mod acl; @@ -32,7 +34,7 @@ use ahash::AHashSet; use imap_proto::ResponseType; use registry::{ schema::{ - enums::{Permission, SpecialUse}, + enums::{NetworkListenerProtocol, Permission, SpecialUse}, prelude::ObjectType, structs::{ Email, EmailFolder, Expression, Imap, MemoryLookupKey, MtaStageAuth, MtaStageData, @@ -51,6 +53,8 @@ pub async fn imap_tests() { .await .with_default_listeners() .await + .with_listener(NetworkListenerProtocol::Pop3, "pop3-plain", 4111, false) + .await .build() .await; diff --git a/tests/src/imap/pop.rs b/tests/src/imap/pop.rs index b86faa69..936441d9 100644 --- a/tests/src/imap/pop.rs +++ b/tests/src/imap/pop.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::{ @@ -10,6 +12,11 @@ use crate::utils::{ server::TestServer, smtp::SmtpConnection, }; +use registry::schema::{prelude::Property, structs::Imap}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpStream, +}; pub async fn test(test: &TestServer) { println!("Running POP3 tests..."); @@ -197,4 +204,59 @@ pub async fn test(test: &TestServer) { .await .assert_contains("+OK 0 0"); pop3.send("QUIT").await; + + // inbuxa: with plain-text sign-in turned off, a clear-text connection is + // offered no mechanism and accepts none, bearer tokens included + let admin = test.account("admin@example.com"); + admin + .registry_update_setting( + Imap { + allow_plain_text_auth: false, + ..Default::default() + }, + &[Property::AllowPlainTextAuth], + ) + .await; + admin.reload_settings().await; + + let (rx, mut tx) = TcpStream::connect("127.0.0.1:4111") + .await + .unwrap() + .into_split(); + let mut rx = BufReader::new(rx).lines(); + assert!(rx.next_line().await.unwrap().unwrap().starts_with("+OK")); + tx.write_all(b"CAPA\r\n").await.unwrap(); + let mut capa = Vec::new(); + loop { + let line = rx.next_line().await.unwrap().unwrap(); + if line == "." { + break; + } + capa.push(line); + } + assert!( + !capa.iter().any(|l| l.starts_with("SASL") || l == "USER"), + "clear-text CAPA offered sign-in: {capa:?}" + ); + for mechanism in ["OAUTHBEARER", "XOAUTH2", "PLAIN"] { + tx.write_all(format!("AUTH {mechanism}\r\n").as_bytes()) + .await + .unwrap(); + let line = rx.next_line().await.unwrap().unwrap(); + assert!( + line.starts_with("-ERR") && line.contains("plain-text"), + "clear-text AUTH {mechanism} was not refused: {line}" + ); + } + + admin + .registry_update_setting( + Imap { + allow_plain_text_auth: true, + ..Default::default() + }, + &[Property::AllowPlainTextAuth], + ) + .await; + admin.reload_settings().await; } diff --git a/tests/src/imap/store.rs b/tests/src/imap/store.rs index 2b361715..b64f72e3 100644 --- a/tests/src/imap/store.rs +++ b/tests/src/imap/store.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{AssertResult, ImapConnection, Type}; @@ -72,4 +74,22 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection, t .await .assert_count("FLAGS", 3) .assert_count("Answered", 0); + + // inbuxa: a keyword that is not an atom (here stored from a literal, as + // JMAP or Sieve could) is never written into FLAGS, so it cannot inject + // a response + for op in ["+", "-"] { + imap.send(&format!("UID STORE 1 {op}FLAGS (custom {{16}}")) + .await; + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + imap.send_untagged("x)\r\n* 99 EXPUNGE)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("99 EXPUNGE", 0); + imap.send("UID FETCH 1 (FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("99 EXPUNGE", 0) + .assert_count("custom", if op == "+" { 1 } else { 0 }); + } } diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs index c76f49d0..8ee98b4e 100644 --- a/tests/src/jmap/calendar/acl.rs +++ b/tests/src/jmap/calendar/acl.rs @@ -754,6 +754,275 @@ pub async fn test(test: &TestServer) { [john_calendar_id.as_str()] ); + // inbuxa: sharing a calendar shares its events only. An event in a private + // calendar whose id happens to equal the shared calendar's id stays private. + let private_calendar_id = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Private", + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let shared_calendar_id = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Shared", + "shareWith": { + &jane_id : { + "mayReadItems": true, + } + } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let mut private_event_id = None; + for num in 0..20 { + let event_id = john + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "uid": format!("private-event-{num}@example.com"), + "title": "John's private event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &private_calendar_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + if event_id == shared_calendar_id { + private_event_id = Some(event_id); + break; + } + } + let private_event_id = private_event_id.expect("no event got the shared calendar's id"); + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [private_event_id.as_str()], + ) + .await + .not_found() + .collect::>(), + [private_event_id.as_str()] + ); + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + }), + ) + .await; + assert!( + !response.ids().any(|id| id == private_event_id), + "private event listed to a sharee: {response:?}" + ); + + // inbuxa: a sharee sees only the time of a private or confidential event, + // and no other attendees of an event that hides them. + let event = |uid: &str, title: &str, extra: serde_json::Value| { + let mut event = json!({ + "uid": uid, + "title": title, + "description": "Room 4, bring the files", + "start": "2020-02-03T10:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &shared_calendar_id: true + }, + "participants": { + "a": { + "calendarAddress": "mailto:organizer@remote.org", + "roles": { "owner": true, "attendee": true } + }, + "b": { + "calendarAddress": "mailto:jdoe@example.com", + "roles": { "attendee": true } + }, + "c": { + "calendarAddress": "mailto:guest@remote.org", + "roles": { "attendee": true } + } + }, + }); + for (key, value) in extra.as_object().unwrap() { + event[key] = value.clone(); + } + event + }; + let response = john + .jmap_create( + MethodObject::CalendarEvent, + [ + event( + "private-event@example.com", + "Doctor appointment", + json!({"privacy": "private"}), + ), + event( + "confidential-event@example.com", + "Job interview", + json!({"privacy": "secret"}), + ), + event( + "hidden-attendees@example.com", + "Board meeting", + json!({"hideAttendees": true}), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let private_id = response.created(0).id().to_string(); + let confidential_id = response.created(1).id().to_string(); + let hidden_id = response.created(2).id().to_string(); + + // The owner sees everything + let response = john + .jmap_get( + MethodObject::CalendarEvent, + Vec::<&str>::new(), + [private_id.as_str(), confidential_id.as_str()], + ) + .await; + assert_eq!(response.list().len(), 2, "{response:?}"); + assert_eq!( + response.list()[0].get("title").and_then(|v| v.as_str()), + Some("Doctor appointment") + ); + + // A private event and a confidential one (JMAP "secret", stored as + // CLASS:CONFIDENTIAL) both show the sharee their time only + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + }), + ) + .await; + let ids = response.ids().collect::>(); + assert!(ids.contains(&private_id.as_str()), "{response:?}"); + assert!(ids.contains(&confidential_id.as_str()), "{response:?}"); + for (event_id, title) in [ + (private_id.as_str(), "Doctor"), + (confidential_id.as_str(), "Job"), + ] { + let response = jane + .jmap_get_account( + john, + MethodObject::CalendarEvent, + Vec::<&str>::new(), + [event_id], + ) + .await; + assert_eq!(response.not_found().count(), 0, "{response:?}"); + let shared_event = &response.list()[0]; + assert_eq!( + shared_event.get("start").and_then(|v| v.as_str()), + Some("2020-02-03T10:00:00"), + "{response:?}" + ); + assert_eq!( + shared_event.get("duration").and_then(|v| v.as_str()), + Some("PT1H"), + "{response:?}" + ); + for property in [ + "title", + "description", + "participants", + "organizerCalendarAddress", + ] { + assert!( + shared_event.get(property).is_none(), + "{property} of event {event_id} shown to a sharee: {response:?}" + ); + } + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + "filter": {"title": title}, + }), + ) + .await; + assert_eq!(response.ids().count(), 0, "{response:?}"); + let response = jane + .jmap_method_call( + "CalendarEvent/copy", + json!({ + "fromAccountId": &john_id, + "accountId": &jane_id, + "create": { + event_id: { + "calendarIds": { + &jane_calendar_id: true + } + } + } + }), + ) + .await; + assert_eq!( + response + .pointer(&format!("/methodResponses/0/1/notCreated/{event_id}/type")) + .and_then(|v| v.as_str()), + Some("forbidden"), + "{response:?}" + ); + } + + // An event that hides its attendees shows the sharee its owners only + let response = jane + .jmap_get_account( + john, + MethodObject::CalendarEvent, + ["id", "title", "participants"], + [hidden_id.as_str()], + ) + .await; + let participants = response.list()[0] + .get("participants") + .and_then(|v| v.as_object()) + .unwrap_or_else(|| panic!("{response:?}")); + let addresses = participants + .values() + .filter_map(|p| p.get("calendarAddress").and_then(|v| v.as_str())) + .collect::>(); + assert_eq!(addresses, ["mailto:organizer@remote.org"], "{response:?}"); + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + "filter": {"attendee": "guest@remote.org"}, + }), + ) + .await; + assert!(!response.ids().any(|id| id == hidden_id), "{response:?}"); + // Destroy all mailboxes john.destroy_all_calendars().await; jane.destroy_all_calendars().await; diff --git a/tests/src/jmap/calendar/event.rs b/tests/src/jmap/calendar/event.rs index 3d74b891..1778de7f 100644 --- a/tests/src/jmap/calendar/event.rs +++ b/tests/src/jmap/calendar/event.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::{ @@ -707,6 +709,37 @@ END:VCALENDAR } ])); + // inbuxa: components nested far below an event are cut at a fixed depth + // instead of being converted level by level (which overflowed the stack) + let depth = 100_000; + let deep = format!( + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:deep\r\nDTSTART:20260101T100000Z\r\n{}{}END:VEVENT\r\nEND:VCALENDAR\r\n", + "BEGIN:X-DEEP\r\n".repeat(depth), + "END:X-DEEP\r\n".repeat(depth) + ); + let response = account + .jmap_method_calls(json!([ + ["Blob/upload", { + "accountId": account.id_string(), + "create": { "deep": { "data": [{ "data:asText": deep }] } } + }, "S1"], + ["CalendarEvent/parse", { + "accountId": account.id_string(), + "blobIds": ["#deep"] + }, "P1"] + ])) + .await + .into_inner(); + let parsed = response + .pointer("/methodResponses/1/1/parsed") + .and_then(|parsed| parsed.as_object()) + .and_then(|parsed| parsed.values().next()) + .unwrap_or_else(|| panic!("{}", response.to_string().len())) + .to_string(); + assert!(parsed.contains("\"uid\":\"deep\""), "{parsed}"); + let levels = parsed.matches("x-deep").count(); + assert!((1..=32).contains(&levels), "{levels} levels"); + // Deletion tests test.wait_for_tasks().await; assert_eq!( diff --git a/tests/src/jmap/calendar/notification.rs b/tests/src/jmap/calendar/notification.rs index bdf76682..ffdb3a78 100644 --- a/tests/src/jmap/calendar/notification.rs +++ b/tests/src/jmap/calendar/notification.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::{ @@ -500,6 +502,62 @@ pub async fn test(test: &TestServer) { "organizerCalendarAddress": "mailto:jdoe@example.com" })); + // inbuxa: an invitation to an event that hides its attendees names only + // the organizer and the attendee it goes to + let mut hidden_event = test_event(); + hidden_event["uid"] = json!("hidden-attendees-imip"); + hidden_event["hideAttendees"] = json!(true); + john.jmap_create( + MethodObject::CalendarEvent, + [hidden_event.with_property( + JSCalendarProperty::::CalendarIds, + [john_calendar_id.as_str()].into_jmap_set(), + )], + [("sendSchedulingMessages", true)], + ) + .await + .created(0); + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + test.wait_for_tasks().await; + for (client, other) in [ + (jane, "mailto:bill@example.com"), + (bill, "mailto:jane.smith@example.com"), + ] { + let response = client + .jmap_method_calls(json!([ + [ + "CalendarEvent/query", + { + "accountId": client.id_string(), + "filter": {"uid": "hidden-attendees-imip"} + }, + "Q" + ], + [ + "CalendarEvent/get", + { + "accountId": client.id_string(), + "#ids": {"resultOf": "Q", "name": "CalendarEvent/query", "path": "/ids"}, + "properties": ["participants"] + }, + "G" + ] + ])) + .await; + let participants = response + .pointer("/methodResponses/1/1/list/0/participants") + .and_then(|v| v.as_object()) + .unwrap_or_else(|| panic!("invitation not received: {response:?}")); + let addresses = participants + .values() + .filter_map(|p| p.get("calendarAddress").and_then(|v| v.as_str())) + .collect::>(); + assert!( + addresses.contains(&"mailto:jdoe@example.com") && !addresses.contains(&other), + "{addresses:?}" + ); + } + // Cleanup test.wait_for_tasks().await; for client in [john, jane, bill] { diff --git a/tests/src/jmap/core/push_subscription.rs b/tests/src/jmap/core/push_subscription.rs index 538e4f0f..10d21bd0 100644 --- a/tests/src/jmap/core/push_subscription.rs +++ b/tests/src/jmap/core/push_subscription.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -377,6 +379,29 @@ pub async fn test(test: &TestServer) { ) .await; + // inbuxa: a host name that resolves to a local address is never + // contacted, not even for the verification request. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let local_id = client + .push_subscription_create( + "local", + &format!( + "https://localhost:{}/push?skip_checks=true", + listener.local_addr().unwrap().port() + ), + None, + ) + .await + .unwrap() + .take_id(); + assert!( + tokio::time::timeout(Duration::from_secs(2), listener.accept()) + .await + .is_err(), + "the push verification was sent to a local address" + ); + client.push_subscription_destroy(&local_id).await.unwrap(); + // Test the EmailPush object builder (filters and size limits) directly test_email_push_object(test).await; diff --git a/tests/src/jmap/mail/get.rs b/tests/src/jmap/mail/get.rs index 89b041e4..67ab4637 100644 --- a/tests/src/jmap/mail/get.rs +++ b/tests/src/jmap/mail/get.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{jmap::replace_blob_ids, utils::server::TestServer}; @@ -166,6 +168,73 @@ pub async fn test(test: &TestServer) { } } + // inbuxa: a property listed twice is returned once, in Email/get and + // Email/parse alike, for the object and for each body part + let blob_id = client + .upload( + None, + b"From: a@example.com\r\nSubject: twice\r\n\r\nThe body.\r\n".to_vec(), + None, + ) + .await + .unwrap() + .take_blob_id(); + let email_id = client + .email_import( + b"From: a@example.com\r\nSubject: twice\r\n\r\nThe body.\r\n".to_vec(), + [mailbox_id.clone()], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + let repeated = [ + "bodyValues", + "bodyValues", + "textBody", + "textBody", + "bodyValues", + ]; + let response = account + .jmap_raw_post( + serde_json::json!({ + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + "methodCalls": [ + ["Email/get", { + "accountId": account.id_string(), + "ids": [&email_id], + "properties": repeated, + "bodyProperties": ["partId", "partId", "size", "partId"], + "fetchAllBodyValues": true + }, "0"], + ["Email/parse", { + "accountId": account.id_string(), + "blobIds": [&blob_id], + "properties": repeated, + "bodyProperties": ["partId", "partId", "size", "partId"], + "fetchAllBodyValues": true + }, "1"] + ] + }) + .to_string(), + "application/json", + ) + .await + .text(); + for (name, count) in [ + ("\"bodyValues\"", 2), + ("\"textBody\"", 2), + ("\"partId\"", 2), + ("\"The body.", 2), + ] { + assert_eq!( + response.matches(name).count(), + count, + "{name} in {response}" + ); + } + test.destroy_all_mailboxes(account).await; test.account("admin@example.com") .registry_destroy_all(ObjectType::SpamTrainingSample) diff --git a/tests/src/jmap/mail/query.rs b/tests/src/jmap/mail/query.rs index 27eefcc0..4f3edf24 100644 --- a/tests/src/jmap/mail/query.rs +++ b/tests/src/jmap/mail/query.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::store::{deflate_test_resource, query::FIELDS}; @@ -335,7 +337,7 @@ pub async fn query(client: &Client, can_stem: bool) { (email::query::Filter::has_keyword("T")), ]), vec![ - email::query::Comparator::has_keyword("attributed to"), + email::query::Comparator::has_keyword("attributed_to"), email::query::Comparator::from(), email::query::Comparator::sent_at(), ], @@ -355,7 +357,7 @@ pub async fn query(client: &Client, can_stem: bool) { (email::query::Filter::has_keyword("T")), ]), vec![ - email::query::Comparator::has_keyword("attributed to").descending(), + email::query::Comparator::has_keyword("attributed_to").descending(), email::query::Comparator::from(), email::query::Comparator::sent_at(), ], @@ -813,7 +815,8 @@ pub async fn create(test: &TestServer, account: &Account) { || keyword == "artist" || keyword == "Bronze" { - keywords.push(keyword); + // inbuxa: a keyword can't hold a space (RFC 8621 section 4.1.1) + keywords.push(keyword.replace(' ', "_")); } } diff --git a/tests/src/jmap/mail/set.rs b/tests/src/jmap/mail/set.rs index 0080382e..67e67abb 100644 --- a/tests/src/jmap/mail/set.rs +++ b/tests/src/jmap/mail/set.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -236,6 +238,50 @@ async fn update(client: &Client, root_mailbox_id: &str) { ) .await; + // inbuxa: a keyword must be a valid name (RFC 8621 section 4.1.1), + // whether the keywords are set whole or patched one at a time + for keywords in [&["test1", "x)\r\n* 1 EXPUNGE"][..], &["two words"]] { + let mut request = client.build(); + request + .set_email() + .update(mailbox.id(0)) + .keywords(keywords.iter().copied()); + assert!(matches!( + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + } + let mut request = client.build(); + request + .set_email() + .update(mailbox.id(0)) + .keyword("x)\r\n* 1 EXPUNGE", true); + assert!(matches!( + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + assert_email_properties( + client, + mailbox.id(0), + &[&test_mailbox2_id], + &["test1", "test3"], + ) + .await; + // Orphan messages should not be permitted let mut request = client.build(); request diff --git a/tests/src/jmap/mail/submission.rs b/tests/src/jmap/mail/submission.rs index 52827cd7..8c6f05ab 100644 --- a/tests/src/jmap/mail/submission.rs +++ b/tests/src/jmap/mail/submission.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -235,6 +237,73 @@ pub async fn test(test: &TestServer) { ) .await; + // Every Bcc header is removed, including repeated and very long ones + let long_bcc = (0..3500) + .map(|i| format!("hidden_{i}@remote.org")) + .collect::>() + .join(",\r\n "); + let bcc_body = format!( + "From: jdoe@example.com\r\nTo: jane_smith@remote.org\r\nBcc: {long_bcc}\r\nSubject: long bcc\r\n\r\ntest" + ); + assert!(long_bcc.len() > u16::MAX as usize); + let bcc_email_id = client + .email_import( + bcc_body.into_bytes(), + [&mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + client + .email_submission_create_envelope( + &bcc_email_id, + &identity_id, + "jdoe@example.com", + ["jane_smith@remote.org"], + ) + .await + .unwrap(); + let delivered = expect_message_delivery(&mut smtp_rx).await; + if let Some(pos) = delivered.message.find("hidden_") { + panic!( + "Bcc leaked: [...{}...]", + &delivered.message[pos.saturating_sub(100)..(pos + 100).min(delivered.message.len())] + ); + } + assert!(delivered.message.contains("Subject: long bcc")); + + let bcc_body = concat!( + "From: jdoe@example.com\r\n", + "Bcc: hidden_one@remote.org\r\n", + "To: jane_smith@remote.org\r\n", + "Bcc: hidden_two@remote.org\r\n", + "Subject: two bccs\r\n\r\n", + "test" + ); + let bcc_email_id = client + .email_import( + bcc_body.as_bytes().to_vec(), + [&mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + client + .email_submission_create(&bcc_email_id, &identity_id) + .await + .unwrap(); + let delivered = expect_message_delivery(&mut smtp_rx).await; + assert!( + !delivered.message.contains("hidden_"), + "Bcc leaked: [{}]", + delivered.message + ); + assert!(delivered.message.contains("Subject: two bccs")); + // Manually add recipients to the envelope and confirm submission let email_submission_id = client .email_submission_create_envelope( diff --git a/tests/src/jmap/mail/thread_merge.rs b/tests/src/jmap/mail/thread_merge.rs index 1a4f4617..aedf5e2f 100644 --- a/tests/src/jmap/mail/thread_merge.rs +++ b/tests/src/jmap/mail/thread_merge.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -11,7 +13,10 @@ use crate::{ use ::email::{ cache::MessageCacheFetch, mailbox::INBOX_ID, - message::ingest::{EmailIngest, IngestEmail, IngestSource}, + message::{ + delivery::VerifiedSender, + ingest::{EmailIngest, IngestEmail, IngestSource}, + }, }; use common::auth::AccessToken; use jmap_client::{email, mailbox::Role}; @@ -26,6 +31,58 @@ use types::id::Id; pub async fn test(test: &TestServer) { test_single_thread(test).await; test_multi_thread(test).await; + test_scan_limit(test).await; +} + +// inbuxa: a new message is compared with the newest THREAD_SCAN_LIMIT messages +// sharing its thread name, not with every one of them +async fn test_scan_limit(test: &TestServer) { + println!("Running Email thread scan limit tests..."); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + let inbox_id = Id::from(INBOX_ID).to_string(); + let import = |message: String| { + let client = &client; + let inbox_id = &inbox_id; + async move { + let id = client + .email_import( + message.into_bytes(), + [inbox_id.clone()], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + Id::from_str(&id).unwrap().prefix_id() + } + }; + + let oldest_thread = import("Message-ID: \nSubject: Same\n\nm\n".into()).await; + let mut newest_thread = 0; + for i in 1..=::email::message::ingest::THREAD_SCAN_LIMIT { + newest_thread = import(format!("Message-ID: \nSubject: Same\n\nm\n")).await; + } + let newest = ::email::message::ingest::THREAD_SCAN_LIMIT; + assert_eq!( + import(format!( + "Message-ID: \nReferences: \nSubject: re: Same\n\nr\n" + )) + .await, + newest_thread + ); + assert_ne!( + import( + "Message-ID: \nReferences: \nSubject: re: Same\n\nr\n".into() + ) + .await, + oldest_thread + ); + + test.destroy_all_mailboxes(account).await; + test.wait_for_tasks().await; + test.assert_is_empty().await; } async fn test_single_thread(test_server: &TestServer) { @@ -232,7 +289,8 @@ async fn test_multi_thread(test: &TestServer) { received_at: None, source: IngestSource::Smtp { deliver_to: "test@domain.org", - is_sender_authenticated: true, + envelope_from: "", + verified_sender: VerifiedSender::FromHeader, is_spam: false, }, session_id: 0, diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs index 39ad6219..7a2a3a1b 100644 --- a/tests/src/smtp/inbound/antispam.rs +++ b/tests/src/smtp/inbound/antispam.rs @@ -376,6 +376,7 @@ async fn antispam() { let mut expect_headers = String::new(); let mut body_params = 0; let mut is_tls = false; + let mut expect_urls = None; for line in lines.by_ref() { if in_params { @@ -489,6 +490,9 @@ async fn antispam() { "tls.version" => { is_tls = true; } + "expect_urls" => { + expect_urls = Some(value.parse::().unwrap()); + } _ => panic!("Invalid parameter {param:?}"), } } else { @@ -679,6 +683,10 @@ async fn antispam() { _ => panic!("Invalid test {test_name:?}"), } + if let Some(expect_urls) = expect_urls { + assert_eq!(spam_ctx.output.urls.len(), expect_urls); + } + // Compare tags if spam_ctx.result.tags != expected_tags { for tag in &spam_ctx.result.tags { diff --git a/tests/src/smtp/inbound/data.rs b/tests/src/smtp/inbound/data.rs index a9596b21..350359a7 100644 --- a/tests/src/smtp/inbound/data.rs +++ b/tests/src/smtp/inbound/data.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -255,6 +257,21 @@ async fn data() { .await; test.expect_message().await; + // A BDAT chunk size is not allocated before its bytes arrive + size_session.mail_from("bill@doe.org", "250").await; + size_session.rcpt_to("mike@test.com", "250").await; + size_session + .ingest(b"BDAT 268435456\r\n0123456789") + .await + .unwrap(); + assert!( + size_session.data.message.capacity() < 1024 * 1024, + "BDAT reserved {} bytes before they arrived", + size_session.data.message.capacity() + ); + size_session.reset(); + size_session.state = Default::default(); + // Send broken message session .send_message("john@doe.org", &["bill@foobar.org"], "invalid", "550 5.7.7") diff --git a/tests/src/smtp/inbound/dmarc.rs b/tests/src/smtp/inbound/dmarc.rs index f29c9503..407331ba 100644 --- a/tests/src/smtp/inbound/dmarc.rs +++ b/tests/src/smtp/inbound/dmarc.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -480,4 +482,28 @@ async fn dmarc() { ) .await; test.assert_no_events(); + + // A From header naming several domains is checked against each of them + test.server.txt_add( + "test.net", + Spf::parse(b"v=spf1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "_dmarc.example.com", + Dmarc::parse(b"v=DMARC1; p=reject; aspf=s; adkim=s;").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + session + .send_message( + "joe@test.net", + &["jdoe@localdomain.org"], + concat!( + "From: joe@test.net, bill@example.com\r\n", + "To: jdoe@localdomain.org\r\n", + "Subject: test\r\n\r\ntest" + ), + "550 5.7.1", + ) + .await; } diff --git a/tests/src/smtp/outbound/mta_sts.rs b/tests/src/smtp/outbound/mta_sts.rs index 48feaa3c..a62da033 100644 --- a/tests/src/smtp/outbound/mta_sts.rs +++ b/tests/src/smtp/outbound/mta_sts.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -26,7 +28,10 @@ use registry::{ }, types::{map::Map, socketaddr::SocketAddr}, }; -use smtp::outbound::mta_sts::{lookup::STS_TEST_POLICY, parse::ParsePolicy}; +use smtp::outbound::mta_sts::{ + lookup::{MtaStsLookup, STS_TEST_POLICY}, + parse::ParsePolicy, +}; use std::{ str::FromStr, sync::Arc, @@ -264,6 +269,20 @@ async fn mta_sts_verify() { ) ); assert!(report.failure.is_none()); + + // A new policy id whose policy can't be fetched keeps the cached policy + local.server.txt_add( + "_mta-sts.foobar.org", + MtaSts::parse(b"v=STSv1; id=policy_rotated;").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + STS_TEST_POLICY.lock().clear(); + let cached = local + .server + .lookup_mta_sts_policy("foobar.org", Duration::from_secs(5)) + .await + .expect("cached MTA-STS policy was dropped after a failed fetch"); + assert_eq!(cached.id, "policy_will_work"); } #[tokio::test] diff --git a/tests/src/smtp/reporting/tls.rs b/tests/src/smtp/reporting/tls.rs index 742b2d5e..fa0ef1ca 100644 --- a/tests/src/smtp/reporting/tls.rs +++ b/tests/src/smtp/reporting/tls.rs @@ -294,5 +294,41 @@ async fn report_tls() { ["reports@foobar.org"] ); + // inbuxa: a rua host name that resolves to a local address is never + // contacted; the report goes to the mailto address instead. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let tls_record = Arc::new( + TlsRpt::parse( + format!("v=TLSRPTv1;rua=https://localhost:{port}/tls,mailto:reports@foobar.org") + .as_bytes(), + ) + .unwrap(), + ); + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: common::ipc::PolicyType::None, + failure: None, + tls_record, + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + let reports = test.read_report_events::().await; + assert_eq!(reports.len(), 1); + test.server + .send_tls_aggregate_report(reports.first().unwrap().0.id()) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(200), listener.accept()) + .await + .is_err(), + "the TLS report was sent to a local address" + ); + test.expect_message().await; + test.assert_report_is_empty::().await; } diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index bcbb56b8..a22795e8 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -23,6 +23,7 @@ use common::auth::oauth::{ ClientRegistrationRequest, ClientRegistrationResponse, TokenEndpointAuthMethod, }, }; +use futures::StreamExt; use http::auth::oauth::{ DeviceAuthResponse, ErrorType, TokenResponse, auth::{LoginRequest, LoginResponse}, @@ -30,6 +31,7 @@ use http::auth::oauth::{ use imap_proto::ResponseType; use jmap_client::{ client::{Client, Credentials}, + client_ws::WebSocketMessage, mailbox::query::Filter, }; use registry::schema::{ @@ -673,7 +675,8 @@ pub async fn test(test: &mut TestServer) { obtain_auth_code(&http, &confidential_id, confidential_redirect).await, ); params.insert("client_secret".to_string(), confidential_secret.to_string()); - let (token, _, _) = unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); + let (token, confidential_refresh, _) = + unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); let confidential_client = Client::new() .credentials(Credentials::bearer(&token)) .accept_invalid_certs(true) @@ -686,6 +689,66 @@ pub async fn test(test: &mut TestServer) { user_id.to_string() ); + // inbuxa: a WebSocket opened with the token serves it only while the + // token is valid + let mut ws = confidential_client.connect_ws().await.unwrap(); + let mut request = confidential_client.build(); + request.get_mailbox(); + request.send_ws().await.unwrap(); + assert!( + matches!( + tokio::time::timeout(Duration::from_secs(5), ws.next()).await, + Ok(Some(Ok(WebSocketMessage::Response(_)))) + ), + "the WebSocket serves a valid token" + ); + tokio::time::sleep(Duration::from_secs(6)).await; // past the 5s token + let mut request = confidential_client.build(); + request.get_mailbox(); + request.send_ws().await.unwrap(); + let after_expiry = tokio::time::timeout(Duration::from_secs(5), ws.next()).await; + assert!( + !matches!(after_expiry, Ok(Some(Ok(WebSocketMessage::Response(_))))), + "the WebSocket must stop serving an expired token, got {after_expiry:?}" + ); + drop(ws); + + // inbuxa: a refresh token is redeemed only by the client it was issued + // to, and a confidential client has to authenticate to redeem it + let mut params = AHashMap::from_iter([ + ("grant_type".to_string(), "refresh_token".to_string()), + ( + "refresh_token".to_string(), + confidential_refresh.expect("a refresh token"), + ), + ]); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + }, + "a confidential client's refresh token needs the client's secret" + ); + params.insert("client_id".to_string(), confidential_id.to_string()); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + }, + "naming the client is not authenticating it" + ); + params.insert("client_id".to_string(), client_id.to_string()); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidGrant + }, + "another client can't redeem the refresh token" + ); + params.insert("client_id".to_string(), confidential_id.to_string()); + params.insert("client_secret".to_string(), confidential_secret.to_string()); + unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); + // The correct secret in the Authorization header (client_secret_basic) also works let mut params = base_params(); params.remove("client_id"); @@ -893,7 +956,17 @@ pub async fn test(test: &mut TestServer) { ); // Wait 1 second and make sure the access token expired - tokio::time::sleep(Duration::from_secs(6)).await; // inbuxa: past the 5s token + // inbuxa: used once 3s in, the token must still stop working when it + // expires, not when its cached sign-in would + tokio::time::sleep(Duration::from_secs(3)).await; + Client::new() + .credentials(Credentials::bearer(&token)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; // inbuxa: past the 5s token assert_unauthorized("https://127.0.0.1:8899", &token).await; // Wait another second for the refresh token to be about to expire diff --git a/tests/src/system/quota.rs b/tests/src/system/quota.rs index 1fcdf536..9ec0c120 100644 --- a/tests/src/system/quota.rs +++ b/tests/src/system/quota.rs @@ -26,7 +26,7 @@ use registry::{ }, types::{EnumImpl, list::List, map::Map}, }; -use serde_json::json; +use serde_json::{Value, json}; use types::id::Id; use utils::map::vec_map::VecMap; @@ -440,6 +440,18 @@ pub async fn test(test: &mut TestServer) { 0 ); + // inbuxa: calendar events, contacts and files count towards the quota of + // the account they are written into, all the items of a request together + groupware_quota(test, &account, &other_account).await; + test.wait_for_tasks().await; + assert_eq!( + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(), + 0 + ); + // Test delivery quota let mut lmtp = SmtpConnection::connect().await; for i in 0..2 { @@ -506,6 +518,242 @@ pub async fn test(test: &mut TestServer) { test.cleanup().await; } +async fn groupware_quota(test: &TestServer, account: &Account, other_account: &Account) { + let other_id = other_account.id_string().to_string(); + let used = || async { + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap() as usize + }; + // The size of an item that fits the quota alone but not twice over + let half_size = + |used: usize, overhead: usize| ((1024 - used) / 2 + 50).saturating_sub(overhead); + + // A sharee writing into the account is held to the account's quota + let calendar_id = account + .jmap_create( + "Calendar", + [json!({ + "name": "Quota", + "shareWith": { &other_id: { "mayReadItems": true, "mayWriteAll": true } } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let event = |uid: &str, size: usize| { + json!({ + "@type": "Event", + "uid": uid, + "title": "Quota", + "description": "x".repeat(size), + "start": "2026-01-15T13:00:00", + "duration": "PT1H", + "calendarIds": { &calendar_id: true }, + }) + }; + assert_eq!( + other_account + .jmap_create_account( + account, + "CalendarEvent", + [event("quota-shared", 1200)], + Vec::<(&str, &str)>::new(), + ) + .await + .not_created(0) + .typ(), + "overQuota" + ); + + // Items created together count together + let before = used().await; + let small_id = account + .jmap_create( + "CalendarEvent", + [event("quota-0", 0)], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let overhead = used().await - before; + account + .jmap_destroy( + "CalendarEvent", + [small_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + let size = half_size(used().await, overhead); + let response = account + .jmap_create( + "CalendarEvent", + [event("quota-1", size), event("quota-2", size)], + Vec::<(&str, &str)>::new(), + ) + .await; + response.created(0); + assert_eq!(response.not_created(1).typ(), "overQuota"); + account.destroy_all_calendars().await; + + let book_id = account + .jmap_create( + "AddressBook", + [json!({ "name": "Quota" })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let contact = |uid: &str, size: usize| { + json!({ + "uid": uid, + "name": { "full": "Quota" }, + "notes": { "n1": { "note": "x".repeat(size) } }, + "addressBookIds": { &book_id: true }, + }) + }; + let before = used().await; + let small_id = account + .jmap_create( + "ContactCard", + [contact("quota-0", 1)], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let overhead = used().await - before; + account + .jmap_destroy( + "ContactCard", + [small_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + let size = half_size(used().await, overhead); + let response = account + .jmap_create( + "ContactCard", + [contact("quota-1", size), contact("quota-2", size)], + Vec::<(&str, &str)>::new(), + ) + .await; + response.created(0); + assert_eq!(response.not_created(1).typ(), "overQuota"); + account.destroy_all_addressbooks().await; + + // Files count towards the quota, created or copied in + let file_with_blob = |owner: &Account, name: &str, size: usize, extra: Value| { + let mut file = json!({ "name": name, "parentId": null, "blobId": "#file" }); + if let (Value::Object(file), Value::Object(extra)) = (&mut file, extra) { + file.extend(extra); + } + json!([ + ["Blob/upload", { + "accountId": owner.id_string(), + "create": { "file": { "data": [{ "data:asText": "f".repeat(size) }] } } + }, "0"], + ["FileNode/set", { + "accountId": owner.id_string(), + "create": { "i0": file } + }, "1"] + ]) + }; + let response = account + .jmap_method_calls(file_with_blob(account, "small.txt", 100, json!({}))) + .await + .into_inner(); + assert!( + response + .pointer("/methodResponses/1/1/created/i0/id") + .is_some(), + "{response}" + ); + let response = account + .jmap_method_calls(file_with_blob(account, "big.txt", 2000, json!({}))) + .await + .into_inner(); + assert_eq!( + response.pointer("/methodResponses/1/1/notCreated/i0/type"), + Some(&json!("overQuota")), + "{response}" + ); + + // (the upload limit is three files a second) + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + let account_id = account.id_string().to_string(); + let folder_id = other_account + .jmap_create( + "FileNode", + [json!({ + "name": "shared", + "parentId": null, + "shareWith": { &account_id: { "mayRead": true } } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let response = other_account + .jmap_method_calls(file_with_blob( + other_account, + "big.txt", + 2000, + json!({ "parentId": &folder_id }), + )) + .await + .into_inner(); + let file_id = response + .pointer("/methodResponses/1/1/created/i0/id") + .and_then(|id| id.as_str()) + .unwrap_or_else(|| panic!("{response}")) + .to_string(); + let response = account + .jmap_copy( + other_account, + account, + "FileNode", + [(&file_id, json!({ "parentId": null, "name": "copied.txt" }))], + false, + ) + .await; + assert_eq!( + response.method_response()["notCreated"][&file_id].typ(), + "overQuota" + ); + + for owner in [account, other_account] { + owner + .jmap_method_calls(json!([ + ["FileNode/get", { + "accountId": owner.id_string(), + "ids": null, + "properties": ["id"] + }, "R1"], + ["FileNode/set", { + "accountId": owner.id_string(), + "#destroy": { "resultOf": "R1", "name": "FileNode/get", "path": "/list/*/id" }, + "onDestroyRemoveChildren": true + }, "R2"] + ])) + .await; + } +} + fn assert_over_quota(result: Result) { match result { Ok(result) => panic!("Expected error, got {:?}", result), diff --git a/tests/src/system/security.rs b/tests/src/system/security.rs index dbae92c8..c4f9b4f6 100644 --- a/tests/src/system/security.rs +++ b/tests/src/system/security.rs @@ -221,6 +221,44 @@ pub async fn test(test: &mut TestServer) { ) .await; + // inbuxa: an address that fails again after its ban ran out is banned + // again, although the expired ban is still remembered and its record + // not yet cleaned up + for _ in 0..105 { + validate_password_with_ip("unknown@example.org", "wrong password", "10.0.0.2", false).await; + } + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "10.0.0.2", + false, + ) + .await; + let blocked_ids = admin + .registry_query_ids( + ObjectType::BlockedIp, + [(Property::Address, "10.0.0.2")], + Vec::<&str>::new(), + ) + .await; + assert_eq!(blocked_ids.len(), 1); + assert!( + admin + .registry_get::(blocked_ids[0]) + .await + .expires_at + .is_some_and(|at| at.timestamp() > store::write::now() as i64), + "the second ban's record runs from the second ban" + ); + tokio::time::sleep(Duration::from_secs(6)).await; + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "10.0.0.2", + true, + ) + .await; + // inbuxa: personal-data catalog, D2: the daily clean-up removes the // expired ban's record, without waiting for settings to reload test.server.purge_expired_blocked_ips().await.unwrap(); diff --git a/tests/src/utils/sieve.rs b/tests/src/utils/sieve.rs index d9877b97..7bd91206 100644 --- a/tests/src/utils/sieve.rs +++ b/tests/src/utils/sieve.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use base64::{Engine, engine::general_purpose}; @@ -87,6 +89,14 @@ impl SieveConnection { } } + pub async fn read_line(&mut self) -> String { + tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()) + .await + .expect("Timeout while waiting for server response") + .unwrap() + .expect("Connection closed") + } + pub async fn send(&mut self, text: &str) { //println!("-> {:?}", text); self.writer.write_all(text.as_bytes()).await.unwrap(); diff --git a/tests/src/webdav/acl.rs b/tests/src/webdav/acl.rs index a177df7f..b9d3934f 100644 --- a/tests/src/webdav/acl.rs +++ b/tests/src/webdav/acl.rs @@ -508,6 +508,133 @@ pub async fn test(test: &TestServer) { .await .with_status(StatusCode::NO_CONTENT); + // inbuxa: COPY and MOVE into a shared folder need the rights PUT and + // DELETE need there, and what lands there takes the folder's ACL + let file_base = DavResourceName::File.base_path(); + let jane_principal = format!( + "{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ); + let owner_drop = format!("{file_base}/bill%40example.com/drop-box/"); + let owner_team = format!("{file_base}/bill%40example.com/team-folder/"); + let owner_keep = format!("{owner_team}keep/"); + let owner_keep_file = format!("{owner_keep}notes.txt"); + let sharee_src = format!("{file_base}/john%40example.com/outbox/"); + let sharee_src_file = format!("{sharee_src}payload.txt"); + for folder in [&owner_drop, &owner_team, &owner_keep] { + owner_client + .request("MKCOL", folder, "") + .await + .with_status(StatusCode::CREATED); + } + owner_client + .request("PUT", &owner_keep_file, "keep me") + .await + .with_status(StatusCode::CREATED); + for (path, grants) in [ + (&owner_drop, ["read", "write-properties"].as_slice()), + (&owner_team, ["read", "write-content"].as_slice()), + (&owner_keep, ["read", "write"].as_slice()), + (&owner_keep_file, ["read"].as_slice()), + ] { + owner_client + .acl(path, john_principal.as_str(), grants.iter().copied()) + .await + .with_status(StatusCode::OK); + } + sharee_client + .request("MKCOL", &sharee_src, "") + .await + .with_status(StatusCode::CREATED); + sharee_client + .acl(&sharee_src, jane_principal.as_str(), ["read", "write"]) + .await + .with_status(StatusCode::OK); + sharee_client + .request("PUT", &sharee_src_file, "payload") + .await + .with_status(StatusCode::CREATED); + + // Adding to a folder needs the right to add to it, as for PUT + sharee_client + .request("PUT", &format!("{owner_drop}direct.txt"), "payload") + .await + .with_status(StatusCode::FORBIDDEN); + let copy_dest = format!("{owner_drop}payload.txt"); + sharee_client + .request_with_headers( + "COPY", + &sharee_src_file, + [("destination", copy_dest.as_str())], + "", + ) + .await + .with_status(StatusCode::FORBIDDEN); + + // Overwriting a folder needs the right to delete everything in it, as + // for DELETE + sharee_client + .request("DELETE", &owner_keep, "") + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request_with_headers( + "COPY", + &sharee_src, + [("destination", owner_keep.as_str()), ("overwrite", "T")], + "", + ) + .await + .with_status(StatusCode::FORBIDDEN); + owner_client + .request("GET", &owner_keep_file, "") + .await + .with_status(StatusCode::OK) + .with_body("keep me"); + + // A copy or a move from another account takes the destination folder's + // ACL: John's grant to Jane stays behind + for (method, name) in [("COPY", "copied"), ("MOVE", "moved")] { + let dest = format!("{owner_team}{name}/"); + sharee_client + .request_with_headers(method, &sharee_src, [("destination", dest.as_str())], "") + .await + .with_status(StatusCode::CREATED); + let dest_file = format!("{dest}payload.txt"); + let response = owner_client + .propfind(&dest, [DavProperty::WebDav(WebDavProperty::Acl)]) + .await; + for href in [dest.as_str(), dest_file.as_str()] { + response + .properties(href) + .get(DavProperty::WebDav(WebDavProperty::Acl)) + .with_some_values([format!("D:ace.D:principal.D:href:{john_principal}").as_str()]) + .without_values([format!("D:ace.D:principal.D:href:{jane_principal}").as_str()]); + } + } + for folder in [&owner_drop, &owner_team] { + owner_client + .request("DELETE", folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + // Jane's grants left John's account with the move: a grant made and + // taken back refreshes her view of it + sharee_client + .request("MKCOL", &sharee_src, "") + .await + .with_status(StatusCode::CREATED); + for grants in [["read"].as_slice(), [].as_slice()] { + sharee_client + .acl(&sharee_src, jane_principal.as_str(), grants.iter().copied()) + .await + .with_status(StatusCode::OK); + } + sharee_client + .request("DELETE", &sharee_src, "") + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client.delete_default_containers().await; owner_client.delete_default_containers().await; test.assert_is_empty().await; diff --git a/tests/src/webdav/cal_itip.rs b/tests/src/webdav/cal_itip.rs index 0c6aaaff..60c7c788 100644 --- a/tests/src/webdav/cal_itip.rs +++ b/tests/src/webdav/cal_itip.rs @@ -276,6 +276,7 @@ pub fn test() { &message.message, itip_snapshots, message.from.clone(), + 100, ) { Ok(result) => match result { MergeResult::Actions(changes) => { diff --git a/tests/src/webdav/cal_scheduling.rs b/tests/src/webdav/cal_scheduling.rs index a05ec662..9fdd2408 100644 --- a/tests/src/webdav/cal_scheduling.rs +++ b/tests/src/webdav/cal_scheduling.rs @@ -2,9 +2,12 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ + smtp::session::TestSession, utils::{server::TestServer, webdav::DummyWebDavClient}, webdav::prop::ALL_DAV_PROPERTIES, }; @@ -929,6 +932,45 @@ pub async fn test(test: &TestServer) { Vec::::new() ); + // A signed-in user can't send an iMIP request in someone else's name + let jane_events = jane_client.fetch_icals().await.len(); + let mut session = test.new_mta_session(); + session.data.authenticated_as = Some( + test.server + .account_info(bill_client.account_id) + .await + .unwrap(), + ); + session.eval_session_params().await; + session.ehlo("mx.example.com").await; + session + .send_message( + "bill@example.com", + &["jane.smith@example.com"], + &TEST_IMIP_SPOOFED + .replace( + "$START", + &DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ) + .replace( + "$END", + &DateTime::from_timestamp(now() as i64 + 2 * 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ), + "250", + ) + .await; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + test.wait_for_tasks().await; + let cals = jane_client.fetch_icals().await; + assert!( + cals.len() == jane_events && !cals.iter().any(|cal| cal.ical.contains("SPOOF9263504")), + "spoofed iMIP request was accepted: {cals:?}" + ); + for client in [bill_client, jane_client, john_client] { client.delete_default_containers().await; } @@ -1316,6 +1358,29 @@ END:VEVENT END:VCALENDAR "#; +const TEST_IMIP_SPOOFED: &str = "From: jdoe@example.com\r +To: jane.smith@example.com\r +Subject: Invitation\r +MIME-Version: 1.0\r +Content-Type: text/calendar; method=REQUEST; charset=utf-8\r +\r +BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//Example Corp.//CalDAV Client//EN\r +METHOD:REQUEST\r +BEGIN:VEVENT\r +UID:SPOOF9263504\r +SEQUENCE:0\r +DTSTART:$START\r +DTEND:$END\r +DTSTAMP:20090602T170000Z\r +SUMMARY:Spoofed\r +ORGANIZER:mailto:jdoe@example.com\r +ATTENDEE;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:jane.smith@example.com\r +END:VEVENT\r +END:VCALENDAR\r +"; + const TEST_ITIP_EXTERNAL: &str = r#"BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN diff --git a/tests/src/webdav/card_query.rs b/tests/src/webdav/card_query.rs index 39a8214f..c7a3066f 100644 --- a/tests/src/webdav/card_query.rs +++ b/tests/src/webdav/card_query.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServer; @@ -141,6 +143,25 @@ END:VCARD ) .with_href_count(3); + // Test 5: A limit of zero still caps the results + client + .request( + "REPORT", + &default_path, + &QUERY4.replace("2<", "0<"), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 507 Insufficient Storage", + ) + .with_value( + "D:multistatus.D:response.D:responsedescription", + "The number of matches exceeds the limit of 1", + ) + .with_href_count(2); + client.delete_default_containers().await; test.assert_is_empty().await; } diff --git a/tests/src/webdav/lock.rs b/tests/src/webdav/lock.rs index 4d729c03..2b02a6c7 100644 --- a/tests/src/webdav/lock.rs +++ b/tests/src/webdav/lock.rs @@ -15,6 +15,109 @@ use hyper::StatusCode; pub async fn test(test: &TestServer) { let client = test.account("john@example.com").webdav_client(); + // inbuxa: every locked resource a request touches needs one of its + // locks' tokens, not just the first one found + println!("Running LOCK tests (several resources)..."); + let base_path = format!("{}/john%40example.com", DavResourceName::File.base_path()); + let src_path = format!("{base_path}/lock-src"); + let dst_path = format!("{base_path}/lock-dst"); + let mut tokens = Vec::new(); + for path in [&src_path, &dst_path] { + client + .request("MKCOL", path, "") + .await + .with_status(StatusCode::CREATED); + tokens.push( + client + .lock_create(path, "some-client", true, "infinity", "Second-123") + .await + .with_status(StatusCode::CREATED) + .lock_token() + .to_string(), + ); + } + let src_only = format!("(<{}>)", tokens[0]); + let both = format!("(<{}>) (<{}>)", tokens[0], tokens[1]); + client + .request_with_headers( + "MOVE", + &src_path, + [ + ("destination", dst_path.as_str()), + ("overwrite", "T"), + ("if", src_only.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &dst_path); + client + .request_with_headers( + "MOVE", + &src_path, + [ + ("destination", dst_path.as_str()), + ("overwrite", "T"), + ("if", both.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + for (path, token) in [&src_path, &dst_path].into_iter().zip(&tokens) { + client + .unlock(path, token) + .await + .with_status(StatusCode::NO_CONTENT); + } + + // A shared lock's holder writes with its own token alone + let shared_path = format!("{base_path}/lock-shared"); + client + .request("MKCOL", &shared_path, "") + .await + .with_status(StatusCode::CREATED); + let mut tokens = Vec::new(); + for owner in ["client-a", "client-b"] { + tokens.push( + client + .lock_create(&shared_path, owner, false, "infinity", "Second-123") + .await + .with_status(StatusCode::CREATED) + .lock_token() + .to_string(), + ); + } + let file_path = format!("{shared_path}/file.txt"); + client + .request("PUT", &file_path, "content") + .await + .with_status(StatusCode::LOCKED); + client + .request_with_headers( + "PUT", + &file_path, + [("if", format!("(<{}>)", tokens[1]).as_str())], + "content", + ) + .await + .with_status(StatusCode::CREATED); + for token in &tokens { + client + .unlock(&shared_path, token) + .await + .with_status(StatusCode::NO_CONTENT); + } + client + .request("DELETE", &shared_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + client + .request("DELETE", &dst_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + for resource_type in [ DavResourceName::File, DavResourceName::Cal, diff --git a/tests/src/webdav/sync.rs b/tests/src/webdav/sync.rs index 5a86511e..272d474a 100644 --- a/tests/src/webdav/sync.rs +++ b/tests/src/webdav/sync.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::{server::TestServer, webdav::GenerateTestDavResource}; @@ -287,6 +289,85 @@ pub async fn test(test: &TestServer) { ); } + // inbuxa: a sync reports only deletions inside the synced collection + // that the caller could see + let sharee_client = test.account("jane@example.com").webdav_client(); + let sharee_principal = format!( + "{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ); + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running REPORT sync-collection deletion scope tests ({})...", + resource_type.base_path() + ); + let user_base_path = format!("{}/john%40example.com/", resource_type.base_path()); + let shared = format!("{user_base_path}sync-shared/"); + let private = format!("{user_base_path}sync-private/"); + let shared_item = format!("{shared}item"); + let private_item = format!("{private}secret-plans"); + for (folder, item) in [(&shared, &shared_item), (&private, &private_item)] { + client + .request("MKCOL", folder, "") + .await + .with_status(StatusCode::CREATED); + client + .request("PUT", item, resource_type.generate()) + .await + .with_status(StatusCode::CREATED); + } + client + .acl(&shared, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + if resource_type == DavResourceName::File { + client + .acl(&shared_item, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + } + let sharee_token = sharee_client + .sync_collection(&shared, "", Depth::One, None, ["D:getetag"]) + .await + .sync_token() + .to_string(); + let owner_token = client + .sync_collection(&shared, "", Depth::One, None, ["D:getetag"]) + .await + .sync_token() + .to_string(); + client + .request("DELETE", &private_item, "") + .await + .with_status(StatusCode::NO_CONTENT); + for (client, token) in [(&sharee_client, &sharee_token), (&client, &owner_token)] { + let response = client + .sync_collection(&shared, token, Depth::One, None, ["D:getetag"]) + .await; + assert_eq!(response.hrefs(), Vec::<&str>::new()); + } + let mut shared_paths = vec![&shared]; + if resource_type == DavResourceName::File { + shared_paths.push(&shared_item); + } + for path in shared_paths { + client + .acl(path, sharee_principal.as_str(), []) + .await + .with_status(StatusCode::OK); + } + for folder in [&shared, &private] { + client + .request("DELETE", folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + } + client.delete_default_containers().await; test.assert_is_empty().await; } diff --git a/vendor/sieve-rs/VENDORED.md b/vendor/sieve-rs/VENDORED.md index d5e40258..42c3fab5 100644 --- a/vendor/sieve-rs/VENDORED.md +++ b/vendor/sieve-rs/VENDORED.md @@ -20,6 +20,15 @@ Every changed file carries the AGPL 5(a) notice in its header. (the server sets its own name on both of its runtimes anyway), and the test-only capability is `vnd.inbuxa.testsuite`. - `src/lib.rs`: the test-suite environment variables are `vnd.inbuxa.*`. +- `src/runtime/tests/comparator.rs`, `src/compiler/grammar/mod.rs`: every + `:regex` pattern is built through `build_regex`, which holds the compiled + matcher and its lazy DFA to 1 MiB each (fancy-regex's default is 10 MiB), + and a pattern built from variables is compiled once per run (`RegexCache`, + 32 patterns, a field of `Context` in `src/lib.rs` and + `src/runtime/context.rs`) instead of once for every value it is tested + against. Every caller of `Comparator::regex` passes the cache. The + `sieve_regexes_are_size_limited` and `sieve_variable_patterns_compile_once` + tests in `crates/common` cover both. - `Cargo.toml`: the example target is dropped, with the top-level `examples/` and `tests/`, which the server never builds. (`src/` keeps its own `tests` modules: the crate declares them.) diff --git a/vendor/sieve-rs/src/compiler/grammar/mod.rs b/vendor/sieve-rs/src/compiler/grammar/mod.rs index f9f4c187..855875bb 100644 --- a/vendor/sieve-rs/src/compiler/grammar/mod.rs +++ b/vendor/sieve-rs/src/compiler/grammar/mod.rs @@ -487,7 +487,7 @@ impl CompilerState<'_> { if matches!(match_type, MatchType::Regex(_)) { for key in key_list { if let Value::Text(expr) = key { - match fancy_regex::Regex::new(expr) { + match crate::runtime::tests::comparator::build_regex(expr) { Ok(regex) => { *key = Value::Regex(Regex::new(expr.to_string(), regex)); } diff --git a/vendor/sieve-rs/src/lib.rs b/vendor/sieve-rs/src/lib.rs index da509b56..0ad11608 100644 --- a/vendor/sieve-rs/src/lib.rs +++ b/vendor/sieve-rs/src/lib.rs @@ -137,6 +137,7 @@ pub struct Context<'x> { pub(crate) num_redirects: usize, pub(crate) num_instructions: usize, pub(crate) num_out_messages: usize, + pub(crate) regex_cache: runtime::tests::comparator::RegexCache, } #[derive(Debug, Clone, Eq, PartialEq, Hash)] diff --git a/vendor/sieve-rs/src/runtime/actions/action_editheader.rs b/vendor/sieve-rs/src/runtime/actions/action_editheader.rs index 6c4c88b3..a124cf6a 100644 --- a/vendor/sieve-rs/src/runtime/actions/action_editheader.rs +++ b/vendor/sieve-rs/src/runtime/actions/action_editheader.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::borrow::Cow; @@ -91,6 +93,7 @@ impl DeleteHeader { &mut Vec::new(), ), MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/context.rs b/vendor/sieve-rs/src/runtime/context.rs index d48fcf5c..64a6c4ff 100644 --- a/vendor/sieve-rs/src/runtime/context.rs +++ b/vendor/sieve-rs/src/runtime/context.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::{borrow::Cow, sync::Arc, time::SystemTime}; @@ -70,6 +72,7 @@ impl<'x> Context<'x> { num_redirects: 0, num_instructions: 0, num_out_messages: 0, + regex_cache: Default::default(), last_message_id: 0, main_message_id: 0, virus_status: VirusStatus::Unknown, @@ -680,6 +683,7 @@ impl<'x> Context<'x> { num_redirects: 0, num_instructions: 0, num_out_messages: 0, + regex_cache: Default::default(), last_message_id: 0, main_message_id: 0, virus_status: VirusStatus::Unknown, diff --git a/vendor/sieve-rs/src/runtime/tests/comparator.rs b/vendor/sieve-rs/src/runtime/tests/comparator.rs index c8e6a551..21b92e50 100644 --- a/vendor/sieve-rs/src/runtime/tests/comparator.rs +++ b/vendor/sieve-rs/src/runtime/tests/comparator.rs @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use std::{borrow::Cow, sync::Arc}; +use std::{borrow::Cow, cell::RefCell, sync::Arc}; use crate::{ MatchAs, @@ -17,6 +19,50 @@ use crate::{ use super::glob::GlobPattern; +// inbuxa: the most a compiled pattern may take (each of the matcher and its +// lazy DFA cache); the default of 10 MiB let every :regex key of a stored +// script hold that much +pub(crate) const REGEX_SIZE_LIMIT: usize = 1024 * 1024; + +pub(crate) fn build_regex(expr: &str) -> Result { + fancy_regex::RegexBuilder::new(expr) + .delegate_size_limit(REGEX_SIZE_LIMIT) + .delegate_dfa_size_limit(REGEX_SIZE_LIMIT) + .build() +} + +// inbuxa: patterns built from variables ("${x}") are compiled once per run, not +// once for every value they are tested against +const REGEX_CACHE_SIZE: usize = 32; + +#[derive(Debug, Clone, Default)] +pub(crate) struct RegexCache(RefCell)>>); + +impl RegexCache { + fn eval( + &self, + expr: &str, + value: &str, + capture_positions: u64, + captured_values: &mut Vec<(usize, String)>, + ) -> bool { + let mut cache = self.0.borrow_mut(); + let idx = if let Some(idx) = cache.iter().position(|(cached, _)| cached == expr) { + idx + } else { + if cache.len() == REGEX_CACHE_SIZE { + cache.remove(0); + } + cache.push((expr.to_string(), build_regex(expr).ok())); + cache.len() - 1 + }; + cache[idx] + .1 + .as_ref() + .is_some_and(|regex| eval_regex(regex, value, capture_positions, captured_values)) + } +} + pub(crate) trait Comparable { fn to_str(&'_ self) -> Cow<'_, str>; fn to_number(&self) -> Number; @@ -68,8 +114,10 @@ impl Comparator { } } + #[allow(clippy::too_many_arguments)] pub(crate) fn regex( &self, + cache: &RegexCache, pattern: &Value, pattern_expr: &Variable, value: &str, @@ -81,7 +129,7 @@ impl Comparator { if let Some(regex) = lazy_regex.as_ref() { eval_regex(regex, value, capture_positions, captured_values) } else { - match fancy_regex::Regex::new(®ex.expr) { + match build_regex(®ex.expr) { Ok(fancy_regex) => { let result = eval_regex(&fancy_regex, value, capture_positions, captured_values); @@ -95,13 +143,12 @@ impl Comparator { } } } else { - match fancy_regex::Regex::new(pattern_expr.to_string().as_ref()) { - Ok(regex) => eval_regex(®ex, value, capture_positions, captured_values), - Err(err) => { - debug_assert!(false, "Failed to compile regex: {err:?}"); - false - } - } + cache.eval( + pattern_expr.to_string().as_ref(), + value, + capture_positions, + captured_values, + ) } } diff --git a/vendor/sieve-rs/src/runtime/tests/test_address.rs b/vendor/sieve-rs/src/runtime/tests/test_address.rs index 827b07e3..13cbe48c 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_address.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_address.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::{ @@ -90,6 +92,7 @@ impl TestAddress { return true; } } else if self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_body.rs b/vendor/sieve-rs/src/runtime/tests/test_body.rs index 79e5d4b3..4ab03c6b 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_body.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_body.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::{MimeHeaders, PartType, decoders::html::html_to_text}; @@ -45,10 +47,14 @@ impl TestBody { 0, &mut Vec::new(), ), - MatchType::Regex(_) => { - self.comparator - .regex(pattern, key, subject, 0, &mut Vec::new()) - } + MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, + pattern, + key, + subject, + 0, + &mut Vec::new(), + ), _ => break, }; @@ -203,10 +209,14 @@ impl TestBody { 0, &mut Vec::new(), ), - MatchType::Regex(_) => { - self.comparator - .regex(pattern, key, text.as_ref(), 0, &mut Vec::new()) - } + MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, + pattern, + key, + text.as_ref(), + 0, + &mut Vec::new(), + ), _ => false, }; diff --git a/vendor/sieve-rs/src/runtime/tests/test_envelope.rs b/vendor/sieve-rs/src/runtime/tests/test_envelope.rs index 57f33247..7eb6921c 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_envelope.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_envelope.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::DateTime; @@ -63,6 +65,7 @@ impl TestEnvelope { return true; } } else if self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_header.rs b/vendor/sieve-rs/src/runtime/tests/test_header.rs index 64b188ad..58ddd483 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_header.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_header.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::{Header, HeaderName, HeaderValue, parsers::MessageStream}; @@ -89,6 +91,7 @@ impl TestHeader { return true; } } else if self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_metadata.rs b/vendor/sieve-rs/src/runtime/tests/test_metadata.rs index a1ca0308..012a94ae 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_metadata.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_metadata.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -81,6 +83,7 @@ impl TestMetadata { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, pattern, &key, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_notify.rs b/vendor/sieve-rs/src/runtime/tests/test_notify.rs index 9504b190..60079880 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_notify.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_notify.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::borrow::Cow; @@ -83,10 +85,14 @@ impl TestNotifyMethodCapability { 0, &mut Vec::new(), ), - MatchType::Regex(_) => { - self.comparator - .regex(pattern, &key, "maybe", 0, &mut Vec::new()) - } + MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, + pattern, + &key, + "maybe", + 0, + &mut Vec::new(), + ), _ => false, } { return TestResult::Bool(true ^ self.is_not); diff --git a/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs b/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs index a05102b7..d0958e42 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -41,6 +43,7 @@ impl TestSpamTest { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, &self.value, &value, status.to_string().as_ref(), @@ -85,6 +88,7 @@ impl TestVirusTest { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, &self.value, &value, status.to_string().as_ref(), diff --git a/vendor/sieve-rs/src/runtime/tests/test_string.rs b/vendor/sieve-rs/src/runtime/tests/test_string.rs index 098594c7..e918ed79 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_string.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_string.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -79,6 +81,7 @@ impl TestString { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, pattern, &key, source.to_string().as_ref(),