A shared mailbox (support@, legal@) belongs to no one person: nobody
signs in to it, and the people assigned open it beside their own mail
at an access level an administrator chose. An account lock already is
most of that: it keeps receiving mail, refuses every sign-in, and its
delegates reach it through real grants on every container (so IMAP,
DAV and JMAP honor them), never including Share. So a shared mailbox is
a lock of a second kind (multi-account spec, MA-S; John, 2026-10-05).
Lock gains kind: "lock" (the default, so stored locks read as before)
or "sharedMailbox", set on create and fixed after. A shared mailbox:
- needs no reason to make, change or end;
- holds up to 100 people, where a lock holds 10;
- runs its own Sieve replies and redirects, so an automatic
acknowledgement goes out (a lock answers no one);
- records only what is sent as it (audit_send_as, which now covers it),
not AL-9's access and per-change records, which would bury the log
for a busy desk;
- sends only as itself (MA-S3): From and Reply-To must be its own
addresses, so answers come back to the mailbox and not to whoever
replied; anything else is forbiddenFrom.
The session marks it delegation: {locked: true, kind: "sharedMailbox"},
so a front end that knows no kind still treats it as a lock. The
console's layout gains Management › Directory › Shared Mailboxes
(CustomComponent/SharedMailboxes).
Tests: the account lock suite now goes on to a shared mailbox: made
without a reason with twelve people, sign-in refused, the session's
kind, its vacation reply delivered, an answer sent as it and recorded
as the agent with no per-change records, and a Reply-To naming the
agent refused; a lock unit test reads a stored lock without a kind.
account_lock_tests, jmap_tests, audit_log_tests and imap_tests pass
(RocksDB).
362 lines
9.6 KiB
Rust
362 lines
9.6 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::{
|
|
expr::if_block::IfBlock,
|
|
network::limiter::ConcurrencyLimiter,
|
|
storage::{ObjectQuota, TenantQuota},
|
|
};
|
|
use directory::Credentials;
|
|
use quick_cache::Equivalent;
|
|
use registry::{
|
|
schema::enums::{Locale, Permission},
|
|
types::{EnumImpl, ipmask::IpAddrOrMask},
|
|
};
|
|
use std::{
|
|
hash::{Hash, Hasher},
|
|
net::IpAddr,
|
|
sync::Arc,
|
|
};
|
|
use tinyvec::TinyVec;
|
|
use trc::ipc::bitset::Bitset;
|
|
use types::collection::Collection;
|
|
use utils::{cache::CacheItemWeight, map::bitmap::Bitmap};
|
|
|
|
pub mod access_token;
|
|
pub mod authentication;
|
|
pub mod credential;
|
|
pub mod oauth;
|
|
pub mod permissions;
|
|
pub mod rate_limit;
|
|
|
|
pub const RECOVERY_ADMIN_ID: u32 = u32::MAX;
|
|
const PERMISSIONS_BITSET_SIZE: usize = Permission::COUNT.div_ceil(std::mem::size_of::<usize>());
|
|
pub type Permissions = Bitset<PERMISSIONS_BITSET_SIZE>;
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
|
pub struct EmailAddress {
|
|
pub local_part: Box<str>,
|
|
pub domain_id: u32,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub struct EmailAddressRef<'x> {
|
|
local_part: &'x str,
|
|
domain_id: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EmailCache {
|
|
Account(u32),
|
|
MailingList(u32),
|
|
DisabledAccountAddress(u32),
|
|
DisabledListAddress(u32),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct DomainCache {
|
|
pub names: Box<[Box<str>]>,
|
|
pub id: u32,
|
|
pub id_directory: Option<u32>,
|
|
pub id_tenant: Option<u32>,
|
|
pub catch_all: Option<Box<str>>,
|
|
pub sub_addressing_custom: Option<Box<IfBlock>>,
|
|
pub flags: u8,
|
|
}
|
|
|
|
pub const DOMAIN_FLAG_RELAY: u8 = 1;
|
|
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
|
|
// inbuxa: SCIM-15, SCIM-58
|
|
pub const DOMAIN_FLAG_SCIM: u8 = 1 << 2;
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AccountCache {
|
|
pub name: Box<str>,
|
|
pub id: u32,
|
|
pub addresses: Box<[EmailAddress]>,
|
|
pub id_tenant: Option<u32>,
|
|
pub id_member_of: TinyVec<[u32; 3]>,
|
|
pub quota_disk: u64,
|
|
pub quota_objects: Option<Box<ObjectQuota>>,
|
|
pub description: Option<Box<str>>,
|
|
pub encryption_key: Option<EncryptionKeys>,
|
|
pub locale: Locale,
|
|
pub flags: u64,
|
|
}
|
|
|
|
pub type EncryptionKeys = Box<[Box<[u8]>]>;
|
|
|
|
pub const ACCOUNT_IS_USER: u64 = 1;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER: u64 = 1 << 1;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME: u64 = 1 << 2;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_METHOD_PGP: u64 = 1 << 3;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256: u64 = 1 << 4;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES128: u64 = 1 << 5;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_APPEND: u64 = 1 << 6;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM: u64 = 1 << 7;
|
|
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305: u64 = 1 << 8;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RoleCache {
|
|
pub id_roles: TinyVec<[u32; 3]>,
|
|
pub permissions: PermissionsGroup,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MailingListCache {
|
|
pub addresses: Box<[EmailAddress]>,
|
|
pub recipients: Arc<[Box<str>]>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TenantCache {
|
|
pub id_roles: TinyVec<[u32; 3]>,
|
|
pub quota_disk: u64,
|
|
pub quota_objects: Option<Box<TenantQuota>>,
|
|
pub permissions: Option<Box<PermissionsGroup>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct PermissionsGroup {
|
|
pub enabled: Permissions,
|
|
pub disabled: Permissions,
|
|
pub merge: bool,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct AccessToken {
|
|
scope_idx: usize,
|
|
inner: Arc<AccessTokenInner>,
|
|
// inbuxa: how this session signed in, for the audit log (AU-5)
|
|
origin: Option<Arc<inbuxa_features::audit::Via>>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct AccessTokenInner {
|
|
pub(crate) account_id: u32,
|
|
pub(crate) tenant_id: Option<u32>,
|
|
pub(crate) member_of: TinyVec<[u32; 3]>,
|
|
pub(crate) access_to: Box<[AccessTo]>,
|
|
pub(crate) scopes: Box<[AccessScope]>,
|
|
pub(crate) concurrent_http_requests: Option<ConcurrencyLimiter>,
|
|
pub(crate) concurrent_imap_requests: Option<ConcurrencyLimiter>,
|
|
pub(crate) concurrent_uploads: Option<ConcurrencyLimiter>,
|
|
pub(crate) revision_account: u64,
|
|
pub(crate) revision: u64,
|
|
pub(crate) credential_version: u64,
|
|
pub(crate) obj_size: u64,
|
|
// inbuxa: AL-2: the account is locked; it may not authenticate
|
|
pub(crate) locked: bool,
|
|
// inbuxa: MA-S: the lock is a shared mailbox
|
|
pub(crate) shared_mailbox: bool,
|
|
// inbuxa: AL-5: locked accounts handed to this one
|
|
pub(crate) delegations: Box<[Delegation]>,
|
|
}
|
|
|
|
/// inbuxa: a locked account this one may open, and how (AL-5, AL-6).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Delegation {
|
|
/// The locked account.
|
|
pub account_id: u32,
|
|
pub access: inbuxa_features::lock::Access,
|
|
pub send_as: bool,
|
|
/// Seconds since the epoch.
|
|
pub until: Option<u64>,
|
|
/// MA-S: a locked account, or a shared mailbox.
|
|
pub kind: inbuxa_features::lock::Kind,
|
|
}
|
|
|
|
#[derive(Debug, Default, Hash, Clone)]
|
|
pub struct AccessScope {
|
|
pub permissions: Permissions,
|
|
pub credential_id: u32,
|
|
pub expires_at: u64,
|
|
pub allowed_ips: Box<[IpAddrOrMask]>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Hash, PartialEq, Eq, Clone)]
|
|
pub(crate) struct AccessTo {
|
|
pub account_id: u32,
|
|
pub collections: Bitmap<Collection>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AccountInfo {
|
|
pub account_id: u32,
|
|
pub account: Arc<AccountCache>,
|
|
pub addresses: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct AccountTenantIds {
|
|
pub account_id: u32,
|
|
pub tenant_id: Option<u32>,
|
|
}
|
|
|
|
pub struct AuthRequest {
|
|
pub credentials: Credentials,
|
|
pub session_id: u64,
|
|
pub remote_ip: IpAddr,
|
|
}
|
|
|
|
impl CacheItemWeight for AccessTokenInner {
|
|
fn weight(&self) -> u64 {
|
|
self.obj_size
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for EmailAddress {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<EmailAddress>() as u64 + self.local_part.len() as u64
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for EmailCache {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<EmailCache>() as u64
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for DomainCache {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<DomainCache>() as u64
|
|
+ self
|
|
.names
|
|
.iter()
|
|
.map(|s| s.len() as u64 + std::mem::size_of::<Box<str>>() as u64)
|
|
.sum::<u64>()
|
|
+ self.catch_all.as_ref().map_or(0, |s| s.len() as u64)
|
|
+ self
|
|
.sub_addressing_custom
|
|
.as_ref()
|
|
.map_or(0, |s| s.weight())
|
|
}
|
|
}
|
|
|
|
impl Equivalent<EmailAddress> for EmailAddressRef<'_> {
|
|
fn equivalent(&self, key: &EmailAddress) -> bool {
|
|
self.local_part == &*key.local_part && self.domain_id == key.domain_id
|
|
}
|
|
}
|
|
|
|
impl Hash for EmailAddress {
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
self.local_part.as_ref().hash(state);
|
|
self.domain_id.hash(state);
|
|
}
|
|
}
|
|
|
|
impl Hash for EmailAddressRef<'_> {
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
self.local_part.hash(state);
|
|
self.domain_id.hash(state);
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for AccountCache {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<AccountCache>() as u64
|
|
+ self.name.len() as u64
|
|
+ self
|
|
.addresses
|
|
.iter()
|
|
.map(|s| s.local_part.len() as u64 + std::mem::size_of::<EmailAddress>() as u64)
|
|
.sum::<u64>()
|
|
+ self.description.as_ref().map_or(0, |s| s.len() as u64)
|
|
+ self.encryption_key.as_ref().map_or(0, |keys| {
|
|
keys.iter()
|
|
.map(|k| k.len() as u64 + std::mem::size_of::<Box<[u8]>>() as u64)
|
|
.sum::<u64>()
|
|
})
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for RoleCache {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<RoleCache>() as u64
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for MailingListCache {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<MailingListCache>() as u64
|
|
+ self
|
|
.addresses
|
|
.iter()
|
|
.map(|s| s.local_part.len() as u64 + std::mem::size_of::<EmailAddress>() as u64)
|
|
.sum::<u64>()
|
|
+ self
|
|
.recipients
|
|
.iter()
|
|
.map(|s| s.len() as u64 + std::mem::size_of::<Box<str>>() as u64)
|
|
.sum::<u64>()
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for TenantCache {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<TenantCache>() as u64
|
|
+ self.permissions.as_ref().map_or(0, |p| p.weight())
|
|
}
|
|
}
|
|
|
|
impl CacheItemWeight for PermissionsGroup {
|
|
fn weight(&self) -> u64 {
|
|
std::mem::size_of::<PermissionsGroup>() as u64
|
|
}
|
|
}
|
|
|
|
pub trait BuildAccessToken {
|
|
fn build(self) -> AccessToken;
|
|
}
|
|
|
|
impl BuildAccessToken for Arc<AccessTokenInner> {
|
|
fn build(self) -> AccessToken {
|
|
AccessToken {
|
|
scope_idx: 0,
|
|
origin: None,
|
|
inner: self,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EmailAddress {
|
|
pub fn new(local_part: impl Into<Box<str>>, domain_id: u32) -> Self {
|
|
Self {
|
|
local_part: local_part.into(),
|
|
domain_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'x> EmailAddressRef<'x> {
|
|
pub fn new(local_part: &'x str, domain_id: u32) -> Self {
|
|
Self {
|
|
local_part,
|
|
domain_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AccountCache {
|
|
pub fn domain_id(&self) -> Option<u32> {
|
|
self.addresses.first().map(|address| address.domain_id)
|
|
}
|
|
}
|
|
|
|
impl DomainCache {
|
|
pub fn name(&self) -> &str {
|
|
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
|
|
}
|
|
|
|
// inbuxa: SCIM-15, SCIM-58
|
|
pub fn allows_scim(&self) -> bool {
|
|
self.flags & DOMAIN_FLAG_SCIM != 0
|
|
}
|
|
}
|