Who may share mail: a server switch, and a tenant's that can only be stricter #149

Merged
jcoffey-dev merged 1 commits from feat/sharing-policy into main 2026-10-05 23:07:11 +00:00
24 changed files with 1078 additions and 3 deletions

No files matched your search

+50
View File
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
use xxhash_rust::xxh3; use xxhash_rust::xxh3;
impl Server { impl Server {
/// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail
/// (the server's switch, narrowed by the tenant's).
pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result<bool> {
let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant);
Ok(
inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id)
.await
.caused_by(trc::location!())?
.mail_sharing,
)
}
/// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked
/// account's or shared mailbox's grants are an administrator's and always
/// do; anyone else's only while their tenant allows mail sharing.
pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result<bool> {
if inbuxa_features::lock::get(self.store(), owner)
.await
.caused_by(trc::location!())?
.is_some()
{
return Ok(true);
}
self.mail_sharing_allowed(owner).await
}
async fn build_access_token( async fn build_access_token(
&self, &self,
account: Account, account: Account,
@@ -100,6 +126,9 @@ impl Server {
.map(|m| m.id() as u32) .map(|m| m.id() as u32)
.collect::<TinyVec<[u32; 3]>>(); .collect::<TinyVec<[u32; 3]>>();
let mut access_to: Vec<AccessTo> = Vec::new(); let mut access_to: Vec<AccessTo> = Vec::new();
// inbuxa: MA-C: whether an owner's mail shares are honored,
// looked up once per owner
let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new();
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) { for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
for acl_item in self for acl_item in self
.store() .store()
@@ -120,6 +149,27 @@ impl Server {
.caused_by(trc::location!())); .caused_by(trc::location!()));
} }
// inbuxa: MA-C: a mail share from an account whose
// tenant (or server) has mail sharing off gives
// nothing while it is off. It stays stored, so it
// comes back when sharing does. A lock's and a
// shared mailbox's grants are an administrator's,
// and always count.
if collection == Collection::Mailbox {
let owner = acl_item.to_account_id;
let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) {
Some((_, honored)) => *honored,
None => {
let honored = self.mail_shares_honored(owner).await?;
mail_shares_honored.push((owner, honored));
honored
}
};
if !honored {
continue;
}
}
let mut collections: Bitmap<Collection> = Bitmap::new(); let mut collections: Bitmap<Collection> = Bitmap::new();
if acl.contains(Acl::Read) { if acl.contains(Acl::Read) {
collections.insert(collection); collections.insert(collection);
+1
View File
@@ -15,4 +15,5 @@ pub mod legacy_use;
pub mod log_files; pub mod log_files;
pub mod listeners; pub mod listeners;
pub mod protocol_policy; pub mod protocol_policy;
pub mod sharing_policy;
pub mod tenant_protocol_policy; pub mod tenant_protocol_policy;
@@ -0,0 +1,188 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SharingPolicy`, whether people may share their own mail and add
//! other accounts to the webmail (multi-account spec, MA-C, MA-10 to MA-14).
//!
//! Two levels, as the legacy-protocols switch has: the server's policy, and
//! one per tenant that can only be stricter. Stored as JSON in the fork's
//! subspace, `W` + `p` for the server and `W` + `t` + tenant for a tenant;
//! unset reads as the defaults, which are on, so a server keeps today's
//! behavior until someone turns it off.
//!
//! "Off" refuses new shares and stops honoring the ones already made, which
//! stay stored, so turning it back on restores them (John, 2026-10-05).
//! Group membership and shared mailboxes aren't users' shares and are never
//! affected.
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// One level's switches. `None` on a tenant means "as the server says".
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharingPolicy {
/// People may share their own mail folders (MA-11). Default on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail_sharing: Option<bool>,
/// People may add their other accounts to the webmail (MA-B). Default on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub add_accounts: Option<bool>,
/// Seconds since the epoch, and who: the console shows them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_by: Option<String>,
}
/// What applies to one account: the server's switch, narrowed by its
/// tenant's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Effective {
pub mail_sharing: bool,
pub add_accounts: bool,
}
impl Default for Effective {
fn default() -> Self {
Effective {
mail_sharing: true,
add_accounts: true,
}
}
}
/// A tenant can be stricter than the server, never looser (MA-C).
pub fn effective(server: &SharingPolicy, tenant: Option<&SharingPolicy>) -> Effective {
let server_mail = server.mail_sharing.unwrap_or(true);
let server_add = server.add_accounts.unwrap_or(true);
Effective {
mail_sharing: server_mail && tenant.and_then(|t| t.mail_sharing).unwrap_or(true),
add_accounts: server_add && tenant.and_then(|t| t.add_accounts).unwrap_or(true),
}
}
/// Why a tenant can't turn a switch on: the server has it off.
pub fn looser_than_server(server: &SharingPolicy, tenant: &SharingPolicy) -> Option<&'static str> {
if tenant.mail_sharing == Some(true) && server.mail_sharing == Some(false) {
return Some("The server has mail sharing off; a tenant can only be stricter.");
}
if tenant.add_accounts == Some(true) && server.add_accounts == Some(false) {
return Some("The server has adding accounts off; a tenant can only be stricter.");
}
None
}
fn key(tenant_id: Option<u32>) -> ValueClass {
let mut key = Vec::with_capacity(6);
match tenant_id {
None => key.extend_from_slice(b"Wp"),
Some(tenant_id) => {
key.extend_from_slice(b"Wt");
key.extend_from_slice(&tenant_id.to_be_bytes());
}
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
struct Json(SharingPolicy);
impl Deserialize for Json {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.caused_by(trc::location!())
.reason(err)
})
}
}
/// The server's policy (`None`) or a tenant's.
pub async fn get(data: &Store, tenant_id: Option<u32>) -> trc::Result<SharingPolicy> {
Ok(data
.get_value::<Json>(ValueKey::from(key(tenant_id)))
.await
.caused_by(trc::location!())?
.map(|Json(policy)| policy)
.unwrap_or_default())
}
/// What applies to an account in `tenant_id`.
pub async fn effective_for(data: &Store, tenant_id: Option<u32>) -> trc::Result<Effective> {
let server = get(data, None).await?;
let tenant = match tenant_id {
Some(tenant_id) => Some(get(data, Some(tenant_id)).await?),
None => None,
};
Ok(effective(&server, tenant.as_ref()))
}
/// Stores a policy.
pub async fn set(data: &Store, tenant_id: Option<u32>, policy: &SharingPolicy) -> trc::Result<()> {
let bytes = serde_json::to_vec(policy).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(key(tenant_id), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn on_off(mail: Option<bool>, add: Option<bool>) -> SharingPolicy {
SharingPolicy {
mail_sharing: mail,
add_accounts: add,
..Default::default()
}
}
#[test]
fn unset_is_on() {
assert_eq!(effective(&SharingPolicy::default(), None), Effective::default());
assert_eq!(
effective(&SharingPolicy::default(), Some(&SharingPolicy::default())),
Effective::default()
);
}
#[test]
fn a_tenant_is_only_ever_stricter() {
// The server off wins over a tenant on
let server = on_off(Some(false), None);
let tenant = on_off(Some(true), Some(false));
let e = effective(&server, Some(&tenant));
assert!(!e.mail_sharing);
assert!(!e.add_accounts, "the tenant's own off holds");
assert!(looser_than_server(&server, &tenant).is_some());
// A tenant off under a server on
let e = effective(&on_off(Some(true), Some(true)), Some(&on_off(Some(false), None)));
assert!(!e.mail_sharing && e.add_accounts);
assert!(looser_than_server(&on_off(None, None), &on_off(Some(true), Some(true))).is_none());
}
#[test]
fn keys_stay_apart() {
let ValueClass::Any(server) = key(None) else { panic!() };
let ValueClass::Any(tenant) = key(Some(7)) else { panic!() };
assert_eq!(server.key, b"Wp");
assert_eq!(tenant.key, [b'W', b't', 0, 0, 0, 7]);
}
}
+28
View File
@@ -377,6 +377,34 @@ impl<T: SessionStream> Session<T> {
} }
} }
// inbuxa: MA-C: with mail sharing off, nobody here starts or
// widens a share (narrowing or ending one is always allowed)
let had = current_mailbox
.inner
.acls
.iter()
.find(|item| item.account_id == acl_account_id)
.map_or(0, |item| item.grants.clone().into_inner());
let has = mailbox
.acls
.iter()
.find(|item| item.account_id == acl_account_id)
.map_or(0, |item| item.grants.clone().into_inner());
if has & !had != 0
&& !access_token.has_permission(Permission::Impersonate)
&& !data
.server
.mail_sharing_allowed(mailbox_id.account_id)
.await
.imap_ctx(&arguments.tag, trc::location!())?
{
return Err(trc::ImapEvent::Error
.into_err()
.details("Your organization has turned off sharing mail folders.")
.code(ResponseCode::NoPerm)
.id(arguments.tag.to_string()));
}
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item { if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
return Err(trc::ImapEvent::Error return Err(trc::ImapEvent::Error
.into_err() .into_err()
@@ -0,0 +1,185 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SharingPolicy/get` and `/set` under `urn:inbuxa:jmap`: whether
//! people may share their own mail and add other accounts to the webmail
//! (multi-account spec, MA-C). The server's policy has the singleton id;
//! each tenant's has the tenant's id.
//!
//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client
//! that sets them is answered with `invalidProperties`.
use crate::object::{AnyId, JmapObject, JmapObjectId};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct SharingPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SharingPolicyProperty {
Id,
/// Server-set: the tenant this is the policy of, or null for the server's.
TenantId,
/// `enabled` or `disabled`: people may share their own mail folders.
MailSharing,
/// `enabled` or `disabled`: people may add other accounts to the webmail.
AddAccounts,
ChangedAt,
ChangedBy,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SharingPolicyValue {
Id(Id),
}
impl Property for SharingPolicyProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
SharingPolicyProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
SharingPolicyProperty::Id => "id",
SharingPolicyProperty::TenantId => "tenantId",
SharingPolicyProperty::MailSharing => "mailSharing",
SharingPolicyProperty::AddAccounts => "addAccounts",
SharingPolicyProperty::ChangedAt => "changedAt",
SharingPolicyProperty::ChangedBy => "changedBy",
}
.into()
}
}
impl SharingPolicyProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => SharingPolicyProperty::Id,
b"tenantId" => SharingPolicyProperty::TenantId,
b"mailSharing" => SharingPolicyProperty::MailSharing,
b"addAccounts" => SharingPolicyProperty::AddAccounts,
b"changedAt" => SharingPolicyProperty::ChangedAt,
b"changedBy" => SharingPolicyProperty::ChangedBy,
)
}
}
impl SharingPolicyProperty {
/// Whether this property is the server's to say. A client that sets one
/// is answered with `invalidProperties`.
pub fn is_server_set(&self) -> bool {
matches!(
self,
SharingPolicyProperty::TenantId
| SharingPolicyProperty::ChangedAt
| SharingPolicyProperty::ChangedBy
)
}
}
impl FromStr for SharingPolicyProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
SharingPolicyProperty::parse(s).ok_or(())
}
}
impl Element for SharingPolicyValue {
type Property = SharingPolicyProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(SharingPolicyProperty::Id) => {
Id::from_str(value).ok().map(SharingPolicyValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
SharingPolicyValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for SharingPolicy {
type Property = SharingPolicyProperty;
type Element = SharingPolicyValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = SharingPolicyProperty::Id;
}
impl From<Id> for SharingPolicyValue {
fn from(id: Id) -> Self {
SharingPolicyValue::Id(id)
}
}
impl JmapObjectId for SharingPolicyValue {
fn as_id(&self) -> Option<Id> {
match self {
SharingPolicyValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
SharingPolicyValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = SharingPolicyValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for SharingPolicyProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -38,6 +38,7 @@ pub mod inbuxa_hold_export; // inbuxa: legal hold exports
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail
pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod inbuxa_deleted_account; // inbuxa: undelete
pub mod file_node; pub mod file_node;
pub mod identity; pub mod identity;
+3
View File
@@ -109,6 +109,9 @@ impl Response<'_> {
GetResponseMethod::TenantProtocolPolicy(response) => { GetResponseMethod::TenantProtocolPolicy(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
GetResponseMethod::SharingPolicy(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Principal(response) => { GetResponseMethod::Principal(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -64,6 +64,9 @@ impl Response<'_> {
GetRequestMethod::TenantProtocolPolicy(request) => { GetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self)? request.resolve_references(self)?
} }
GetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self)?
}
GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Principal(request) => request.resolve_references(self)?,
GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?,
GetRequestMethod::Blob(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?,
@@ -158,6 +161,9 @@ impl Response<'_> {
SetRequestMethod::TenantProtocolPolicy(request) => { SetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
SetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AddressBook(request) => { SetRequestMethod::AddressBook(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
@@ -183,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
/// spec, EX-1 to EX-4). /// spec, EX-1 to EX-4).
#[serde(rename(serialize = "aiExplain"))] #[serde(rename(serialize = "aiExplain"))]
pub ai_explain: bool, pub ai_explain: bool,
/// MA-C: whether the principal may share their own mail folders, and
/// add other accounts to the webmail: the stricter of the server's
/// switch and its tenant's.
#[serde(rename(serialize = "mailSharing"))]
pub mail_sharing: bool,
#[serde(rename(serialize = "addAccounts"))]
pub add_accounts: bool,
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
+11
View File
@@ -77,6 +77,7 @@ pub enum MethodObject {
JournalExport, JournalExport,
JournalVerification, JournalVerification,
TenantProtocolPolicy, TenantProtocolPolicy,
SharingPolicy,
} }
impl MethodObject { impl MethodObject {
@@ -124,6 +125,7 @@ impl MethodObject {
| MethodObject::JournalVerification => Capability::Inbuxa, | MethodObject::JournalVerification => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
MethodObject::SharingPolicy => Capability::Inbuxa,
} }
} }
} }
@@ -344,6 +346,12 @@ impl MethodName {
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => { (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
"inbuxa:TenantProtocolPolicy/set" "inbuxa:TenantProtocolPolicy/set"
} }
(MethodFunction::Get, MethodObject::SharingPolicy) => {
"inbuxa:SharingPolicy/get"
}
(MethodFunction::Set, MethodObject::SharingPolicy) => {
"inbuxa:SharingPolicy/set"
}
(method, MethodObject::Registry(obj)) => { (method, MethodObject::Registry(obj)) => {
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
} }
@@ -504,6 +512,8 @@ impl MethodName {
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), "inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set), "inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
).or_else(|| { ).or_else(|| {
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
@@ -578,6 +588,7 @@ impl Display for MethodObject {
MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::HoldExport => "inbuxa:HoldExport",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
MethodObject::Registry(obj) => { MethodObject::Registry(obj) => {
f.write_str("x:")?; f.write_str("x:")?;
return f.write_str(obj.as_str()); return f.write_str(obj.as_str());
+6
View File
@@ -134,6 +134,9 @@ pub enum GetRequestMethod {
TenantProtocolPolicy( TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
), ),
SharingPolicy(
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -178,6 +181,9 @@ pub enum SetRequestMethod<'x> {
TenantProtocolPolicy( TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
), ),
SharingPolicy(
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
} }
#[derive(Debug)] #[derive(Debug)]
+18
View File
@@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Get, MethodObject::SharingPolicy) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::Get(GetRequestMethod::SharingPolicy(value))
}
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
@@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Set, MethodObject::SharingPolicy) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::Set(SetRequestMethod::SharingPolicy(value))
}
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
+26
View File
@@ -121,6 +121,9 @@ pub enum GetResponseMethod {
TenantProtocolPolicy( TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>, GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
), ),
SharingPolicy(
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -166,6 +169,9 @@ pub enum SetResponseMethod {
TenantProtocolPolicy( TenantProtocolPolicy(
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
), ),
SharingPolicy(
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -352,6 +358,16 @@ impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
} }
} }
impl<'x> From<GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
for ResponseMethod<'x>
{
fn from(
value: GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
) -> Self {
ResponseMethod::Get(GetResponseMethod::SharingPolicy(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>> impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
for ResponseMethod<'x> for ResponseMethod<'x>
{ {
@@ -362,6 +378,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
} }
} }
impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
for ResponseMethod<'x>
{
fn from(
value: SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
) -> Self {
ResponseMethod::Set(SetResponseMethod::SharingPolicy(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> { impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self { fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
ResponseMethod::Get(GetResponseMethod::AiLimits(value)) ResponseMethod::Get(GetResponseMethod::AiLimits(value))
+14 -1
View File
@@ -130,6 +130,10 @@ impl JmapAuthorization for AccessToken {
// sign-in on the tenant's domains, so it takes the domain's // sign-in on the tenant's domains, so it takes the domain's
// permissions, which a tenant administrator already holds. // permissions, which a tenant administrator already holds.
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet, GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
// inbuxa: MA-C, who may share mail: a tenant administrator
// manages their tenant's, so the domain's permissions; the
// server's own also needs sysSharingUpdate (see the method)
GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet,
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
GetRequestMethod::Blob(_) => Permission::JmapBlobGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
@@ -370,6 +374,14 @@ impl JmapAuthorization for AccessToken {
Permission::SysDomainUpdate, Permission::SysDomainUpdate,
Permission::SysDomainUpdate, Permission::SysDomainUpdate,
), ),
// inbuxa: MA-C, who may share mail, with the domain's
SetRequestMethod::SharingPolicy(s) => validate_set(
s,
self,
Permission::SysDomainUpdate,
Permission::SysDomainUpdate,
Permission::SysDomainUpdate,
),
SetRequestMethod::VacationResponse(s) => validate_set( SetRequestMethod::VacationResponse(s) => validate_set(
s, s,
self, self,
@@ -500,7 +512,8 @@ impl JmapAuthorization for AccessToken {
| MethodObject::JournalExport | MethodObject::JournalExport
| MethodObject::JournalVerification | MethodObject::JournalVerification
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, | MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads // inbuxa: x:MaskedEmail/changes reads what /get reads
MethodObject::Registry(object_type) => object_type.get_permission(), MethodObject::Registry(object_type) => object_type.get_permission(),
}, },
+27
View File
@@ -317,6 +317,9 @@ impl RequestHandler for Server {
SetResponseMethod::TenantProtocolPolicy(set_response) => { SetResponseMethod::TenantProtocolPolicy(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
SetResponseMethod::SharingPolicy(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AddressBook(set_response) => { SetResponseMethod::AddressBook(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
@@ -574,6 +577,13 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail)
GetRequestMethod::SharingPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::sharing_policy::get(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::Principal(req) => { GetRequestMethod::Principal(req) => {
self.principal_get(*req, access_token).await?.into() self.principal_get(*req, access_token).await?.into()
} }
@@ -1132,6 +1142,23 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail)
SetRequestMethod::SharingPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| Box::pin(crate::inbuxa::sharing_policy::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::AddressBook(mut req) => { SetRequestMethod::AddressBook(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_has_access(req.account_id, Collection::AddressBook)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
+9
View File
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
let ai_explain = access_token.has_permission(Permission::SysAiExplain) let ai_explain = access_token.has_permission(Permission::SysAiExplain)
&& access_token.tenant_id().is_none() && access_token.tenant_id().is_none()
&& self.ai_explain_model(&self.ai_limits().await).await.is_some(); && self.ai_explain_model(&self.ai_limits().await).await.is_some();
// inbuxa: MA-C: what the sharing switches leave this principal
let sharing = inbuxa_features::security::sharing_policy::effective_for(
self.store(),
access_token.tenant_id(),
)
.await
.caused_by(trc::location!())?;
account.account_capabilities.append( account.account_capabilities.append(
Capability::Inbuxa, Capability::Inbuxa,
Capabilities::Inbuxa(InbuxaAccountCapabilities { Capabilities::Inbuxa(InbuxaAccountCapabilities {
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
legacy_protocols, legacy_protocols,
legacy_allowed, legacy_allowed,
ai_explain, ai_explain,
mail_sharing: sharing.mail_sharing,
add_accounts: sharing.add_accounts,
}), }),
); );
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
+1
View File
@@ -439,6 +439,7 @@ impl IntermediateChangesResponse {
| MethodObject::HeldMessage | MethodObject::HeldMessage
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy | MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy
| MethodObject::Registry(_) => unreachable!(), | MethodObject::Registry(_) => unreachable!(),
}) })
} }
+1
View File
@@ -28,6 +28,7 @@ pub mod webhook_test;
pub mod explanation; pub mod explanation;
pub mod protocol_policy; pub mod protocol_policy;
pub mod tenant_protocol_policy; pub mod tenant_protocol_policy;
pub mod sharing_policy;
pub mod deleted_account; pub mod deleted_account;
pub mod fastmail; pub mod fastmail;
pub mod masked_email; pub mod masked_email;
+225
View File
@@ -0,0 +1,225 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SharingPolicy/get` and `/set`: whether people may share their own
//! mail and add other accounts to the webmail (multi-account spec, MA-C).
//!
//! The server's policy has the singleton id; each tenant's has the tenant's
//! id. At server level `/get` with no ids answers with the server's and every
//! tenant's; inside a tenant, with the server's (to read) and its own tenant's
//! (MT-1). Only a server administrator holding `sysSharingUpdate` changes the
//! server's; a tenant's administrator changes their tenant's, and can only be
//! stricter than the server.
//!
//! A change rebuilds every access token, here and on every node: what a share
//! still gives is worked out when a token is built.
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
use inbuxa_features::{
security::sharing_policy::{self, SharingPolicy as Policy, looser_than_server},
tenancy::quota::all_tenants,
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_sharing_policy::{SharingPolicy, SharingPolicyProperty as P, SharingPolicyValue},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use registry::schema::enums::Permission;
use types::id::Id;
type PValue = Value<'static, P, SharingPolicyValue>;
const ALL: &[P] = &[P::Id, P::TenantId, P::MailSharing, P::AddAccounts, P::ChangedAt, P::ChangedBy];
fn switch_str(on: Option<bool>) -> &'static str {
if on.unwrap_or(true) { "enabled" } else { "disabled" }
}
fn to_value(tenant_id: Option<u32>, policy: &Policy, properties: &[P]) -> PValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(SharingPolicyValue::Id(
tenant_id.map_or_else(Id::singleton, Id::from),
)),
P::TenantId => tenant_id
.map(|t| Value::Element(SharingPolicyValue::Id(Id::from(t))))
.unwrap_or(Value::Null),
P::MailSharing => Value::Str(switch_str(policy.mail_sharing).into()),
P::AddAccounts => Value::Str(switch_str(policy.add_accounts).into()),
P::ChangedAt => policy
.changed_at
.map(|at| Value::Number(at.into()))
.unwrap_or(Value::Null),
P::ChangedBy => policy
.changed_by
.as_ref()
.map(|by| Value::Str(by.clone().into()))
.unwrap_or(Value::Null),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// The tenants this principal may reach: its own inside a tenant (MT-1),
/// every tenant at server level.
async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<Vec<u32>> {
match access_token.tenant_id() {
Some(tenant_id) => Ok(vec![tenant_id]),
None => all_tenants(server.registry()).await,
}
}
/// Which policy an id names: `None` for the server's.
fn target(id: Id) -> Option<u32> {
if id.is_singleton() { None } else { Some(id.document_id()) }
}
/// `inbuxa:SharingPolicy/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<SharingPolicy>,
) -> trc::Result<GetResponse<SharingPolicy>> {
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let reachable = reachable(server, access_token).await?;
let wanted: Vec<Id> = match ids {
None => std::iter::once(Id::singleton())
.chain(reachable.iter().map(|t| Id::from(*t)))
.collect(),
Some(ids) => ids,
};
let data = &server.core.storage.data;
for id in wanted {
match target(id) {
None => {
let policy = sharing_policy::get(data, None).await?;
response.list.push(to_value(None, &policy, &properties));
}
Some(tenant_id) if reachable.contains(&tenant_id) => {
let policy = sharing_policy::get(data, Some(tenant_id)).await?;
response.list.push(to_value(Some(tenant_id), &policy, &properties));
}
Some(_) => response.push_not_found(id),
}
}
Ok(response)
}
/// `inbuxa:SharingPolicy/set`: turns switches. `null` puts one back to its
/// default, on (as far as the server allows).
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, SharingPolicy>,
) -> trc::Result<SetResponse<SharingPolicy>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response.not_created.append(
client_id,
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
);
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
);
}
let reachable = reachable(server, access_token).await?;
let data = &server.core.storage.data;
let mut changed = false;
for (id, value) in request.unwrap_update().into_valid() {
let tenant_id = target(id);
match tenant_id {
None if access_token.tenant_id().is_some()
|| !access_token.has_permission(Permission::SysSharingUpdate) =>
{
response.not_updated.append(
id,
SetError::forbidden()
.with_description("Only a server administrator changes the server's sharing policy."),
);
continue;
}
Some(tenant_id) if !reachable.contains(&tenant_id) => {
response.not_updated.append(id, SetError::not_found());
continue;
}
_ => {}
}
let previous = sharing_policy::get(data, tenant_id).await?;
let mut policy = previous.clone();
let mut error = None;
for (key, value) in value.into_expanded_object() {
let parsed = match value {
Value::Null => Ok(None),
Value::Str(s) if s == "enabled" => Ok(Some(true)),
Value::Str(s) if s == "disabled" => Ok(Some(false)),
_ => Err("must be enabled or disabled".to_string()),
};
let result = match &key {
Key::Property(P::MailSharing) => parsed.map(|v| policy.mail_sharing = v),
Key::Property(P::AddAccounts) => parsed.map(|v| policy.add_accounts = v),
Key::Property(P::Id) => Err("is immutable".to_string()),
Key::Property(_) => Err("is set by the server".to_string()),
_ => Err("is not a property of inbuxa:SharingPolicy".to_string()),
};
if let Err(why) = result {
error = Some(
SetError::invalid_properties()
.with_property(key.into_owned())
.with_description(why),
);
break;
}
}
if let Some(error) = error {
response.not_updated.append(id, error);
continue;
}
// A tenant can only be stricter than the server
if tenant_id.is_some()
&& let Some(why) = looser_than_server(&sharing_policy::get(data, None).await?, &policy)
{
response
.not_updated
.append(id, SetError::forbidden().with_description(why));
continue;
}
if policy.mail_sharing != previous.mail_sharing || policy.add_accounts != previous.add_accounts {
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
sharing_policy::set(data, tenant_id, &policy).await?;
changed = true;
}
response.updated.append(id, None);
}
if changed {
// Shares are honored, or not, as tokens are built
server.invalidate_all_local_caches();
server.cluster_broadcast(BroadcastEvent::CacheInvalidateAll).await;
}
Ok(response)
}
+22 -1
View File
@@ -31,7 +31,7 @@ use jmap_proto::{
types::state::State, types::state::State,
}; };
use jmap_tools::{JsonPointerItem, Key, Map, Value}; use jmap_tools::{JsonPointerItem, Key, Map, Value};
use registry::schema::enums::StorageQuota; use registry::schema::enums::{Permission, StorageQuota};
use std::future::Future; use std::future::Future;
use store::{ use store::{
ValueKey, ValueKey,
@@ -622,6 +622,27 @@ impl MailboxSet for Server {
))); )));
} }
// inbuxa: MA-C: with mail sharing off, nobody here starts or
// widens a share (narrowing or ending one is always allowed)
let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default();
let widens = changes.acls.iter().any(|grant| {
let had = before
.iter()
.find(|old| old.account_id == grant.account_id)
.map_or(0, |old| old.grants.clone().into_inner());
grant.grants.clone().into_inner() & !had != 0
});
if widens
&& !ctx.access_token.has_permission(Permission::Impersonate)
&& !self.mail_sharing_allowed(ctx.account_id).await?
{
return Ok(Err(SetError::forbidden()
.with_property(MailboxProperty::ShareWith)
.with_description(
"Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.",
)));
}
if !changes.acls.is_empty() if !changes.acls.is_empty()
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await && let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
{ {
+10
View File
@@ -244,6 +244,16 @@ retention = "unbounded"
changedBy = ["identifier"] changedBy = ["identifier"]
recentLegacyUse = ["identifier", "metadata"] recentLegacyUse = ["identifier", "metadata"]
[object."inbuxa:SharingPolicy"]
file = "inbuxa_sharing_policy.rs"
default = "none"
whose = ["administrator", "holder"]
where = ["data-store"]
scope = "tenant"
retention = "unbounded"
[object."inbuxa:SharingPolicy".properties]
changedBy = ["identifier"]
[object."inbuxa:AiLimits"] [object."inbuxa:AiLimits"]
file = "inbuxa_ai_limits.rs" file = "inbuxa_ai_limits.rs"
default = "none" default = "none"
+1 -1
View File
@@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) {
// inbuxa: MT-22, the logo that applies to the account, and // inbuxa: MT-22, the logo that applies to the account, and
// LP-19, whether the legacy protocols are open to it, and // LP-19, whether the legacy protocols are open to it, and
// ai-explain EX-1, whether Explain can be offered // ai-explain EX-1, whether Explain can be offered
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false }, "urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false, "mailSharing": true, "addAccounts": true },
"https://www.fastmail.com/dev/maskedemail": {} "https://www.fastmail.com/dev/maskedemail": {}
} }
} }
+1
View File
@@ -12,6 +12,7 @@ pub mod ai;
pub mod ai_calibration; pub mod ai_calibration;
pub mod ai_explain; pub mod ai_explain;
pub mod account_lock; // inbuxa: account lock with delegation pub mod account_lock; // inbuxa: account lock with delegation
pub mod sharing_policy; // inbuxa: MA-C, who may share mail
pub mod legal_hold; // inbuxa: legal hold pub mod legal_hold; // inbuxa: legal hold
pub mod compliance; // inbuxa: the compliance roles pub mod compliance; // inbuxa: the compliance roles
pub mod mail_rules; // inbuxa: DLP and mail flow rules pub mod mail_rules; // inbuxa: DLP and mail flow rules
+237
View File
@@ -0,0 +1,237 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Who may share mail (multi-account spec, MA-C): the server's switch and a
//! tenant's, which can only be stricter. Off refuses new shares and stops
//! honoring old ones, which come back when it's on again; locks and shared
//! mailboxes are never affected.
use crate::utils::{account::Account, server::TestServerBuilder};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
};
use serde_json::{Value, json};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:inbuxa:jmap",
];
impl Account {
async fn one(&self, method: &str, arguments: Value) -> Value {
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
response
.0
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0))
}
async fn policy(&self, update: Value) -> Value {
self.one(
"inbuxa:SharingPolicy/set",
json!({"accountId": self.id_string(), "update": update}),
)
.await[1]
.clone()
}
async fn inbox(&self) -> String {
let response = self
.one(
"Mailbox/get",
json!({"accountId": self.id_string(), "ids": null, "properties": ["role"]}),
)
.await;
response[1]["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "inbox")
.unwrap_or_else(|| panic!("no Inbox: {response}"))["id"]
.as_str()
.unwrap()
.to_string()
}
/// Shares the Inbox with `with` (read), or stops with `None` rights.
async fn share_inbox(&self, with: &Account, read: bool) -> Value {
let inbox = self.inbox().await;
let rights = if read { json!({"mayReadItems": true}) } else { Value::Null };
self.one(
"Mailbox/set",
json!({"accountId": self.id_string(),
"update": {inbox: {format!("shareWith/{}", with.id_string()): rights}}}),
)
.await[1]
.clone()
}
async fn sees(&self, other: &Account) -> bool {
self.jmap_session_object().await.0["accounts"]
.get(other.id_string())
.is_some()
}
async fn mail_sharing(&self) -> Value {
self.jmap_session_object().await.0["accounts"][self.id_string()]["accountCapabilities"]
["urn:inbuxa:jmap"]["mailSharing"]
.clone()
}
}
/// Runs these tests alone: `cargo test -p tests sharing_policy_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn sharing_policy_tests() {
let mut test = TestServerBuilder::new("sharing_policy_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
println!("Running sharing policy tests...");
let tenant = admin
.registry_create_object(Tenant {
name: "School".to_string(),
..Default::default()
})
.await;
admin
.registry_create_object(Domain {
name: "school.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(tenant),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
let ann = admin
.create_user_account("[email protected]", "ann-secret-6610", "Ann", &[], vec![])
.await;
let ben = admin
.create_user_account("[email protected]", "ben-secret-6611", "Ben", &[], vec![])
.await;
let head = admin
.create_user_account("[email protected]", "head-secret-6612", "Head", &[], vec![])
.await;
admin
.registry_update_object(
ObjectType::Account,
head.id(),
json!({Property::Roles: UserRoles::Admin}),
)
.await;
let carl = admin
.create_user_account("[email protected]", "carl-secret-6613", "Carl", &[], vec![])
.await;
// Shares never cross tenants (MT-3), so Carl's neighbour is outside too
let dan = admin
.create_user_account("[email protected]", "dan-secret-6614", "Dan", &[], vec![])
.await;
let tenant_id = tenant.to_string();
// MA-10: on by default
assert_eq!(ann.mail_sharing().await, true, "MA-10");
let response = ann.share_inbox(&ben, true).await;
assert!(response["updated"].is_object(), "MA-10: {response}");
assert!(ben.sees(&ann).await, "MA-10: the share gives nothing");
// The school turns mail sharing off, as its own administrator
let response = head
.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
.await;
assert!(response["updated"].is_object(), "MA-13: {response}");
// ...but can't touch the server's
let response = head
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
.await;
assert_eq!(response["notUpdated"]["singleton"]["type"], "forbidden", "MA-13: {response}");
// MA-11: what was shared gives nothing now, and nothing new is shared
assert_eq!(ann.mail_sharing().await, false, "MA-11");
assert!(!ben.sees(&ann).await, "MA-11: an old share still honored");
let response = ann.share_inbox(&head, true).await;
assert_eq!(
response["notUpdated"].as_object().and_then(|o| o.values().next()).map(|e| e["type"].clone()),
Some(json!("forbidden")),
"MA-11: {response}"
);
// MA-12: a shared mailbox isn't anyone's share, and keeps working
let office = admin
.create_user_account("[email protected]", "office-secret-6615", "Office", &[], vec![])
.await;
let response = admin
.one(
"inbuxa:AccountLock/set",
json!({"accountId": admin.id_string(), "create": {"o": {"accountId": office.id_string(),
"kind": "sharedMailbox",
"delegates": [{"accountId": ben.id_string(), "access": "organize"}]}}}),
)
.await;
assert!(response[1]["created"]["o"].is_object(), "MA-12: {response}");
assert!(ben.sees(&office).await, "MA-12: the switch reached a shared mailbox");
// Outside the school nothing changed
let response = carl.share_inbox(&dan, true).await;
assert!(response["updated"].is_object(), "MA-C: another tenant's switch reached Carl: {response}");
assert!(dan.sees(&carl).await, "MA-C");
// A tenant can only be stricter than the server
let response = admin
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
.await;
assert!(response["updated"].is_object(), "MA-C: {response}");
let response = head
.policy(json!({tenant_id.as_str(): {"mailSharing": "enabled"}}))
.await;
assert_eq!(
response["notUpdated"][tenant_id.as_str()]["type"],
"forbidden",
"MA-C: {response}"
);
assert!(!dan.sees(&carl).await, "MA-C: the server's switch didn't reach Carl's share");
// Turned back on, the old shares are honored again
admin
.policy(json!({"singleton": {"mailSharing": null}}))
.await;
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
.await;
assert!(ben.sees(&ann).await, "MA-C: an old share didn't come back");
assert!(dan.sees(&carl).await, "MA-C");
// Ending a share is always allowed, even while sharing is off
head.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
.await;
let response = ann.share_inbox(&ben, false).await;
assert!(response["updated"].is_object(), "MA-11: ending a share refused: {response}");
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
.await;
assert!(!ben.sees(&ann).await, "MA-11: the ended share came back");
// MA-14: every change is in the audit log
let query = admin
.one(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:SharingPolicy"}}),
)
.await;
assert!(
query[1]["ids"].as_array().is_some_and(|ids| ids.len() >= 5),
"MA-14: {query}"
);
if test.is_reset() {
test.temp_dir.delete();
}
}