Files
inbuxa-server/crates/jmap/src/api/session.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

218 lines
9.0 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 common::{Server, auth::AccessToken};
use jmap_proto::request::capability::{
Account, Capabilities, Capability, EmptyCapabilities, InbuxaAccountCapabilities, InbuxaDelegatedCapabilities, DelegationInfo, Session,
};
use registry::schema::enums::Permission;
use std::future::Future;
use trc::AddContext;
use types::id::Id;
use utils::map::vec_map::VecMap;
pub trait SessionHandler: Sync + Send {
fn handle_session_resource(
&self,
base_url: String,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<Session>> + Send;
}
impl SessionHandler for Server {
async fn handle_session_resource(
&self,
base_url: String,
access_token: &AccessToken,
) -> trc::Result<Session> {
let mut session = Session::new(base_url, &self.core.jmap.capabilities);
session.set_state(access_token.state());
let account_capabilities = &self.core.jmap.capabilities.account;
// Set primary account
let account = self
.account(access_token.account_id())
.await
.caused_by(trc::location!())?;
session.username = account.name().to_string();
let account_id = Id::from(access_token.account_id());
let mut account = Account {
name: account.name().to_string(),
is_personal: true,
is_read_only: false,
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
};
for capability in access_token.account_capabilities() {
session.primary_accounts.append(capability, account_id);
account.account_capabilities.append(
capability,
account_capabilities
.get(&capability)
.map(|v| v.to_account_capabilities(account_id.into(), true))
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
);
}
// inbuxa: MT-22: the logo that applies to the signed-in principal
let logo =
inbuxa_features::tenancy::logo::for_account(self.registry(), access_token.account_id())
.await
.caused_by(trc::location!())?;
session.capabilities.append(
Capability::Inbuxa,
Capabilities::Empty(EmptyCapabilities::default()),
);
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
// per protocol. `legacyProtocols` stays for older webmail builds:
// `disabled` only when every protocol is off.
let legacy_off = self.legacy_off_for_account(access_token).await?;
let legacy_protocols = if legacy_off.all() {
"disabled"
} else {
"enabled"
};
let legacy_allowed = legacy_off.allowed();
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
&& access_token.tenant_id().is_none()
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
// inbuxa: MA-C: what the sharing switches leave this principal
let sharing = inbuxa_features::security::sharing_policy::effective_for(
self.store(),
access_token.tenant_id(),
)
.await
.caused_by(trc::location!())?;
account.account_capabilities.append(
Capability::Inbuxa,
Capabilities::Inbuxa(InbuxaAccountCapabilities {
logo,
legacy_protocols,
legacy_allowed,
ai_explain,
mail_sharing: sharing.mail_sharing,
add_accounts: sharing.add_accounts,
}),
);
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
if access_token.has_permission(Permission::SysMaskedEmailGet) {
session.capabilities.append(
Capability::FastmailMaskedEmail,
Capabilities::Empty(EmptyCapabilities::default()),
);
account.account_capabilities.append(
Capability::FastmailMaskedEmail,
Capabilities::Empty(EmptyCapabilities::default()),
);
session
.primary_accounts
.append(Capability::FastmailMaskedEmail, account_id);
}
session.accounts.append(account_id, account);
// Add secondary accounts
for &account_id in access_token.secondary_ids() {
let is_owner = access_token.is_member(account_id);
let Some(account) = self
.try_account(account_id)
.await
.caused_by(trc::location!())?
else {
trc::event!(
Auth(trc::AuthEvent::Warning),
AccountId = account_id,
Reason = "Skipping orphan secondary account id in session",
);
continue;
};
// inbuxa: AL-6, AL-7: a delegated locked account says so, and is
// read-only at the read level
let delegation = access_token.delegation(account_id).cloned();
let account_id = Id::from(account_id);
let mut account = Account {
name: account.name().to_string(),
is_personal: false,
is_read_only: delegation
.as_ref()
.is_some_and(|d| d.access == inbuxa_features::lock::Access::Read),
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
};
for capability in access_token.account_capabilities() {
account.account_capabilities.append(
capability,
account_capabilities
.get(&capability)
.map(|v| v.to_account_capabilities(account_id.into(), is_owner))
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
);
}
if let Some(delegation) = delegation {
account.account_capabilities.append(
Capability::Inbuxa,
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
delegation: DelegationInfo {
locked: true,
kind: delegation.kind.as_str(),
access: delegation.access.as_str(),
send_as: delegation.send_as,
until: delegation.until.map(|until| {
jmap_proto::types::date::UTCDate::from_timestamp(until as i64)
.to_string()
}),
},
}),
);
}
session.accounts.append(account_id, account);
}
Ok(session)
}
}
trait AccountCapabilities {
fn account_capabilities(&self) -> impl Iterator<Item = Capability>;
}
impl AccountCapabilities for AccessToken {
fn account_capabilities(&self) -> impl Iterator<Item = Capability> {
Capability::all_capabilities()
.iter()
.filter(move |capability| {
let permission = match capability {
Capability::Mail | Capability::MailShare | Capability::EmailPush => {
Permission::JmapEmailGet
}
Capability::Submission => Permission::JmapEmailSubmissionCreate,
Capability::VacationResponse => Permission::JmapVacationResponseGet,
Capability::Contacts => Permission::JmapContactCardGet,
Capability::ContactsParse => Permission::JmapContactCardParse,
Capability::Calendars => Permission::JmapCalendarEventGet,
Capability::CalendarsParse => Permission::JmapCalendarEventParse,
Capability::Sieve => Permission::JmapSieveScriptGet,
Capability::Blob => Permission::JmapBlobGet,
Capability::Quota => Permission::JmapQuotaGet,
Capability::FileNode => Permission::JmapFileNodeGet,
Capability::WebSocket
| Capability::Principals
| Capability::PrincipalsAvailability
| Capability::Stalwart => return true,
Capability::Core
| Capability::PrincipalsOwner
| Capability::WebPushVapid
| Capability::Inbuxa
| Capability::FastmailMaskedEmail => {
return false;
}
};
self.has_permission(permission)
})
.copied()
}
}