Files
inbuxa-server/crates/jmap-proto/src/request/capability.rs
T
jcoffey-dev cc6f1eb298
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m53s
Rename the identifiers that carried the upstream name
Everything clients, users and operators meet now carries the fork's name,
with no aliases (SPEC.md §2.4, changed here from "protocol identifiers
stay"):

- JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside
  the fork's own urn:inbuxa:jmap.
- WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once.
- Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these
  into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in;
  a unit test fails if Cargo.lock ever moves past the vendored copy. The
  trusted runtime now names itself too, rather than answering sieve-rs's
  default.
- The web interface's OAuth client is inbuxa-webui. On every start the old
  stalwart-webui client is removed and any application naming it is moved
  over.
- The spam filter's blobs are INBUXA_SPAM_*; every start moves any left
  under the old keys, so a trained model survives.
- SQL stores and log files default to inbuxa, in the code and in the
  schema served to the admin (checksum regenerated).
- Settings are INBUXA_* only. A STALWART_* variable that's set where its
  INBUXA_* one isn't stops the server at startup, naming it.
- The version-upgrade messages link docs.inbuxa.org's migration page, and
  the OpenAPI description, smtp crate metadata and web-push test fixtures
  lose the name.

Kept on purpose, allowlisted with reasons: the OAuth key-derivation
contexts (renaming them would end every session and invalidate every
sealed client id) and the hashed application prefix.

Also fixes a latent start-up failure: ensure_client updated an existing
first-party client with a revision of 0, which the registry's assertion
never matches, so adding a redirect URI or changing the webmail secret
failed start-up. And the principal session test now expects
legacyProtocols (C-1, added 2026-09-21), which it had missed.

Tested: the server builds without warnings; common's 106 unit tests,
including the vendoring check; a new integration test for the two
start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
2026-09-22 19:33:02 -07:00

551 lines
20 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 std::fmt;
use crate::{
object::{email::EmailComparator, file_node::FileNodeComparator},
response::serialize::serialize_hex,
types::date::UTCDate,
};
use ahash::AHashMap;
use serde::{Deserialize, Deserializer};
use types::{id::Id, type_state::DataType};
use utils::map::vec_map::VecMap;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Session {
#[serde(rename(serialize = "capabilities"))]
pub capabilities: VecMap<Capability, Capabilities>,
#[serde(rename(serialize = "accounts"))]
pub accounts: VecMap<Id, Account>,
#[serde(rename(serialize = "primaryAccounts"))]
pub primary_accounts: VecMap<Capability, Id>,
#[serde(rename(serialize = "username"))]
pub username: String,
#[serde(rename(serialize = "apiUrl"))]
pub api_url: String,
#[serde(rename(serialize = "downloadUrl"))]
pub download_url: String,
#[serde(rename(serialize = "uploadUrl"))]
pub upload_url: String,
#[serde(rename(serialize = "eventSourceUrl"))]
pub event_source_url: String,
#[serde(rename(serialize = "state"))]
#[serde(serialize_with = "serialize_hex")]
pub state: u32,
#[serde(skip)]
pub base_url: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Account {
#[serde(rename(serialize = "name"))]
pub name: String,
#[serde(rename(serialize = "isPersonal"))]
pub is_personal: bool,
#[serde(rename(serialize = "isReadOnly"))]
pub is_read_only: bool,
#[serde(rename(serialize = "accountCapabilities"))]
pub account_capabilities: VecMap<Capability, Capabilities>,
}
#[derive(Debug, Clone, Copy, serde::Serialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum Capability {
#[serde(rename(serialize = "urn:ietf:params:jmap:core"))]
Core = 1 << 0,
#[serde(rename(serialize = "urn:ietf:params:jmap:mail"))]
Mail = 1 << 1,
#[serde(rename(serialize = "urn:ietf:params:jmap:submission"))]
Submission = 1 << 2,
#[serde(rename(serialize = "urn:ietf:params:jmap:vacationresponse"))]
VacationResponse = 1 << 3,
#[serde(rename(serialize = "urn:ietf:params:jmap:contacts"))]
Contacts = 1 << 4,
#[serde(rename(serialize = "urn:ietf:params:jmap:contacts:parse"))]
ContactsParse = 1 << 5,
#[serde(rename(serialize = "urn:ietf:params:jmap:calendars"))]
Calendars = 1 << 6,
#[serde(rename(serialize = "urn:ietf:params:jmap:calendars:parse"))]
CalendarsParse = 1 << 7,
#[serde(rename(serialize = "urn:ietf:params:jmap:websocket"))]
WebSocket = 1 << 8,
#[serde(rename(serialize = "urn:ietf:params:jmap:sieve"))]
Sieve = 1 << 9,
#[serde(rename(serialize = "urn:ietf:params:jmap:blob"))]
Blob = 1 << 10,
#[serde(rename(serialize = "urn:ietf:params:jmap:quota"))]
Quota = 1 << 11,
#[serde(rename(serialize = "urn:ietf:params:jmap:principals"))]
Principals = 1 << 12,
#[serde(rename(serialize = "urn:ietf:params:jmap:principals:owner"))]
PrincipalsOwner = 1 << 13,
#[serde(rename(serialize = "urn:ietf:params:jmap:principals:availability"))]
PrincipalsAvailability = 1 << 14,
#[serde(rename(serialize = "urn:ietf:params:jmap:filenode"))]
FileNode = 1 << 15,
#[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))]
MailShare = 1 << 16,
#[serde(rename(serialize = "urn:inbuxa:jmap:registry"))]
Stalwart = 1 << 17,
#[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))]
WebPushVapid = 1 << 18,
#[serde(rename(serialize = "urn:ietf:params:jmap:emailpush"))]
EmailPush = 1 << 19,
// inbuxa: the fork's own capability (contract C-1, multi-tenancy MT-22)
#[serde(rename(serialize = "urn:inbuxa:jmap"))]
Inbuxa = 1 << 20,
// inbuxa: Fastmail's Masked Email API (masked email)
#[serde(rename(serialize = "https://www.fastmail.com/dev/maskedemail"))]
FastmailMaskedEmail = 1 << 21,
}
#[derive(Debug, Clone, Copy, Default)]
#[repr(transparent)]
pub struct CapabilityIds(pub u32);
impl CapabilityIds {
pub fn contains(&self, capability: Capability) -> bool {
self.0 & capability as u32 != 0
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(untagged)]
#[allow(dead_code)]
pub enum Capabilities {
Core(CoreCapabilities),
Mail(MailCapabilities),
Submission(SubmissionCapabilities),
WebSocket(WebSocketCapabilities),
SieveAccount(SieveAccountCapabilities),
SieveSession(SieveSessionCapabilities),
Blob(BlobCapabilities),
Contacts(ContactsCapabilities),
Principals(PrincipalCapabilities),
PrincipalsAvailability(PrincipalAvailabilityCapabilities),
Calendar(CalendarCapabilities),
FileNode(FileNodeCapabilities),
WebPush(WebPushCapabilities),
Inbuxa(InbuxaAccountCapabilities),
Empty(EmptyCapabilities),
}
/// inbuxa: `urn:inbuxa:jmap` on the signed-in principal's own account.
#[derive(Debug, Clone, serde::Serialize)]
pub struct InbuxaAccountCapabilities {
/// The logo that applies to the principal (MT-22): a URL or a data URL.
#[serde(rename(serialize = "logo"))]
pub logo: Option<String>,
/// Whether legacy mail protocols are `enabled` or `disabled` for the
/// principal: the stricter of the server's switch and its tenant's
/// (legacy-protocols spec, Interfaces; LP-19).
#[serde(rename(serialize = "legacyProtocols"))]
pub legacy_protocols: &'static str,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CoreCapabilities {
#[serde(rename(serialize = "maxSizeUpload"))]
pub max_size_upload: u64,
#[serde(rename(serialize = "maxConcurrentUpload"))]
pub max_concurrent_upload: u64,
#[serde(rename(serialize = "maxSizeRequest"))]
pub max_size_request: u64,
#[serde(rename(serialize = "maxConcurrentRequests"))]
pub max_concurrent_requests: u64,
#[serde(rename(serialize = "maxCallsInRequest"))]
pub max_calls_in_request: u64,
#[serde(rename(serialize = "maxObjectsInGet"))]
pub max_objects_in_get: u64,
#[serde(rename(serialize = "maxObjectsInSet"))]
pub max_objects_in_set: u64,
#[serde(rename(serialize = "collationAlgorithms"))]
pub collation_algorithms: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct WebSocketCapabilities {
#[serde(rename(serialize = "url"))]
pub url: String,
#[serde(rename(serialize = "supportsPush"))]
pub supports_push: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SieveSessionCapabilities {
#[serde(rename(serialize = "implementation"))]
pub implementation: &'static str,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SieveAccountCapabilities {
#[serde(rename(serialize = "maxSizeScriptName"))]
pub max_script_name: u64,
#[serde(rename(serialize = "maxSizeScript"))]
pub max_script_size: u64,
#[serde(rename(serialize = "maxNumberScripts"))]
pub max_scripts: u64,
#[serde(rename(serialize = "maxNumberRedirects"))]
pub max_redirects: u64,
#[serde(rename(serialize = "sieveExtensions"))]
pub extensions: Vec<String>,
#[serde(rename(serialize = "notificationMethods"))]
pub notification_methods: Option<Vec<String>>,
#[serde(rename(serialize = "externalLists"))]
pub ext_lists: Option<Vec<String>>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MailCapabilities {
#[serde(rename(serialize = "maxMailboxesPerEmail"))]
pub max_mailboxes_per_email: Option<u64>,
#[serde(rename(serialize = "maxMailboxDepth"))]
pub max_mailbox_depth: u64,
#[serde(rename(serialize = "maxSizeMailboxName"))]
pub max_size_mailbox_name: u64,
#[serde(rename(serialize = "maxSizeAttachmentsPerEmail"))]
pub max_size_attachments_per_email: u64,
#[serde(rename(serialize = "emailQuerySortOptions"))]
pub email_query_sort_options: Vec<EmailComparator>,
#[serde(rename(serialize = "mayCreateTopLevelMailbox"))]
pub may_create_top_level_mailbox: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SubmissionCapabilities {
#[serde(rename(serialize = "maxDelayedSend"))]
pub max_delayed_send: u64,
#[serde(rename(serialize = "submissionExtensions"))]
pub submission_extensions: VecMap<String, Vec<String>>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BlobCapabilities {
#[serde(rename(serialize = "maxSizeBlobSet"))]
pub max_size_blob_set: u64,
#[serde(rename(serialize = "maxDataSources"))]
pub max_data_sources: u64,
#[serde(rename(serialize = "supportedTypeNames"))]
pub supported_type_names: Vec<DataType>,
#[serde(rename(serialize = "supportedDigestAlgorithms"))]
pub supported_digest_algorithms: Vec<&'static str>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CalendarCapabilities {
#[serde(rename(serialize = "maxCalendarsPerEvent"))]
pub max_calendars_per_event: Option<u64>,
#[serde(rename(serialize = "minDateTime"))]
pub min_date_time: UTCDate,
#[serde(rename(serialize = "maxDateTime"))]
pub max_date_time: UTCDate,
#[serde(rename(serialize = "maxExpandedQueryDuration"))]
pub max_expanded_query_duration: String,
#[serde(rename(serialize = "maxParticipantsPerEvent"))]
pub max_participants_per_event: Option<u64>,
#[serde(rename(serialize = "mayCreateCalendar"))]
pub may_create_calendar: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ContactsCapabilities {
#[serde(rename(serialize = "maxAddressBooksPerCard"))]
pub max_address_books_per_card: Option<u64>,
#[serde(rename(serialize = "mayCreateAddressBook"))]
pub may_create_address_book: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PrincipalAvailabilityCapabilities {
#[serde(rename(serialize = "maxAvailabilityDuration"))]
pub max_availability_duration: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PrincipalCapabilities {
#[serde(rename(serialize = "currentUserPrincipalId"))]
pub current_user_principal_id: Option<Id>,
}
/*#[derive(Debug, Clone, serde::Serialize)]
pub struct PrincipalOwnerCapabilities {
#[serde(rename(serialize = "accountIdForPrincipal"))]
pub account_id_for_principal: Id,
#[serde(rename(serialize = "principalId"))]
pub principal_id: Id,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PrincipalCalendarCapabilities {
#[serde(rename(serialize = "accountIdForPrincipal"))]
pub account_id_for_principal: Option<Id>,
#[serde(rename(serialize = "mayGetAvailability"))]
pub may_get_availability: bool,
#[serde(rename(serialize = "mayShareWith"))]
pub may_share_with: bool,
#[serde(rename(serialize = "calendarAddress"))]
pub calendar_address: String,
}*/
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileNodeCapabilities {
#[serde(rename(serialize = "maxFileNodeDepth"))]
pub max_file_node_depth: Option<u64>,
#[serde(rename(serialize = "maxSizeFileNodeName"))]
pub max_size_file_node_name: u64,
#[serde(rename(serialize = "forbiddenNameChars"))]
pub forbidden_name_chars: Option<String>,
#[serde(rename(serialize = "forbiddenNodeNames"))]
pub forbidden_node_names: Option<Vec<String>>,
#[serde(rename(serialize = "fileNodeQuerySortOptions"))]
pub file_node_query_sort_options: Vec<FileNodeComparator>,
#[serde(rename(serialize = "mayCreateTopLevelFileNode"))]
pub may_create_top_level_file_node: bool,
#[serde(rename(serialize = "caseInsensitiveNames"))]
pub case_insensitive_names: bool,
#[serde(rename(serialize = "webTrashUrl"))]
pub web_trash_url: Option<String>,
#[serde(rename(serialize = "webUrlTemplate"))]
pub web_url_template: Option<String>,
#[serde(rename(serialize = "webWriteUrlTemplate"))]
pub web_write_url_template: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct WebPushCapabilities {
#[serde(rename(serialize = "applicationServerKey"))]
pub application_server_key: String,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct EmptyCapabilities {}
#[derive(Default, Clone)]
pub struct BaseCapabilities {
pub session: VecMap<Capability, Capabilities>,
pub account: AHashMap<Capability, Capabilities>,
}
impl Capability {
pub fn as_str(&self) -> &'static str {
match self {
Capability::Core => "urn:ietf:params:jmap:core",
Capability::Mail => "urn:ietf:params:jmap:mail",
Capability::Submission => "urn:ietf:params:jmap:submission",
Capability::VacationResponse => "urn:ietf:params:jmap:vacationresponse",
Capability::Contacts => "urn:ietf:params:jmap:contacts",
Capability::ContactsParse => "urn:ietf:params:jmap:contacts:parse",
Capability::Calendars => "urn:ietf:params:jmap:calendars",
Capability::CalendarsParse => "urn:ietf:params:jmap:calendars:parse",
Capability::WebSocket => "urn:ietf:params:jmap:websocket",
Capability::Sieve => "urn:ietf:params:jmap:sieve",
Capability::Blob => "urn:ietf:params:jmap:blob",
Capability::Quota => "urn:ietf:params:jmap:quota",
Capability::Principals => "urn:ietf:params:jmap:principals",
Capability::PrincipalsOwner => "urn:ietf:params:jmap:principals:owner",
Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability",
Capability::FileNode => "urn:ietf:params:jmap:filenode",
Capability::MailShare => "urn:ietf:params:jmap:mail:share",
Capability::Stalwart => "urn:inbuxa:jmap:registry",
Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid",
Capability::EmailPush => "urn:ietf:params:jmap:emailpush",
Capability::Inbuxa => "urn:inbuxa:jmap",
Capability::FastmailMaskedEmail => "https://www.fastmail.com/dev/maskedemail",
}
}
pub fn all_capabilities() -> &'static [Capability] {
&[
Capability::Core,
Capability::Mail,
Capability::Submission,
Capability::VacationResponse,
Capability::Contacts,
Capability::ContactsParse,
Capability::Calendars,
Capability::CalendarsParse,
Capability::WebSocket,
Capability::Sieve,
Capability::Blob,
Capability::Quota,
Capability::Principals,
Capability::PrincipalsAvailability,
Capability::FileNode,
Capability::MailShare,
Capability::Stalwart,
Capability::WebPushVapid,
Capability::EmailPush,
]
}
}
impl Session {
pub fn new(base_url: impl Into<String>, base_capabilities: &BaseCapabilities) -> Session {
let base_url = base_url.into();
let mut capabilities = base_capabilities.session.clone();
capabilities.append(
Capability::WebSocket,
Capabilities::WebSocket(WebSocketCapabilities::new(&base_url)),
);
Session {
capabilities,
accounts: VecMap::new(),
primary_accounts: VecMap::new(),
username: "".to_string(),
api_url: format!("{}/jmap/", base_url),
download_url: format!(
"{}/jmap/download/{{accountId}}/{{blobId}}/{{name}}?accept={{type}}",
base_url
),
upload_url: format!("{}/jmap/upload/{{accountId}}/", base_url),
event_source_url: format!(
"{}/jmap/eventsource/?types={{types}}&closeafter={{closeafter}}&ping={{ping}}",
base_url
),
base_url,
state: 0,
}
}
pub fn set_state(&mut self, state: u32) {
self.state = state;
}
pub fn api_url(&self) -> &str {
&self.api_url
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
impl Default for SieveSessionCapabilities {
fn default() -> Self {
Self {
implementation: concat!(types::brand!(), " ", types::brand_version_full!()),
}
}
}
impl WebSocketCapabilities {
pub fn new(base_url: &str) -> Self {
WebSocketCapabilities {
url: format!(
"ws{}/jmap/ws",
base_url.strip_prefix("http").unwrap_or_default()
),
supports_push: true,
}
}
}
impl Capabilities {
pub fn to_account_capabilities(
&self,
current_user_principal_id: Option<Id>,
may_create: bool,
) -> Capabilities {
match self {
Capabilities::Contacts(contacts_capabilities) => {
Capabilities::Contacts(ContactsCapabilities {
may_create_address_book: may_create,
..contacts_capabilities.clone()
})
}
Capabilities::Principals(_) => Capabilities::Principals(PrincipalCapabilities {
current_user_principal_id,
}),
Capabilities::Calendar(calendar_capabilities) => {
Capabilities::Calendar(CalendarCapabilities {
may_create_calendar: may_create,
..calendar_capabilities.clone()
})
}
Capabilities::FileNode(file_node_capabilities) => {
Capabilities::FileNode(FileNodeCapabilities {
may_create_top_level_file_node: may_create,
..file_node_capabilities.clone()
})
}
_ => self.clone(),
}
}
}
impl Capability {
pub fn parse(s: &str) -> Option<Self> {
hashify::tiny_map!(s.as_bytes(),
"urn:ietf:params:jmap:core" => Capability::Core,
"urn:ietf:params:jmap:mail" => Capability::Mail,
"urn:ietf:params:jmap:submission" => Capability::Submission,
"urn:ietf:params:jmap:vacationresponse" => Capability::VacationResponse,
"urn:ietf:params:jmap:contacts" => Capability::Contacts,
"urn:ietf:params:jmap:calendars" => Capability::Calendars,
"urn:ietf:params:jmap:websocket" => Capability::WebSocket,
"urn:ietf:params:jmap:sieve" => Capability::Sieve,
"urn:ietf:params:jmap:blob" => Capability::Blob,
"urn:ietf:params:jmap:quota" => Capability::Quota,
"urn:ietf:params:jmap:principals" => Capability::Principals,
"urn:ietf:params:jmap:principals:owner" => Capability::PrincipalsOwner,
"urn:ietf:params:jmap:filenode" => Capability::FileNode,
"urn:ietf:params:jmap:principals:availability" => Capability::PrincipalsAvailability,
"urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse,
"urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse,
"urn:ietf:params:jmap:mail:share" => Capability::MailShare,
"urn:inbuxa:jmap:registry" => Capability::Stalwart,
"urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid,
"urn:ietf:params:jmap:emailpush" => Capability::EmailPush,
"urn:inbuxa:jmap" => Capability::Inbuxa,
"https://www.fastmail.com/dev/maskedemail" => Capability::FastmailMaskedEmail,
)
}
}
impl<'de> Deserialize<'de> for CapabilityIds {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct CapabilityIdsVisitor;
impl<'de> serde::de::Visitor<'de> for CapabilityIdsVisitor {
type Value = CapabilityIds;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("an array of capability strings")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut capability_flags = 0u32;
while let Some(capability_str) = seq.next_element::<std::borrow::Cow<str>>()? {
let capability =
Capability::parse(capability_str.as_ref()).ok_or_else(|| {
serde::de::Error::custom(format!(
"Unknown capability: {capability_str:?}"
))
})?;
capability_flags |= capability as u32;
}
Ok(CapabilityIds(capability_flags))
}
}
deserializer.deserialize_seq(CapabilityIdsVisitor)
}
}