From 9976d52e29e976b395ddc117c8b604f8981d792e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:27:52 -0700 Subject: [PATCH 1/2] Shared mailboxes: a second kind of account lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared mailbox (support@, legal@) belongs to no one person: nobody signs in to it, and the people assigned open it beside their own mail at an access level an administrator chose. An account lock already is most of that: it keeps receiving mail, refuses every sign-in, and its delegates reach it through real grants on every container (so IMAP, DAV and JMAP honor them), never including Share. So a shared mailbox is a lock of a second kind (multi-account spec, MA-S; John, 2026-10-05). Lock gains kind: "lock" (the default, so stored locks read as before) or "sharedMailbox", set on create and fixed after. A shared mailbox: - needs no reason to make, change or end; - holds up to 100 people, where a lock holds 10; - runs its own Sieve replies and redirects, so an automatic acknowledgement goes out (a lock answers no one); - records only what is sent as it (audit_send_as, which now covers it), not AL-9's access and per-change records, which would bury the log for a busy desk; - sends only as itself (MA-S3): From and Reply-To must be its own addresses, so answers come back to the mailbox and not to whoever replied; anything else is forbiddenFrom. The session marks it delegation: {locked: true, kind: "sharedMailbox"}, so a front end that knows no kind still treats it as a lock. The console's layout gains Management › Directory › Shared Mailboxes (CustomComponent/SharedMailboxes). Tests: the account lock suite now goes on to a shared mailbox: made without a reason with twelve people, sign-in refused, the session's kind, its vacation reply delivered, an answer sent as it and recorded as the agent with no per-change records, and a Reply-To naming the agent refused; a lock unit test reads a stored lock without a kind. account_lock_tests, jmap_tests, audit_log_tests and imap_tests pass (RocksDB). --- crates/common/src/audit.rs | 13 +- crates/common/src/auth/access_token.rs | 28 +++- crates/common/src/auth/mod.rs | 4 + crates/email/src/sieve/ingest.rs | 12 +- crates/features/src/lock/mod.rs | 75 ++++++++- .../src/object/inbuxa_account_lock.rs | 4 + crates/jmap-proto/src/request/capability.rs | 6 +- crates/jmap/src/api/request.rs | 23 +-- crates/jmap/src/api/session.rs | 1 + crates/jmap/src/inbuxa/account_lock.rs | 49 ++++-- crates/jmap/src/submission/set.rs | 51 +++++- resources/schema/schema.json.gz | Bin 153498 -> 153512 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/account_lock.rs | 150 ++++++++++++++++++ 14 files changed, 382 insertions(+), 36 deletions(-) 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..9b7fe48 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -46,19 +46,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, @@ -247,6 +250,7 @@ impl Server { .map(ConcurrencyLimiter::new), obj_size: 0, locked, + shared_mailbox, delegations: delegations.clone(), revision, revision_account, @@ -300,6 +304,7 @@ impl Server { .map(ConcurrencyLimiter::new), obj_size: 0, locked, + shared_mailbox, delegations: delegations.clone(), revision, revision_account, @@ -658,6 +663,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 +854,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 +946,7 @@ impl AccessToken { credential_version: Default::default(), obj_size: Default::default(), locked: false, + shared_mailbox: false, delegations: Default::default(), }), } @@ -988,6 +1007,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/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/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 0344e15..de11758 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"))] diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 3a144f2..910aa63 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) = diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index bd2cd1b..38e1eda 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -148,6 +148,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/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/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/schema/schema.json.gz b/resources/schema/schema.json.gz index 326969b37765440dcee942de563c54fa1aa0d5a2..2fc1ca2e8fba7d031173a1cba25f5efaeed661ad 100644 GIT binary patch delta 2293 zcmVC`tGT8RM^Q=*dM_PW6bG~{q zTo;wa-krfUTe$F*w)#BJ6@SRLJvEl?Q}|55{V_)}{k!p$b3Uq4wfVx%fBNv@9^q}j zvW$GHKhhd3=Wchvs~10LuAV8})W%{P$1`jL;6KAn(c2<2*46#ta}Jy~W~f_ zV|`a)@gC&U3+qe&Gt#hEe@ooIr1&;$yBAMbMhbyFDA5;*#`~IK43-G)7_!L&Zy`pC zfM0-DzLa6ebti#$m&iMLqzu-@iAes3@AUlPF^KvdTp9+s${*8wA#c-xHC!{Gs1)#C zUa=k<)5xg!f?yELUoEk5C}bn9ctOwcR>5^m7f+Q^9V>3JyFu@fe=z27Uo-+^UmcZ) za<|UgMBQMG;1C=2uyB_|n{OhkL<~-Gp-dC&wD5Y~gMXq*y>)_ewgAR;SMUW7LBr)x z*XAV|e9P}alXadwZS`2)da<0ExN7$G@0#nf!dFTJlzH!=rcu68OejxFRO+^xWKD^) zwsl=V8n|u`n(S;-e^FQ??b+Gan^fh4pSiiapl`R1RQC{a)XAv2!)$W)gGTRj8tzWe z)QHGxPa_z5L_@~eXT_^Olq1bOq5y##;gjovE^J5g+j|5usUPr9&k%&b*X>|UN^oB$ z!BG0xy8N5+1RY(_wmH|je*>Sahd!#eB1bpeQw^ABi#)Q;}aAlu%2JzwZXM8Tu#bn zF@$mn%HNPJo<8l1_oS$XG+1K!iPBj9dn*t7I#3q6$5A+S9k^S8<5bjea^wuC4dCZ{zZF^ES6U$<5EslQ%b47jcmIR_x{&ypL~3%5k&W zMe5;Xe||fU%b%EVA96i{p`fzdlHqjr+MVNtaJTK_u z9|Z%G);LOU^)$O<3CS=So-o{p96}0%y@hPZe-e~tAo^(Q-rzh8%JT0WxOO$ujm#*Q z$QzQOi^H8Nc={xIo?wj3aXiB|4yath`qKD7L?-HZ%?yU+CoShnetg%B=M&PQ#ANxP zt#&HH;+mP37rVQu(z2y@a_$$GEbmR}OOG_wo4jZz9UflvjDE*Zl;|7^7S8HHlEw7+ ze@-oEq#D&Bz&{IKc~vL2uC8Q}4m;t~nNbIQk=-TUG*dMjybSq7`xGFL_l#o*Hr{|X z$hB0Ix2Q4mD9n@dvk{{-a?SGy%=twU&&;`gDNr`M?7vl_lp=)NPFg8Y?k2iCELK%5 z%iX5;+DFF=5IffBQ%bq1NV)Xok&^`B;XrE5oLqpZqIZfpXP+ zm+gO`da7d6uA%dSn(NHC8YK8Ci=Xym>9s zdfS8$h#C>r5o}-M>g-zy6z#QMxI@*5T^E&ObmdV+G3FjP!^|dBAPtW|2SwUw&m6w& zjw_uUTfTKT2iq%Rg~wmx1Bwo7e-b*Y-_=HqCq@oZ*B$KW$^a zS&+~Rd8WN@N7_e418+oMw0cCn)n_tK-nSU}ur&kj*aQzNv@|&Dz!R3JuVHNP$hHA4 z7H44j>zYv@i#2lK0f(j=e`x~?>1qQT9*bEm_}kJJFs#f_sCSCp&2=KAIl<25UWdLP ziE?7Dd0a>4D`GfB|Bf?j?_I06licFu7tomtQ5b3{neV{EN||8o>;AhPL^>$pdJDrF z_(jau@PO~y(J6MI)~E-B-O5r+V%}t)=ZW^`u_`97SFp%;O%`?Ue-=De0*i+J!myhQ zs+-{Gj3(-|2!zWS8E@CHxdw-jbsz&hIow5eyToucLQ_mYxIPnSTzKV{CR(wB+MeLv zJ$(yAbBW!+=(4hUZm+|=$}RP7OVzcJlQ+{{W_eemXr zKjhn<8q4-6e5T-zlOvh_-MF+lA62Q^&R}PK_;8Q#wqF89KGmN+4JK~4e>>nEi61mq z&lGNIV_J>l8MXoNpW&wHjRqO(>i%dy2TmI^)GRl>g+s7{u^JQV_e`5W$9kVW0&zPJ z9(tU9#n=vMp>h%#rF72WR$%&Adj9YjL`4j4_kvvIk7>T*w&}nct{KoM3V7JASdWcqWK?`XFo?PjCvY&; z#-WgnxZ(vpNB95hnr?_H?Kf83Vt0dX9%0PkVrK-#zT+tmS5t7 ziN@JP)?FB!;zF4w)@k9Hy9fX1lX~j}EouRb>#pDn9)gC;q3*X!GWh7;gC^@ddurve zx}IWrA5~xZDzCXND|}8wK$-WhWE$lg1#j}yIi(k?N!FA&Yg^Y>qk((zpmNSO6~!OY zE|`72NmV{LgquqSfBG8gNOcdfL7j}M%f=>WKWOwmr{V4dO^t}G_B4W_hY`?*m#=vB zhjOI3M-(9N_j__((9P;detXwFCiMgU=^26$_zWGaNeOPCBp6B`TbF-RE}NqZ+BWA} z_iy0b`2+sZTzu4#kvz|kmtKs2MF1YnLLyj--_KFgPZW}NI7m+yGT8p%x~v$`4bbDL#{_K zl$6G+f6|w?h+~@CS$_{{Y%A3F6aQ-(!Z1WuO1$-*uqnq|GLdPd4OQeWvErI(Hlt}p z#vl^e{a*Nx0qI#HM(IwN832L^Q9WEcyC(W0xd=1xjpqfO@uOg1(i%tUt)6CgEFl>t z!xM)4kVEWWu(yy6S%T6GL?3P48=QwhS^m8Pe;=%7x{(>>5_v;1baA-+1aFi?&l8N1 zIgV%8#sQV@R$m$)h{!}8ubIKH{G{bv$&c^4@q9u$l$b0ZwAD^USX?vH@?v*4Ra&<6 zPR{+}lI6WAed&>=dXpFJq*K0&p3(0ZiV~ee!NOTRNV1q7->C(SRHHfsI8woLtmwel}v1 zMy`1tfjPfO;+Z+uPx{Gbm;JX&lv0Fn+es@0%H2elhsCO@Wx3n*e%$o&`h1FN)7w1R zN5FWoEGA4jWFLtk)Y|+J&9FH#AImUye`VOz^CNm?D^Na_@3Q?5R8MuxBBi6W0#PHvI@IiIT%CO@ zfug@KRy#>d*tQ>chfFu0X-h?J;#@8GO z5FwlrAn7b-MJ}MtWNA%8>2Uq2#jFvoh>TPq1s4%L@$fKD!A81~@&N5Wf9|#c62}mR z(+yaO$5!fzFf)p#JqGqchsKtU#}|F=*|llr6X6Ue?4oHK^UZ>U?!q(eCOgtTDr|Qn z`l8h%>a9MLdGfx+$cL>Na2Y0eSfQoCSqI*lM12i|ct^Gka0564%U{=w0$Hq)0}nVf z-AEf)NLL%!@L0@h!7-M$e}G|ShGMc)kZrCLAe=r9UL?mL4UN+lxC#W83FA>s>T31JAek!SQ_aL-nsM8 z40q2gS9(KEZ>k)2S>5CQXa<2pgej9`)b>)WKst>^eS(Z9{{{@e6AJ-4~4gf$k BXBGee 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/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`. From fb785b8635a497302501bcf82a587fd0f6137325 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:59:55 -0700 Subject: [PATCH 2/2] Who may share mail: a server switch, and a tenant's that can only be stricter A school, or any organization that doesn't want people's mailboxes shared, can now turn that off (multi-account spec, MA-C). Two switches at two levels, as the legacy-protocols switch has: - mailSharing: people may share their own mail folders; - addAccounts: people may add other accounts to the webmail (read by the webmail's account switcher, MA-B). inbuxa:SharingPolicy/get and /set hold them: the server's policy has the singleton id, each tenant's has the tenant's id. Both default to on, so nothing changes until someone turns one off. A tenant's administrator changes their own tenant's (the domain's permissions, as for its protocols switch); only a server administrator with sysSharingUpdate changes the server's; a tenant can never be looser than the server (forbidden). Every change goes through the audit log, and rebuilds every access token, here and on every node. With mail sharing off for an account's tenant (or the server): - Mailbox/set and IMAP SETACL refuse to start or widen a share (forbidden / NO [NOPERM]); narrowing or ending one is always allowed; - shares already made give nothing while it is off: an access token leaves out mailbox grants from such an owner. They stay stored, so turning sharing back on restores them (John, 2026-10-05); - a lock's and a shared mailbox's grants are an administrator's and always count, and group membership was never a share. The session's own account says mailSharing and addAccounts, the stricter of the two levels, so front ends can hide what is off. Tests: a new sharing_policy suite with a school tenant, its own administrator and two people outside it: on by default; the school's administrator turns it off but can't touch the server's; an old share stops working and a new one is refused while someone outside the school is unaffected; a shared mailbox in the school keeps working; the server off can't be loosened by the tenant; on again restores the old share; ending a share works while off; and every change is audited. A unit test covers the stricter-only rule. sharing_policy_tests, jmap_tests, imap_tests, account_lock_tests and audit_log_tests pass (RocksDB). --- crates/common/src/auth/access_token.rs | 50 ++++ crates/features/src/security/mod.rs | 1 + .../features/src/security/sharing_policy.rs | 188 ++++++++++++++ crates/imap/src/op/acl.rs | 28 +++ .../src/object/inbuxa_sharing_policy.rs | 185 ++++++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 6 + crates/jmap-proto/src/request/capability.rs | 7 + crates/jmap-proto/src/request/method.rs | 11 + crates/jmap-proto/src/request/mod.rs | 6 + crates/jmap-proto/src/request/parser.rs | 18 ++ crates/jmap-proto/src/response/mod.rs | 26 ++ crates/jmap/src/api/auth.rs | 15 +- crates/jmap/src/api/request.rs | 27 ++ crates/jmap/src/api/session.rs | 9 + crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/mod.rs | 1 + crates/jmap/src/inbuxa/sharing_policy.rs | 225 +++++++++++++++++ crates/jmap/src/mailbox/set.rs | 23 +- resources/privacy/catalog.toml | 10 + tests/src/jmap/principal/get.rs | 2 +- tests/src/system/mod.rs | 1 + tests/src/system/sharing_policy.rs | 237 ++++++++++++++++++ 24 files changed, 1078 insertions(+), 3 deletions(-) create mode 100644 crates/features/src/security/sharing_policy.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_sharing_policy.rs create mode 100644 crates/jmap/src/inbuxa/sharing_policy.rs create mode 100644 tests/src/system/sharing_policy.rs diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 9b7fe48..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, @@ -100,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() @@ -120,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); 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_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 de11758..becfccf 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -183,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 910aa63..b847aed 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -317,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); } @@ -574,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() } @@ -1132,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 38e1eda..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 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/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/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/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/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(); + } +}