Security fixes for 2026.10.29 and 27.1-rc.2 #226
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use calcard::vcard::VCardVersion;
|
||||
@@ -121,6 +157,68 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
// inbuxa: dead properties are stored from one user's PROPPATCH or LOCK
|
||||
// and shown to everyone who can read the resource: what comes back out
|
||||
// must be the same well-formed XML, whatever quoting came in
|
||||
#[test]
|
||||
fn dead_property_round_trip_is_well_formed() {
|
||||
let xml = r#"<?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="&"" c='<q>'><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" ?>
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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!())?;
|
||||
}
|
||||
|
||||
@@ -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(¶m.name));
|
||||
attendee_entry.params.extend(add_parameters);
|
||||
|
||||
let mut entries = series
|
||||
.comp
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::Organizer
|
||||
| ICalendarProperty::Uid
|
||||
| ICalendarProperty::Sequence
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.chain([attendee_entry])
|
||||
.collect::<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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' ');
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(' ', "_"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user