diff --git a/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs b/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs new file mode 100644 index 0000000..66b251f --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs @@ -0,0 +1,195 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:ProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: the +//! server-wide legacy mail protocols switch (legacy-protocols spec). A +//! singleton, id `singleton`. +//! +//! Three of its properties are the server's to say, not the client's: +//! `savedListeners` (LP-1), `lockedProtocols` (LP-21) and `wouldClose` +//! (LP-16). A client that sets them is answered with `invalidProperties`. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct ProtocolPolicy; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProtocolPolicyProperty { + Id, + /// The switch: `enabled` or `disabled`. + LegacyProtocols, + /// Whether submission closes with it. Forced false while SMTP is locked. + CloseSubmission, + /// Server-set: the listeners taken away, for LP-5. + SavedListeners, + ChangedAt, + ChangedBy, + /// Server-set: the protocols that cannot be closed, so the selector can + /// render them locked rather than carry its own list (LP-21). + LockedProtocols, + /// Server-set: exactly which listeners turning the switch would close, + /// by name and port, for the confirmation (LP-16). + WouldClose, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProtocolPolicyValue { + Id(Id), +} + +impl Property for ProtocolPolicyProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + ProtocolPolicyProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ProtocolPolicyProperty::Id => "id", + ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", + ProtocolPolicyProperty::CloseSubmission => "closeSubmission", + ProtocolPolicyProperty::SavedListeners => "savedListeners", + ProtocolPolicyProperty::ChangedAt => "changedAt", + ProtocolPolicyProperty::ChangedBy => "changedBy", + ProtocolPolicyProperty::LockedProtocols => "lockedProtocols", + ProtocolPolicyProperty::WouldClose => "wouldClose", + } + .into() + } +} + +impl ProtocolPolicyProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => ProtocolPolicyProperty::Id, + b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols, + b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission, + b"savedListeners" => ProtocolPolicyProperty::SavedListeners, + b"changedAt" => ProtocolPolicyProperty::ChangedAt, + b"changedBy" => ProtocolPolicyProperty::ChangedBy, + b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols, + b"wouldClose" => ProtocolPolicyProperty::WouldClose, + ) + } +} + +impl ProtocolPolicyProperty { + /// Whether this property is the server's to say. A client that sets one + /// is answered with `invalidProperties`. + pub fn is_server_set(&self) -> bool { + matches!( + self, + ProtocolPolicyProperty::SavedListeners + | ProtocolPolicyProperty::ChangedAt + | ProtocolPolicyProperty::ChangedBy + | ProtocolPolicyProperty::LockedProtocols + | ProtocolPolicyProperty::WouldClose + ) + } +} + +impl FromStr for ProtocolPolicyProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + ProtocolPolicyProperty::parse(s).ok_or(()) + } +} + +impl Element for ProtocolPolicyValue { + type Property = ProtocolPolicyProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(ProtocolPolicyProperty::Id) => Id::from_str(value).ok().map(ProtocolPolicyValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ProtocolPolicyValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for ProtocolPolicy { + type Property = ProtocolPolicyProperty; + + type Element = ProtocolPolicyValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = ProtocolPolicyProperty::Id; +} + +impl From for ProtocolPolicyValue { + fn from(id: Id) -> Self { + ProtocolPolicyValue::Id(id) + } +} + +impl JmapObjectId for ProtocolPolicyValue { + fn as_id(&self) -> Option { + match self { + ProtocolPolicyValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + ProtocolPolicyValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = ProtocolPolicyValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for ProtocolPolicyProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 8aefd07..b61ac6f 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -22,6 +22,7 @@ pub mod email; pub mod email_submission; pub mod fastmail_masked_email; // inbuxa: masked email pub mod inbuxa_ai_limits; // inbuxa: AI spam classification +pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod file_node; pub mod identity; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 3c6bf47..860525b 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -61,6 +61,9 @@ impl Response<'_> { GetResponseMethod::AiLimits(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::ProtocolPolicy(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index ff8286f..78d6ce3 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -46,6 +46,7 @@ impl Response<'_> { GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?, GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, + GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?, @@ -89,6 +90,9 @@ impl Response<'_> { SetRequestMethod::AiLimits(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::ProtocolPolicy(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 73da873..42c0a10 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -49,6 +49,7 @@ pub enum MethodObject { DeletedAccount, // inbuxa: AI call limits AiLimits, + ProtocolPolicy, } impl MethodObject { @@ -75,6 +76,7 @@ impl MethodObject { MethodObject::MaskedEmail => Capability::FastmailMaskedEmail, MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa, + MethodObject::ProtocolPolicy => Capability::Inbuxa, } } } @@ -252,6 +254,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set", (MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get", (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", + (MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get", + (MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set", (method, MethodObject::Registry(obj)) => { return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); } @@ -377,6 +381,8 @@ impl MethodName { "inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set), "inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get), "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), + "inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get), + "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; @@ -430,6 +436,7 @@ impl Display for MethodObject { MethodObject::MaskedEmail => "MaskedEmail", MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::AiLimits => "inbuxa:AiLimits", + MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 64432d0..cdca15d 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -116,6 +116,7 @@ pub enum GetRequestMethod { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + ProtocolPolicy(Box>), } #[derive(Debug)] @@ -139,6 +140,7 @@ pub enum SetRequestMethod<'x> { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + ProtocolPolicy(Box>), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 88b4f00..381eace 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -169,6 +169,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), @@ -334,6 +341,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::ProtocolPolicy) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ProtocolPolicy(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 8be3493..9ab4de8 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -103,6 +103,7 @@ pub enum GetResponseMethod { MaskedEmail(GetResponse), DeletedAccount(GetResponse), AiLimits(GetResponse), + ProtocolPolicy(GetResponse), } #[derive(Debug, serde::Serialize)] @@ -127,6 +128,7 @@ pub enum SetResponseMethod { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + ProtocolPolicy(Box>), } #[derive(Debug, serde::Serialize)] @@ -287,6 +289,22 @@ impl<'x> From From> + for ResponseMethod<'x> +{ + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ProtocolPolicy(value)) + } +} + +impl<'x> From> + for ResponseMethod<'x> +{ + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ProtocolPolicy(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::AiLimits(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index d4d7605..baa73fa 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -77,6 +77,9 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet, // inbuxa: AI call limits, with the classifier's permissions GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet, + // inbuxa: legacy protocols off. It takes listeners away and + // puts them back, so it takes the listener's permissions + GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -173,6 +176,14 @@ impl JmapAuthorization for AccessToken { Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate, ), + // inbuxa: legacy protocols off, with the listener's + SetRequestMethod::ProtocolPolicy(s) => validate_set( + s, + self, + Permission::SysNetworkListenerUpdate, + Permission::SysNetworkListenerUpdate, + Permission::SysNetworkListenerUpdate, + ), SetRequestMethod::VacationResponse(s) => validate_set( s, self, @@ -282,7 +293,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::SieveScript | MethodObject::MaskedEmail | MethodObject::DeletedAccount - | MethodObject::AiLimits => Permission::JmapEmailChanges, + | MethodObject::AiLimits + | MethodObject::ProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 7baaadb..b521727 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -221,6 +221,9 @@ impl RequestHandler for Server { SetResponseMethod::AiLimits(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::ProtocolPolicy(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -376,6 +379,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:ProtocolPolicy/get (legacy protocols off) + GetRequestMethod::ProtocolPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::protocol_policy::get(self, access_token, *req) + .await? + .into() + } GetRequestMethod::Principal(req) => { self.principal_get(*req, access_token).await?.into() } @@ -617,6 +627,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:ProtocolPolicy/set (legacy protocols off) + SetRequestMethod::ProtocolPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::protocol_policy::set(self, access_token, *req) + .await? + .into() + } SetRequestMethod::AddressBook(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index c4bf279..9f091ae 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -418,6 +418,7 @@ impl IntermediateChangesResponse { | MethodObject::MaskedEmail | MethodObject::DeletedAccount | MethodObject::AiLimits + | MethodObject::ProtocolPolicy | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 77dc4f2..a04c2c5 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -9,6 +9,7 @@ pub mod access; pub mod ai_limits; +pub mod protocol_policy; pub mod deleted_account; pub mod fastmail; pub mod masked_email; diff --git a/crates/jmap/src/inbuxa/protocol_policy.rs b/crates/jmap/src/inbuxa/protocol_policy.rs new file mode 100644 index 0000000..e382dbd --- /dev/null +++ b/crates/jmap/src/inbuxa/protocol_policy.rs @@ -0,0 +1,310 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:ProtocolPolicy/get` and `/set`: the server-wide legacy mail +//! protocols switch (legacy-protocols spec). Server-level: a principal in a +//! tenant can neither read nor change it, and turns its own switch instead +//! (LP-9). +//! +//! `/set` does not write the policy itself. It hands what was asked to +//! [`Server::set_protocol_policy`], which applies the locks (LP-21), removes +//! or restores the listener objects (LP-1, LP-5) and closes or opens their +//! sockets (LP-2). What comes back is what actually happened. + +use common::{Server, auth::AccessToken, network::legacy::PolicyChange}; +use inbuxa_features::security::{ + listeners, + protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener}, +}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_protocol_policy::{ + ProtocolPolicy, ProtocolPolicyProperty as P, ProtocolPolicyValue, + }, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use types::id::Id; + +type PValue = Value<'static, P, ProtocolPolicyValue>; + +const ALL: &[P] = &[ + P::Id, + P::LegacyProtocols, + P::CloseSubmission, + P::SavedListeners, + P::ChangedAt, + P::ChangedBy, + P::LockedProtocols, + P::WouldClose, +]; + +fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("The server-wide protocol policy is server-level.")) + } else { + Ok(()) + } +} + +/// A saved or would-be-closed listener, as the confirmation shows it (LP-16). +fn listener_value(listener: &SavedListener) -> PValue { + let mut out = Map::with_capacity(3); + out.insert_unchecked( + Key::Property(P::Id), + Value::Str(listener.id.clone().into()), + ); + out.insert_unchecked( + Key::Property(P::LegacyProtocols), + Value::Str(listener.protocol.clone().into()), + ); + out.insert_unchecked( + Key::Property(P::WouldClose), + Value::Array( + listener + .ports + .iter() + .map(|port| Value::Number((*port as u64).into())) + .collect(), + ), + ); + Value::Object(out) +} + +fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) -> PValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())), + P::LegacyProtocols => Value::Str( + match policy.legacy_protocols { + LegacyProtocols::Enabled => "enabled", + LegacyProtocols::Disabled => "disabled", + } + .into(), + ), + P::CloseSubmission => Value::Bool(policy.close_submission), + P::SavedListeners => Value::Array( + policy + .saved_listeners + .iter() + .map(listener_value) + .collect(), + ), + P::ChangedAt => policy + .changed_at + .map(|at| Value::Number(at.into())) + .unwrap_or(Value::Null), + P::ChangedBy => policy + .changed_by + .as_ref() + .map(|by| Value::Str(by.clone().into())) + .unwrap_or(Value::Null), + // The selector renders these locked rather than carrying its own + // list, so unlocking later needs no admin release (LP-21). + P::LockedProtocols => Value::Array( + LOCKED_PROTOCOLS + .iter() + .map(|protocol| Value::Str((*protocol).into())) + .collect(), + ), + // Exactly what turning the switch on would close, by name and + // port, so the confirmation can say so before anything happens + // (LP-16). + P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// The listeners turning the switch on would close, whatever it is now. +async fn would_close(server: &Server, policy: &Policy) -> trc::Result> { + let mut hypothetical = policy.clone(); + hypothetical.legacy_protocols = LegacyProtocols::Disabled; + hypothetical.apply_locks(); + listeners::would_close(server.registry(), &hypothetical).await +} + +/// `inbuxa:ProtocolPolicy/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + assert_server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(1)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + + let policy = server.protocol_policy().await?; + // Only worth asking the registry when the answer is wanted. + let would_close = if properties.contains(&P::WouldClose) { + would_close(server, &policy).await? + } else { + Vec::new() + }; + + match ids { + None => response + .list + .push(to_value(&policy, &would_close, &properties)), + Some(ids) => { + for id in ids { + if id.is_singleton() { + response + .list + .push(to_value(&policy, &would_close, &properties)); + } else { + response.push_not_found(id); + } + } + } + } + Ok(response) +} + +fn apply( + policy: &mut Policy, + property: &P, + value: &Value<'_, P, ProtocolPolicyValue>, +) -> Result<(), String> { + match property { + P::LegacyProtocols => { + policy.legacy_protocols = match value.as_str().as_deref() { + Some("enabled") => LegacyProtocols::Enabled, + Some("disabled") => LegacyProtocols::Disabled, + _ => return Err(r#"must be "enabled" or "disabled""#.to_string()), + } + } + P::CloseSubmission => { + policy.close_submission = value + .as_bool() + .ok_or_else(|| "must be true or false".to_string())? + } + P::Id => return Err("is immutable".to_string()), + // savedListeners, changedAt, changedBy, lockedProtocols and wouldClose + // are the server's to say (LP-1, LP-16, LP-21). + other if other.is_server_set() => return Err("is set by the server".to_string()), + _ => return Err("is immutable".to_string()), + } + Ok(()) +} + +/// Puts a property back to its default (a `null` in `/set`). +fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> { + match property { + P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols, + P::CloseSubmission => policy.close_submission = defaults.close_submission, + P::Id => return Err("is immutable".to_string()), + other if other.is_server_set() => return Err("is set by the server".to_string()), + _ => return Err("is immutable".to_string()), + } + Ok(()) +} + +/// What the server made of the update, when that differs from what was asked. +/// +/// A locked property is overruled rather than refused (LP-21), so the client +/// is told by being handed the value that was actually stored. `None` when +/// nothing was overruled, which JMAP reads as "exactly as you asked". +fn updated_value(change: &PolicyChange) -> Option { + if change.overruled.is_empty() { + return None; + } + let mut out = Map::with_capacity(change.overruled.len()); + for property in &change.overruled { + if *property == "closeSubmission" { + out.insert_unchecked(Key::Property(P::CloseSubmission), Value::Bool(false)); + } + } + Some(Value::Object(out)) +} + +/// `inbuxa:ProtocolPolicy/set`: turns the switch. Unset (`null`) restores a +/// property's default. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, ProtocolPolicy>, +) -> trc::Result> { + assert_server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response.not_created.append(client_id, SetError::singleton()); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::singleton()); + } + + for (id, value) in request.unwrap_update().into_valid() { + if !id.is_singleton() { + response.not_updated.append(id, SetError::not_found()); + continue; + } + + let mut policy = server.protocol_policy().await?; + let defaults = Policy::default(); + let mut error = None; + + for (key, value) in value.into_expanded_object() { + let Key::Property(property) = &key else { + error = Some(SetError::invalid_properties().with_property(key.into_owned())); + break; + }; + let result = if matches!(value, Value::Null) { + reset(&mut policy, property, &defaults) + } else { + apply(&mut policy, property, &value) + }; + if let Err(why) = result { + error = Some( + SetError::invalid_properties() + .with_property(property.clone()) + .with_description(why), + ); + break; + } + } + + if error.is_none() + && let Err((property, why)) = policy.check() + { + error = Some( + SetError::invalid_properties() + .with_property(property.parse::

().unwrap_or(P::Id)) + .with_description(format!("{property} {why}.")), + ); + } + + match error { + Some(error) => response.not_updated.append(id, error), + None => { + let change = server + .set_protocol_policy(policy, Some(Id::from(access_token.account_id()).to_string())) + .await?; + + // An overruled property is reported, not refused: the value + // is specified and the lock is temporary (LP-21). The update + // succeeded, so the client is told by being handed what was + // actually stored. + response.updated.append(id, updated_value(&change)); + } + } + } + Ok(response) +}