diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 129b0c9..2e79d41 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -446,9 +446,10 @@ impl Server { } /// MA-D0a: a message sent from an address that isn't the sender's own: - /// a group's, today. The message itself only says `From:` the group, so - /// the audit log is where the person who sent it is named. A delegate's - /// send is AL-9's record, not this one. + /// a group's or a shared mailbox's. The message itself only says + /// `From:` that address, so the audit log is where the person who sent + /// it is named. A locked account's delegate's send is AL-9's record, not + /// this one. pub async fn audit_send_as( &self, token: &AccessToken, @@ -459,7 +460,11 @@ impl Server { let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else { return; }; - if as_account_id == token.account_id() || token.delegation(as_account_id).is_some() { + if as_account_id == token.account_id() + || token + .delegation(as_account_id) + .is_some_and(|delegation| delegation.kind.is_lock()) + { return; } let actor = self.audit_actor(token).await; diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index da38492..0d53b52 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem}; use xxhash_rust::xxh3; 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 { + 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 { + 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( &self, account: Account, @@ -46,19 +72,22 @@ impl Server { // inbuxa: AL-2, AL-5: whether this account is locked, and which // locked accounts are handed to it. The token is their cache: every // change to a lock invalidates the tokens it touches. - let locked = inbuxa_features::lock::get(self.store(), account_id) + let lock_kind = inbuxa_features::lock::get(self.store(), account_id) .await .caused_by(trc::location!())? - .is_some(); + .map(|lock| lock.kind); + let locked = lock_kind.is_some(); + let shared_mailbox = lock_kind == Some(inbuxa_features::lock::Kind::SharedMailbox); let now_secs = now(); let delegations: Box<[super::Delegation]> = inbuxa_features::lock::delegated_to(self.store(), account_id) .await .caused_by(trc::location!())? .into_iter() - .filter(|(_, delegate)| delegate.is_current(now_secs)) - .map(|(locked_id, delegate)| super::Delegation { + .filter(|(_, delegate, _)| delegate.is_current(now_secs)) + .map(|(locked_id, delegate, kind)| super::Delegation { account_id: locked_id, + kind, access: delegate.access, send_as: delegate.send_as, until: delegate.until, @@ -97,6 +126,9 @@ impl Server { .map(|m| m.id() as u32) .collect::>(); let mut access_to: Vec = 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 acl_item in self .store() @@ -117,6 +149,27 @@ impl Server { .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 = Bitmap::new(); if acl.contains(Acl::Read) { collections.insert(collection); @@ -247,6 +300,7 @@ impl Server { .map(ConcurrencyLimiter::new), obj_size: 0, locked, + shared_mailbox, delegations: delegations.clone(), revision, revision_account, @@ -300,6 +354,7 @@ impl Server { .map(ConcurrencyLimiter::new), obj_size: 0, locked, + shared_mailbox, delegations: delegations.clone(), revision, revision_account, @@ -658,6 +713,7 @@ impl AccessToken { credential_version: old_inner.credential_version, obj_size: old_inner.obj_size, locked: old_inner.locked, + shared_mailbox: old_inner.shared_mailbox, delegations: old_inner.delegations.clone(), }; @@ -848,6 +904,18 @@ impl AccessToken { self.inner.locked } + /// inbuxa: MA-S: the account is a shared mailbox (a lock of that kind). + pub fn is_shared_mailbox(&self) -> bool { + self.inner.shared_mailbox + } + + /// inbuxa: MA-S: this account's delegation into `account_id` is to a + /// shared mailbox, not a locked account. + pub fn delegated_shared_mailbox(&self, account_id: u32) -> bool { + self.delegation(account_id) + .is_some_and(|d| d.kind == inbuxa_features::lock::Kind::SharedMailbox) + } + /// inbuxa: AL-5: this account's delegation into a locked account, if it /// has one that hasn't ended. /// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to @@ -928,6 +996,7 @@ impl AccessToken { credential_version: Default::default(), obj_size: Default::default(), locked: false, + shared_mailbox: false, delegations: Default::default(), }), } @@ -988,6 +1057,7 @@ impl AccessTokenInner { credential_version: Default::default(), obj_size: Default::default(), locked: false, + shared_mailbox: false, delegations: Default::default(), } } diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 87cf335..648d5d6 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -152,6 +152,8 @@ pub struct AccessTokenInner { pub(crate) obj_size: u64, // inbuxa: AL-2: the account is locked; it may not authenticate pub(crate) locked: bool, + // inbuxa: MA-S: the lock is a shared mailbox + pub(crate) shared_mailbox: bool, // inbuxa: AL-5: locked accounts handed to this one pub(crate) delegations: Box<[Delegation]>, } @@ -165,6 +167,8 @@ pub struct Delegation { pub send_as: bool, /// Seconds since the epoch. pub until: Option, + /// MA-S: a locked account, or a shared mailbox. + pub kind: inbuxa_features::lock::Kind, } #[derive(Debug, Default, Hash, Clone)] diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index 1a7c2c7..0980898 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -290,7 +290,11 @@ impl SieveScriptIngest for Server { // inbuxa: AL-4: a locked account answers no sender, so a // rejection is kept instead; sieve has already cleared // the implicit keep, so it is filed here - Event::Reject { .. } if access_token.is_locked() => { + // A shared mailbox (MA-S) is a role address and answers + // as one: its Sieve script runs as written + Event::Reject { .. } + if access_token.is_locked() && !access_token.is_shared_mailbox() => + { if let Some(message) = messages.get_mut(0) && !message.file_into.contains(&INBOX_ID) { @@ -403,7 +407,11 @@ impl SieveScriptIngest for Server { // inbuxa: AL-4: a locked account sends nothing on its // own: no redirect, vacation reply or notification. An // unsent redirect leaves the message to be kept. - Event::SendMessage { .. } if access_token.is_locked() => { + // A shared mailbox's acknowledgements and redirects go + // out (MA-S). + Event::SendMessage { .. } + if access_token.is_locked() && !access_token.is_shared_mailbox() => + { trc::event!( Sieve(SieveEvent::ActionReject), Details = "Account is locked: nothing is sent", diff --git a/crates/features/src/lock/mod.rs b/crates/features/src/lock/mod.rs index 3d875c4..9dc7af1 100644 --- a/crates/features/src/lock/mod.rs +++ b/crates/features/src/lock/mod.rs @@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd'; /// Most delegates one lock may have (AL-5). pub const MAX_DELEGATES: usize = 10; +/// Most people one shared mailbox may have (MA-S): a help desk is bigger +/// than the handful a departed colleague's mail is handed to. +pub const MAX_SHARED_MAILBOX_DELEGATES: usize = 100; + +/// What a lock is for (multi-account spec, MA-S). +/// +/// Both kinds keep receiving mail, can't be signed in to, and are opened by +/// delegates through real grants. A shared mailbox is a role address such +/// as support@: it needs no reason, holds more people, runs its own Sieve +/// replies (an automatic acknowledgement), records only what is sent as it, +/// and may only send as its own addresses. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kind { + #[default] + Lock, + SharedMailbox, +} + +impl Kind { + pub fn as_str(&self) -> &'static str { + match self { + Kind::Lock => "lock", + Kind::SharedMailbox => "sharedMailbox", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "lock" => Some(Kind::Lock), + "sharedMailbox" => Some(Kind::SharedMailbox), + _ => None, + } + } + + pub fn is_lock(&self) -> bool { + matches!(self, Kind::Lock) + } + + pub fn max_delegates(&self) -> usize { + match self { + Kind::Lock => MAX_DELEGATES, + Kind::SharedMailbox => MAX_SHARED_MAILBOX_DELEGATES, + } + } +} + /// What a delegate may do in the locked account (AL-6). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)] #[serde(rename_all = "camelCase")] @@ -189,6 +236,9 @@ pub struct Replaced { #[serde(rename_all = "camelCase")] pub struct Lock { pub account_id: u32, + /// Absent on locks written before shared mailboxes existed: a lock. + #[serde(default, skip_serializing_if = "Kind::is_lock")] + pub kind: Kind, pub reason: String, /// Seconds since the epoch. pub locked_at: u64, @@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result> { Ok(locks) } -/// The accounts delegated to `delegate`, with its delegation in each. -pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result> { +/// The accounts delegated to `delegate`, with its delegation in each and +/// the kind of lock it is in. +pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result> { let mut locked = Vec::new(); data.iterate( IterateParams::new( @@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result trc::Result<()> { mod tests { use super::*; + #[test] + fn kind_reads_back_and_defaults_to_lock() { + // MA-S: a lock stored before shared mailboxes existed has no kind + let stored = r#"{"accountId":1,"reason":"r","lockedAt":0,"lockedBy":"admin","delegates":[]}"#; + let lock: Lock = serde_json::from_str(stored).unwrap(); + assert_eq!(lock.kind, Kind::Lock); + assert!(!serde_json::to_string(&lock).unwrap().contains("kind"), "a lock is written as before"); + + let shared = Lock { kind: Kind::SharedMailbox, ..lock }; + let written = serde_json::to_string(&shared).unwrap(); + assert!(written.contains(r#""kind":"sharedMailbox""#), "{written}"); + assert_eq!(serde_json::from_str::(&written).unwrap().kind, Kind::SharedMailbox); + assert_eq!(Kind::parse("sharedMailbox"), Some(Kind::SharedMailbox)); + assert_eq!(Kind::SharedMailbox.max_delegates(), MAX_SHARED_MAILBOX_DELEGATES); + } + #[test] fn keys_read_back() { let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else { @@ -509,6 +576,7 @@ mod tests { fn lock_with(delegates: Vec, replaced: Vec) -> Lock { Lock { account_id: 1, + kind: Kind::Lock, reason: "r".into(), locked_at: 0, locked_by: "admin".into(), @@ -623,6 +691,7 @@ mod tests { fn expired_delegations_grant_nothing() { let lock = Lock { account_id: 1, + kind: Kind::Lock, reason: "Left the company".into(), locked_at: 100, locked_by: "admin".into(), diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index 7bf8b9b..7ad5f33 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -15,4 +15,5 @@ pub mod legacy_use; pub mod log_files; pub mod listeners; pub mod protocol_policy; +pub mod sharing_policy; pub mod tenant_protocol_policy; diff --git a/crates/features/src/security/sharing_policy.rs b/crates/features/src/security/sharing_policy.rs new file mode 100644 index 0000000..105ab14 --- /dev/null +++ b/crates/features/src/security/sharing_policy.rs @@ -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, + /// 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, + /// Seconds since the epoch, and who: the console shows them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_by: Option, +} + +/// 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) -> 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 { + 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) -> trc::Result { + Ok(data + .get_value::(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) -> trc::Result { + 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, 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, add: Option) -> 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]); + } +} diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index b738ad2..800a2f0 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -377,6 +377,34 @@ impl Session { } } + // 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 { return Err(trc::ImapEvent::Error .into_err() diff --git a/crates/jmap-proto/src/object/inbuxa_account_lock.rs b/crates/jmap-proto/src/object/inbuxa_account_lock.rs index 6634263..044170c 100644 --- a/crates/jmap-proto/src/object/inbuxa_account_lock.rs +++ b/crates/jmap-proto/src/object/inbuxa_account_lock.rs @@ -28,6 +28,8 @@ pub enum AccountLockProperty { /// The locked account (on create; afterwards the same as `id`). AccountId, Name, + /// MA-S: `lock` (the default) or `sharedMailbox`; set on create only. + Kind, Reason, LockedAt, LockedBy, @@ -53,6 +55,7 @@ impl Property for AccountLockProperty { AccountLockProperty::Id => "id", AccountLockProperty::AccountId => "accountId", AccountLockProperty::Name => "name", + AccountLockProperty::Kind => "kind", AccountLockProperty::Reason => "reason", AccountLockProperty::LockedAt => "lockedAt", AccountLockProperty::LockedBy => "lockedBy", @@ -68,6 +71,7 @@ impl AccountLockProperty { b"id" => AccountLockProperty::Id, b"accountId" => AccountLockProperty::AccountId, b"name" => AccountLockProperty::Name, + b"kind" => AccountLockProperty::Kind, b"reason" => AccountLockProperty::Reason, b"lockedAt" => AccountLockProperty::LockedAt, b"lockedBy" => AccountLockProperty::LockedBy, diff --git a/crates/jmap-proto/src/object/inbuxa_sharing_policy.rs b/crates/jmap-proto/src/object/inbuxa_sharing_policy.rs new file mode 100644 index 0000000..4b412ed --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_sharing_policy.rs @@ -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 { + 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 { + 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 { + SharingPolicyProperty::parse(s).ok_or(()) + } +} + +impl Element for SharingPolicyValue { + type Property = SharingPolicyProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + 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 for SharingPolicyValue { + fn from(id: Id) -> Self { + SharingPolicyValue::Id(id) + } +} + +impl JmapObjectId for SharingPolicyValue { + fn as_id(&self) -> Option { + match self { + SharingPolicyValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + 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 { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 9a965f5..fe691ba 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -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_protocol_policy; // inbuxa: legacy protocols off 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 file_node; pub mod identity; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index a944476..942dbf3 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -109,6 +109,9 @@ impl Response<'_> { GetResponseMethod::TenantProtocolPolicy(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::SharingPolicy(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 0032fd4..bdca276 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -64,6 +64,9 @@ impl Response<'_> { GetRequestMethod::TenantProtocolPolicy(request) => { request.resolve_references(self)? } + GetRequestMethod::SharingPolicy(request) => { + request.resolve_references(self)? + } GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?, @@ -158,6 +161,9 @@ impl Response<'_> { SetRequestMethod::TenantProtocolPolicy(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::SharingPolicy(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 0344e15..becfccf 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities { #[derive(Debug, Clone, serde::Serialize)] pub struct DelegationInfo { - /// Always true: only locked accounts are delegated. + /// Always true: only locked accounts are delegated. A shared mailbox is + /// a lock too, so a front end that knows no `kind` still treats it as + /// one it may only reach as a delegate. pub locked: bool, + /// MA-S: `lock` or `sharedMailbox`. + pub kind: &'static str, /// `read`, `organize` or `full`. pub access: &'static str, #[serde(rename(serialize = "sendAs"))] @@ -179,6 +183,13 @@ pub struct InbuxaAccountCapabilities { /// spec, EX-1 to EX-4). #[serde(rename(serialize = "aiExplain"))] 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)] diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index d9ef681..4c86ae1 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -77,6 +77,7 @@ pub enum MethodObject { JournalExport, JournalVerification, TenantProtocolPolicy, + SharingPolicy, } impl MethodObject { @@ -124,6 +125,7 @@ impl MethodObject { | MethodObject::JournalVerification => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, + MethodObject::SharingPolicy => Capability::Inbuxa, } } } @@ -344,6 +346,12 @@ impl MethodName { (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => { "inbuxa:TenantProtocolPolicy/set" } + (MethodFunction::Get, MethodObject::SharingPolicy) => { + "inbuxa:SharingPolicy/get" + } + (MethodFunction::Set, MethodObject::SharingPolicy) => { + "inbuxa:SharingPolicy/set" + } (method, MethodObject::Registry(obj)) => { 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:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), "inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set), + "inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get), + "inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set), ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; @@ -578,6 +588,7 @@ impl Display for MethodObject { MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", + MethodObject::SharingPolicy => "inbuxa:SharingPolicy", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 48c0de7..eb7be0c 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -134,6 +134,9 @@ pub enum GetRequestMethod { TenantProtocolPolicy( Box>, ), + SharingPolicy( + Box>, + ), } #[derive(Debug)] @@ -178,6 +181,9 @@ pub enum SetRequestMethod<'x> { TenantProtocolPolicy( Box>, ), + SharingPolicy( + Box>, + ), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 518e8f3..f4723a3 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor { 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() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), @@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor { 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() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 500ab4e..28de17f 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -121,6 +121,9 @@ pub enum GetResponseMethod { TenantProtocolPolicy( GetResponse, ), + SharingPolicy( + GetResponse, + ), } #[derive(Debug, serde::Serialize)] @@ -166,6 +169,9 @@ pub enum SetResponseMethod { TenantProtocolPolicy( Box>, ), + SharingPolicy( + Box>, + ), } #[derive(Debug, serde::Serialize)] @@ -352,6 +358,16 @@ impl<'x> From From> + for ResponseMethod<'x> +{ + fn from( + value: GetResponse, + ) -> Self { + ResponseMethod::Get(GetResponseMethod::SharingPolicy(value)) + } +} + impl<'x> From> for ResponseMethod<'x> { @@ -362,6 +378,16 @@ impl<'x> From From> + for ResponseMethod<'x> +{ + fn from( + value: SetResponse, + ) -> Self { + ResponseMethod::Set(SetResponseMethod::SharingPolicy(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::AiLimits(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 4884c05..674c78e 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -130,6 +130,10 @@ impl JmapAuthorization for AccessToken { // sign-in on the tenant's domains, so it takes the domain's // permissions, which a tenant administrator already holds. 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::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -370,6 +374,14 @@ impl JmapAuthorization for AccessToken { 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( s, self, @@ -500,7 +512,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::JournalExport | MethodObject::JournalVerification | MethodObject::ProtocolPolicy - | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, + | MethodObject::TenantProtocolPolicy + | MethodObject::SharingPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 3a144f2..b847aed 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -204,15 +204,20 @@ impl RequestHandler for Server { // inbuxa: AL-9: a delegate's access, and what it // changes, are recorded; anyone else here impersonated if let Some(delegation) = access_token.delegation(account_id) { - let access = delegation.access.as_str(); - self.audit_delegate( - access_token, - account_id, - access, - is_write.then_some(call_name.as_str()), - result.as_ref().err(), - ) - .await; + // MA-S: in a shared mailbox only what is sent as it + // is recorded (audit_send_as); every read and flag + // on a busy desk would bury the log + if delegation.kind.is_lock() { + let access = delegation.access.as_str(); + self.audit_delegate( + access_token, + account_id, + access, + is_write.then_some(call_name.as_str()), + result.as_ref().err(), + ) + .await; + } if makes_containers && result.is_ok() && let Err(err) = @@ -312,6 +317,9 @@ impl RequestHandler for Server { SetResponseMethod::TenantProtocolPolicy(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::SharingPolicy(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -569,6 +577,13 @@ impl RequestHandler for Server { .await? .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) => { self.principal_get(*req, access_token).await?.into() } @@ -1127,6 +1142,23 @@ impl RequestHandler for Server { .await? .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) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?; diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index bd2cd1b..b7c0b63 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -80,6 +80,13 @@ impl SessionHandler for Server { let ai_explain = access_token.has_permission(Permission::SysAiExplain) && access_token.tenant_id().is_none() && 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( Capability::Inbuxa, Capabilities::Inbuxa(InbuxaAccountCapabilities { @@ -87,6 +94,8 @@ impl SessionHandler for Server { legacy_protocols, legacy_allowed, ai_explain, + mail_sharing: sharing.mail_sharing, + add_accounts: sharing.add_accounts, }), ); // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks @@ -148,6 +157,7 @@ impl SessionHandler for Server { Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities { delegation: DelegationInfo { locked: true, + kind: delegation.kind.as_str(), access: delegation.access.as_str(), send_as: delegation.send_as, until: delegation.until.map(|until| { diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index f18b7de..d0ab35b 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -439,6 +439,7 @@ impl IntermediateChangesResponse { | MethodObject::HeldMessage | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy + | MethodObject::SharingPolicy | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/account_lock.rs b/crates/jmap/src/inbuxa/account_lock.rs index 4f5479c..6b8837c 100644 --- a/crates/jmap/src/inbuxa/account_lock.rs +++ b/crates/jmap/src/inbuxa/account_lock.rs @@ -15,7 +15,7 @@ use common::{ }; use email::inbuxa_lock::apply_grants; use groupware::inbuxa_lock::invalidate; -use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES}; +use inbuxa_features::lock::{self, Access, Delegate, Kind, Lock}; use jmap_proto::{ error::set::SetError, method::{ @@ -39,6 +39,7 @@ const ALL: &[P] = &[ P::Id, P::AccountId, P::Name, + P::Kind, P::Reason, P::LockedAt, P::LockedBy, @@ -75,6 +76,7 @@ async fn parse_delegates( server: &Server, access_token: &AccessToken, locked_id: u32, + kind: Kind, value: LValue, ) -> Result, SetError

> { let invalid = |why: String| { @@ -86,8 +88,10 @@ async fn parse_delegates( let Some(items) = json.as_array() else { return Err(invalid("delegates must be a list.".into())); }; - if items.len() > MAX_DELEGATES { - return Err(invalid(format!("At most {MAX_DELEGATES} delegates."))); + // MA-S: a shared mailbox holds more people than a lock hands over + let max = kind.max_delegates(); + if items.len() > max { + return Err(invalid(format!("At most {max} delegates."))); } let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant); let mut delegates: Vec = Vec::with_capacity(items.len()); @@ -173,6 +177,7 @@ async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue { let value = match property { P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))), P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()), + P::Kind => Value::Str(Cow::Borrowed(lock.kind.as_str())), P::Reason => Value::Str(lock.reason.clone().into()), P::LockedAt => date(lock.locked_at), P::LockedBy => Value::Str(lock.locked_by.clone().into()), @@ -283,6 +288,7 @@ pub async fn set( for (client_id, value) in request.unwrap_create() { let mut account_id = None; + let mut kind = Kind::Lock; let mut reason = None; let mut delegates_value = None; let mut invalid = None; @@ -291,6 +297,17 @@ pub async fn set( (Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => { account_id = Some(id.document_id()) } + (Key::Property(P::Kind), Value::Str(k)) => match Kind::parse(&k) { + Some(k) => kind = k, + None => { + invalid = Some( + SetError::invalid_properties() + .with_property(P::Kind) + .with_description("kind must be lock or sharedMailbox."), + ); + break; + } + }, (Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)), (Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()), _ => { @@ -310,9 +327,14 @@ pub async fn set( ); continue; }; - let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else { - response.not_created.append(client_id, reason_required()); - continue; + // MA-S: a shared mailbox needs no reason; a lock always does + let reason = match reason.or_else(|| reason_of(arguments.reason.as_deref())) { + Some(reason) => reason, + None if kind == Kind::SharedMailbox => String::new(), + None => { + response.not_created.append(client_id, reason_required()); + continue; + } }; if let Err(error) = assert_reach(server, access_token, account_id).await { response.not_created.append(client_id, error); @@ -321,12 +343,13 @@ pub async fn set( if lock::get(data, account_id).await?.is_some() { response.not_created.append( client_id, - SetError::already_exists().with_description("That account is already locked."), + SetError::already_exists() + .with_description("That account is already locked or a shared mailbox."), ); continue; } let delegates = match delegates_value { - Some(value) => match parse_delegates(server, access_token, account_id, value).await { + Some(value) => match parse_delegates(server, access_token, account_id, kind, value).await { Ok(delegates) => delegates, Err(error) => { response.not_created.append(client_id, error); @@ -337,6 +360,7 @@ pub async fn set( }; let mut created = Lock { account_id, + kind, reason, locked_at: now(), locked_by: actor.name.clone(), @@ -370,7 +394,7 @@ pub async fn set( response.not_updated.append(id, SetError::not_found()); continue; }; - if reason_of(arguments.reason.as_deref()).is_none() { + if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() { response.not_updated.append(id, reason_required()); continue; } @@ -379,7 +403,9 @@ pub async fn set( for (key, value) in value.into_expanded_object() { match (&key, value) { (Key::Property(P::Delegates), value) => { - match parse_delegates(server, access_token, account_id, value.into_owned()).await { + match parse_delegates(server, access_token, account_id, current.kind, value.into_owned()) + .await + { Ok(delegates) => updated.delegates = delegates, Err(error) => { invalid = Some(error); @@ -389,6 +415,7 @@ pub async fn set( } (Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) { Some(r) => updated.reason = r, + None if !current.kind.is_lock() => updated.reason = String::new(), None => { invalid = Some(reason_required()); break; @@ -420,7 +447,7 @@ pub async fn set( response.not_destroyed.append(id, SetError::not_found()); continue; }; - if reason_of(arguments.reason.as_deref()).is_none() { + if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() { response.not_destroyed.append(id, reason_required()); continue; } diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index d058281..2e420ef 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -28,6 +28,7 @@ pub mod webhook_test; pub mod explanation; pub mod protocol_policy; pub mod tenant_protocol_policy; +pub mod sharing_policy; pub mod deleted_account; pub mod fastmail; pub mod masked_email; diff --git a/crates/jmap/src/inbuxa/sharing_policy.rs b/crates/jmap/src/inbuxa/sharing_policy.rs new file mode 100644 index 0000000..7fb2a4c --- /dev/null +++ b/crates/jmap/src/inbuxa/sharing_policy.rs @@ -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) -> &'static str { + if on.unwrap_or(true) { "enabled" } else { "disabled" } +} + +fn to_value(tenant_id: Option, 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> { + 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 { + 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, +) -> trc::Result> { + 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 = 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> { + 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) +} diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 94b92fe..07dc784 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -31,7 +31,7 @@ use jmap_proto::{ types::state::State, }; use jmap_tools::{JsonPointerItem, Key, Map, Value}; -use registry::schema::enums::StorageQuota; +use registry::schema::enums::{Permission, StorageQuota}; use std::future::Future; use store::{ 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() && let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await { diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index ce187f7..18c5713 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -58,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send { fn send_message( &self, account_id: u32, + own_addresses_only: bool, response: &SetResponse, instance: &Arc, object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, @@ -83,7 +84,14 @@ impl EmailSubmissionSet for Server { let mut batch = BatchBuilder::new(); for (id, object) in request.unwrap_create() { match self - .send_message(account_id, &response, instance, object) + .send_message( + account_id, + // inbuxa: MA-S3: a shared mailbox's people send only as it + access_token.delegated_shared_mailbox(account_id), + &response, + instance, + object, + ) .await? { Ok(submission) => { @@ -400,6 +408,7 @@ impl EmailSubmissionSet for Server { async fn send_message( &self, account_id: u32, + own_addresses_only: bool, response: &SetResponse, instance: &Arc, object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, @@ -629,6 +638,46 @@ impl EmailSubmissionSet for Server { .unarchive::() .caused_by(trc::location!())?; + // inbuxa: MA-S3: mail that came to a shared mailbox goes out as it, + // so the answer comes back to the mailbox and not to whoever sent + // it: every From and Reply-To address must be the mailbox's own + if own_addresses_only { + let mut named = Vec::new(); + for header in metadata.contents[0].parts[0].headers.iter() { + if !matches!( + header.name, + ArchivedMetadataHeaderName::From | ArchivedMetadataHeaderName::ReplyTo + ) { + continue; + } + match &header.value { + ArchivedMetadataHeaderValue::AddressList(addr) => { + named.extend(addr.iter().filter_map(|a| a.address.as_ref().map(|v| v.to_string()))); + } + ArchivedMetadataHeaderValue::AddressGroup(groups) => { + for group in groups.iter() { + named.extend( + group + .addresses + .iter() + .filter_map(|a| a.address.as_ref().map(|v| v.to_string())), + ); + } + } + _ => {} + } + } + for address in named { + if self.account_id_from_email(&address, true).await? != Some(account_id) { + return Ok(Err(SetError::new(SetErrorType::ForbiddenFrom).with_description( + format!( + "A shared mailbox sends only as its own addresses, so replies come back to it; {address} isn't one." + ), + ))); + } + } + } + // Add recipients to envelope if missing let mut bcc_header = None; if rcpt_to.is_empty() { diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 0c0c0a2..1d96c99 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -244,6 +244,16 @@ retention = "unbounded" changedBy = ["identifier"] 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"] file = "inbuxa_ai_limits.rs" default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 326969b..2fc1ca2 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 27abc91..4a862a7 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ \ No newline at end of file +OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo \ No newline at end of file diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs index 2679c64..eee2748 100644 --- a/tests/src/jmap/principal/get.rs +++ b/tests/src/jmap/principal/get.rs @@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) { // inbuxa: MT-22, the logo that applies to the account, and // LP-19, whether the legacy protocols are open to it, and // 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": {} } } diff --git a/tests/src/system/account_lock.rs b/tests/src/system/account_lock.rs index 9117751..19e2163 100644 --- a/tests/src/system/account_lock.rs +++ b/tests/src/system/account_lock.rs @@ -447,6 +447,156 @@ pub async fn test(test: &mut TestServer) { query["ids"].as_array().is_some_and(|ids| ids.len() >= 2), "AL-9: the delegate's access and changes weren't recorded: {query}" ); + + shared_mailbox(admin, &mut smtp_rx, &mut lmtp).await; +} + +/// MA-S (specs/multi-account.md): a shared mailbox is a lock of its own +/// kind. No reason is needed, more people fit, its Sieve replies go out, +/// only what is sent as it is recorded, and it sends only as itself. +async fn shared_mailbox( + admin: &Account, + smtp_rx: &mut tokio::sync::mpsc::Receiver, + lmtp: &mut SmtpConnection, +) { + println!("Running shared mailbox tests..."); + let support = admin + .create_user_account("support@example.com", "support-secret-3317", "Support", &[], vec![]) + .await; + let agent = admin + .create_user_account("agent@example.com", "agent-secret-5520", "Agent", &[], vec![]) + .await; + let support_id = support.id_string().to_string(); + + // An automatic acknowledgement, set up while it could still sign in + support + .jmap_client() + .await + .vacation_response_enable("Received", "We'll get back to you.".into(), None::) + .await + .unwrap(); + + // More people than a lock may have + let mut delegates = vec![json!({"accountId": agent.id_string(), "access": "organize", "sendAs": true})]; + for n in 0..11 { + let name: &'static str = Box::leak(format!("desk{n}@example.com").into_boxed_str()); + let desk = admin.create_user_account(name, "desk-secret-7781", "Desk", &[], vec![]).await; + delegates.push(json!({"accountId": desk.id_string(), "access": "read"})); + } + + // No reason needed + let response = admin + .lock_set(json!({"create": {"s": {"accountId": support_id, "kind": "sharedMailbox", + "delegates": delegates}}})) + .await; + assert_eq!(response["created"]["s"]["id"], support_id.as_str(), "MA-S1: {response}"); + let (_, got) = admin + .call("inbuxa:AccountLock/get", json!({"accountId": admin.id_string(), "ids": [support_id]})) + .await; + assert_eq!(got["list"][0]["kind"], "sharedMailbox", "MA-S1: {got}"); + + // Nobody signs in to it + assert_ne!(support.session_status().await, 200, "MA-S1: a shared mailbox signed in"); + + // It says what it is to the people in it + let session = agent.jmap_session_object().await.0; + let delegation = &session["accounts"][support_id.as_str()]["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"]; + assert_eq!(delegation["kind"], "sharedMailbox", "MA-S: {session}"); + assert_eq!(delegation["locked"], true, "MA-S: front ends that know no kind still see a lock"); + + // Its Sieve replies go out, where a lock's are held back + lmtp.ingest( + "carol@remote.org", + &["support@example.com"], + // Addressed to it: a vacation reply answers only mail sent to it + "From: carol@remote.org\r\nTo: support@example.com\r\nSubject: My order\r\n\r\nHello.\r\n", + ) + .await; + assert_message_delivery( + smtp_rx, + MockMessage::new("", [""], "@Received"), + ) + .await; + + // The agent answers as support@: sent, and recorded as the agent + let (_, mailboxes) = agent + .call("Mailbox/get", json!({"accountId": support_id, "ids": null, "properties": ["role"]})) + .await; + let drafts = mailboxes["list"] + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "drafts") + .unwrap_or_else(|| panic!("no Drafts: {mailboxes}"))["id"] + .clone(); + let (_, identities) = agent + .call("Identity/get", json!({"accountId": support_id, "ids": null})) + .await; + let identity = identities["list"][0]["id"].clone(); + let send = |reply_to: Option<&str>, subject: &str| { + let mut email = json!({ + "mailboxIds": {drafts.as_str().unwrap(): true}, + "from": [{"email": "support@example.com"}], + "to": [{"email": "carol@remote.org"}], + "subject": subject, + "bodyValues": {"t": {"value": "Thanks for writing."}}, + "textBody": [{"partId": "t", "type": "text/plain"}] + }); + if let Some(reply_to) = reply_to { + email["replyTo"] = json!([{"email": reply_to}]); + } + json!([ + ["Email/set", {"accountId": support_id, "create": {"m": email}}, "e"], + ["EmailSubmission/set", {"accountId": support_id, + "create": {"s": {"identityId": identity, "emailId": "#m"}}}, "s"] + ]) + }; + let response = agent.jmap_request(USING, send(None, "Re: My order")).await.0; + assert!( + response.pointer("/methodResponses/1/1/created/s").is_some(), + "MA-S3: the answer didn't go out: {response}" + ); + assert_message_delivery( + smtp_rx, + MockMessage::new("", [""], "@Re: My order"), + ) + .await; + + // MA-S3: a reply can't be steered to the agent's own address + let response = agent + .jmap_request(USING, send(Some("agent@example.com"), "Write to me directly")) + .await + .0; + assert_eq!( + response.pointer("/methodResponses/1/1/notCreated/s/type"), + Some(&json!("forbiddenFrom")), + "MA-S3: {response}" + ); + expect_nothing(smtp_rx).await; + + // MA-D0a: the send names the agent; AL-9's per-change records don't + // apply in a shared mailbox + let (_, query) = admin + .call( + "inbuxa:AuditEvent/query", + json!({"accountId": admin.id_string(), + "filter": {"actorId": agent.id_string(), "accountId": support_id}}), + ) + .await; + let (_, records) = admin + .call("inbuxa:AuditEvent/get", json!({"accountId": admin.id_string(), "ids": query["ids"]})) + .await; + let kinds = records["list"] + .as_array() + .unwrap() + .iter() + .map(|r| r["target"]["kind"].as_str().unwrap_or_default().to_string()) + .collect::>(); + assert_eq!(kinds, ["EmailSubmission"], "MA-D0a: {records}"); + + // Ending it needs no reason either + let response = admin.lock_set(json!({"destroy": [support_id]})).await; + assert_eq!(response["destroyed"][0], support_id.as_str(), "MA-S: {response}"); } /// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`. diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 1db96a2..d0f7d32 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -12,6 +12,7 @@ pub mod ai; pub mod ai_calibration; pub mod ai_explain; 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 compliance; // inbuxa: the compliance roles pub mod mail_rules; // inbuxa: DLP and mail flow rules diff --git a/tests/src/system/sharing_policy.rs b/tests/src/system/sharing_policy.rs new file mode 100644 index 0000000..a6f4e5a --- /dev/null +++ b/tests/src/system/sharing_policy.rs @@ -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("admin@example.org").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("ann@school.example.org", "ann-secret-6610", "Ann", &[], vec![]) + .await; + let ben = admin + .create_user_account("ben@school.example.org", "ben-secret-6611", "Ben", &[], vec![]) + .await; + let head = admin + .create_user_account("head@school.example.org", "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("carl@example.org", "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("dan@example.org", "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("office@school.example.org", "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(); + } +}