The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).
legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.
SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.
Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.
Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
208 lines
8.5 KiB
Rust
208 lines
8.5 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();
|
|
account.account_capabilities.append(
|
|
Capability::Inbuxa,
|
|
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
|
logo,
|
|
legacy_protocols,
|
|
legacy_allowed,
|
|
ai_explain,
|
|
}),
|
|
);
|
|
// 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,
|
|
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()
|
|
}
|
|
}
|