Merge branch 'main' into fix/group-collections-no-onward-share
This commit is contained in:
commit
461f5fab3c
34 files changed
+1460
-39
No files matched your search
@@ -446,9 +446,10 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// MA-D0a: a message sent from an address that isn't the sender's own:
|
/// 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
|
/// a group's or a shared mailbox's. The message itself only says
|
||||||
/// the audit log is where the person who sent it is named. A delegate's
|
/// `From:` that address, so the audit log is where the person who sent
|
||||||
/// send is AL-9's record, not this one.
|
/// it is named. A locked account's delegate's send is AL-9's record, not
|
||||||
|
/// this one.
|
||||||
pub async fn audit_send_as(
|
pub async fn audit_send_as(
|
||||||
&self,
|
&self,
|
||||||
token: &AccessToken,
|
token: &AccessToken,
|
||||||
@@ -459,7 +460,11 @@ impl Server {
|
|||||||
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let actor = self.audit_actor(token).await;
|
let actor = self.audit_actor(token).await;
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
|
|||||||
use xxhash_rust::xxh3;
|
use xxhash_rust::xxh3;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
/// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail
|
||||||
|
/// (the server's switch, narrowed by the tenant's).
|
||||||
|
pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant);
|
||||||
|
Ok(
|
||||||
|
inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.mail_sharing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked
|
||||||
|
/// account's or shared mailbox's grants are an administrator's and always
|
||||||
|
/// do; anyone else's only while their tenant allows mail sharing.
|
||||||
|
pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
if inbuxa_features::lock::get(self.store(), owner)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
self.mail_sharing_allowed(owner).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn build_access_token(
|
async fn build_access_token(
|
||||||
&self,
|
&self,
|
||||||
account: Account,
|
account: Account,
|
||||||
@@ -46,19 +72,22 @@ impl Server {
|
|||||||
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
||||||
// locked accounts are handed to it. The token is their cache: every
|
// locked accounts are handed to it. The token is their cache: every
|
||||||
// change to a lock invalidates the tokens it touches.
|
// 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
|
.await
|
||||||
.caused_by(trc::location!())?
|
.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 now_secs = now();
|
||||||
let delegations: Box<[super::Delegation]> =
|
let delegations: Box<[super::Delegation]> =
|
||||||
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, delegate)| delegate.is_current(now_secs))
|
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
|
||||||
.map(|(locked_id, delegate)| super::Delegation {
|
.map(|(locked_id, delegate, kind)| super::Delegation {
|
||||||
account_id: locked_id,
|
account_id: locked_id,
|
||||||
|
kind,
|
||||||
access: delegate.access,
|
access: delegate.access,
|
||||||
send_as: delegate.send_as,
|
send_as: delegate.send_as,
|
||||||
until: delegate.until,
|
until: delegate.until,
|
||||||
@@ -97,6 +126,9 @@ impl Server {
|
|||||||
.map(|m| m.id() as u32)
|
.map(|m| m.id() as u32)
|
||||||
.collect::<TinyVec<[u32; 3]>>();
|
.collect::<TinyVec<[u32; 3]>>();
|
||||||
let mut access_to: Vec<AccessTo> = Vec::new();
|
let mut access_to: Vec<AccessTo> = Vec::new();
|
||||||
|
// inbuxa: MA-C: whether an owner's mail shares are honored,
|
||||||
|
// looked up once per owner
|
||||||
|
let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new();
|
||||||
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
||||||
for acl_item in self
|
for acl_item in self
|
||||||
.store()
|
.store()
|
||||||
@@ -117,6 +149,27 @@ impl Server {
|
|||||||
.caused_by(trc::location!()));
|
.caused_by(trc::location!()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: a mail share from an account whose
|
||||||
|
// tenant (or server) has mail sharing off gives
|
||||||
|
// nothing while it is off. It stays stored, so it
|
||||||
|
// comes back when sharing does. A lock's and a
|
||||||
|
// shared mailbox's grants are an administrator's,
|
||||||
|
// and always count.
|
||||||
|
if collection == Collection::Mailbox {
|
||||||
|
let owner = acl_item.to_account_id;
|
||||||
|
let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) {
|
||||||
|
Some((_, honored)) => *honored,
|
||||||
|
None => {
|
||||||
|
let honored = self.mail_shares_honored(owner).await?;
|
||||||
|
mail_shares_honored.push((owner, honored));
|
||||||
|
honored
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !honored {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut collections: Bitmap<Collection> = Bitmap::new();
|
let mut collections: Bitmap<Collection> = Bitmap::new();
|
||||||
if acl.contains(Acl::Read) {
|
if acl.contains(Acl::Read) {
|
||||||
collections.insert(collection);
|
collections.insert(collection);
|
||||||
@@ -247,6 +300,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -300,6 +354,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -658,6 +713,7 @@ impl AccessToken {
|
|||||||
credential_version: old_inner.credential_version,
|
credential_version: old_inner.credential_version,
|
||||||
obj_size: old_inner.obj_size,
|
obj_size: old_inner.obj_size,
|
||||||
locked: old_inner.locked,
|
locked: old_inner.locked,
|
||||||
|
shared_mailbox: old_inner.shared_mailbox,
|
||||||
delegations: old_inner.delegations.clone(),
|
delegations: old_inner.delegations.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -848,6 +904,18 @@ impl AccessToken {
|
|||||||
self.inner.locked
|
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
|
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||||
/// has one that hasn't ended.
|
/// has one that hasn't ended.
|
||||||
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
/// 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(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -988,6 +1057,7 @@ impl AccessTokenInner {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
|
|||||||
pub(crate) obj_size: u64,
|
pub(crate) obj_size: u64,
|
||||||
// inbuxa: AL-2: the account is locked; it may not authenticate
|
// inbuxa: AL-2: the account is locked; it may not authenticate
|
||||||
pub(crate) locked: bool,
|
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
|
// inbuxa: AL-5: locked accounts handed to this one
|
||||||
pub(crate) delegations: Box<[Delegation]>,
|
pub(crate) delegations: Box<[Delegation]>,
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,8 @@ pub struct Delegation {
|
|||||||
pub send_as: bool,
|
pub send_as: bool,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub until: Option<u64>,
|
pub until: Option<u64>,
|
||||||
|
/// MA-S: a locked account, or a shared mailbox.
|
||||||
|
pub kind: inbuxa_features::lock::Kind,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Hash, Clone)]
|
#[derive(Debug, Default, Hash, Clone)]
|
||||||
|
|||||||
@@ -290,7 +290,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account answers no sender, so a
|
// inbuxa: AL-4: a locked account answers no sender, so a
|
||||||
// rejection is kept instead; sieve has already cleared
|
// rejection is kept instead; sieve has already cleared
|
||||||
// the implicit keep, so it is filed here
|
// 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)
|
if let Some(message) = messages.get_mut(0)
|
||||||
&& !message.file_into.contains(&INBOX_ID)
|
&& !message.file_into.contains(&INBOX_ID)
|
||||||
{
|
{
|
||||||
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account sends nothing on its
|
// inbuxa: AL-4: a locked account sends nothing on its
|
||||||
// own: no redirect, vacation reply or notification. An
|
// own: no redirect, vacation reply or notification. An
|
||||||
// unsent redirect leaves the message to be kept.
|
// 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!(
|
trc::event!(
|
||||||
Sieve(SieveEvent::ActionReject),
|
Sieve(SieveEvent::ActionReject),
|
||||||
Details = "Account is locked: nothing is sent",
|
Details = "Account is locked: nothing is sent",
|
||||||
|
|||||||
@@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd';
|
|||||||
/// Most delegates one lock may have (AL-5).
|
/// Most delegates one lock may have (AL-5).
|
||||||
pub const MAX_DELEGATES: usize = 10;
|
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<Self> {
|
||||||
|
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).
|
/// What a delegate may do in the locked account (AL-6).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -189,6 +236,9 @@ pub struct Replaced {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Lock {
|
pub struct Lock {
|
||||||
pub account_id: u32,
|
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,
|
pub reason: String,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub locked_at: u64,
|
pub locked_at: u64,
|
||||||
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
|
|||||||
Ok(locks)
|
Ok(locks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The accounts delegated to `delegate`, with its delegation in each.
|
/// The accounts delegated to `delegate`, with its delegation in each and
|
||||||
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
|
/// the kind of lock it is in.
|
||||||
|
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate, Kind)>> {
|
||||||
let mut locked = Vec::new();
|
let mut locked = Vec::new();
|
||||||
data.iterate(
|
data.iterate(
|
||||||
IterateParams::new(
|
IterateParams::new(
|
||||||
@@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32,
|
|||||||
if let Some(lock) = get(data, account_id).await?
|
if let Some(lock) = get(data, account_id).await?
|
||||||
&& let Some(delegation) = lock.delegate(delegate)
|
&& let Some(delegation) = lock.delegate(delegate)
|
||||||
{
|
{
|
||||||
delegations.push((account_id, delegation.clone()));
|
delegations.push((account_id, delegation.clone(), lock.kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(delegations)
|
Ok(delegations)
|
||||||
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::<Lock>(&written).unwrap().kind, Kind::SharedMailbox);
|
||||||
|
assert_eq!(Kind::parse("sharedMailbox"), Some(Kind::SharedMailbox));
|
||||||
|
assert_eq!(Kind::SharedMailbox.max_delegates(), MAX_SHARED_MAILBOX_DELEGATES);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_read_back() {
|
fn keys_read_back() {
|
||||||
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
||||||
@@ -509,6 +576,7 @@ mod tests {
|
|||||||
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
||||||
Lock {
|
Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "r".into(),
|
reason: "r".into(),
|
||||||
locked_at: 0,
|
locked_at: 0,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
@@ -623,6 +691,7 @@ mod tests {
|
|||||||
fn expired_delegations_grant_nothing() {
|
fn expired_delegations_grant_nothing() {
|
||||||
let lock = Lock {
|
let lock = Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "Left the company".into(),
|
reason: "Left the company".into(),
|
||||||
locked_at: 100,
|
locked_at: 100,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ pub mod legacy_use;
|
|||||||
pub mod log_files;
|
pub mod log_files;
|
||||||
pub mod listeners;
|
pub mod listeners;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
|
pub mod sharing_policy;
|
||||||
pub mod tenant_protocol_policy;
|
pub mod tenant_protocol_policy;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy`, whether people may share their own mail and add
|
||||||
|
//! other accounts to the webmail (multi-account spec, MA-C, MA-10 to MA-14).
|
||||||
|
//!
|
||||||
|
//! Two levels, as the legacy-protocols switch has: the server's policy, and
|
||||||
|
//! one per tenant that can only be stricter. Stored as JSON in the fork's
|
||||||
|
//! subspace, `W` + `p` for the server and `W` + `t` + tenant for a tenant;
|
||||||
|
//! unset reads as the defaults, which are on, so a server keeps today's
|
||||||
|
//! behavior until someone turns it off.
|
||||||
|
//!
|
||||||
|
//! "Off" refuses new shares and stops honoring the ones already made, which
|
||||||
|
//! stay stored, so turning it back on restores them (John, 2026-10-05).
|
||||||
|
//! Group membership and shared mailboxes aren't users' shares and are never
|
||||||
|
//! affected.
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// One level's switches. `None` on a tenant means "as the server says".
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SharingPolicy {
|
||||||
|
/// People may share their own mail folders (MA-11). Default on.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mail_sharing: Option<bool>,
|
||||||
|
/// People may add their other accounts to the webmail (MA-B). Default on.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub add_accounts: Option<bool>,
|
||||||
|
/// Seconds since the epoch, and who: the console shows them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub changed_at: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub changed_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applies to one account: the server's switch, narrowed by its
|
||||||
|
/// tenant's.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Effective {
|
||||||
|
pub mail_sharing: bool,
|
||||||
|
pub add_accounts: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Effective {
|
||||||
|
fn default() -> Self {
|
||||||
|
Effective {
|
||||||
|
mail_sharing: true,
|
||||||
|
add_accounts: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tenant can be stricter than the server, never looser (MA-C).
|
||||||
|
pub fn effective(server: &SharingPolicy, tenant: Option<&SharingPolicy>) -> Effective {
|
||||||
|
let server_mail = server.mail_sharing.unwrap_or(true);
|
||||||
|
let server_add = server.add_accounts.unwrap_or(true);
|
||||||
|
Effective {
|
||||||
|
mail_sharing: server_mail && tenant.and_then(|t| t.mail_sharing).unwrap_or(true),
|
||||||
|
add_accounts: server_add && tenant.and_then(|t| t.add_accounts).unwrap_or(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a tenant can't turn a switch on: the server has it off.
|
||||||
|
pub fn looser_than_server(server: &SharingPolicy, tenant: &SharingPolicy) -> Option<&'static str> {
|
||||||
|
if tenant.mail_sharing == Some(true) && server.mail_sharing == Some(false) {
|
||||||
|
return Some("The server has mail sharing off; a tenant can only be stricter.");
|
||||||
|
}
|
||||||
|
if tenant.add_accounts == Some(true) && server.add_accounts == Some(false) {
|
||||||
|
return Some("The server has adding accounts off; a tenant can only be stricter.");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(tenant_id: Option<u32>) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
match tenant_id {
|
||||||
|
None => key.extend_from_slice(b"Wp"),
|
||||||
|
Some(tenant_id) => {
|
||||||
|
key.extend_from_slice(b"Wt");
|
||||||
|
key.extend_from_slice(&tenant_id.to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Json(SharingPolicy);
|
||||||
|
|
||||||
|
impl Deserialize for Json {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's policy (`None`) or a tenant's.
|
||||||
|
pub async fn get(data: &Store, tenant_id: Option<u32>) -> trc::Result<SharingPolicy> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json>(ValueKey::from(key(tenant_id)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(policy)| policy)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applies to an account in `tenant_id`.
|
||||||
|
pub async fn effective_for(data: &Store, tenant_id: Option<u32>) -> trc::Result<Effective> {
|
||||||
|
let server = get(data, None).await?;
|
||||||
|
let tenant = match tenant_id {
|
||||||
|
Some(tenant_id) => Some(get(data, Some(tenant_id)).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(effective(&server, tenant.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a policy.
|
||||||
|
pub async fn set(data: &Store, tenant_id: Option<u32>, policy: &SharingPolicy) -> trc::Result<()> {
|
||||||
|
let bytes = serde_json::to_vec(policy).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})?;
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(key(tenant_id), bytes);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn on_off(mail: Option<bool>, add: Option<bool>) -> SharingPolicy {
|
||||||
|
SharingPolicy {
|
||||||
|
mail_sharing: mail,
|
||||||
|
add_accounts: add,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_is_on() {
|
||||||
|
assert_eq!(effective(&SharingPolicy::default(), None), Effective::default());
|
||||||
|
assert_eq!(
|
||||||
|
effective(&SharingPolicy::default(), Some(&SharingPolicy::default())),
|
||||||
|
Effective::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_is_only_ever_stricter() {
|
||||||
|
// The server off wins over a tenant on
|
||||||
|
let server = on_off(Some(false), None);
|
||||||
|
let tenant = on_off(Some(true), Some(false));
|
||||||
|
let e = effective(&server, Some(&tenant));
|
||||||
|
assert!(!e.mail_sharing);
|
||||||
|
assert!(!e.add_accounts, "the tenant's own off holds");
|
||||||
|
assert!(looser_than_server(&server, &tenant).is_some());
|
||||||
|
// A tenant off under a server on
|
||||||
|
let e = effective(&on_off(Some(true), Some(true)), Some(&on_off(Some(false), None)));
|
||||||
|
assert!(!e.mail_sharing && e.add_accounts);
|
||||||
|
assert!(looser_than_server(&on_off(None, None), &on_off(Some(true), Some(true))).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_stay_apart() {
|
||||||
|
let ValueClass::Any(server) = key(None) else { panic!() };
|
||||||
|
let ValueClass::Any(tenant) = key(Some(7)) else { panic!() };
|
||||||
|
assert_eq!(server.key, b"Wp");
|
||||||
|
assert_eq!(tenant.key, [b'W', b't', 0, 0, 0, 7]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -377,6 +377,34 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
||||||
|
// widens a share (narrowing or ending one is always allowed)
|
||||||
|
let had = current_mailbox
|
||||||
|
.inner
|
||||||
|
.acls
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.account_id == acl_account_id)
|
||||||
|
.map_or(0, |item| item.grants.clone().into_inner());
|
||||||
|
let has = mailbox
|
||||||
|
.acls
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.account_id == acl_account_id)
|
||||||
|
.map_or(0, |item| item.grants.clone().into_inner());
|
||||||
|
if has & !had != 0
|
||||||
|
&& !access_token.has_permission(Permission::Impersonate)
|
||||||
|
&& !data
|
||||||
|
.server
|
||||||
|
.mail_sharing_allowed(mailbox_id.account_id)
|
||||||
|
.await
|
||||||
|
.imap_ctx(&arguments.tag, trc::location!())?
|
||||||
|
{
|
||||||
|
return Err(trc::ImapEvent::Error
|
||||||
|
.into_err()
|
||||||
|
.details("Your organization has turned off sharing mail folders.")
|
||||||
|
.code(ResponseCode::NoPerm)
|
||||||
|
.id(arguments.tag.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
||||||
return Err(trc::ImapEvent::Error
|
return Err(trc::ImapEvent::Error
|
||||||
.into_err()
|
.into_err()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub enum AccountLockProperty {
|
|||||||
/// The locked account (on create; afterwards the same as `id`).
|
/// The locked account (on create; afterwards the same as `id`).
|
||||||
AccountId,
|
AccountId,
|
||||||
Name,
|
Name,
|
||||||
|
/// MA-S: `lock` (the default) or `sharedMailbox`; set on create only.
|
||||||
|
Kind,
|
||||||
Reason,
|
Reason,
|
||||||
LockedAt,
|
LockedAt,
|
||||||
LockedBy,
|
LockedBy,
|
||||||
@@ -53,6 +55,7 @@ impl Property for AccountLockProperty {
|
|||||||
AccountLockProperty::Id => "id",
|
AccountLockProperty::Id => "id",
|
||||||
AccountLockProperty::AccountId => "accountId",
|
AccountLockProperty::AccountId => "accountId",
|
||||||
AccountLockProperty::Name => "name",
|
AccountLockProperty::Name => "name",
|
||||||
|
AccountLockProperty::Kind => "kind",
|
||||||
AccountLockProperty::Reason => "reason",
|
AccountLockProperty::Reason => "reason",
|
||||||
AccountLockProperty::LockedAt => "lockedAt",
|
AccountLockProperty::LockedAt => "lockedAt",
|
||||||
AccountLockProperty::LockedBy => "lockedBy",
|
AccountLockProperty::LockedBy => "lockedBy",
|
||||||
@@ -68,6 +71,7 @@ impl AccountLockProperty {
|
|||||||
b"id" => AccountLockProperty::Id,
|
b"id" => AccountLockProperty::Id,
|
||||||
b"accountId" => AccountLockProperty::AccountId,
|
b"accountId" => AccountLockProperty::AccountId,
|
||||||
b"name" => AccountLockProperty::Name,
|
b"name" => AccountLockProperty::Name,
|
||||||
|
b"kind" => AccountLockProperty::Kind,
|
||||||
b"reason" => AccountLockProperty::Reason,
|
b"reason" => AccountLockProperty::Reason,
|
||||||
b"lockedAt" => AccountLockProperty::LockedAt,
|
b"lockedAt" => AccountLockProperty::LockedAt,
|
||||||
b"lockedBy" => AccountLockProperty::LockedBy,
|
b"lockedBy" => AccountLockProperty::LockedBy,
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy/get` and `/set` under `urn:inbuxa:jmap`: whether
|
||||||
|
//! people may share their own mail and add other accounts to the webmail
|
||||||
|
//! (multi-account spec, MA-C). The server's policy has the singleton id;
|
||||||
|
//! each tenant's has the tenant's id.
|
||||||
|
//!
|
||||||
|
//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client
|
||||||
|
//! that sets them is answered with `invalidProperties`.
|
||||||
|
|
||||||
|
use crate::object::{AnyId, JmapObject, JmapObjectId};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SharingPolicy;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SharingPolicyProperty {
|
||||||
|
Id,
|
||||||
|
/// Server-set: the tenant this is the policy of, or null for the server's.
|
||||||
|
TenantId,
|
||||||
|
/// `enabled` or `disabled`: people may share their own mail folders.
|
||||||
|
MailSharing,
|
||||||
|
/// `enabled` or `disabled`: people may add other accounts to the webmail.
|
||||||
|
AddAccounts,
|
||||||
|
ChangedAt,
|
||||||
|
ChangedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SharingPolicyValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for SharingPolicyProperty {
|
||||||
|
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
SharingPolicyProperty::parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyProperty::Id => "id",
|
||||||
|
SharingPolicyProperty::TenantId => "tenantId",
|
||||||
|
SharingPolicyProperty::MailSharing => "mailSharing",
|
||||||
|
SharingPolicyProperty::AddAccounts => "addAccounts",
|
||||||
|
SharingPolicyProperty::ChangedAt => "changedAt",
|
||||||
|
SharingPolicyProperty::ChangedBy => "changedBy",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharingPolicyProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => SharingPolicyProperty::Id,
|
||||||
|
b"tenantId" => SharingPolicyProperty::TenantId,
|
||||||
|
b"mailSharing" => SharingPolicyProperty::MailSharing,
|
||||||
|
b"addAccounts" => SharingPolicyProperty::AddAccounts,
|
||||||
|
b"changedAt" => SharingPolicyProperty::ChangedAt,
|
||||||
|
b"changedBy" => SharingPolicyProperty::ChangedBy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharingPolicyProperty {
|
||||||
|
/// Whether this property is the server's to say. A client that sets one
|
||||||
|
/// is answered with `invalidProperties`.
|
||||||
|
pub fn is_server_set(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
SharingPolicyProperty::TenantId
|
||||||
|
| SharingPolicyProperty::ChangedAt
|
||||||
|
| SharingPolicyProperty::ChangedBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for SharingPolicyProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
SharingPolicyProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for SharingPolicyValue {
|
||||||
|
type Property = SharingPolicyProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(SharingPolicyProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(SharingPolicyValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for SharingPolicy {
|
||||||
|
type Property = SharingPolicyProperty;
|
||||||
|
|
||||||
|
type Element = SharingPolicyValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = SharingPolicyProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for SharingPolicyValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
SharingPolicyValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SharingPolicyValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = SharingPolicyValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SharingPolicyProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
|||||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||||
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||||
|
pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail
|
||||||
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
||||||
pub mod file_node;
|
pub mod file_node;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::TenantProtocolPolicy(response) => {
|
GetResponseMethod::TenantProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::SharingPolicy(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::Principal(response) => {
|
GetResponseMethod::Principal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self)?
|
request.resolve_references(self)?
|
||||||
}
|
}
|
||||||
|
GetRequestMethod::SharingPolicy(request) => {
|
||||||
|
request.resolve_references(self)?
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||||
@@ -158,6 +161,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::TenantProtocolPolicy(request) => {
|
SetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::SharingPolicy(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(request) => {
|
SetRequestMethod::AddressBook(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct DelegationInfo {
|
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,
|
pub locked: bool,
|
||||||
|
/// MA-S: `lock` or `sharedMailbox`.
|
||||||
|
pub kind: &'static str,
|
||||||
/// `read`, `organize` or `full`.
|
/// `read`, `organize` or `full`.
|
||||||
pub access: &'static str,
|
pub access: &'static str,
|
||||||
#[serde(rename(serialize = "sendAs"))]
|
#[serde(rename(serialize = "sendAs"))]
|
||||||
@@ -179,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
|
|||||||
/// spec, EX-1 to EX-4).
|
/// spec, EX-1 to EX-4).
|
||||||
#[serde(rename(serialize = "aiExplain"))]
|
#[serde(rename(serialize = "aiExplain"))]
|
||||||
pub ai_explain: bool,
|
pub ai_explain: bool,
|
||||||
|
/// MA-C: whether the principal may share their own mail folders, and
|
||||||
|
/// add other accounts to the webmail: the stricter of the server's
|
||||||
|
/// switch and its tenant's.
|
||||||
|
#[serde(rename(serialize = "mailSharing"))]
|
||||||
|
pub mail_sharing: bool,
|
||||||
|
#[serde(rename(serialize = "addAccounts"))]
|
||||||
|
pub add_accounts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ pub enum MethodObject {
|
|||||||
JournalExport,
|
JournalExport,
|
||||||
JournalVerification,
|
JournalVerification,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
|
SharingPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MethodObject {
|
impl MethodObject {
|
||||||
@@ -124,6 +125,7 @@ impl MethodObject {
|
|||||||
| MethodObject::JournalVerification => Capability::Inbuxa,
|
| MethodObject::JournalVerification => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
|
MethodObject::SharingPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,6 +346,12 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
||||||
"inbuxa:TenantProtocolPolicy/set"
|
"inbuxa:TenantProtocolPolicy/set"
|
||||||
}
|
}
|
||||||
|
(MethodFunction::Get, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/get"
|
||||||
|
}
|
||||||
|
(MethodFunction::Set, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/set"
|
||||||
|
}
|
||||||
(method, MethodObject::Registry(obj)) => {
|
(method, MethodObject::Registry(obj)) => {
|
||||||
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
||||||
}
|
}
|
||||||
@@ -504,6 +512,8 @@ impl MethodName {
|
|||||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
||||||
|
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
|
||||||
|
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
|
||||||
|
|
||||||
).or_else(|| {
|
).or_else(|| {
|
||||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||||
@@ -578,6 +588,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
|
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
f.write_str("x:")?;
|
f.write_str("x:")?;
|
||||||
return f.write_str(obj.as_str());
|
return f.write_str(obj.as_str());
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ pub enum GetRequestMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -178,6 +181,9 @@ pub enum SetRequestMethod<'x> {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::SharingPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Get(GetRequestMethod::SharingPolicy(value))
|
||||||
|
}
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::SharingPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Set(SetRequestMethod::SharingPolicy(value))
|
||||||
|
}
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ pub enum GetResponseMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -166,6 +169,9 @@ pub enum SetResponseMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -352,6 +358,16 @@ impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::SharingPolicy(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||||
for ResponseMethod<'x>
|
for ResponseMethod<'x>
|
||||||
{
|
{
|
||||||
@@ -362,6 +378,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::SharingPolicy(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// sign-in on the tenant's domains, so it takes the domain's
|
// sign-in on the tenant's domains, so it takes the domain's
|
||||||
// permissions, which a tenant administrator already holds.
|
// permissions, which a tenant administrator already holds.
|
||||||
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
||||||
|
// inbuxa: MA-C, who may share mail: a tenant administrator
|
||||||
|
// manages their tenant's, so the domain's permissions; the
|
||||||
|
// server's own also needs sysSharingUpdate (see the method)
|
||||||
|
GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet,
|
||||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||||
@@ -370,6 +374,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysDomainUpdate,
|
Permission::SysDomainUpdate,
|
||||||
Permission::SysDomainUpdate,
|
Permission::SysDomainUpdate,
|
||||||
),
|
),
|
||||||
|
// inbuxa: MA-C, who may share mail, with the domain's
|
||||||
|
SetRequestMethod::SharingPolicy(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
),
|
||||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -500,7 +512,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::JournalExport
|
| MethodObject::JournalExport
|
||||||
| MethodObject::JournalVerification
|
| MethodObject::JournalVerification
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,15 +204,20 @@ impl RequestHandler for Server {
|
|||||||
// inbuxa: AL-9: a delegate's access, and what it
|
// inbuxa: AL-9: a delegate's access, and what it
|
||||||
// changes, are recorded; anyone else here impersonated
|
// changes, are recorded; anyone else here impersonated
|
||||||
if let Some(delegation) = access_token.delegation(account_id) {
|
if let Some(delegation) = access_token.delegation(account_id) {
|
||||||
let access = delegation.access.as_str();
|
// MA-S: in a shared mailbox only what is sent as it
|
||||||
self.audit_delegate(
|
// is recorded (audit_send_as); every read and flag
|
||||||
access_token,
|
// on a busy desk would bury the log
|
||||||
account_id,
|
if delegation.kind.is_lock() {
|
||||||
access,
|
let access = delegation.access.as_str();
|
||||||
is_write.then_some(call_name.as_str()),
|
self.audit_delegate(
|
||||||
result.as_ref().err(),
|
access_token,
|
||||||
)
|
account_id,
|
||||||
.await;
|
access,
|
||||||
|
is_write.then_some(call_name.as_str()),
|
||||||
|
result.as_ref().err(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
if makes_containers
|
if makes_containers
|
||||||
&& result.is_ok()
|
&& result.is_ok()
|
||||||
&& let Err(err) =
|
&& let Err(err) =
|
||||||
@@ -312,6 +317,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::SharingPolicy(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::AddressBook(set_response) => {
|
SetResponseMethod::AddressBook(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -569,6 +577,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail)
|
||||||
|
GetRequestMethod::SharingPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::sharing_policy::get(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(req) => {
|
GetRequestMethod::Principal(req) => {
|
||||||
self.principal_get(*req, access_token).await?.into()
|
self.principal_get(*req, access_token).await?.into()
|
||||||
}
|
}
|
||||||
@@ -1127,6 +1142,23 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail)
|
||||||
|
SetRequestMethod::SharingPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
// inbuxa: AU-1.2, AU-3
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::sharing_policy::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(mut req) => {
|
SetRequestMethod::AddressBook(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
|
|||||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||||
&& access_token.tenant_id().is_none()
|
&& access_token.tenant_id().is_none()
|
||||||
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
||||||
|
// inbuxa: MA-C: what the sharing switches leave this principal
|
||||||
|
let sharing = inbuxa_features::security::sharing_policy::effective_for(
|
||||||
|
self.store(),
|
||||||
|
access_token.tenant_id(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
account.account_capabilities.append(
|
account.account_capabilities.append(
|
||||||
Capability::Inbuxa,
|
Capability::Inbuxa,
|
||||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||||
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
|
|||||||
legacy_protocols,
|
legacy_protocols,
|
||||||
legacy_allowed,
|
legacy_allowed,
|
||||||
ai_explain,
|
ai_explain,
|
||||||
|
mail_sharing: sharing.mail_sharing,
|
||||||
|
add_accounts: sharing.add_accounts,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||||
@@ -148,6 +157,7 @@ impl SessionHandler for Server {
|
|||||||
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
||||||
delegation: DelegationInfo {
|
delegation: DelegationInfo {
|
||||||
locked: true,
|
locked: true,
|
||||||
|
kind: delegation.kind.as_str(),
|
||||||
access: delegation.access.as_str(),
|
access: delegation.access.as_str(),
|
||||||
send_as: delegation.send_as,
|
send_as: delegation.send_as,
|
||||||
until: delegation.until.map(|until| {
|
until: delegation.until.map(|until| {
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use common::{
|
|||||||
};
|
};
|
||||||
use email::inbuxa_lock::apply_grants;
|
use email::inbuxa_lock::apply_grants;
|
||||||
use groupware::inbuxa_lock::invalidate;
|
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::{
|
use jmap_proto::{
|
||||||
error::set::SetError,
|
error::set::SetError,
|
||||||
method::{
|
method::{
|
||||||
@@ -39,6 +39,7 @@ const ALL: &[P] = &[
|
|||||||
P::Id,
|
P::Id,
|
||||||
P::AccountId,
|
P::AccountId,
|
||||||
P::Name,
|
P::Name,
|
||||||
|
P::Kind,
|
||||||
P::Reason,
|
P::Reason,
|
||||||
P::LockedAt,
|
P::LockedAt,
|
||||||
P::LockedBy,
|
P::LockedBy,
|
||||||
@@ -75,6 +76,7 @@ async fn parse_delegates(
|
|||||||
server: &Server,
|
server: &Server,
|
||||||
access_token: &AccessToken,
|
access_token: &AccessToken,
|
||||||
locked_id: u32,
|
locked_id: u32,
|
||||||
|
kind: Kind,
|
||||||
value: LValue,
|
value: LValue,
|
||||||
) -> Result<Vec<Delegate>, SetError<P>> {
|
) -> Result<Vec<Delegate>, SetError<P>> {
|
||||||
let invalid = |why: String| {
|
let invalid = |why: String| {
|
||||||
@@ -86,8 +88,10 @@ async fn parse_delegates(
|
|||||||
let Some(items) = json.as_array() else {
|
let Some(items) = json.as_array() else {
|
||||||
return Err(invalid("delegates must be a list.".into()));
|
return Err(invalid("delegates must be a list.".into()));
|
||||||
};
|
};
|
||||||
if items.len() > MAX_DELEGATES {
|
// MA-S: a shared mailbox holds more people than a lock hands over
|
||||||
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
|
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 locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
||||||
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
||||||
@@ -173,6 +177,7 @@ async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
|
|||||||
let value = match property {
|
let value = match property {
|
||||||
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
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::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::Reason => Value::Str(lock.reason.clone().into()),
|
||||||
P::LockedAt => date(lock.locked_at),
|
P::LockedAt => date(lock.locked_at),
|
||||||
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
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() {
|
for (client_id, value) in request.unwrap_create() {
|
||||||
let mut account_id = None;
|
let mut account_id = None;
|
||||||
|
let mut kind = Kind::Lock;
|
||||||
let mut reason = None;
|
let mut reason = None;
|
||||||
let mut delegates_value = None;
|
let mut delegates_value = None;
|
||||||
let mut invalid = None;
|
let mut invalid = None;
|
||||||
@@ -291,6 +297,17 @@ pub async fn set(
|
|||||||
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
||||||
account_id = Some(id.document_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::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
||||||
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -310,9 +327,14 @@ pub async fn set(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
|
// MA-S: a shared mailbox needs no reason; a lock always does
|
||||||
response.not_created.append(client_id, reason_required());
|
let reason = match reason.or_else(|| reason_of(arguments.reason.as_deref())) {
|
||||||
continue;
|
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 {
|
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -321,12 +343,13 @@ pub async fn set(
|
|||||||
if lock::get(data, account_id).await?.is_some() {
|
if lock::get(data, account_id).await?.is_some() {
|
||||||
response.not_created.append(
|
response.not_created.append(
|
||||||
client_id,
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let delegates = match delegates_value {
|
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,
|
Ok(delegates) => delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -337,6 +360,7 @@ pub async fn set(
|
|||||||
};
|
};
|
||||||
let mut created = Lock {
|
let mut created = Lock {
|
||||||
account_id,
|
account_id,
|
||||||
|
kind,
|
||||||
reason,
|
reason,
|
||||||
locked_at: now(),
|
locked_at: now(),
|
||||||
locked_by: actor.name.clone(),
|
locked_by: actor.name.clone(),
|
||||||
@@ -370,7 +394,7 @@ pub async fn set(
|
|||||||
response.not_updated.append(id, SetError::not_found());
|
response.not_updated.append(id, SetError::not_found());
|
||||||
continue;
|
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());
|
response.not_updated.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -379,7 +403,9 @@ pub async fn set(
|
|||||||
for (key, value) in value.into_expanded_object() {
|
for (key, value) in value.into_expanded_object() {
|
||||||
match (&key, value) {
|
match (&key, value) {
|
||||||
(Key::Property(P::Delegates), 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,
|
Ok(delegates) => updated.delegates = delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
invalid = Some(error);
|
invalid = Some(error);
|
||||||
@@ -389,6 +415,7 @@ pub async fn set(
|
|||||||
}
|
}
|
||||||
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
||||||
Some(r) => updated.reason = r,
|
Some(r) => updated.reason = r,
|
||||||
|
None if !current.kind.is_lock() => updated.reason = String::new(),
|
||||||
None => {
|
None => {
|
||||||
invalid = Some(reason_required());
|
invalid = Some(reason_required());
|
||||||
break;
|
break;
|
||||||
@@ -420,7 +447,7 @@ pub async fn set(
|
|||||||
response.not_destroyed.append(id, SetError::not_found());
|
response.not_destroyed.append(id, SetError::not_found());
|
||||||
continue;
|
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());
|
response.not_destroyed.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub mod webhook_test;
|
|||||||
pub mod explanation;
|
pub mod explanation;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
pub mod tenant_protocol_policy;
|
pub mod tenant_protocol_policy;
|
||||||
|
pub mod sharing_policy;
|
||||||
pub mod deleted_account;
|
pub mod deleted_account;
|
||||||
pub mod fastmail;
|
pub mod fastmail;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy/get` and `/set`: whether people may share their own
|
||||||
|
//! mail and add other accounts to the webmail (multi-account spec, MA-C).
|
||||||
|
//!
|
||||||
|
//! The server's policy has the singleton id; each tenant's has the tenant's
|
||||||
|
//! id. At server level `/get` with no ids answers with the server's and every
|
||||||
|
//! tenant's; inside a tenant, with the server's (to read) and its own tenant's
|
||||||
|
//! (MT-1). Only a server administrator holding `sysSharingUpdate` changes the
|
||||||
|
//! server's; a tenant's administrator changes their tenant's, and can only be
|
||||||
|
//! stricter than the server.
|
||||||
|
//!
|
||||||
|
//! A change rebuilds every access token, here and on every node: what a share
|
||||||
|
//! still gives is worked out when a token is built.
|
||||||
|
|
||||||
|
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
|
||||||
|
use inbuxa_features::{
|
||||||
|
security::sharing_policy::{self, SharingPolicy as Policy, looser_than_server},
|
||||||
|
tenancy::quota::all_tenants,
|
||||||
|
};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_sharing_policy::{SharingPolicy, SharingPolicyProperty as P, SharingPolicyValue},
|
||||||
|
request::IntoValid,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use registry::schema::enums::Permission;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type PValue = Value<'static, P, SharingPolicyValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[P::Id, P::TenantId, P::MailSharing, P::AddAccounts, P::ChangedAt, P::ChangedBy];
|
||||||
|
|
||||||
|
fn switch_str(on: Option<bool>) -> &'static str {
|
||||||
|
if on.unwrap_or(true) { "enabled" } else { "disabled" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value(tenant_id: Option<u32>, policy: &Policy, properties: &[P]) -> PValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id => Value::Element(SharingPolicyValue::Id(
|
||||||
|
tenant_id.map_or_else(Id::singleton, Id::from),
|
||||||
|
)),
|
||||||
|
P::TenantId => tenant_id
|
||||||
|
.map(|t| Value::Element(SharingPolicyValue::Id(Id::from(t))))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
P::MailSharing => Value::Str(switch_str(policy.mail_sharing).into()),
|
||||||
|
P::AddAccounts => Value::Str(switch_str(policy.add_accounts).into()),
|
||||||
|
P::ChangedAt => policy
|
||||||
|
.changed_at
|
||||||
|
.map(|at| Value::Number(at.into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
P::ChangedBy => policy
|
||||||
|
.changed_by
|
||||||
|
.as_ref()
|
||||||
|
.map(|by| Value::Str(by.clone().into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tenants this principal may reach: its own inside a tenant (MT-1),
|
||||||
|
/// every tenant at server level.
|
||||||
|
async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<Vec<u32>> {
|
||||||
|
match access_token.tenant_id() {
|
||||||
|
Some(tenant_id) => Ok(vec![tenant_id]),
|
||||||
|
None => all_tenants(server.registry()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which policy an id names: `None` for the server's.
|
||||||
|
fn target(id: Id) -> Option<u32> {
|
||||||
|
if id.is_singleton() { None } else { Some(id.document_id()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:SharingPolicy/get`.
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<SharingPolicy>,
|
||||||
|
) -> trc::Result<GetResponse<SharingPolicy>> {
|
||||||
|
let properties = request.unwrap_properties(ALL);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
let reachable = reachable(server, access_token).await?;
|
||||||
|
let wanted: Vec<Id> = match ids {
|
||||||
|
None => std::iter::once(Id::singleton())
|
||||||
|
.chain(reachable.iter().map(|t| Id::from(*t)))
|
||||||
|
.collect(),
|
||||||
|
Some(ids) => ids,
|
||||||
|
};
|
||||||
|
let data = &server.core.storage.data;
|
||||||
|
for id in wanted {
|
||||||
|
match target(id) {
|
||||||
|
None => {
|
||||||
|
let policy = sharing_policy::get(data, None).await?;
|
||||||
|
response.list.push(to_value(None, &policy, &properties));
|
||||||
|
}
|
||||||
|
Some(tenant_id) if reachable.contains(&tenant_id) => {
|
||||||
|
let policy = sharing_policy::get(data, Some(tenant_id)).await?;
|
||||||
|
response.list.push(to_value(Some(tenant_id), &policy, &properties));
|
||||||
|
}
|
||||||
|
Some(_) => response.push_not_found(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:SharingPolicy/set`: turns switches. `null` puts one back to its
|
||||||
|
/// default, on (as far as the server allows).
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, SharingPolicy>,
|
||||||
|
) -> trc::Result<SetResponse<SharingPolicy>> {
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reachable = reachable(server, access_token).await?;
|
||||||
|
let data = &server.core.storage.data;
|
||||||
|
let mut changed = false;
|
||||||
|
for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
let tenant_id = target(id);
|
||||||
|
match tenant_id {
|
||||||
|
None if access_token.tenant_id().is_some()
|
||||||
|
|| !access_token.has_permission(Permission::SysSharingUpdate) =>
|
||||||
|
{
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden()
|
||||||
|
.with_description("Only a server administrator changes the server's sharing policy."),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(tenant_id) if !reachable.contains(&tenant_id) => {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = sharing_policy::get(data, tenant_id).await?;
|
||||||
|
let mut policy = previous.clone();
|
||||||
|
let mut error = None;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
let parsed = match value {
|
||||||
|
Value::Null => Ok(None),
|
||||||
|
Value::Str(s) if s == "enabled" => Ok(Some(true)),
|
||||||
|
Value::Str(s) if s == "disabled" => Ok(Some(false)),
|
||||||
|
_ => Err("must be enabled or disabled".to_string()),
|
||||||
|
};
|
||||||
|
let result = match &key {
|
||||||
|
Key::Property(P::MailSharing) => parsed.map(|v| policy.mail_sharing = v),
|
||||||
|
Key::Property(P::AddAccounts) => parsed.map(|v| policy.add_accounts = v),
|
||||||
|
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||||
|
Key::Property(_) => Err("is set by the server".to_string()),
|
||||||
|
_ => Err("is not a property of inbuxa:SharingPolicy".to_string()),
|
||||||
|
};
|
||||||
|
if let Err(why) = result {
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(key.into_owned())
|
||||||
|
.with_description(why),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(error) = error {
|
||||||
|
response.not_updated.append(id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tenant can only be stricter than the server
|
||||||
|
if tenant_id.is_some()
|
||||||
|
&& let Some(why) = looser_than_server(&sharing_policy::get(data, None).await?, &policy)
|
||||||
|
{
|
||||||
|
response
|
||||||
|
.not_updated
|
||||||
|
.append(id, SetError::forbidden().with_description(why));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy.mail_sharing != previous.mail_sharing || policy.add_accounts != previous.add_accounts {
|
||||||
|
policy.changed_at = Some(store::write::now() * 1000);
|
||||||
|
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||||
|
sharing_policy::set(data, tenant_id, &policy).await?;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
response.updated.append(id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
// Shares are honored, or not, as tokens are built
|
||||||
|
server.invalidate_all_local_caches();
|
||||||
|
server.cluster_broadcast(BroadcastEvent::CacheInvalidateAll).await;
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ use jmap_proto::{
|
|||||||
types::state::State,
|
types::state::State,
|
||||||
};
|
};
|
||||||
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
||||||
use registry::schema::enums::StorageQuota;
|
use registry::schema::enums::{Permission, StorageQuota};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use store::{
|
use store::{
|
||||||
ValueKey,
|
ValueKey,
|
||||||
@@ -622,6 +622,27 @@ impl MailboxSet for Server {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
||||||
|
// widens a share (narrowing or ending one is always allowed)
|
||||||
|
let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default();
|
||||||
|
let widens = changes.acls.iter().any(|grant| {
|
||||||
|
let had = before
|
||||||
|
.iter()
|
||||||
|
.find(|old| old.account_id == grant.account_id)
|
||||||
|
.map_or(0, |old| old.grants.clone().into_inner());
|
||||||
|
grant.grants.clone().into_inner() & !had != 0
|
||||||
|
});
|
||||||
|
if widens
|
||||||
|
&& !ctx.access_token.has_permission(Permission::Impersonate)
|
||||||
|
&& !self.mail_sharing_allowed(ctx.account_id).await?
|
||||||
|
{
|
||||||
|
return Ok(Err(SetError::forbidden()
|
||||||
|
.with_property(MailboxProperty::ShareWith)
|
||||||
|
.with_description(
|
||||||
|
"Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
if !changes.acls.is_empty()
|
if !changes.acls.is_empty()
|
||||||
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send {
|
|||||||
fn send_message(
|
fn send_message(
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
|
own_addresses_only: bool,
|
||||||
response: &SetResponse<email_submission::EmailSubmission>,
|
response: &SetResponse<email_submission::EmailSubmission>,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
||||||
@@ -83,7 +84,14 @@ impl EmailSubmissionSet for Server {
|
|||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
for (id, object) in request.unwrap_create() {
|
for (id, object) in request.unwrap_create() {
|
||||||
match self
|
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?
|
.await?
|
||||||
{
|
{
|
||||||
Ok(submission) => {
|
Ok(submission) => {
|
||||||
@@ -400,6 +408,7 @@ impl EmailSubmissionSet for Server {
|
|||||||
async fn send_message(
|
async fn send_message(
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
|
own_addresses_only: bool,
|
||||||
response: &SetResponse<email_submission::EmailSubmission>,
|
response: &SetResponse<email_submission::EmailSubmission>,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
||||||
@@ -629,6 +638,46 @@ impl EmailSubmissionSet for Server {
|
|||||||
.unarchive::<MessageMetadata>()
|
.unarchive::<MessageMetadata>()
|
||||||
.caused_by(trc::location!())?;
|
.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
|
// Add recipients to envelope if missing
|
||||||
let mut bcc_header = None;
|
let mut bcc_header = None;
|
||||||
if rcpt_to.is_empty() {
|
if rcpt_to.is_empty() {
|
||||||
|
|||||||
@@ -244,6 +244,16 @@ retention = "unbounded"
|
|||||||
changedBy = ["identifier"]
|
changedBy = ["identifier"]
|
||||||
recentLegacyUse = ["identifier", "metadata"]
|
recentLegacyUse = ["identifier", "metadata"]
|
||||||
|
|
||||||
|
[object."inbuxa:SharingPolicy"]
|
||||||
|
file = "inbuxa_sharing_policy.rs"
|
||||||
|
default = "none"
|
||||||
|
whose = ["administrator", "holder"]
|
||||||
|
where = ["data-store"]
|
||||||
|
scope = "tenant"
|
||||||
|
retention = "unbounded"
|
||||||
|
[object."inbuxa:SharingPolicy".properties]
|
||||||
|
changedBy = ["identifier"]
|
||||||
|
|
||||||
[object."inbuxa:AiLimits"]
|
[object."inbuxa:AiLimits"]
|
||||||
file = "inbuxa_ai_limits.rs"
|
file = "inbuxa_ai_limits.rs"
|
||||||
default = "none"
|
default = "none"
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ
|
OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo
|
||||||
@@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
// inbuxa: MT-22, the logo that applies to the account, and
|
// inbuxa: MT-22, the logo that applies to the account, and
|
||||||
// LP-19, whether the legacy protocols are open to it, and
|
// LP-19, whether the legacy protocols are open to it, and
|
||||||
// ai-explain EX-1, whether Explain can be offered
|
// ai-explain EX-1, whether Explain can be offered
|
||||||
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false },
|
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false, "mailSharing": true, "addAccounts": true },
|
||||||
"https://www.fastmail.com/dev/maskedemail": {}
|
"https://www.fastmail.com/dev/maskedemail": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,6 +447,156 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
||||||
"AL-9: the delegate's access and changes weren't recorded: {query}"
|
"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<crate::jmap::mail::submission::MockMessage>,
|
||||||
|
lmtp: &mut SmtpConnection,
|
||||||
|
) {
|
||||||
|
println!("Running shared mailbox tests...");
|
||||||
|
let support = admin
|
||||||
|
.create_user_account("[email protected]", "support-secret-3317", "Support", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let agent = admin
|
||||||
|
.create_user_account("[email protected]", "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::<String>)
|
||||||
|
.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(
|
||||||
|
"[email protected]",
|
||||||
|
&["[email protected]"],
|
||||||
|
// Addressed to it: a vacation reply answers only mail sent to it
|
||||||
|
"From: [email protected]\r\nTo: [email protected]\r\nSubject: My order\r\n\r\nHello.\r\n",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_message_delivery(
|
||||||
|
smtp_rx,
|
||||||
|
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@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": "[email protected]"}],
|
||||||
|
"to": [{"email": "[email protected]"}],
|
||||||
|
"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("<[email protected]>", ["<[email protected]>"], "@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("[email protected]"), "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::<Vec<_>>();
|
||||||
|
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`.
|
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod ai;
|
|||||||
pub mod ai_calibration;
|
pub mod ai_calibration;
|
||||||
pub mod ai_explain;
|
pub mod ai_explain;
|
||||||
pub mod account_lock; // inbuxa: account lock with delegation
|
pub mod account_lock; // inbuxa: account lock with delegation
|
||||||
|
pub mod sharing_policy; // inbuxa: MA-C, who may share mail
|
||||||
pub mod legal_hold; // inbuxa: legal hold
|
pub mod legal_hold; // inbuxa: legal hold
|
||||||
pub mod compliance; // inbuxa: the compliance roles
|
pub mod compliance; // inbuxa: the compliance roles
|
||||||
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Who may share mail (multi-account spec, MA-C): the server's switch and a
|
||||||
|
//! tenant's, which can only be stricter. Off refuses new shares and stops
|
||||||
|
//! honoring old ones, which come back when it's on again; locks and shared
|
||||||
|
//! mailboxes are never affected.
|
||||||
|
|
||||||
|
use crate::utils::{account::Account, server::TestServerBuilder};
|
||||||
|
use registry::schema::{
|
||||||
|
prelude::{ObjectType, Property},
|
||||||
|
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
const USING: &[&str] = &[
|
||||||
|
"urn:ietf:params:jmap:core",
|
||||||
|
"urn:ietf:params:jmap:mail",
|
||||||
|
"urn:inbuxa:jmap",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
async fn one(&self, method: &str, arguments: Value) -> Value {
|
||||||
|
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
|
||||||
|
response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/0")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| panic!("{method}: {}", response.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn policy(&self, update: Value) -> Value {
|
||||||
|
self.one(
|
||||||
|
"inbuxa:SharingPolicy/set",
|
||||||
|
json!({"accountId": self.id_string(), "update": update}),
|
||||||
|
)
|
||||||
|
.await[1]
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inbox(&self) -> String {
|
||||||
|
let response = self
|
||||||
|
.one(
|
||||||
|
"Mailbox/get",
|
||||||
|
json!({"accountId": self.id_string(), "ids": null, "properties": ["role"]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
response[1]["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m["role"] == "inbox")
|
||||||
|
.unwrap_or_else(|| panic!("no Inbox: {response}"))["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shares the Inbox with `with` (read), or stops with `None` rights.
|
||||||
|
async fn share_inbox(&self, with: &Account, read: bool) -> Value {
|
||||||
|
let inbox = self.inbox().await;
|
||||||
|
let rights = if read { json!({"mayReadItems": true}) } else { Value::Null };
|
||||||
|
self.one(
|
||||||
|
"Mailbox/set",
|
||||||
|
json!({"accountId": self.id_string(),
|
||||||
|
"update": {inbox: {format!("shareWith/{}", with.id_string()): rights}}}),
|
||||||
|
)
|
||||||
|
.await[1]
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sees(&self, other: &Account) -> bool {
|
||||||
|
self.jmap_session_object().await.0["accounts"]
|
||||||
|
.get(other.id_string())
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mail_sharing(&self) -> Value {
|
||||||
|
self.jmap_session_object().await.0["accounts"][self.id_string()]["accountCapabilities"]
|
||||||
|
["urn:inbuxa:jmap"]["mailSharing"]
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs these tests alone: `cargo test -p tests sharing_policy_tests -- --ignored`.
|
||||||
|
#[ignore]
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
pub async fn sharing_policy_tests() {
|
||||||
|
let mut test = TestServerBuilder::new("sharing_policy_tests")
|
||||||
|
.await
|
||||||
|
.with_default_listeners()
|
||||||
|
.await
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
let admin = test.create_admin_account("[email protected]").await;
|
||||||
|
println!("Running sharing policy tests...");
|
||||||
|
|
||||||
|
let tenant = admin
|
||||||
|
.registry_create_object(Tenant {
|
||||||
|
name: "School".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: "school.example.org".to_string(),
|
||||||
|
is_enabled: true,
|
||||||
|
member_tenant_id: Some(tenant),
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dns_management: DnsManagement::Manual,
|
||||||
|
dkim_management: DkimManagement::Manual,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let ann = admin
|
||||||
|
.create_user_account("[email protected]", "ann-secret-6610", "Ann", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let ben = admin
|
||||||
|
.create_user_account("[email protected]", "ben-secret-6611", "Ben", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let head = admin
|
||||||
|
.create_user_account("[email protected]", "head-secret-6612", "Head", &[], vec![])
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Account,
|
||||||
|
head.id(),
|
||||||
|
json!({Property::Roles: UserRoles::Admin}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let carl = admin
|
||||||
|
.create_user_account("[email protected]", "carl-secret-6613", "Carl", &[], vec![])
|
||||||
|
.await;
|
||||||
|
// Shares never cross tenants (MT-3), so Carl's neighbour is outside too
|
||||||
|
let dan = admin
|
||||||
|
.create_user_account("[email protected]", "dan-secret-6614", "Dan", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let tenant_id = tenant.to_string();
|
||||||
|
|
||||||
|
// MA-10: on by default
|
||||||
|
assert_eq!(ann.mail_sharing().await, true, "MA-10");
|
||||||
|
let response = ann.share_inbox(&ben, true).await;
|
||||||
|
assert!(response["updated"].is_object(), "MA-10: {response}");
|
||||||
|
assert!(ben.sees(&ann).await, "MA-10: the share gives nothing");
|
||||||
|
|
||||||
|
// The school turns mail sharing off, as its own administrator
|
||||||
|
let response = head
|
||||||
|
.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
|
||||||
|
.await;
|
||||||
|
assert!(response["updated"].is_object(), "MA-13: {response}");
|
||||||
|
// ...but can't touch the server's
|
||||||
|
let response = head
|
||||||
|
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["notUpdated"]["singleton"]["type"], "forbidden", "MA-13: {response}");
|
||||||
|
|
||||||
|
// MA-11: what was shared gives nothing now, and nothing new is shared
|
||||||
|
assert_eq!(ann.mail_sharing().await, false, "MA-11");
|
||||||
|
assert!(!ben.sees(&ann).await, "MA-11: an old share still honored");
|
||||||
|
let response = ann.share_inbox(&head, true).await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"].as_object().and_then(|o| o.values().next()).map(|e| e["type"].clone()),
|
||||||
|
Some(json!("forbidden")),
|
||||||
|
"MA-11: {response}"
|
||||||
|
);
|
||||||
|
// MA-12: a shared mailbox isn't anyone's share, and keeps working
|
||||||
|
let office = admin
|
||||||
|
.create_user_account("[email protected]", "office-secret-6615", "Office", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let response = admin
|
||||||
|
.one(
|
||||||
|
"inbuxa:AccountLock/set",
|
||||||
|
json!({"accountId": admin.id_string(), "create": {"o": {"accountId": office.id_string(),
|
||||||
|
"kind": "sharedMailbox",
|
||||||
|
"delegates": [{"accountId": ben.id_string(), "access": "organize"}]}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(response[1]["created"]["o"].is_object(), "MA-12: {response}");
|
||||||
|
assert!(ben.sees(&office).await, "MA-12: the switch reached a shared mailbox");
|
||||||
|
|
||||||
|
// Outside the school nothing changed
|
||||||
|
let response = carl.share_inbox(&dan, true).await;
|
||||||
|
assert!(response["updated"].is_object(), "MA-C: another tenant's switch reached Carl: {response}");
|
||||||
|
assert!(dan.sees(&carl).await, "MA-C");
|
||||||
|
|
||||||
|
// A tenant can only be stricter than the server
|
||||||
|
let response = admin
|
||||||
|
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
|
||||||
|
.await;
|
||||||
|
assert!(response["updated"].is_object(), "MA-C: {response}");
|
||||||
|
let response = head
|
||||||
|
.policy(json!({tenant_id.as_str(): {"mailSharing": "enabled"}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][tenant_id.as_str()]["type"],
|
||||||
|
"forbidden",
|
||||||
|
"MA-C: {response}"
|
||||||
|
);
|
||||||
|
assert!(!dan.sees(&carl).await, "MA-C: the server's switch didn't reach Carl's share");
|
||||||
|
|
||||||
|
// Turned back on, the old shares are honored again
|
||||||
|
admin
|
||||||
|
.policy(json!({"singleton": {"mailSharing": null}}))
|
||||||
|
.await;
|
||||||
|
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
|
||||||
|
.await;
|
||||||
|
assert!(ben.sees(&ann).await, "MA-C: an old share didn't come back");
|
||||||
|
assert!(dan.sees(&carl).await, "MA-C");
|
||||||
|
|
||||||
|
// Ending a share is always allowed, even while sharing is off
|
||||||
|
head.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
|
||||||
|
.await;
|
||||||
|
let response = ann.share_inbox(&ben, false).await;
|
||||||
|
assert!(response["updated"].is_object(), "MA-11: ending a share refused: {response}");
|
||||||
|
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
|
||||||
|
.await;
|
||||||
|
assert!(!ben.sees(&ann).await, "MA-11: the ended share came back");
|
||||||
|
|
||||||
|
// MA-14: every change is in the audit log
|
||||||
|
let query = admin
|
||||||
|
.one(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:SharingPolicy"}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
query[1]["ids"].as_array().is_some_and(|ids| ids.len() >= 5),
|
||||||
|
"MA-14: {query}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if test.is_reset() {
|
||||||
|
test.temp_dir.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user