Security fixes for 2026.10.29 and 27.1-rc.2 #226

Merged
jcoffey-dev merged 34 commits from sec/batch-2026-10-29 into main 2026-10-29 05:02:05 +00:00
125 changed files with 4605 additions and 644 deletions

No files matched your search

+26
View File
@@ -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<u64> {
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<u64>) -> 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(),
+29 -11
View File
@@ -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<u64> {
serde_json::from_slice::<serde_json::Value>(&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()
+3
View File
@@ -134,6 +134,8 @@ pub struct AccessToken {
inner: Arc<AccessTokenInner>,
// inbuxa: how this session signed in, for the audit log (AU-5)
origin: Option<Arc<inbuxa_features::audit::Via>>,
// inbuxa: when the bearer token this session signed in with expires
expires_at: Option<u64>,
}
#[derive(Debug, Default, Clone)]
@@ -320,6 +322,7 @@ impl BuildAccessToken for Arc<AccessTokenInner> {
AccessToken {
scope_idx: 0,
origin: None,
expires_at: None,
inner: self,
}
}
+2 -2
View File
@@ -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));
@@ -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:?}");
}
}
@@ -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(),
+13 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
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
+9
View File
@@ -303,6 +303,8 @@ pub struct HttpAuthCache {
pub expires: Instant,
// inbuxa: how the cached credentials signed in (AU-5)
pub origin: Option<Arc<inbuxa_features::audit::Via>>,
// inbuxa: when the cached bearer token expires
pub expires_at: Option<u64>,
}
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,
+1
View File
@@ -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;
+125
View File
@@ -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::<Vec<_>>();
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::<IpAddr>() {
// 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"
);
}
}
+58 -14
View File
@@ -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<IpAddr>) {
if self
.blocked_ip_addresses
.get(&ip)
.is_none_or(|existing| existing.expires_at < ip.expires_at)
{
self.blocked_ip_addresses.replace(ip);
}
}
}
impl<T: PartialEq + Eq + Hash> Hash for IpWithTtl<T> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.ip.hash(state);
+32 -1
View File
@@ -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<Item = Acl>,
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,
+10
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
@@ -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
+30
View File
@@ -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<AccountCache>,
reserved: u64,
}
impl QuotaBudget {
pub fn new(account: Arc<AccountCache>) -> Self {
Self {
account,
reserved: 0,
}
}
}
impl Server {
pub async fn get_used_quota_account(&self, account_id: u32) -> trc::Result<i64> {
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]
+101 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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#"<?xml version="1.0" encoding="utf-8" ?>
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:example:z">
<D:set>
<D:prop>
<Z:note a='x" ><evil/>' b="&amp;&quot;" c='&lt;q&gt;'><Z:outer><Z:inner>text</Z:inner><Z:empty/></Z:outer></Z:note>
</D:prop>
</D:set>
</D:propertyupdate>"#;
let update = PropertyUpdate::parse(&mut Tokenizer::new(xml.as_bytes())).unwrap();
let output = update
.set
.iter()
.map(|property| property.to_string())
.collect::<String>();
let wrapped = format!("<root>{output}</root>");
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" ><evil/>"#), ("b", r#"&""#), ("c", "<q>")] {
assert!(
attributes.contains(&(name.to_string(), value.to_string())),
"{name}={value:?} not in {attributes:?}"
);
}
}
#[test]
fn parse_address_data_version() {
let xml = r#"<?xml version="1.0" encoding="utf-8" ?>
+83 -9
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
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, "</{name}>")?;
}
}
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, "</{name}>")?;
}
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" ><evil/>" b="y""#.to_string()),
}),
DeadPropertyTag::ElementStart(DeadElementTag {
name: "inner".to_string(),
attrs: Some(r#"c="<evil/>""#.to_string()),
}),
DeadPropertyTag::Text("text".to_string()),
DeadPropertyTag::ElementEnd,
DeadPropertyTag::ElementEnd,
]);
assert_eq!(
Format(property).to_string(),
r#"<note xmlns="urn:example:z" a="x"><inner>text</inner></note>"#
);
}
#[test]
fn parse_responses() {
for (num, test) in [
+6 -11
View File
@@ -2,11 +2,13 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use 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))
}
}
+7 -2
View File
@@ -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,
+51 -17
View File
@@ -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::<Vec<_>>();
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<String> = 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::<Vec<_>>();
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;
+32 -8
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use 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();
}
+40 -16
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use 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<String>,
pub document_id: Option<u32>,
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<AclGrant>,
}
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::<FileNode>()
.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(),
}
}
}
+146
View File
@@ -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<Vec<String>> {
}
}
/// 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!("[email protected]");
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<Account> {
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("[email protected]"),
"{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!(
+113 -47
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use 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<Arc<Semaphore>> = LazyLock::new(|| {
Arc::new(Semaphore::new(
std::thread::available_parallelism().map_or(4, |n| n.get()),
))
});
async fn run_hasher<T: Send + 'static>(
hasher: impl FnOnce() -> T + Send + 'static,
) -> trc::Result<T> {
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<bool> {
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<bool> {
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<b
Scrypt::default().verify_password(&secret, &hash)
};
tx.send(Ok(result.is_ok())).ok();
Ok(result.is_ok())
}
Err(err) => {
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<u8>) -> trc::Result<String> {
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<u8>) -> 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<u8>) -> 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()
+16 -3
View File
@@ -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<IngestRecipient>,
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,
+63 -58
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use 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;
+5 -4
View File
@@ -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,
+4 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use 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),
},
}
}
+8 -2
View File
@@ -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 {
+81
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<SupportedComponent> 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::<String, String>(ConversionOptions::default()),
)
.unwrap();
jcal.matches("x-deep").count()
})
.unwrap()
.join()
.unwrap();
assert!(
(1..=MAX_COMPONENT_DEPTH).contains(&jcal),
"{jcal} levels kept"
);
}
}
+233
View File
@@ -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<ItipMessage<ICalendar>>) -> Vec<ItipMessage<ICalendar>> {
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<ItipMessage<ICalendar>>,
event_flags: u16,
) -> Vec<ItipMessage<ICalendar>> {
if event_flags & EVENT_HIDE_ATTENDEES != 0 {
itip_hide_attendees(messages)
} else {
messages
}
}
+2 -1
View File
@@ -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<Archive<&ArchivedCalendarEvent>> {
.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!())?;
}
+186 -70
View File
@@ -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<MergeResult, ItipError> {
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<MergeAction>,
) -> 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(&param.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::<Vec<_>>();
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<ICalendarParameterName>, Vec<ICalendarParameter>) {
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<i64> {
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::<Vec<_>>();
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<MergeAction>) {
let mut remove_component_ids: Vec<u32> = Vec::new();
for action in changes {
+26 -13
View File
@@ -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)
+38 -16
View File
@@ -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!())
+6 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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()
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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' ');
}
+12
View File
@@ -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<u8>) {
buf.extend_from_slice(match self {
Flag::Seen => b"\\Seen",
+31 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<T: CommandParser> {
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<u8>,
}
@@ -325,10 +332,10 @@ impl<T: CommandParser> Receiver<T> {
)));
}
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<u8> {
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::<Command>::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 [
+18 -1
View File
@@ -444,7 +444,24 @@ impl<T: SessionStream> Session<T> {
| 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
+19
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<T: SessionStream> SessionData<T> {
.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<bool> {
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,
}
}
}
+21 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<T: SessionStream> Session<T> {
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<T: SessionStream> Session<T> {
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,
+9 -1
View File
@@ -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<T: SessionStream> Session<T> {
.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<T: SessionStream> Session<T> {
pub async fn handle_unauthenticate(&mut self, request: Request<Command>) -> 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;
+10 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<T: SessionStream> SessionData<T> {
// 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
+65 -19
View File
@@ -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<T: SessionStream> SessionData<T> {
}
// 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<T: SessionStream> SessionData<T> {
.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;
+5 -12
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<T: JmapObject> Default for GetRequest<T> {
impl<T: JmapObject> GetRequest<T> {
pub fn unwrap_properties(&mut self, default: &[T::Property]) -> Vec<T::Property> {
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);
}
+4 -1
View File
@@ -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)),
+10 -1
View File
@@ -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<Item = Self::Item>;
}
// 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<T: FromStr + Hash + Eq + Clone>(list: Vec<MaybeInvalid<T>>) -> Vec<T> {
let mut seen = ahash::AHashSet::with_capacity(list.len());
list.into_valid()
.filter(|item| seen.insert(item.clone()))
.collect()
}
impl<T: FromStr> IntoValid for Vec<MaybeInvalid<T>> {
type Item = T;
+11 -2
View File
@@ -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,
}
+43 -4
View File
@@ -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::<RoaringBitmap>()
} 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::<CalendarEvent>()
.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(),
+36 -28
View File
@@ -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::<RoaringBitmap>()
} 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::<CalendarEvent>()
.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::<Id, BlobId>(
ConversionOptions::default()
+6 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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::<Id, BlobId>(ConversionOptions::default())
.into_inner()
+44 -6
View File
@@ -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<TimeRange> = 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?;
+26 -19
View File
@@ -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<RoaringBitmap>,
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::<Id, BlobId>();
@@ -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<RoaringBitmap>,
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()));
+4 -3
View File
@@ -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<CopyResponse<contact::ContactCard>> {
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(),
+33 -33
View File
@@ -2,15 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<RoaringBitmap>,
js_contact: JSContact<'_, Id, BlobId>,
@@ -65,7 +64,8 @@ impl ContactCardSet for Server {
_session: &HttpSessionData,
) -> trc::Result<SetResponse<contact::ContactCard>> {
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<RoaringBitmap>,
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))
}
}
+8 -1
View File
@@ -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())
+2 -2
View File
@@ -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,
+11 -1
View File
@@ -2,10 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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,
+10
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<EmailProperty>),
}
/// 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<EmailProperty> {
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<EmailProperty>,
value: Value<'_, EmailProperty, EmailValue>,
+28 -29
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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,
+15 -1
View File
@@ -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()
+17 -1
View File
@@ -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<u32> = 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());
+33 -1
View File
@@ -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<u32>> = AHashMap::new();
let mut implicit_destroys: AHashSet<u32> = 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());
+34 -17
View File
@@ -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::<NullIo>::local(
@@ -1003,3 +989,34 @@ fn rcpt_into_static(rcpt: RcptTo<Cow<'_, str>>) -> RcptTo<Cow<'static, str>> {
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<u8>) -> Vec<u8> {
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::<Vec<_>>()
})
})
.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
}
+107 -7
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<AccessToken> {
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<TokioIo<Upgraded>>,
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;
}
+24 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<T: SessionStream> Session<T> {
}
pub async fn into_tls(self) -> Result<Session<TlsStream<T>>, ()> {
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
+9 -1
View File
@@ -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<T: SessionStream> Session<T> {
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<Vec<u8>> {
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),
+3 -13
View File
@@ -205,10 +205,9 @@ impl<T: SessionStream> Session<T> {
) -> trc::Result<Command<String, Mechanism>> {
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<T: SessionStream> Session<T> {
.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)
+4 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<T: SessionStream> Session<T> {
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!(
+8 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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()
}
+67 -5
View File
@@ -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<T: SessionStream> Session<T> {
}
};
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::<Vec<_>>()
} else {
vec![Cow::Borrowed(&auth_message)]
};
let mut dmarc_output: Option<DmarcOutput> = 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<T: SessionStream> Session<T> {
},
))
.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<String> {
let mut domains: Vec<String> = 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),
}
}
+3 -5
View File
@@ -79,11 +79,9 @@ impl<T: SessionStream> Session<T> {
} 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!(
+14 -4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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,
+53 -35
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<Arc<Policy>, 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 {
+12
View File
@@ -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
+33 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<String> = 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<Option<String>> {
#[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<ElementLocation<UrlParts<'static>>>,
inferred_urls: &mut HashSet<ElementLocation<UrlParts<'static>>>,
+58
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<Keyword>> From<Keyword> for Value<'x, P, E> {
fn from(id: Keyword) -> Self {
Value::Element(E::from(id))
@@ -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 [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
SEQUENCE:0
ORGANIZER:mailto:[email protected]
ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:[email protected]
ATTENDEE;RSVP=TRUE:mailto:[email protected]
ATTENDEE;RSVP=TRUE:mailto:[email protected]
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 [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
METHOD:REFRESH
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
ORGANIZER:mailto:[email protected]
ATTENDEE:mailto:[email protected]
DTSTAMP:20261027T130000Z
END:VEVENT
END:VCALENDAR
> send
SenderIsNotParticipant("[email protected]")
# Organizer removes the second attendee (1)
> put [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
SEQUENCE:1
ORGANIZER:mailto:[email protected]
ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:[email protected]
ATTENDEE;RSVP=TRUE:mailto:[email protected]
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 [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
METHOD:REFRESH
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
ORGANIZER:mailto:[email protected]
ATTENDEE:mailto:[email protected]
DTSTAMP:20261027T140000Z
END:VEVENT
END:VCALENDAR
> send
SenderIsNotParticipant("[email protected]")
# The removed attendee replies to an instance the organizer hasn't changed
> itip [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
METHOD:REPLY
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
SEQUENCE:1
RECURRENCE-ID:20270105T180000Z
ORGANIZER:mailto:[email protected]
ATTENDEE;PARTSTAT=ACCEPTED:mailto:[email protected]
DTSTAMP:20261027T150000Z
END:VEVENT
END:VCALENDAR
> send
SenderIsNotParticipant("[email protected]")
# An attendee replies to one instance and adds a stranger to it
> itip [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
METHOD:REPLY
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
SEQUENCE:1
RECURRENCE-ID:20270105T180000Z
ORGANIZER;CN="Not A":mailto:[email protected]
ATTENDEE;PARTSTAT=DECLINED:mailto:[email protected]
ATTENDEE;PARTSTAT=ACCEPTED:mailto:[email protected]
DTSTAMP:20261027T160000Z
END:VEVENT
END:VCALENDAR
> send
# An attendee replies to a date the series doesn't have
> itip [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
METHOD:REPLY
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
SEQUENCE:1
RECURRENCE-ID:20270110T180000Z
ORGANIZER:mailto:[email protected]
ATTENDEE;PARTSTAT=DECLINED:mailto:[email protected]
DTSTAMP:20261027T170000Z
END:VEVENT
END:VCALENDAR
> send
# Only the attendee's own answer reaches the organizer's copy
> get [email protected] [email protected]
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
VERSION:2.0
BEGIN:VEVENT
ATTENDEE;PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:[email protected]
ORGANIZER:mailto:[email protected]
RECURRENCE-ID:20270105T180000Z
UID:[email protected]
DTSTAMP:1
SEQUENCE:2
END:VEVENT
BEGIN:VEVENT
STATUS:CONFIRMED
SUMMARY:Standup
DTEND:20270104T183000Z
DTSTART:20270104T180000Z
ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:[email protected]
ATTENDEE;RSVP=TRUE:mailto:[email protected]
ORGANIZER:mailto:[email protected]
UID:[email protected]
RRULE:FREQ=DAILY;COUNT=3
DTSTAMP:0
SEQUENCE:2
END:VEVENT
END:VCALENDAR
+4 -4
View File
@@ -436,7 +436,7 @@ ATTENDEE;PARTSTAT=IN-PROCESS:mailto:[email protected]
PERCENT-COMPLETE:75
UID:[email protected]
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:[email protected]
ORGANIZER:mailto:[email protected]
RECURRENCE-ID:19980101T170000Z
RECURRENCE-ID:19980102T100000Z
UID:[email protected]
DTSTAMP:0
SEQUENCE:1
@@ -489,9 +489,9 @@ SEQUENCE:1
END:VTODO
BEGIN:VTODO
PERCENT-COMPLETE:75
ATTENDEE;PARTSTAT=IN-PROCESS:mailto:[email protected]
ATTENDEE;CUTYPE=INDIVIDUAL;PARTSTAT=IN-PROCESS;SCHEDULE-STATUS=2.0:mailto:[email protected]
ORGANIZER:mailto:[email protected]
RECURRENCE-ID:19980101T170000Z
RECURRENCE-ID:19980102T100000Z
UID:[email protected]
DTSTAMP:0
SEQUENCE:1
+22 -21
View File
@@ -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:[email protected]
ORGANIZER;CN="Cyrus Daboo":mailto:[email protected]
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:[email protected]
ORGANIZER;CN="Cyrus Daboo":mailto:[email protected]
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:[email protected]
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:[email protected]
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:[email protected]
ORGANIZER;CN="Cyrus Daboo":mailto:[email protected]
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:[email protected]
ORGANIZER;CN="Cyrus Daboo":mailto:[email protected]
RECURRENCE-ID;TZID=America/Montreal:20090602T150000
UID:9263504FD3AD
DTSTAMP:0
SEQUENCE:1
END:VEVENT
BEGIN:VTIMEZONE
TZID:America/Montreal
BEGIN:STANDARD
+27
View File
@@ -46,6 +46,33 @@ Subject: redirect to omograph
login to https://www.redirect.com/?https://xn--twiter-507b.com
<!-- NEXT TEST -->
expect REDIRECTOR_URL HOMOGRAPH_URL
Subject: plain http redirect to omograph
login to http://www.redirect.com/?https://xn--twiter-507b.com
<!-- NEXT TEST -->
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/
<!-- NEXT TEST -->
expect HAS_ONION_URI HAS_ANON_DOMAIN
Subject: url in title darkweb.onion/login
+43
View File
@@ -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 [email protected] +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 [email protected] -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/[email protected]/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 [email protected]").await;
imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await;
@@ -400,6 +431,18 @@ pub async fn test(
.assert_contains("\"[email protected]\" rl")
.assert_count("[email protected]", 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
+6
View File
@@ -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");
}
+14
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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
+5 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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;
+62
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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("[email protected]");
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;
}
+20
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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 });
}
}
+269
View File
@@ -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>::Id, JSCalendarProperty::Title],
[private_event_id.as_str()],
)
.await
.not_found()
.collect::<Vec<_>>(),
[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:[email protected]",
"roles": { "owner": true, "attendee": true }
},
"b": {
"calendarAddress": "mailto:[email protected]",
"roles": { "attendee": true }
},
"c": {
"calendarAddress": "mailto:[email protected]",
"roles": { "attendee": true }
}
},
});
for (key, value) in extra.as_object().unwrap() {
event[key] = value.clone();
}
event
};
let response = john
.jmap_create(
MethodObject::CalendarEvent,
[
event(
"[email protected]",
"Doctor appointment",
json!({"privacy": "private"}),
),
event(
"[email protected]",
"Job interview",
json!({"privacy": "secret"}),
),
event(
"[email protected]",
"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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(addresses, ["mailto:[email protected]"], "{response:?}");
let response = jane
.jmap_method_call(
"CalendarEvent/query",
json!({
"accountId": &john_id,
"filter": {"attendee": "[email protected]"},
}),
)
.await;
assert!(!response.ids().any(|id| id == hidden_id), "{response:?}");
// Destroy all mailboxes
john.destroy_all_calendars().await;
jane.destroy_all_calendars().await;
+33
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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!(
+58
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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:[email protected]"
}));
// 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::<Id>::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:[email protected]"),
(bill, "mailto:[email protected]"),
] {
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::<Vec<_>>();
assert!(
addresses.contains(&"mailto:[email protected]") && !addresses.contains(&other),
"{addresses:?}"
);
}
// Cleanup
test.wait_for_tasks().await;
for client in [john, jane, bill] {
+25
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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;
+69
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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: [email protected]\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: [email protected]\r\nSubject: twice\r\n\r\nThe body.\r\n".to_vec(),
[mailbox_id.clone()],
None::<Vec<String>>,
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("[email protected]")
.registry_destroy_all(ObjectType::SpamTrainingSample)
+6 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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(' ', "_"));
}
}
+46
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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
+69
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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::<Vec<_>>()
.join(",\r\n ");
let bcc_body = format!(
"From: [email protected]\r\nTo: [email protected]\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::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id();
client
.email_submission_create_envelope(
&bcc_email_id,
&identity_id,
"[email protected]",
["[email protected]"],
)
.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: [email protected]\r\n",
"Bcc: [email protected]\r\n",
"To: [email protected]\r\n",
"Bcc: [email protected]\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::<Vec<&str>>,
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(
+60 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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("[email protected]");
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::<Vec<String>>,
None,
)
.await
.unwrap()
.take_id();
Id::from_str(&id).unwrap().prefix_id()
}
};
let oldest_thread = import("Message-ID: <scan-0@x>\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: <scan-{i}@x>\nSubject: Same\n\nm\n")).await;
}
let newest = ::email::message::ingest::THREAD_SCAN_LIMIT;
assert_eq!(
import(format!(
"Message-ID: <reply-new@x>\nReferences: <scan-{newest}@x>\nSubject: re: Same\n\nr\n"
))
.await,
newest_thread
);
assert_ne!(
import(
"Message-ID: <reply-old@x>\nReferences: <scan-0@x>\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: "[email protected]",
is_sender_authenticated: true,
envelope_from: "",
verified_sender: VerifiedSender::FromHeader,
is_spam: false,
},
session_id: 0,
+8
View File
@@ -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::<usize>().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 {
+17
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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("[email protected]", "250").await;
size_session.rcpt_to("[email protected]", "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("[email protected]", &["[email protected]"], "invalid", "550 5.7.7")
+26
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected], [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: test\r\n\r\ntest"
),
"550 5.7.1",
)
.await;
}
+20 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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]
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more