Files
inbuxa-server/crates/imap/src/op/create.rs
T
jcoffey-dev 447229f871
ci / fork-checks (pull_request) Successful in 1m4s
ci / build (pull_request) Successful in 8m47s
Lock accounts: keep receiving mail, no sign-in, hand to delegates
A locked account can't sign in (it fails as a wrong password does), its
sessions end on every node, refresh tokens stop working, and its Sieve
scripts forward and reply to nothing. Mail keeps arriving.

Delegates get real ACL grants on the account's mailboxes, calendars,
address books and files at read, organize or full, with the rights they
replaced restored on unlock. Folders made later are granted after the
create and in a daily sweep. Organize delegates can't destroy; send-as
needs organize or full. The JMAP session marks delegated accounts in
urn:inbuxa:jmap.

New inbuxa:AccountLock object with get/set, permissions 665-668, and a
Compliance > Locked Accounts entry in the schema. Lock, unlock and
delegate changes need a reason and are audited; delegate access and
writes are audited too (audit-hold-lock spec AL-1 to AL-12).
2026-09-27 14:46:06 -07:00

394 lines
14 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::{
core::{Session, SessionData},
op::ImapContext,
spawn_op,
};
use common::{network::SessionStream, storage::index::ObjectIndexBuilder};
use email::cache::{MessageCacheFetch, mailbox::MailboxCacheAccess};
use imap_proto::{
Command, ResponseCode, StatusResponse,
protocol::{ObjectId, create::Arguments, list::Attribute},
receiver::Request,
};
use registry::schema::enums::{Permission, StorageQuota};
use std::time::Instant;
use store::write::BatchBuilder;
use trc::AddContext;
use types::{acl::Acl, collection::Collection, id::Id, special_use::SpecialUse};
impl<T: SessionStream> Session<T> {
pub async fn handle_create(&mut self, requests: Vec<Request<Command>>) -> trc::Result<()> {
// Validate access
self.assert_has_permission(Permission::ImapCreate)?;
let data = self.state.session_data();
let is_utf8 = self.is_utf8;
let is_objectid = self.is_objectid;
spawn_op!(data, {
for request in requests {
match request.parse_create(is_utf8) {
Ok(argument) => match data.create_folder(argument, is_objectid).await {
Ok(response) => {
data.write_bytes(response.into_bytes()).await?;
}
Err(error) => {
data.write_error(error).await?;
}
},
Err(err) => data.write_error(err).await?,
}
}
Ok(())
})
}
}
impl<T: SessionStream> SessionData<T> {
pub async fn create_folder(
&self,
arguments: Arguments,
is_objectid: bool,
) -> trc::Result<StatusResponse> {
let op_start = Instant::now();
// Refresh mailboxes
self.synchronize_mailboxes(false)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
// Validate mailbox name
let params = self
.validate_mailbox_create(&arguments.mailbox_name, arguments.mailbox_role)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
debug_assert!(!params.path.is_empty());
// Validate quota
let account = self
.server
.account(params.account_id)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
let mailbox_count = self
.server
.get_cached_messages(params.account_id)
.await
.imap_ctx(&arguments.tag, trc::location!())?
.mailboxes
.items
.len();
if mailbox_count + params.path.len()
> self
.server
.object_quota(account.object_quotas(), StorageQuota::MaxMailboxes)
as usize
{
return Err(trc::ImapEvent::Error
.into_err()
.details(concat!(
"There are too many mailboxes, ",
"please delete some before adding a new one."
))
.code(ResponseCode::OverQuota)
.id(arguments.tag.clone()));
}
// Build batch
let mut parent_id = params.parent_mailbox_id.map(|id| id + 1).unwrap_or(0);
let mut create_ids = Vec::with_capacity(params.path.len());
let mut next_document_id = self
.server
.store()
.assign_document_ids(
params.account_id,
Collection::Mailbox,
params.path.len() as u64,
)
.await
.caused_by(trc::location!())?;
let mut batch = BatchBuilder::new();
for (pos, &path_item) in params.path.iter().enumerate() {
let mut mailbox = email::mailbox::Mailbox::new(path_item).with_parent_id(parent_id);
if pos == params.path.len() - 1
&& let Some(mailbox_role) = arguments.mailbox_role.map(attr_to_role)
{
mailbox.role = mailbox_role;
}
let mailbox_id = next_document_id;
next_document_id -= 1;
batch
.with_account_id(params.account_id)
.with_collection(Collection::Mailbox)
.with_document(mailbox_id)
.custom(ObjectIndexBuilder::<(), _>::new().with_changes(mailbox))
.imap_ctx(&arguments.tag, trc::location!())?
.commit_point();
parent_id = mailbox_id + 1;
create_ids.push(mailbox_id);
}
self.server
.commit_batch(batch)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
// inbuxa: AL-7: a folder a delegate makes in a locked account gets
// the lock's grants, so the delegate can see it
if params.account_id != self.account_id
&& let Err(err) = email::inbuxa_lock::reconcile(&self.server, params.account_id).await
{
trc::error!(err.details("Failed to grant a lock's delegates on a new folder"));
}
trc::event!(
Imap(trc::ImapEvent::CreateMailbox),
SpanId = self.session_id,
MailboxName = arguments.mailbox_name.clone(),
AccountId = params.account_id,
MailboxId = create_ids
.iter()
.map(|&id| trc::Value::from(id))
.collect::<Vec<_>>(),
Elapsed = op_start.elapsed()
);
// Build response
let response = StatusResponse::ok("Mailbox created.").with_tag(arguments.tag);
Ok(if is_objectid {
response.with_code(ResponseCode::ObjectId(ObjectId {
mailbox_id: Some(Id::from(parent_id - 1)),
account_id: Some(Id::from(params.account_id)),
..Default::default()
}))
} else {
response
})
}
pub async fn validate_mailbox_create<'x>(
&self,
mailbox_name: &'x str,
mailbox_role: Option<Attribute>,
) -> trc::Result<CreateParams<'x>> {
// Remove leading and trailing separators
let mut name = mailbox_name.trim();
if let Some(suffix) = name.strip_prefix('/') {
name = suffix.trim();
};
if let Some(prefix) = name.strip_suffix('/') {
name = prefix.trim();
}
if name.is_empty() {
return Err(trc::ImapEvent::Error
.into_err()
.details(format!("Invalid folder name '{mailbox_name}'.",)));
}
// Build path
let mut path = Vec::new();
if name.contains('/') {
// Locate parent mailbox
for path_item in name.split('/') {
let path_item = path_item.trim();
if path_item.is_empty() {
return Err(trc::ImapEvent::Error
.into_err()
.details("Invalid empty path item."));
} else if path_item.len() > self.server.core.email.mailbox_name_max_len {
return Err(trc::ImapEvent::Error
.into_err()
.details("Mailbox name is too long."));
}
path.push(path_item);
}
if path.len() > self.server.core.email.mailbox_max_depth {
return Err(trc::ImapEvent::Error
.into_err()
.details("Mailbox path is too deep."));
}
} else {
path.push(name);
}
// Validate special folders
let mut parent_mailbox_id = None;
let mut parent_mailbox_name = None;
let (account_id, path) = {
let mailboxes = self.mailboxes.lock();
let (account, full_path, prefix) =
if path.first() == Some(&self.server.core.email.shared_folder.as_str()) {
// Shared Folders/<username>/<folder>
if path.len() < 3 {
return Err(trc::ImapEvent::Error
.into_err()
.details("Mailboxes under root shared folders are not allowed.")
.code(ResponseCode::Cannot));
}
// Build path
let root = &mut path[2];
if root.eq_ignore_ascii_case("INBOX") {
*root = "INBOX";
}
let full_path = path.join("/");
let prefix = Some(format!("{}/{}", path[0], path[1]));
// Locate account
if let Some(account) = mailboxes
.iter()
.skip(1)
.find(|account| account.prefix == prefix)
{
(account, full_path, prefix)
} else {
#[allow(clippy::unnecessary_literal_unwrap)]
return Err(trc::ImapEvent::Error.into_err().details(format!(
"Shared account '{}' not found.",
prefix.unwrap_or_default()
)));
}
} else if let Some(account) = mailboxes.first() {
let root = &mut path[0];
if root.eq_ignore_ascii_case("INBOX") {
*root = "INBOX";
}
(account, path.join("/"), None)
} else {
return Err(trc::ImapEvent::Error
.into_err()
.details("Internal server error.")
.caused_by(trc::location!())
.code(ResponseCode::ContactAdmin));
};
// Locate parent mailbox
if account.mailbox_names.contains_key(&full_path) {
return Err(trc::ImapEvent::Error
.into_err()
.details(format!("Mailbox '{}' already exists.", full_path))
.code(ResponseCode::AlreadyExists));
}
(
account.account_id,
if path.len() > 1 {
let mut create_path = Vec::with_capacity(path.len());
while !path.is_empty() {
let mailbox_name: String = path.join("/");
if let Some(&mailbox_id) = account.mailbox_names.get(&mailbox_name) {
parent_mailbox_id = mailbox_id.into();
parent_mailbox_name = mailbox_name.into();
break;
} else if prefix
.as_ref()
.is_some_and(|prefix| prefix == &mailbox_name)
{
break;
} else {
create_path.push(path.pop().unwrap());
}
}
create_path.reverse();
create_path
} else {
path
},
)
};
// Validate ACLs
if let Some(parent_mailbox_id) = parent_mailbox_id {
if !self
.check_mailbox_acl(account_id, parent_mailbox_id, Acl::CreateChild)
.await?
{
return Err(trc::ImapEvent::Error
.into_err()
.details("You are not allowed to create sub mailboxes under this mailbox.")
.code(ResponseCode::NoPerm));
}
} else if self.account_id != account_id
&& !self
.refresh_access_token()
.await
.caused_by(trc::location!())?
.is_member(account_id)
{
return Err(trc::ImapEvent::Error
.into_err()
.details("You are not allowed to create root folders under shared folders.")
.code(ResponseCode::Cannot));
}
Ok(CreateParams {
account_id,
path,
parent_mailbox_id,
parent_mailbox_name,
special_use: if let Some(mailbox_role) = mailbox_role {
// Make sure role is unique
let special_use = attr_to_role(mailbox_role);
if self
.server
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?
.mailbox_by_role(&special_use)
.is_some()
{
return Err(trc::ImapEvent::Error
.into_err()
.details(format!(
"A mailbox with role '{}' already exists.",
special_use.as_str().unwrap_or_default()
))
.code(ResponseCode::UseAttr));
}
Some(mailbox_role)
} else {
None
},
is_rename: false,
})
}
}
#[derive(Debug)]
pub struct CreateParams<'x> {
pub account_id: u32,
pub path: Vec<&'x str>,
pub parent_mailbox_id: Option<u32>,
pub parent_mailbox_name: Option<String>,
pub special_use: Option<Attribute>,
pub is_rename: bool,
}
#[inline]
fn attr_to_role(attr: Attribute) -> SpecialUse {
match attr {
Attribute::Archive => SpecialUse::Archive,
Attribute::Drafts => SpecialUse::Drafts,
Attribute::Junk => SpecialUse::Junk,
Attribute::Sent => SpecialUse::Sent,
Attribute::Trash => SpecialUse::Trash,
Attribute::Important => SpecialUse::Important,
Attribute::Memos => SpecialUse::Memos,
Attribute::Scheduled => SpecialUse::Scheduled,
Attribute::Snoozed => SpecialUse::Snoozed,
_ => SpecialUse::None,
}
}