From 823d42d528fc0d9230a56cf617c041eff9862ea3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 19:30:28 -0700 Subject: [PATCH] Mail rules: group and tenant ids in their JMAP form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit senderGroup, senderTenant and recipientGroup conditions now read and write group and tenant ids as JMAP ids ("b", "c"…), like legal hold scopes and the rest of the API, so the console can use its object pickers; plain numbers are still read. Held as numbers for matching. Unit test for both forms and a bad id; mail_rules_tests round-trips a tenant condition over JMAP. --- crates/features/src/mailflow/rules.rs | 53 +++++++++++++++++++++++++++ tests/src/system/mail_rules.rs | 27 ++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/crates/features/src/mailflow/rules.rs b/crates/features/src/mailflow/rules.rs index 23456e0..1816523 100644 --- a/crates/features/src/mailflow/rules.rs +++ b/crates/features/src/mailflow/rules.rs @@ -60,6 +60,41 @@ fn one() -> u32 { 1 } +/// Group and tenant ids in the JMAP form clients use (`"b"`, `"c"`…), held +/// as numbers for matching. Plain numbers are read too. +mod jmap_ids { + use serde::{Deserialize, Deserializer, Serializer, de::Error, ser::SerializeSeq}; + use std::str::FromStr; + use types::id::Id; + + pub fn serialize(ids: &[u32], serializer: S) -> Result { + let mut seq = serializer.serialize_seq(Some(ids.len()))?; + for id in ids { + seq.serialize_element(&Id::from(*id).to_string())?; + } + seq.end() + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum Either { + Text(String), + Number(u32), + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Vec::::deserialize(deserializer)? + .into_iter() + .map(|id| match id { + Either::Number(n) => Ok(n), + Either::Text(text) => Id::from_str(&text) + .map(|id| id.document_id()) + .map_err(|_| D::Error::custom(format!("\"{text}\" isn't an id"))), + }) + .collect() + } +} + /// A detector and the least it must find. #[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] #[serde(rename_all = "camelCase")] @@ -83,9 +118,11 @@ pub enum Condition { domains: Vec, }, SenderGroup { + #[serde(with = "jmap_ids")] groups: Vec, }, SenderTenant { + #[serde(with = "jmap_ids")] tenants: Vec, }, /// Any recipient is one of these. @@ -96,6 +133,7 @@ pub enum Condition { domains: Vec, }, RecipientGroup { + #[serde(with = "jmap_ids")] groups: Vec, }, /// Any recipient isn't at a domain this server hosts. @@ -609,6 +647,21 @@ mod tests { assert_eq!(back["actions"][0]["notifySender"], true); } + #[test] + fn group_and_tenant_ids_are_jmap_ids() { + let condition: Condition = + serde_json::from_str(r#"{"type":"senderGroup","groups":["b", 7]}"#).unwrap(); + assert_eq!(condition, Condition::SenderGroup { groups: vec![1, 7] }); + assert_eq!( + serde_json::to_value(&condition).unwrap()["groups"], + serde_json::json!(["b", "h"]) + ); + assert!( + serde_json::from_str::(r#"{"type":"senderTenant","tenants":["!!"]}"#) + .is_err() + ); + } + #[test] fn dlp_rules_have_one_dlp_action_on_outgoing_mail() { let block = Action::Block { diff --git a/tests/src/system/mail_rules.rs b/tests/src/system/mail_rules.rs index 856762f..1a38e75 100644 --- a/tests/src/system/mail_rules.rs +++ b/tests/src/system/mail_rules.rs @@ -114,6 +114,33 @@ pub async fn test(test: &mut TestServer) { "{rule}" ); + // Group and tenant ids travel in their JMAP form + let mut by_tenant = transport_rule(); + by_tenant["name"] = "By tenant".into(); + by_tenant["conditions"] = json!([{"type": "senderTenant", "tenants": ["b"]}]); + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"create": {"g": by_tenant}}), + ) + .await; + let tenant_rule = response["created"]["g"]["id"] + .as_str() + .unwrap_or_else(|| panic!("{response}")) + .to_string(); + let (_, response) = call(&admin, "inbuxa:MailRule/get", json!({"ids": [tenant_rule]})).await; + assert_eq!( + response["list"][0]["conditions"][0]["tenants"], + json!(["b"]), + "{response}" + ); + call( + &admin, + "inbuxa:MailRule/set", + json!({"destroy": [tenant_rule]}), + ) + .await; + // Checked when written let mut inbound = dlp_rule(); inbound["direction"] = "incoming".into(); -- 2.54.0