A 3-node rehearsal found that saving an MtaDeliverySchedule left it
unknown to the queue ("Queue strategy not found") until someone ran
x:Action ReloadSettings; only Directory and Authentication writes
reloaded (DIR-17). The admin UI has to remember a separate reload after
every save, and a script or API client that doesn't gets a server
running stale settings.
x:<Object>/set now reloads the running settings when it created,
updated or destroyed an object they are built from, and broadcasts the
same RegistryChange::Reload over the coordinator as ReloadSettings, so
every node applies it:
- Settings objects (MTA, spam filter, listeners, tracers, Sieve system
scripts, cluster roles, directories, ...: the object types the core,
telemetry, listener and directory builders read) get a full reload.
- Certificates, lookup stores and blocked/allowed IPs get their own
targeted reloads.
- Accounts, domains, roles and other data read as needed, stores (they
take a restart) and applications (their own reload action) get none.
Full reloads are coalesced: a write waits for a reload that started
after it was stored and joins one if it can, so a burst of writes, or
a request with many objects, costs one or two reloads, not one each.
The write itself is never undone. When the reload is refused (build
errors in objects that were working, the rule from the previous
commit), the set response says so in a new x:settingsReload field,
{"applied": false, "description": "Saved, but the running settings
were not reloaded. <object>: <error>"}; {"applied": true} otherwise.
The field is absent when the write needs no reload. The description
helper is shared with ReloadSettings' refusal.
Each reload sends the queue a ReloadSettings event, so the SMTP test
harness's read_event, try_read_event and assert_no_events now pass over
those; expect_reload_settings still waits for one.
system::auto_reload::settings_reload_tests (new): an MtaVirtualQueue
and an MtaDeliverySchedule created over JMAP are in the running
settings with no ReloadSettings, and gone once destroyed; eight
concurrent creates all land; a write whose reload fails is stored and
reported applied: false with the error; a domain write carries no
x:settingsReload. On main the new schedule is missing. The cluster
broadcast test (three nodes, PostgreSQL + NATS) now checks that every
node has a schedule created on node 0 without a reload.
320 lines
10 KiB
Rust
320 lines
10 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 super::ahash_is_empty;
|
|
use crate::{
|
|
error::set::{InvalidProperty, SetError},
|
|
object::{JmapObject, JmapObjectId},
|
|
request::{
|
|
MaybeInvalid,
|
|
deserialize::{DeserializeArguments, deserialize_request},
|
|
reference::{MaybeResultReference, ResultReference},
|
|
},
|
|
response::Response,
|
|
types::state::State,
|
|
};
|
|
use ahash::AHashMap;
|
|
use jmap_tools::{Key, Map, Value};
|
|
use serde::{Deserialize, Deserializer};
|
|
use types::id::Id;
|
|
use utils::map::vec_map::VecMap;
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[allow(clippy::type_complexity)]
|
|
pub struct SetRequest<'x, T: JmapObject> {
|
|
pub account_id: Id,
|
|
pub if_in_state: Option<State>,
|
|
pub create: Option<VecMap<String, Value<'x, T::Property, T::Element>>>,
|
|
pub update: Option<VecMap<MaybeInvalid<Id>, Value<'x, T::Property, T::Element>>>,
|
|
pub destroy: Option<MaybeResultReference<Vec<MaybeInvalid<Id>>>>,
|
|
pub arguments: T::SetArguments<'x>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, serde::Serialize)]
|
|
#[allow(clippy::type_complexity)]
|
|
pub struct SetResponse<T: JmapObject> {
|
|
#[serde(rename = "accountId")]
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub account_id: Option<Id>,
|
|
|
|
#[serde(rename = "oldState")]
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub old_state: Option<State>,
|
|
|
|
#[serde(rename = "newState")]
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub new_state: Option<State>,
|
|
|
|
#[serde(rename = "created")]
|
|
#[serde(skip_serializing_if = "ahash_is_empty")]
|
|
pub created: AHashMap<String, Value<'static, T::Property, T::Element>>,
|
|
|
|
#[serde(rename = "updated")]
|
|
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
|
pub updated: VecMap<Id, Option<Value<'static, T::Property, T::Element>>>,
|
|
|
|
#[serde(rename = "destroyed")]
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub destroyed: Vec<Id>,
|
|
|
|
#[serde(rename = "notCreated")]
|
|
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
|
pub not_created: VecMap<String, SetError<T::Property>>,
|
|
|
|
#[serde(rename = "notUpdated")]
|
|
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
|
pub not_updated: VecMap<MaybeInvalid<Id>, SetError<T::Property>>,
|
|
|
|
#[serde(rename = "notDestroyed")]
|
|
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
|
pub not_destroyed: VecMap<MaybeInvalid<Id>, SetError<T::Property>>,
|
|
|
|
// inbuxa: on a registry write that changes the running settings, whether
|
|
// the server applied it
|
|
#[serde(rename = "x:settingsReload")]
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub settings_reload: Option<SettingsReload>,
|
|
}
|
|
|
|
/// inbuxa: the settings reload that followed a registry write.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct SettingsReload {
|
|
/// The running settings (here and, through the cluster, on every node)
|
|
/// include the write.
|
|
pub applied: bool,
|
|
/// Why they don't, when they don't.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> {
|
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
|
where
|
|
A: serde::de::MapAccess<'de>,
|
|
{
|
|
hashify::fnc_map!(key.as_bytes(),
|
|
b"accountId" => {
|
|
self.account_id = crate::request::deserialize_account_id(map)?;
|
|
},
|
|
b"ifInState" => {
|
|
self.if_in_state = map.next_value()?;
|
|
},
|
|
b"create" => {
|
|
self.create = map.next_value()?;
|
|
},
|
|
b"update" => {
|
|
self.update = map.next_value()?;
|
|
},
|
|
b"destroy" => {
|
|
self.destroy = map.next_value::<Option<Vec<MaybeInvalid<Id>>>>()?.map(MaybeResultReference::Value);
|
|
},
|
|
b"#destroy" => {
|
|
self.destroy = Some(MaybeResultReference::Reference(map.next_value::<ResultReference>()?));
|
|
}
|
|
_ => {
|
|
self.arguments.deserialize_argument(key, map)?;
|
|
}
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<'de, T: JmapObject> Deserialize<'de> for SetRequest<'de, T> {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
deserialize_request(deserializer)
|
|
}
|
|
}
|
|
|
|
impl<'x, T: JmapObject> Default for SetRequest<'x, T> {
|
|
fn default() -> Self {
|
|
Self {
|
|
account_id: Id::default(),
|
|
if_in_state: None,
|
|
create: None,
|
|
update: None,
|
|
destroy: None,
|
|
arguments: T::SetArguments::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'x, T: JmapObject> SetRequest<'x, T> {
|
|
pub fn validate(&self, max_objects_in_set: usize) -> trc::Result<()> {
|
|
if self.create.as_ref().map_or(0, |objs| objs.len())
|
|
+ self.update.as_ref().map_or(0, |objs| objs.len())
|
|
+ self.destroy.as_ref().map_or(0, |objs| {
|
|
if let MaybeResultReference::Value(ids) = objs {
|
|
ids.len()
|
|
} else {
|
|
0
|
|
}
|
|
})
|
|
> max_objects_in_set
|
|
{
|
|
Err(trc::JmapEvent::RequestTooLarge.into_err())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn has_updates(&self) -> bool {
|
|
self.update.as_ref().is_some_and(|objs| !objs.is_empty())
|
|
}
|
|
|
|
pub fn has_creates(&self) -> bool {
|
|
self.create.as_ref().is_some_and(|objs| !objs.is_empty())
|
|
}
|
|
|
|
pub fn unwrap_create(&mut self) -> VecMap<String, Value<'x, T::Property, T::Element>> {
|
|
self.create.take().unwrap_or_default()
|
|
}
|
|
|
|
pub fn unwrap_update(
|
|
&mut self,
|
|
) -> VecMap<MaybeInvalid<Id>, Value<'x, T::Property, T::Element>> {
|
|
self.update.take().unwrap_or_default()
|
|
}
|
|
|
|
pub fn unwrap_destroy(&mut self) -> Vec<MaybeInvalid<Id>> {
|
|
self.destroy
|
|
.take()
|
|
.map(|ids| ids.unwrap())
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl<T: JmapObject> SetResponse<T> {
|
|
pub fn from_request(request: &SetRequest<T>, max_objects: usize) -> trc::Result<Self> {
|
|
let n_create = request.create.as_ref().map_or(0, |objs| objs.len());
|
|
let n_update = request.update.as_ref().map_or(0, |objs| objs.len());
|
|
let n_destroy = request.destroy.as_ref().map_or(0, |objs| {
|
|
if let MaybeResultReference::Value(ids) = objs {
|
|
ids.len()
|
|
} else {
|
|
0
|
|
}
|
|
});
|
|
if n_create + n_update + n_destroy <= max_objects {
|
|
Ok(SetResponse {
|
|
account_id: if request.account_id.is_valid() {
|
|
request.account_id.into()
|
|
} else {
|
|
None
|
|
},
|
|
new_state: None,
|
|
old_state: None,
|
|
created: AHashMap::with_capacity(n_create),
|
|
updated: VecMap::with_capacity(n_update),
|
|
destroyed: Vec::with_capacity(n_destroy),
|
|
not_created: VecMap::new(),
|
|
not_updated: VecMap::new(),
|
|
not_destroyed: VecMap::new(),
|
|
settings_reload: None,
|
|
})
|
|
} else {
|
|
Err(trc::JmapEvent::RequestTooLarge.into_err())
|
|
}
|
|
}
|
|
|
|
pub fn with_state(mut self, state: State) -> Self {
|
|
self.old_state = Some(state.clone());
|
|
self.new_state = Some(state);
|
|
self
|
|
}
|
|
|
|
pub fn collect_will_destroy(&mut self, ids: Vec<MaybeInvalid<Id>>) -> Vec<Id> {
|
|
let mut will_destroy = Vec::with_capacity(ids.len());
|
|
for id in ids {
|
|
match id {
|
|
MaybeInvalid::Value(id) => will_destroy.push(id),
|
|
invalid => self.not_destroyed.append(invalid, SetError::not_found()),
|
|
}
|
|
}
|
|
will_destroy
|
|
}
|
|
|
|
pub fn created(&mut self, id: String, document_id: impl Into<T::Id>) {
|
|
self.created.insert(
|
|
id,
|
|
Value::Object(Map::from(vec![(
|
|
Key::Property(T::ID_PROPERTY),
|
|
Value::Element(document_id.into().into()),
|
|
)])),
|
|
);
|
|
}
|
|
|
|
pub fn invalid_property_create(
|
|
&mut self,
|
|
id: String,
|
|
property: impl Into<InvalidProperty<T::Property>>,
|
|
) {
|
|
self.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(property)
|
|
.with_description("Invalid property or value.".to_string()),
|
|
);
|
|
}
|
|
|
|
pub fn invalid_property_update(
|
|
&mut self,
|
|
id: Id,
|
|
property: impl Into<InvalidProperty<T::Property>>,
|
|
) {
|
|
self.not_updated.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(property)
|
|
.with_description("Invalid property or value.".to_string()),
|
|
);
|
|
}
|
|
|
|
pub fn update_created_ids(&self, response: &mut Response) {
|
|
for (user_id, obj) in &self.created {
|
|
if let Value::Object(obj) = obj
|
|
&& let Some(Value::Element(id)) = obj.get(&Key::Property(T::ID_PROPERTY))
|
|
&& let Some(id) = id.as_any_id()
|
|
{
|
|
response.created_ids.insert(user_id.clone(), id);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn get_object_by_id(
|
|
&mut self,
|
|
id: Id,
|
|
) -> Option<&mut Value<'static, T::Property, T::Element>> {
|
|
if let Some(obj) = self.updated.get_mut(&id) {
|
|
if let Some(obj) = obj {
|
|
return Some(obj);
|
|
} else {
|
|
*obj = Some(Value::Object(Map::with_capacity(1)));
|
|
return obj.as_mut().unwrap().into();
|
|
}
|
|
}
|
|
|
|
(&mut self.created)
|
|
.into_iter()
|
|
.map(|(_, obj)| obj)
|
|
.find(|obj| {
|
|
obj.as_object_and_get(&Key::Property(T::ID_PROPERTY))
|
|
.and_then(|v| v.as_element())
|
|
.and_then(|v| v.as_id())
|
|
.is_some_and(|oid| oid == id)
|
|
})
|
|
}
|
|
|
|
pub fn has_changes(&self) -> bool {
|
|
!self.created.is_empty() || !self.updated.is_empty() || !self.destroyed.is_empty()
|
|
}
|
|
}
|