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")] {