Files
inbuxa-server/crates/jmap/src/mailbox/set.rs
T
jcoffey-dev fb785b8635
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 4m6s
github/ci (branch) GitHub Actions
Who may share mail: a server switch, and a tenant's that can only be stricter
A school, or any organization that doesn't want people's mailboxes
shared, can now turn that off (multi-account spec, MA-C). Two switches
at two levels, as the legacy-protocols switch has:

- mailSharing: people may share their own mail folders;
- addAccounts: people may add other accounts to the webmail (read by
  the webmail's account switcher, MA-B).

inbuxa:SharingPolicy/get and /set hold them: the server's policy has
the singleton id, each tenant's has the tenant's id. Both default to
on, so nothing changes until someone turns one off. A tenant's
administrator changes their own tenant's (the domain's permissions, as
for its protocols switch); only a server administrator with
sysSharingUpdate changes the server's; a tenant can never be looser
than the server (forbidden). Every change goes through the audit log,
and rebuilds every access token, here and on every node.

With mail sharing off for an account's tenant (or the server):

- Mailbox/set and IMAP SETACL refuse to start or widen a share
  (forbidden / NO [NOPERM]); narrowing or ending one is always allowed;
- shares already made give nothing while it is off: an access token
  leaves out mailbox grants from such an owner. They stay stored, so
  turning sharing back on restores them (John, 2026-10-05);
- a lock's and a shared mailbox's grants are an administrator's and
  always count, and group membership was never a share.

The session's own account says mailSharing and addAccounts, the
stricter of the two levels, so front ends can hide what is off.

Tests: a new sharing_policy suite with a school tenant, its own
administrator and two people outside it: on by default; the school's
administrator turns it off but can't touch the server's; an old share
stops working and a new one is refused while someone outside the school
is unaffected; a shared mailbox in the school keeps working; the server
off can't be loosened by the tenant; on again restores the old share;
ending a share works while off; and every change is audited. A unit
test covers the stricter-only rule. sharing_policy_tests, jmap_tests,
imap_tests, account_lock_tests and audit_log_tests pass (RocksDB).
2026-10-05 16:00:09 -07:00

666 lines
27 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
api::acl::{JmapAcl, JmapRights},
changes::state::JmapCacheState,
};
use common::{
Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder,
};
#[allow(unused_imports)]
use email::mailbox::{INBOX_ID, JUNK_ID, TRASH_ID, UidMailbox};
use email::{
cache::{MessageCacheFetch, mailbox::MailboxCacheAccess},
mailbox::{
Mailbox,
destroy::{MailboxDestroy, MailboxDestroyError},
},
};
use jmap_proto::{
error::set::{SetError, SetErrorType},
method::set::{SetRequest, SetResponse},
object::mailbox::{self, MailboxProperty, MailboxValue},
references::resolve::ResolveCreatedReference,
request::MaybeInvalid,
types::state::State,
};
use jmap_tools::{JsonPointerItem, Key, Map, Value};
use registry::schema::enums::{Permission, StorageQuota};
use std::future::Future;
use store::{
ValueKey,
roaring::RoaringBitmap,
write::{AlignedBytes, Archive, BatchBuilder, assert::AssertValue},
};
use trc::AddContext;
use types::{
acl::Acl, collection::Collection, field::MailboxField, id::Id, special_use::SpecialUse,
};
pub struct SetContext<'x> {
account_id: u32,
access_token: &'x AccessToken,
is_shared: bool,
response: SetResponse<mailbox::Mailbox>,
mailbox_ids: RoaringBitmap,
will_destroy: Vec<Id>,
}
pub trait MailboxSet: Sync + Send {
fn mailbox_set(
&self,
request: SetRequest<'_, mailbox::Mailbox>,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<SetResponse<mailbox::Mailbox>>> + Send;
fn mailbox_set_item(
&self,
changes_: Map<'_, MailboxProperty, MailboxValue>,
update: Option<(u32, Archive<Mailbox>)>,
ctx: &SetContext,
) -> impl Future<
Output = trc::Result<
Result<ObjectIndexBuilder<Mailbox, Mailbox>, SetError<MailboxProperty>>,
>,
> + Send;
}
impl MailboxSet for Server {
#[allow(clippy::blocks_in_conditions)]
async fn mailbox_set(
&self,
mut request: SetRequest<'_, mailbox::Mailbox>,
access_token: &AccessToken,
) -> trc::Result<SetResponse<mailbox::Mailbox>> {
// Prepare response
let account_id = request.account_id.document_id();
let on_destroy_remove_emails = request.arguments.on_destroy_remove_emails.unwrap_or(false);
let cache = self.get_cached_messages(account_id).await?;
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?
.with_state(cache.assert_state(true, &request.if_in_state)?);
let will_destroy = response.collect_will_destroy(request.unwrap_destroy());
let mut ctx = SetContext {
account_id,
is_shared: access_token.is_shared(account_id),
access_token,
response,
mailbox_ids: RoaringBitmap::from_iter(cache.mailboxes.index.keys()),
will_destroy,
};
let mut change_id = None;
let account_info = self.account(account_id).await?;
// Process creates
let mut batch = BatchBuilder::new();
'create: for (id, object) in request.unwrap_create() {
let Some(object) = object.into_object() else {
continue;
};
// Validate quota
if ctx.mailbox_ids.len()
>= self.object_quota(account_info.object_quotas(), StorageQuota::MaxMailboxes)
as u64
{
ctx.response.not_created.append(
id,
SetError::new(SetErrorType::OverQuota).with_description(concat!(
"There are too many mailboxes, ",
"please delete some before adding a new one."
)),
);
continue 'create;
}
match self.mailbox_set_item(object, None, &ctx).await? {
Ok(builder) => {
batch
.with_account_id(account_id)
.with_collection(Collection::Mailbox);
let parent_id = builder.changes().unwrap().parent_id;
if parent_id > 0 {
batch
.with_document(parent_id - 1)
.assert_value(MailboxField::Archive, AssertValue::Some);
}
let document_id = self
.store()
.assign_document_ids(account_id, Collection::Mailbox, 1)
.await
.caused_by(trc::location!())?;
batch
.with_document(document_id)
.custom(builder)
.caused_by(trc::location!())?
.commit_point();
ctx.mailbox_ids.insert(document_id);
ctx.response.created(id, document_id);
}
Err(err) => {
ctx.response.not_created.append(id, err);
continue 'create;
}
}
}
if !batch.is_empty() {
change_id = self
.commit_batch(batch)
.await
.and_then(|ids| ids.last_change_id(account_id))
.caused_by(trc::location!())?
.into();
}
// Process updates
let mut will_update = Vec::with_capacity(request.update.as_ref().map_or(0, |u| u.len()));
let mut batch = BatchBuilder::new();
'update: for (id, object) in request.unwrap_update() {
let id = match id {
MaybeInvalid::Value(id) => id,
invalid => {
ctx.response
.not_updated
.append(invalid, SetError::not_found());
continue 'update;
}
};
// Make sure id won't be destroyed
if ctx.will_destroy.contains(&id) {
ctx.response
.not_updated
.append(id, SetError::will_destroy());
continue 'update;
}
let Some(object) = object.into_object() else {
continue 'update;
};
// Obtain mailbox
let document_id = id.document_id();
if let Some(mailbox) = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::Mailbox,
document_id,
))
.await?
{
// Validate ACL
let mailbox = mailbox
.into_deserialized::<email::mailbox::Mailbox>()
.caused_by(trc::location!())?;
if ctx.is_shared {
let acl = mailbox.inner.acls.effective_acl(access_token);
let subscription_only = object.keys().all(|key| {
matches!(
key,
Key::Property(MailboxProperty::IsSubscribed | MailboxProperty::Id)
)
});
if subscription_only {
if !acl.contains(Acl::Read) {
ctx.response.not_updated.append(
id,
SetError::forbidden().with_description(
"You are not allowed to access this mailbox.",
),
);
continue 'update;
}
} else if !acl.contains(Acl::Modify) {
ctx.response.not_updated.append(
id,
SetError::forbidden()
.with_description("You are not allowed to modify this mailbox."),
);
continue 'update;
} else if object.contains_key(&Key::Property(MailboxProperty::ShareWith))
&& !acl.contains(Acl::Share)
{
ctx.response.not_updated.append(
id,
SetError::forbidden().with_description(
"You are not allowed to change the permissions of this mailbox.",
),
);
continue 'update;
}
}
match self
.mailbox_set_item(object, (document_id, mailbox).into(), &ctx)
.await?
{
Ok(builder) => {
batch
.with_account_id(account_id)
.with_collection(Collection::Mailbox);
let parent_id = builder.changes().unwrap().parent_id;
if parent_id > 0 {
batch
.with_document(parent_id - 1)
.assert_value(MailboxField::Archive, AssertValue::Some);
}
batch
.with_document(document_id)
.custom(builder)
.caused_by(trc::location!())?
.commit_point();
will_update.push(id);
}
Err(err) => {
ctx.response.not_updated.append(id, err);
continue 'update;
}
}
} else {
ctx.response.not_updated.append(id, SetError::not_found());
}
}
if !batch.is_empty() {
match self
.commit_batch(batch)
.await
.and_then(|ids| ids.last_change_id(account_id))
{
Ok(change_id_) => {
change_id = Some(change_id_);
for id in will_update {
ctx.response.updated.append(id, None);
}
}
Err(err) if err.is_assertion_failure() => {
for id in will_update {
ctx.response.not_updated.append(
id,
SetError::forbidden().with_description(
"Another process modified this mailbox, please try again.",
),
);
}
}
Err(err) => {
return Err(err.caused_by(trc::location!()));
}
}
}
// Process deletions
for id in ctx.will_destroy {
match self
.mailbox_destroy(
account_id,
id.document_id(),
ctx.access_token,
on_destroy_remove_emails,
)
.await?
{
Ok(change_id_) => {
if change_id_.is_some() {
change_id = change_id_;
}
ctx.response.destroyed.push(id);
}
Err(err) => {
ctx.response.not_destroyed.append(
id,
match err {
MailboxDestroyError::CannotDestroy => SetError::forbidden()
.with_description(
"You are not allowed to delete Inbox, Junk or Trash folders.",
),
MailboxDestroyError::Forbidden => SetError::forbidden()
.with_description("You are not allowed to delete this mailbox."),
MailboxDestroyError::HasChildren => {
SetError::new(SetErrorType::MailboxHasChild)
.with_description("Mailbox has at least one children.")
}
MailboxDestroyError::HasEmails => {
SetError::new(SetErrorType::MailboxHasEmail)
.with_description("Mailbox is not empty.")
}
MailboxDestroyError::NotFound => SetError::not_found(),
MailboxDestroyError::AssertionFailed => SetError::forbidden()
.with_description(concat!(
"Another process modified a message in this mailbox ",
"while deleting it, please try again."
)),
},
);
}
}
}
// Write changes
if let Some(change_id) = change_id {
ctx.response.new_state = State::Exact(change_id).into();
}
Ok(ctx.response)
}
#[allow(clippy::blocks_in_conditions)]
async fn mailbox_set_item(
&self,
changes_: Map<'_, MailboxProperty, MailboxValue>,
update: Option<(u32, Archive<Mailbox>)>,
ctx: &SetContext<'_>,
) -> trc::Result<Result<ObjectIndexBuilder<Mailbox, Mailbox>, SetError<MailboxProperty>>> {
// Parse properties
let mut changes = update
.as_ref()
.map(|(_, obj)| obj.inner.clone())
.unwrap_or_else(|| Mailbox::new(String::new()));
let mut has_acl_changes = false;
for (property, mut value) in changes_.into_vec() {
if let Err(err) = ctx.response.resolve_self_references(&mut value, 0, false) {
return Ok(Err(err));
};
match (&property, value) {
(Key::Property(MailboxProperty::Name), Value::Str(value)) => {
let value = value.trim();
if !value.is_empty() && value.len() < self.core.email.mailbox_name_max_len {
changes.name = value.into();
} else {
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::Name)
.with_description(
if !value.is_empty() {
"Mailbox name is too long."
} else {
"Mailbox name cannot be empty."
}
.to_string(),
)));
}
}
(
Key::Property(MailboxProperty::ParentId),
Value::Element(MailboxValue::Id(value)),
) => {
let parent_id = value.document_id();
if ctx.will_destroy.contains(&value) {
return Ok(Err(SetError::will_destroy()
.with_description("Parent ID will be destroyed.")));
} else if !ctx.mailbox_ids.contains(parent_id) {
return Ok(Err(SetError::invalid_properties()
.with_description("Parent ID does not exist.")));
}
changes.parent_id = parent_id + 1;
}
(Key::Property(MailboxProperty::ParentId), Value::Null) => {
changes.parent_id = 0;
}
(Key::Property(MailboxProperty::IsSubscribed), Value::Bool(subscribe)) => {
let account_id = ctx
.access_token
.personal_id(ctx.account_id, Collection::Mailbox);
if subscribe {
if !changes.subscribers.contains(&account_id) {
changes.subscribers.push(account_id);
}
} else {
changes.subscribers.retain(|id| *id != account_id);
}
}
(
Key::Property(MailboxProperty::Role),
Value::Element(MailboxValue::Role(role)),
) => {
changes.role = role;
}
(Key::Property(MailboxProperty::Role), Value::Null) => {
changes.role = SpecialUse::None;
}
(Key::Property(MailboxProperty::SortOrder), Value::Number(value)) => {
changes.sort_order = Some(value.cast_to_u64() as u32);
}
(Key::Property(MailboxProperty::ShareWith), value) => {
match JmapRights::acl_set::<mailbox::Mailbox>(value) {
Ok(acls) => {
has_acl_changes = true;
changes.acls = acls;
continue;
}
Err(err) => {
return Ok(Err(err));
}
}
}
(Key::Property(MailboxProperty::Pointer(pointer)), value)
if matches!(
pointer.first(),
Some(JsonPointerItem::Key(Key::Property(
MailboxProperty::ShareWith
)))
) =>
{
let mut pointer = pointer.iter();
pointer.next();
match JmapRights::acl_patch::<mailbox::Mailbox>(changes.acls, pointer, value) {
Ok(acls) => {
has_acl_changes = true;
changes.acls = acls;
continue;
}
Err(err) => {
return Ok(Err(err));
}
}
}
(Key::Property(MailboxProperty::Id), value) => {
if update
.as_ref()
.map(|(document_id, _)| Id::from(*document_id))
.is_none_or(|expected| !crate::matches_id(&value, expected))
{
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::Id)
.with_description("The id property is immutable.".to_string())));
}
}
_ => {
return Ok(Err(SetError::invalid_properties()
.with_property(property.into_owned())
.with_description("Invalid property or value.".to_string())));
}
}
}
// Validate depth and circular parent-child relationship
if update
.as_ref()
.is_none_or(|(_, m)| m.inner.parent_id != changes.parent_id)
{
let mut mailbox_parent_id = changes.parent_id;
let current_mailbox_id = update
.as_ref()
.map_or(u32::MAX, |(mailbox_id, _)| *mailbox_id + 1);
let mut success = false;
for depth in 0..self.core.email.mailbox_max_depth {
if mailbox_parent_id == current_mailbox_id {
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::ParentId)
.with_description("Mailbox cannot be a parent of itself.")));
} else if mailbox_parent_id == 0 {
if depth == 0 && ctx.is_shared {
return Ok(Err(SetError::forbidden()
.with_description("You are not allowed to create root folders.")));
}
success = true;
break;
}
let parent_document_id = mailbox_parent_id - 1;
if let Some(mailbox_) = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
ctx.account_id,
Collection::Mailbox,
parent_document_id,
))
.await?
{
let mailbox = mailbox_
.unarchive::<email::mailbox::Mailbox>()
.caused_by(trc::location!())?;
if depth == 0
&& ctx.is_shared
&& !mailbox
.acls
.effective_acl(ctx.access_token)
.contains(Acl::CreateChild)
{
return Ok(Err(SetError::forbidden().with_description(
"You are not allowed to create sub mailboxes under this mailbox.",
)));
}
mailbox_parent_id = mailbox.parent_id.into();
} else if ctx.mailbox_ids.contains(parent_document_id) {
// Parent mailbox is probably created within the same request
success = true;
break;
} else {
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::ParentId)
.with_description("Mailbox parent does not exist.")));
}
}
if !success {
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::ParentId)
.with_description(
"Mailbox parent-child relationship is too deep.",
)));
}
}
let cached_mailboxes = self.get_cached_messages(ctx.account_id).await?;
// Verify that the mailbox role is unique.
if update
.as_ref()
.is_none_or(|(_, m)| m.inner.role != changes.role)
{
if !matches!(changes.role, SpecialUse::None)
&& cached_mailboxes.mailbox_by_role(&changes.role).is_some()
{
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::Role)
.with_description(format!(
"A mailbox with role '{}' already exists.",
changes.role.as_str().unwrap_or_default()
))));
}
// Role of internal folders cannot be modified
if update.as_ref().is_some_and(|(document_id, _)| {
*document_id == INBOX_ID || *document_id == TRASH_ID || *document_id == JUNK_ID
}) {
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::Role)
.with_description(
"You are not allowed to change the role of Inbox, Junk or Trash folders.",
)));
}
}
// Verify that the mailbox name is unique.
if !changes.name.is_empty() {
// Obtain parent mailbox id
let lower_name = changes.name.to_lowercase();
if update
.as_ref()
.is_none_or(|(_, m)| m.inner.name != changes.name)
&& let Some(existing) = cached_mailboxes.mailboxes.items.iter().find(|m| {
m.name.to_lowercase() == lower_name
&& m.parent_id().map_or(0, |id| id + 1) == changes.parent_id
})
{
return Ok(Err(SetError::already_exists()
.with_existing_id(Id::from(existing.document_id))
.with_description(format!(
"A mailbox with name '{}' already exists.",
changes.name
))));
}
} else {
return Ok(Err(SetError::invalid_properties()
.with_property(MailboxProperty::Name)
.with_description("Mailbox name cannot be empty.")));
}
// Refresh ACLs
let current = update.map(|(_, current)| current);
if has_acl_changes {
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
if ctx.access_token.is_group_member_only(ctx.account_id) {
return Ok(Err(SetError::forbidden()
.with_property(MailboxProperty::ShareWith)
.with_description(
"This mailbox belongs to a group. Only an administrator can change who has it.",
)));
}
// inbuxa: MA-C: with mail sharing off, nobody here starts or
// widens a share (narrowing or ending one is always allowed)
let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default();
let widens = changes.acls.iter().any(|grant| {
let had = before
.iter()
.find(|old| old.account_id == grant.account_id)
.map_or(0, |old| old.grants.clone().into_inner());
grant.grants.clone().into_inner() & !had != 0
});
if widens
&& !ctx.access_token.has_permission(Permission::Impersonate)
&& !self.mail_sharing_allowed(ctx.account_id).await?
{
return Ok(Err(SetError::forbidden()
.with_property(MailboxProperty::ShareWith)
.with_description(
"Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.",
)));
}
if !changes.acls.is_empty()
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
{
return Ok(Err(err.into()));
}
self.refresh_acls(
&changes.acls,
current.as_ref().map(|m| m.inner.acls.as_slice()),
)
.await
.caused_by(trc::location!())?;
}
// Validate
Ok(Ok(ObjectIndexBuilder::new()
.with_changes(changes)
.with_current_opt(current)))
}
}