From 866d7d3ed54b89c801f605e9920231713613400a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 26 Sep 2026 01:09:46 -0700 Subject: [PATCH] Explain a setting that was never saved, from its defaults A singleton such as x:SpamSettings has no stored object until someone saves it; /get shows its defaults instead. Explain looked only for the stored object, so every setting still at its defaults answered "No such x:SpamSettings." It now falls back to the defaults the same way. --- crates/jmap/src/inbuxa/explanation.rs | 11 ++++++++--- tests/src/system/ai_explain.rs | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/jmap/src/inbuxa/explanation.rs b/crates/jmap/src/inbuxa/explanation.rs index 25d44b2..13eeae0 100644 --- a/crates/jmap/src/inbuxa/explanation.rs +++ b/crates/jmap/src/inbuxa/explanation.rs @@ -36,7 +36,7 @@ use registry::{ jmap::IntoValue, schema::{ enums::SpamClassifyResult, - prelude::ObjectType, + prelude::{OBJ_SINGLETON, Object, ObjectType}, structs::{QueuedMessage, QueuedRecipient, RecipientStatus}, }, types::{EnumImpl, id::ObjectId}, @@ -431,8 +431,13 @@ a negative one toward legitimate mail. The result follows the total against the let Ok(id) = Id::from_str(id) else { return Ok(Err(not_found())); }; - let Some(stored) = server.registry().get(ObjectId::new(object_type, id)).await? else { - return Ok(Err(not_found())); + // A singleton never saved holds its defaults, as its /get shows it + let stored = match server.registry().get(ObjectId::new(object_type, id)).await? { + Some(stored) => stored, + None if id.is_singleton() && object_type.flags() & OBJ_SINGLETON != 0 => { + Object::from(object_type) + } + None => return Ok(Err(not_found())), }; let stored = serde_json::to_value(stored.into_value()).unwrap_or_default(); let current = stored.get(property.as_str()).cloned().unwrap_or(serde_json::Value::Null); diff --git a/tests/src/system/ai_explain.rs b/tests/src/system/ai_explain.rs index a55976d..1a945cf 100644 --- a/tests/src/system/ai_explain.rs +++ b/tests/src/system/ai_explain.rs @@ -25,7 +25,7 @@ use registry::{ Role, }, }, - types::EnumImpl, + types::{EnumImpl, id::ObjectId}, }; use serde_json::{Value, json}; use std::time::Duration; @@ -95,6 +95,29 @@ pub async fn test(test: &mut TestServer) { assert!(user.contains("Current value: true"), "{user}"); assert!(user.contains("-----BEGIN DETAILS "), "{user}"); + // A singleton never saved is explained with its defaults, as /get shows it + let spam_settings = ObjectId::new(ObjectType::SpamSettings, Id::singleton()); + assert!( + test.server + .registry() + .get(spam_settings) + .await + .unwrap() + .is_none(), + "x:SpamSettings is stored; pick a singleton the suite never saves" + ); + stub.set(Mode::Answer("Mail scoring this much is spam.".into())); + let (created, failed) = admin + .explain(json!({"@type": "Setting", "object": "x:SpamSettings", + "id": "singleton", "property": "scoreSpam"})) + .await; + created.unwrap_or_else(|| panic!("unsaved singleton: {failed}")); + let (_, user) = messages(&stub.last().1); + assert!( + user.contains("Current value: 5"), + "unsaved singleton: {user}" + ); + // Acceptance test 7: a secret setting is refused, not masked (EX-9) let before = stub.count(); for (object, property) in [("x:AiModel", "httpAuth"), ("x:AcmeProvider", "accountKey")] {