From c8280de9c342fd468287d2a9d5a81d053bf3c3ba Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 17:18:30 -0700 Subject: [PATCH 1/2] DLP and mail flow rules: the rule model, the engine and the node cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2e of the DLP and mail flow rules spec, in the features crate. - rules.rs: a rule (§2.2) with its conditions (§2.3) and actions (§2.4), as JSON under R/r in the fork's subspace. validate() enforces the spec's shape: DLP rules check outgoing mail and have exactly one of block, warn or hold; transport rules have neither those nor detectors; lists, header names, header values (one line), addresses, texts, word lists, patterns and detector ids are checked. - engine.rs: rules compiled once (word lists to automata, patterns to size-limited regexes) and run in priority order with exceptions and stop processing. Each detector runs at most once per message and only when a rule asks for it. The outcome lists what matched with each detector's count, and decides DLP strictest first: block, hold, warn; an override answers warnings only (§2.5). - cache.rs: each node's compiled copy, refreshed after 30 seconds or at once when this node changes a rule. Nothing calls this yet: the JMAP object and the check at DATA follow. 55 unit tests in mailflow. --- crates/features/src/mailflow/cache.rs | 53 ++ crates/features/src/mailflow/engine.rs | 695 ++++++++++++++++++++++++ crates/features/src/mailflow/mod.rs | 12 +- crates/features/src/mailflow/rules.rs | 717 +++++++++++++++++++++++++ 4 files changed, 1474 insertions(+), 3 deletions(-) create mode 100644 crates/features/src/mailflow/cache.rs create mode 100644 crates/features/src/mailflow/engine.rs create mode 100644 crates/features/src/mailflow/rules.rs diff --git a/crates/features/src/mailflow/cache.rs b/crates/features/src/mailflow/cache.rs new file mode 100644 index 0000000..63c5cc0 --- /dev/null +++ b/crates/features/src/mailflow/cache.rs @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The compiled rules, kept per node so a message doesn't read the store. +//! A change made on this node applies at once; one made on another node +//! within [`TTL`], when the copy here is next refreshed. + +use super::{engine::Compiled, rules}; +use std::{ + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; +use store::Store; + +/// How long a node keeps its copy before reading the rules again. +pub const TTL: Duration = Duration::from_secs(30); + +static CACHE: RwLock)>> = RwLock::new(None); + +/// Forgets the copy, so the next message reads the rules again. +pub fn invalidate() { + if let Ok(mut cache) = CACHE.write() { + *cache = None; + } +} + +/// The enabled rules, compiled. A rule that no longer compiles is left out +/// and reported, once per refresh. +pub async fn compiled(data: &Store) -> trc::Result> { + if let Ok(cache) = CACHE.read() + && let Some((at, compiled)) = cache.as_ref() + && at.elapsed() < TTL + { + return Ok(compiled.clone()); + } + let (compiled, skipped) = Compiled::new(&rules::all(data).await?); + for (id, reason) in skipped { + trc::event!( + Store(trc::StoreEvent::DataCorruption), + Id = u64::from(id), + Reason = reason, + Details = "Mail rule skipped: it no longer compiles" + ); + } + let compiled = Arc::new(compiled); + if let Ok(mut cache) = CACHE.write() { + *cache = Some((Instant::now(), compiled.clone())); + } + Ok(compiled) +} diff --git a/crates/features/src/mailflow/engine.rs b/crates/features/src/mailflow/engine.rs new file mode 100644 index 0000000..e858a5b --- /dev/null +++ b/crates/features/src/mailflow/engine.rs @@ -0,0 +1,695 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Evaluating rules against a message (§2.1–§2.4). Rules are compiled once, +//! when they change: word lists become automata, patterns regexes. A message +//! is then checked against every enabled rule in order; each detector runs +//! at most once per message, and only when some rule asks for it. +//! +//! Pure: the caller parses the message, extracts attachment text +//! ([`super::extract`]) and knows the sender's groups and tenant. What comes +//! back is which rules matched, with each detector's count, and what DLP +//! decided; the matched text itself never leaves here (§2.7). + +use super::{ + detectors::{self, Findings}, + extract::Extracted, + rules::{Action, Condition, Direction, Kind, Rule}, + words::{Pattern, WordList}, +}; +use ahash::AHashMap; +use std::borrow::Cow; + +/// Who sent a message, and to whom. +#[derive(Debug, Clone, Default)] +pub struct Envelope<'a> { + /// Outgoing (an authenticated sender) or incoming. + pub outgoing: bool, + pub sender: &'a str, + pub sender_groups: &'a [u32], + pub sender_tenant: Option, + pub recipients: Vec>, +} + +#[derive(Debug, Clone, Default)] +pub struct Recipient<'a> { + pub address: &'a str, + /// At a domain this server hosts. + pub local: bool, + pub groups: &'a [u32], +} + +#[derive(Debug, Clone)] +pub struct Attachment<'a> { + pub name: Option<&'a str>, + /// Declared type, or detected where the caller knows better. + pub content_type: &'a str, + pub size: u64, + pub extracted: Extracted, +} + +/// What rules look at. +#[derive(Debug, Clone, Default)] +pub struct Content<'a> { + pub subject: &'a str, + /// Each text and HTML part, as text. + pub bodies: Vec>, + pub headers: Vec<(&'a str, &'a str)>, + pub attachments: Vec>, + pub size: u64, + /// Text past the inspection limit wasn't read. + pub truncated: bool, +} + +impl Content<'_> { + fn texts(&self) -> impl Iterator { + std::iter::once(self.subject) + .chain(self.bodies.iter().map(|b| b.as_ref())) + .chain(self.attachments.iter().filter_map(|a| match &a.extracted { + Extracted::Text(text) => Some(text.as_str()), + _ => None, + })) + } + + fn cant_be_inspected(&self) -> bool { + self.truncated + || self + .attachments + .iter() + .any(|a| matches!(a.extracted, Extracted::NotInspectable(_))) + } +} + +enum Check { + Plain(Condition), + Words(WordList, u32), + Pattern(Pattern, u32), + Header { + name: String, + contains: Option, + matches: Option, + }, + AttachmentName(Pattern), +} + +struct CompiledRule { + rule: Rule, + conditions: Vec, + exceptions: Vec, +} + +/// The enabled rules, ready to run. +pub struct Compiled { + rules: Vec, +} + +/// A rule reference, for notices and the audit record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleRef { + pub id: u32, + pub name: String, + pub notice: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Match { + pub rule_id: u32, + pub name: String, + pub kind: Kind, + pub actions: Vec, + /// Each detector (or `words`, `pattern`) that counted, and its count. + pub counts: Vec<(String, usize)>, +} + +#[derive(Debug, Default)] +pub struct Outcome { + pub matched: Vec, + pub blocks: Vec, + pub holds: Vec<(RuleRef, bool)>, + pub warns: Vec, +} + +/// What DLP decided, strictest first (§2.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + Pass, + Block(Vec), + Hold { + rules: Vec, + notify_sender: bool, + }, + Warn(Vec), +} + +impl Outcome { + /// Block beats hold beats warn. An override (§2.5) answers the warnings + /// only: a block or hold still applies. + pub fn decision(&self, overridden: bool) -> Decision { + if !self.blocks.is_empty() { + Decision::Block(self.blocks.clone()) + } else if !self.holds.is_empty() { + Decision::Hold { + rules: self.holds.iter().map(|(r, _)| r.clone()).collect(), + notify_sender: self.holds.iter().any(|(_, notify)| *notify), + } + } else if !self.warns.is_empty() && !overridden { + Decision::Warn(self.warns.clone()) + } else { + Decision::Pass + } + } +} + +fn compile_check(condition: &Condition) -> Result { + Ok(match condition { + Condition::Words { words, at_least } => Check::Words(WordList::new(words)?, *at_least), + Condition::Pattern { pattern, at_least } => { + Check::Pattern(Pattern::new(pattern)?, *at_least) + } + Condition::Header { + name, + contains, + matches, + } => Check::Header { + name: name.to_ascii_lowercase(), + contains: contains.as_ref().map(|c| c.to_lowercase()), + matches: matches.as_deref().map(Pattern::new).transpose()?, + }, + Condition::AttachmentName { pattern } => Check::AttachmentName(Pattern::new(pattern)?), + other => Check::Plain(other.clone()), + }) +} + +impl Compiled { + /// Compiles the enabled rules; one that no longer compiles (a detector + /// renamed since it was saved) is skipped and named in the second list. + pub fn new(rules: &[Rule]) -> (Self, Vec<(u32, String)>) { + let mut compiled = Vec::new(); + let mut skipped = Vec::new(); + for rule in rules.iter().filter(|r| r.enabled) { + let result = rule.validate().map_err(|e| e.reason).and_then(|_| { + Ok(CompiledRule { + rule: rule.clone(), + conditions: rule + .conditions + .iter() + .map(compile_check) + .collect::>()?, + exceptions: rule + .exceptions + .iter() + .map(compile_check) + .collect::>()?, + }) + }); + match result { + Ok(c) => compiled.push(c), + Err(reason) => skipped.push((rule.id, reason)), + } + } + compiled.sort_by_key(|c| (c.rule.priority, c.rule.id)); + (Self { rules: compiled }, skipped) + } + + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } + + /// Whether any rule could apply to mail going this way, so a caller can + /// skip parsing when none can. + pub fn applies_to(&self, outgoing: bool) -> bool { + self.rules + .iter() + .any(|c| direction_matches(c.rule.direction, outgoing)) + } + + pub fn evaluate(&self, envelope: &Envelope<'_>, content: &Content<'_>) -> Outcome { + let mut state = State { + content, + detected: AHashMap::new(), + }; + let mut outcome = Outcome::default(); + for compiled in &self.rules { + let rule = &compiled.rule; + if !direction_matches(rule.direction, envelope.outgoing) { + continue; + } + let mut counts = Vec::new(); + let all_match = compiled + .conditions + .iter() + .all(|check| state.check(check, envelope, &mut counts)); + if !all_match { + continue; + } + let mut ignored = Vec::new(); + if compiled + .exceptions + .iter() + .any(|check| state.check(check, envelope, &mut ignored)) + { + continue; + } + for action in &rule.actions { + let reference = |notice: &str| RuleRef { + id: rule.id, + name: rule.name.clone(), + notice: notice.to_string(), + }; + match action { + Action::Block { notice } => outcome.blocks.push(reference(notice)), + Action::Hold { + notice, + notify_sender, + } => outcome.holds.push((reference(notice), *notify_sender)), + Action::Warn { notice } => outcome.warns.push(reference(notice)), + _ => {} + } + } + outcome.matched.push(Match { + rule_id: rule.id, + name: rule.name.clone(), + kind: rule.kind, + actions: rule.actions.clone(), + counts, + }); + if rule.stop_processing { + break; + } + } + outcome + } +} + +fn direction_matches(direction: Direction, outgoing: bool) -> bool { + match direction { + Direction::Any => true, + Direction::Outgoing => outgoing, + Direction::Incoming => !outgoing, + } +} + +fn domain_of(address: &str) -> &str { + address.rsplit_once('@').map_or("", |(_, d)| d) +} + +fn in_list(value: &str, list: &[String]) -> bool { + list.iter().any(|v| v.eq_ignore_ascii_case(value)) +} + +struct State<'c, 'a> { + content: &'c Content<'a>, + /// Each detector's count, run once per message. + detected: AHashMap<&'static str, usize>, +} + +impl State<'_, '_> { + fn detector_count(&mut self, id: &str) -> usize { + let Some(detector) = detectors::by_id(id) else { + return 0; + }; + if let Some(count) = self.detected.get(detector.id) { + return *count; + } + let mut findings = Findings::default(); + for text in self.content.texts() { + detector.find(text, &mut findings); + } + self.detected.insert(detector.id, findings.len()); + findings.len() + } + + fn check( + &mut self, + check: &Check, + envelope: &Envelope<'_>, + counts: &mut Vec<(String, usize)>, + ) -> bool { + let content = self.content; + match check { + Check::Words(list, at_least) => { + let n: usize = content.texts().map(|t| list.count(t)).sum(); + counts.push(("words".into(), n)); + n >= *at_least as usize + } + Check::Pattern(pattern, at_least) => { + let n: usize = content.texts().map(|t| pattern.count(t)).sum(); + counts.push(("pattern".into(), n)); + n >= *at_least as usize + } + Check::Header { + name, + contains, + matches, + } => content + .headers + .iter() + .filter(|(n, _)| n.eq_ignore_ascii_case(name)) + .any(|(_, value)| match (contains, matches) { + (Some(needle), _) => value.to_lowercase().contains(needle.as_str()), + (_, Some(pattern)) => pattern.count(value) > 0, + _ => true, + }), + Check::AttachmentName(pattern) => content + .attachments + .iter() + .any(|a| a.name.is_some_and(|n| pattern.count(n) > 0)), + Check::Plain(condition) => match condition { + Condition::SenderAddress { addresses } => in_list(envelope.sender, addresses), + Condition::SenderDomain { domains } => in_list(domain_of(envelope.sender), domains), + Condition::SenderGroup { groups } => { + envelope.sender_groups.iter().any(|g| groups.contains(g)) + } + Condition::SenderTenant { tenants } => { + envelope.sender_tenant.is_some_and(|t| tenants.contains(&t)) + } + Condition::RecipientAddress { addresses } => envelope + .recipients + .iter() + .any(|r| in_list(r.address, addresses)), + Condition::RecipientDomain { domains } => envelope + .recipients + .iter() + .any(|r| in_list(domain_of(r.address), domains)), + Condition::RecipientGroup { groups } => envelope + .recipients + .iter() + .any(|r| r.groups.iter().any(|g| groups.contains(g))), + Condition::RecipientOutside => envelope.recipients.iter().any(|r| !r.local), + Condition::AttachmentType { types } => content.attachments.iter().any(|a| { + let ct = a.content_type.to_ascii_lowercase(); + types + .iter() + .any(|t| ct.starts_with(&t.to_ascii_lowercase())) + }), + Condition::AttachmentExtension { extensions } => { + content.attachments.iter().any(|a| { + a.name + .and_then(|n| n.rsplit_once('.')) + .is_some_and(|(_, ext)| { + extensions + .iter() + .any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(ext)) + }) + }) + } + Condition::AttachmentSizeOver { bytes } => { + content.attachments.iter().any(|a| a.size > *bytes) + } + Condition::AttachmentCountOver { count } => { + content.attachments.len() > *count as usize + } + Condition::CantBeInspected => content.cant_be_inspected(), + Condition::MessageSizeOver { bytes } => content.size > *bytes, + Condition::Detected { detectors } => { + let mut any = false; + for d in detectors { + let n = self.detector_count(&d.id); + counts.push((d.id.clone(), n)); + any |= n >= d.at_least as usize; + } + any + } + // Compiled into their own checks + Condition::Words { .. } + | Condition::Pattern { .. } + | Condition::Header { .. } + | Condition::AttachmentName { .. } => false, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mailflow::{ + extract::Why, + rules::{DetectorMin, Position}, + }; + + fn rule(id: u32, kind: Kind, conditions: Vec, action: Action) -> Rule { + Rule { + id, + name: format!("rule {id}"), + description: String::new(), + kind, + enabled: true, + priority: id as i32, + direction: if kind == Kind::Dlp { + Direction::Outgoing + } else { + Direction::Any + }, + conditions, + exceptions: vec![], + actions: vec![action], + stop_processing: false, + created_by: String::new(), + created_at: 0, + updated_at: 0, + } + } + + fn envelope(outside: bool) -> Envelope<'static> { + Envelope { + outgoing: true, + sender: "dana@example.com", + sender_groups: &[7], + sender_tenant: None, + recipients: vec![Recipient { + address: if outside { + "x@elsewhere.org" + } else { + "y@example.com" + }, + local: !outside, + groups: &[], + }], + } + } + + fn cards(n: usize) -> Content<'static> { + let body: String = [ + "4242 4242 4242 4242", + "5555-5555-5555-4444", + "378282246310005", + "6011111111111117", + "3566002020360505", + ] + .iter() + .take(n) + .map(|c| format!("card {c}\n")) + .collect(); + Content { + subject: "Numbers", + bodies: vec![body.into()], + ..Default::default() + } + } + + fn five_cards_outside(action: Action) -> Rule { + rule( + 1, + Kind::Dlp, + vec![ + Condition::RecipientOutside, + Condition::Detected { + detectors: vec![DetectorMin { + id: "payment-card".into(), + at_least: 5, + }], + }, + ], + action, + ) + } + + #[test] + fn detector_threshold_and_recipients() { + let (rules, skipped) = Compiled::new(&[five_cards_outside(Action::Hold { + notice: "Held".into(), + notify_sender: true, + })]); + assert!(skipped.is_empty()); + let outcome = rules.evaluate(&envelope(true), &cards(5)); + assert_eq!( + outcome.matched[0].counts, + vec![("payment-card".to_string(), 5)] + ); + assert!(matches!( + outcome.decision(false), + Decision::Hold { + notify_sender: true, + .. + } + )); + // Four cards, or everyone inside: nothing + assert_eq!( + rules.evaluate(&envelope(true), &cards(4)).decision(false), + Decision::Pass + ); + assert_eq!( + rules.evaluate(&envelope(false), &cards(5)).decision(false), + Decision::Pass + ); + } + + #[test] + fn strictest_wins_and_override_answers_warnings_only() { + let warn = five_cards_outside(Action::Warn { + notice: "Sure?".into(), + }); + let mut block = five_cards_outside(Action::Block { + notice: "No".into(), + }); + block.id = 2; + let (rules, _) = Compiled::new(&[warn.clone(), block]); + let outcome = rules.evaluate(&envelope(true), &cards(5)); + assert!(matches!(outcome.decision(true), Decision::Block(_))); + let (rules, _) = Compiled::new(&[warn]); + let outcome = rules.evaluate(&envelope(true), &cards(5)); + assert!(matches!(outcome.decision(false), Decision::Warn(ref w) if w[0].notice == "Sure?")); + assert_eq!(outcome.decision(true), Decision::Pass); + } + + #[test] + fn exceptions_order_and_stop_processing() { + let disclaimer = |id| { + rule( + id, + Kind::Transport, + vec![Condition::RecipientOutside], + Action::AddDisclaimer { + text: "t".into(), + html: None, + position: Position::Bottom, + }, + ) + }; + let mut first = disclaimer(1); + first.stop_processing = true; + let (rules, _) = Compiled::new(&[disclaimer(2), first.clone()]); + let outcome = rules.evaluate(&envelope(true), &cards(0)); + assert_eq!( + outcome + .matched + .iter() + .map(|m| m.rule_id) + .collect::>(), + vec![1] + ); + + first.stop_processing = false; + first.exceptions = vec![Condition::SenderGroup { groups: vec![7] }]; + let (rules, _) = Compiled::new(&[disclaimer(2), first]); + let outcome = rules.evaluate(&envelope(true), &cards(0)); + assert_eq!( + outcome + .matched + .iter() + .map(|m| m.rule_id) + .collect::>(), + vec![2] + ); + } + + #[test] + fn content_conditions() { + let content = Content { + subject: "Project Falcon", + bodies: vec!["see attached".into()], + headers: vec![("X-Class", "Internal only")], + attachments: vec![ + Attachment { + name: Some("plan.docx"), + content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + size: 40_000, + extracted: Extracted::Text("IBAN GB29 NWBK 6016 1331 9268 19".into()), + }, + Attachment { + name: Some("scan.pdf"), + content_type: "application/pdf", + size: 900_000, + extracted: Extracted::NotInspectable(Why::Pdf), + }, + ], + size: 1_000_000, + truncated: false, + }; + let block = || Action::Block { notice: "n".into() }; + let checks = [ + ( + Condition::Words { + words: vec!["project falcon".into()], + at_least: 1, + }, + true, + ), + ( + Condition::Header { + name: "x-class".into(), + contains: Some("internal".into()), + matches: None, + }, + true, + ), + ( + Condition::AttachmentExtension { + extensions: vec![".PDF".into()], + }, + true, + ), + ( + Condition::AttachmentType { + types: vec!["image/".into()], + }, + false, + ), + (Condition::AttachmentSizeOver { bytes: 500_000 }, true), + (Condition::AttachmentCountOver { count: 2 }, false), + (Condition::CantBeInspected, true), + (Condition::MessageSizeOver { bytes: 2_000_000 }, false), + ( + Condition::Detected { + detectors: vec![DetectorMin { + id: "iban".into(), + at_least: 1, + }], + }, + true, + ), + ( + Condition::SenderDomain { + domains: vec!["EXAMPLE.com".into()], + }, + true, + ), + ]; + for (condition, expected) in checks { + let (rules, skipped) = + Compiled::new(&[rule(1, Kind::Dlp, vec![condition.clone()], block())]); + assert!(skipped.is_empty(), "{condition:?}"); + let matched = !rules.evaluate(&envelope(true), &content).matched.is_empty(); + assert_eq!(matched, expected, "{condition:?}"); + } + } + + #[test] + fn direction_and_disabled_rules() { + let mut r = five_cards_outside(Action::Block { notice: "n".into() }); + let (rules, _) = Compiled::new(std::slice::from_ref(&r)); + assert!(rules.applies_to(true) && !rules.applies_to(false)); + let mut incoming = envelope(true); + incoming.outgoing = false; + assert_eq!( + rules.evaluate(&incoming, &cards(5)).decision(false), + Decision::Pass + ); + r.enabled = false; + assert!(Compiled::new(&[r]).0.is_empty()); + } +} diff --git a/crates/features/src/mailflow/mod.rs b/crates/features/src/mailflow/mod.rs index d218aac..1516faa 100644 --- a/crates/features/src/mailflow/mod.rs +++ b/crates/features/src/mailflow/mod.rs @@ -6,18 +6,24 @@ //! Data loss prevention and mail flow rules (dlp-and-mail-flow-rules spec). //! -//! Pure functions over text and attachment bytes, so everything here is -//! unit-tested without a server: +//! Mostly pure functions over text and attachment bytes, unit-tested +//! without a server: //! //! - [`detectors`]: find identifiers in text (payment cards, IBANs, //! national ID numbers, keys), each by its published format and check //! (§2.3); //! - [`words`]: an organization's own word lists and patterns; -//! - [`extract`]: the text of an attachment, or why it can't be read. +//! - [`extract`]: the text of an attachment, or why it can't be read; +//! - [`rules`]: what a rule is, its checks, and where rules are kept; +//! - [`engine`]: rules compiled and run against a message; +//! - [`cache`]: each node's compiled copy. //! //! Nothing here writes what it finds anywhere: callers get counts, and the //! matched text never leaves the evaluation (§2.7). +pub mod cache; pub mod detectors; +pub mod engine; pub mod extract; +pub mod rules; pub mod words; diff --git a/crates/features/src/mailflow/rules.rs b/crates/features/src/mailflow/rules.rs new file mode 100644 index 0000000..23456e0 --- /dev/null +++ b/crates/features/src/mailflow/rules.rs @@ -0,0 +1,717 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Mail flow rules and DLP rules (dlp-and-mail-flow-rules spec, §2.2–§2.4): +//! what a rule is, what makes one valid, and where it's kept. +//! +//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`), never in the +//! registry, so an upstream schema import never touches them. Every key +//! starts with `R`, then one byte for the kind: +//! +//! - `r` + rule id (u32): the rule, as JSON. +//! +//! Numbers are big-endian. There are few rules, so they're read whole. + +use super::{detectors, words}; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, +}; +use trc::AddContext; + +const FEATURE: u8 = b'R'; +const KIND_RULE: u8 = b'r'; +const CREATE_ATTEMPTS: usize = 5; + +/// Longest text a rule may carry (a notice, a disclaimer), in bytes. +const MAX_TEXT: usize = 16 * 1024; +/// Most entries in one list (words, addresses, domains). +const MAX_LIST: usize = 5_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kind { + Dlp, + Transport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum Direction { + /// Mail an authenticated sender submits, over SMTP or JMAP. + Outgoing, + /// Everything else the server accepts. + Incoming, + Any, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum Position { + Top, + Bottom, +} + +fn one() -> u32 { + 1 +} + +/// A detector and the least it must find. +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetectorMin { + pub id: String, + #[serde(default = "one")] + pub at_least: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Condition { + SenderAddress { + addresses: Vec, + }, + SenderDomain { + domains: Vec, + }, + SenderGroup { + groups: Vec, + }, + SenderTenant { + tenants: Vec, + }, + /// Any recipient is one of these. + RecipientAddress { + addresses: Vec, + }, + RecipientDomain { + domains: Vec, + }, + RecipientGroup { + groups: Vec, + }, + /// Any recipient isn't at a domain this server hosts. + RecipientOutside, + /// Words or phrases in the subject, body or readable attachments. + Words { + words: Vec, + #[serde(default = "one")] + at_least: u32, + }, + /// The organization's regular expression, in the same places. + Pattern { + pattern: String, + #[serde(default = "one")] + at_least: u32, + }, + /// A header exists, or its value contains or matches. + Header { + name: String, + #[serde(default)] + contains: Option, + #[serde(default)] + matches: Option, + }, + /// An attachment's declared or detected type starts with one of these. + AttachmentType { + types: Vec, + }, + AttachmentExtension { + extensions: Vec, + }, + AttachmentName { + pattern: String, + }, + AttachmentSizeOver { + bytes: u64, + }, + AttachmentCountOver { + count: u32, + }, + /// An attachment is encrypted, a PDF, a legacy Office file, an archive + /// inside an archive, or past the inspection limit. + CantBeInspected, + MessageSizeOver { + bytes: u64, + }, + /// Any of these detectors finds at least its minimum (DLP rules only). + Detected { + detectors: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Action { + // Transport actions + AddDisclaimer { + text: String, + #[serde(default)] + html: Option, + position: Position, + }, + AddHeader { + name: String, + value: String, + }, + RemoveHeader { + name: String, + }, + PrefixSubject { + text: String, + }, + AddRecipient { + address: String, + }, + Redirect { + addresses: Vec, + }, + Refuse { + text: String, + }, + Route { + queue: String, + }, + // DLP actions + Block { + notice: String, + }, + Warn { + notice: String, + }, + Hold { + notice: String, + #[serde(default)] + notify_sender: bool, + }, +} + +impl Action { + pub fn is_dlp(&self) -> bool { + matches!( + self, + Action::Block { .. } | Action::Warn { .. } | Action::Hold { .. } + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Rule { + #[serde(default)] + pub id: u32, + pub name: String, + #[serde(default)] + pub description: String, + pub kind: Kind, + #[serde(default = "enabled")] + pub enabled: bool, + #[serde(default)] + pub priority: i32, + pub direction: Direction, + #[serde(default)] + pub conditions: Vec, + #[serde(default)] + pub exceptions: Vec, + pub actions: Vec, + #[serde(default)] + pub stop_processing: bool, + #[serde(default)] + pub created_by: String, + #[serde(default)] + pub created_at: u64, + #[serde(default)] + pub updated_at: u64, +} + +fn enabled() -> bool { + true +} + +/// Why a rule can't be saved: the property at fault, and a sentence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invalid { + pub property: &'static str, + pub reason: String, +} + +fn invalid(property: &'static str, reason: impl Into) -> Invalid { + Invalid { + property, + reason: reason.into(), + } +} + +impl Rule { + /// Everything that can be checked without the rest of the server: the + /// shape (§2.2, §2.4), the detectors, word lists and patterns. + pub fn validate(&self) -> Result<(), Invalid> { + if self.name.trim().is_empty() { + return Err(invalid("name", "A rule needs a name.")); + } + if self.name.len() > 200 || self.description.len() > MAX_TEXT { + return Err(invalid("name", "The name or description is too long.")); + } + if self.actions.is_empty() { + return Err(invalid("actions", "A rule needs something to do.")); + } + let dlp_actions = self.actions.iter().filter(|a| a.is_dlp()).count(); + match self.kind { + Kind::Dlp => { + if self.direction != Direction::Outgoing { + return Err(invalid("direction", "DLP rules check outgoing mail only.")); + } + if dlp_actions != 1 || self.actions.len() != 1 { + return Err(invalid( + "actions", + "A DLP rule has exactly one action: block, warn or hold.", + )); + } + } + Kind::Transport => { + if dlp_actions > 0 { + return Err(invalid( + "actions", + "Block, warn and hold belong to DLP rules.", + )); + } + if self + .conditions + .iter() + .chain(&self.exceptions) + .any(|c| matches!(c, Condition::Detected { .. })) + { + return Err(invalid("conditions", "Detectors belong to DLP rules.")); + } + } + } + for (property, list) in [ + ("conditions", &self.conditions), + ("exceptions", &self.exceptions), + ] { + for condition in list { + validate_condition(condition).map_err(|reason| invalid(property, reason))?; + } + } + for action in &self.actions { + validate_action(action).map_err(|reason| invalid("actions", reason))?; + } + Ok(()) + } +} + +fn nonempty_list(list: &[T], what: &str) -> Result<(), String> { + if list.is_empty() { + Err(format!("The {what} list is empty.")) + } else if list.len() > MAX_LIST { + Err(format!( + "The {what} list is longer than {MAX_LIST} entries." + )) + } else { + Ok(()) + } +} + +fn header_name(name: &str) -> Result<(), String> { + if !name.is_empty() + && name.len() <= 100 + && name.bytes().all(|b| b.is_ascii_graphic() && b != b':') + { + Ok(()) + } else { + Err(format!("\"{name}\" isn't a header name.")) + } +} + +fn text(value: &str, what: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("The {what} is empty.")) + } else if value.len() > MAX_TEXT { + Err(format!("The {what} is longer than {MAX_TEXT} bytes.")) + } else { + Ok(()) + } +} + +fn validate_condition(condition: &Condition) -> Result<(), String> { + match condition { + Condition::SenderAddress { addresses } | Condition::RecipientAddress { addresses } => { + nonempty_list(addresses, "address") + } + Condition::SenderDomain { domains } | Condition::RecipientDomain { domains } => { + nonempty_list(domains, "domain") + } + Condition::SenderGroup { groups } | Condition::RecipientGroup { groups } => { + nonempty_list(groups, "group") + } + Condition::SenderTenant { tenants } => nonempty_list(tenants, "tenant"), + Condition::Words { words, at_least } => { + nonempty_list(words, "word")?; + if *at_least == 0 { + return Err("The least number of words must be 1 or more.".into()); + } + words::WordList::new(words).map(|_| ()) + } + Condition::Pattern { pattern, at_least } => { + if *at_least == 0 { + return Err("The least number of matches must be 1 or more.".into()); + } + words::Pattern::new(pattern).map(|_| ()) + } + Condition::Header { + name, + contains, + matches, + } => { + header_name(name)?; + if let Some(pattern) = matches { + words::Pattern::new(pattern)?; + } + if contains.is_some() && matches.is_some() { + return Err("A header condition is either contains or matches.".into()); + } + Ok(()) + } + Condition::AttachmentType { types } => nonempty_list(types, "type"), + Condition::AttachmentExtension { extensions } => nonempty_list(extensions, "extension"), + Condition::AttachmentName { pattern } => words::Pattern::new(pattern).map(|_| ()), + Condition::Detected { detectors } => { + nonempty_list(detectors, "detector")?; + for d in detectors { + if detectors::by_id(&d.id).is_none() { + return Err(format!("There is no detector \"{}\".", d.id)); + } + if d.at_least == 0 { + return Err("A detector's least count must be 1 or more.".into()); + } + } + Ok(()) + } + Condition::RecipientOutside + | Condition::AttachmentSizeOver { .. } + | Condition::AttachmentCountOver { .. } + | Condition::CantBeInspected + | Condition::MessageSizeOver { .. } => Ok(()), + } +} + +fn validate_action(action: &Action) -> Result<(), String> { + match action { + Action::AddDisclaimer { text: t, html, .. } => { + text(t, "disclaimer")?; + html.as_deref() + .map_or(Ok(()), |h| text(h, "disclaimer's HTML")) + } + Action::AddHeader { name, value } => { + header_name(name)?; + if value.len() > 998 || value.contains(['\r', '\n']) { + Err("A header value is one line of at most 998 characters.".into()) + } else { + Ok(()) + } + } + Action::RemoveHeader { name } => header_name(name), + Action::PrefixSubject { text: t } => text(t, "subject prefix"), + Action::AddRecipient { address } => { + if address.contains('@') { + Ok(()) + } else { + Err(format!("\"{address}\" isn't an address.")) + } + } + Action::Redirect { addresses } => { + nonempty_list(addresses, "address")?; + match addresses.iter().find(|a| !a.contains('@')) { + Some(a) => Err(format!("\"{a}\" isn't an address.")), + None => Ok(()), + } + } + Action::Refuse { text: t } => text(t, "refusal text"), + Action::Route { queue } => text(queue, "queue"), + Action::Block { notice } | Action::Warn { notice } | Action::Hold { notice, .. } => { + text(notice, "notice") + } + } +} + +// --- Storage -------------------------------------------------------------- + +struct Json(T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize mail rule") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid mail rule") + .reason(err) + }) + } +} + +fn class(id: u32) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.push(FEATURE); + key.push(KIND_RULE); + key.extend_from_slice(&id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(id: u32) -> ValueKey { + ValueKey::from(class(id)) +} + +pub async fn get(data: &Store, id: u32) -> trc::Result> { + Ok(data + .get_value::>(key(id)) + .await + .caused_by(trc::location!())? + .map(|Json(rule)| rule)) +} + +/// Every rule, in the order they run: by priority, then oldest first. +pub async fn all(data: &Store) -> trc::Result> { + let mut rules = Vec::new(); + data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| { + if let Ok(Json(rule)) = Json::::deserialize(value) { + rules.push(rule); + } + Ok(true) + }) + .await + .caused_by(trc::location!())?; + rules.sort_by_key(|rule| (rule.priority, rule.id)); + Ok(rules) +} + +/// Writes a new rule under the next free id, which it returns. Two nodes +/// creating rules at once can't take the same id: the key must be absent. +pub async fn create(data: &Store, rule: &Rule) -> trc::Result { + let mut attempt = 0; + loop { + attempt += 1; + let id = all(data).await?.iter().map(|r| r.id).max().unwrap_or(0) + 1; + let stored = Rule { id, ..rule.clone() }; + let mut batch = BatchBuilder::new(); + batch.assert_value(class(id), AssertValue::None); + batch.set(class(id), Json(&stored).serialize()?); + match data.write(batch.build_all()).await { + Ok(_) => { + super::cache::invalidate(); + return Ok(id); + } + Err(err) + if attempt < CREATE_ATTEMPTS + && matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => {} + Err(err) => return Err(err.caused_by(trc::location!())), + } + } +} + +/// Replaces a stored rule (same id). +pub async fn update(data: &Store, rule: &Rule) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set(class(rule.id), Json(rule).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + super::cache::invalidate(); + Ok(()) +} + +pub async fn delete(data: &Store, id: u32) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.clear(class(id)); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + super::cache::invalidate(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(kind: Kind, actions: Vec) -> Rule { + Rule { + id: 0, + name: "Cards outside".into(), + description: String::new(), + kind, + enabled: true, + priority: 0, + direction: Direction::Outgoing, + conditions: vec![Condition::RecipientOutside], + exceptions: vec![], + actions, + stop_processing: false, + created_by: String::new(), + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn wire_format() { + let json = r#"{"name":"Cards","kind":"dlp","direction":"outgoing", + "conditions":[{"type":"recipientOutside"},{"type":"detected","detectors":[{"id":"payment-card","atLeast":5}]}], + "actions":[{"type":"hold","notice":"Held for review","notifySender":true}]}"#; + let parsed: Rule = serde_json::from_str(json).unwrap(); + assert!(parsed.enabled); + assert_eq!( + parsed.conditions[1], + Condition::Detected { + detectors: vec![DetectorMin { + id: "payment-card".into(), + at_least: 5 + }] + } + ); + assert_eq!( + parsed.actions[0], + Action::Hold { + notice: "Held for review".into(), + notify_sender: true + } + ); + assert!(parsed.validate().is_ok()); + let back = serde_json::to_value(&parsed).unwrap(); + assert_eq!(back["actions"][0]["notifySender"], true); + } + + #[test] + fn dlp_rules_have_one_dlp_action_on_outgoing_mail() { + let block = Action::Block { + notice: "No.".into(), + }; + assert!(rule(Kind::Dlp, vec![block.clone()]).validate().is_ok()); + let two = rule( + Kind::Dlp, + vec![ + block.clone(), + Action::Warn { + notice: "Hm.".into(), + }, + ], + ); + assert_eq!(two.validate().unwrap_err().property, "actions"); + let mixed = rule( + Kind::Dlp, + vec![block.clone(), Action::PrefixSubject { text: "[x]".into() }], + ); + assert_eq!(mixed.validate().unwrap_err().property, "actions"); + let mut inbound = rule(Kind::Dlp, vec![block.clone()]); + inbound.direction = Direction::Incoming; + assert_eq!(inbound.validate().unwrap_err().property, "direction"); + assert_eq!( + rule(Kind::Transport, vec![block]) + .validate() + .unwrap_err() + .property, + "actions" + ); + } + + #[test] + fn conditions_and_actions_are_checked() { + let disclaimer = Action::AddDisclaimer { + text: "Sent from Example Co.".into(), + html: None, + position: Position::Bottom, + }; + let mut r = rule(Kind::Transport, vec![disclaimer]); + assert!(r.validate().is_ok()); + r.conditions.push(Condition::Detected { + detectors: vec![DetectorMin { + id: "iban".into(), + at_least: 1, + }], + }); + assert_eq!(r.validate().unwrap_err().property, "conditions"); + + let mut r = rule( + Kind::Dlp, + vec![Action::Block { + notice: "No.".into(), + }], + ); + r.conditions = vec![Condition::Detected { + detectors: vec![DetectorMin { + id: "nope".into(), + at_least: 1, + }], + }]; + assert!(r.validate().unwrap_err().reason.contains("nope")); + r.conditions = vec![Condition::Pattern { + pattern: "(".into(), + at_least: 1, + }]; + assert!(r.validate().is_err()); + r.conditions = vec![Condition::Words { + words: vec![], + at_least: 1, + }]; + assert!(r.validate().is_err()); + r.exceptions = vec![Condition::Header { + name: "X-Bad: yes".into(), + contains: None, + matches: None, + }]; + r.conditions = vec![]; + assert_eq!(r.validate().unwrap_err().property, "exceptions"); + + let header = rule( + Kind::Transport, + vec![Action::AddHeader { + name: "X-Tag".into(), + value: "a\r\nBcc: x@y".into(), + }], + ); + assert!(header.validate().is_err()); + let redirect = rule( + Kind::Transport, + vec![Action::Redirect { + addresses: vec!["nobody".into()], + }], + ); + assert!(redirect.validate().is_err()); + let mut unnamed = rule( + Kind::Transport, + vec![Action::RemoveHeader { + name: "X-Tag".into(), + }], + ); + unnamed.name = " ".into(); + assert_eq!(unnamed.validate().unwrap_err().property, "name"); + } +} -- 2.54.0 From 8afaee7d216596d8f355622eb07de284b6ec0e1c Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 17:29:32 -0700 Subject: [PATCH 2/2] DLP and mail flow rules: inbuxa:MailRule over JMAP, and its permissions Phase 2e of the DLP and mail flow rules spec, the API half. - inbuxa:MailRule/get and /set under urn:inbuxa:jmap. Rules convert through serde, so what a client sends is the stored format. A create or change is validated whole (Rule::validate) and refused with the property at fault; id, createdBy, createdAt and updatedAt are the server's. Every change goes through the request layer's audit record. - Six permissions, ids 674-679 (enum and schema labels): mail flow rules (sysMailRuleGet/Update), DLP rules (sysDlpPolicyGet/Update) and held mail (sysDlpReviewGet/Update, for phase 3). Either kind's permission gets through the gate; the handler shows and changes each rule only with its own kind's. All server-level: a tenant is refused (settled answer 3). - Administrators get all six; the server-level Compliance Officer gets DLP rules to see and held mail to review (settled answer 4), added once to an existing server's officer role by the grant mechanism, which gains an officer audience. - Privacy catalog entry for inbuxa:MailRule. tests/src/system/mail_rules.rs: create, list in order, validation, server-set properties refused, update, kind-separated permissions for an officer, destroy, audit records. --- crates/common/src/auth/permissions.rs | 11 + crates/common/src/manager/compliance_roles.rs | 41 ++- .../common/src/manager/granted_permissions.rs | 97 ++++-- .../jmap-proto/src/object/inbuxa_mail_rule.rs | 218 ++++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/method.rs | 10 +- crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 15 + crates/jmap-proto/src/response/mod.rs | 14 + crates/jmap/src/api/auth.rs | 24 ++ crates/jmap/src/api/request.rs | 24 ++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/mail_rule.rs | 327 ++++++++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + crates/registry/src/schema/enums.rs | 7 + crates/registry/src/schema/enums_impl.rs | 20 +- resources/privacy/catalog.toml | 15 + resources/schema/schema.json.gz | Bin 153302 -> 153379 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/mail_rules.rs | 201 +++++++++++ tests/src/system/mod.rs | 1 + 23 files changed, 994 insertions(+), 45 deletions(-) create mode 100644 crates/jmap-proto/src/object/inbuxa_mail_rule.rs create mode 100644 crates/jmap/src/inbuxa/mail_rule.rs create mode 100644 tests/src/system/mail_rules.rs diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 8fac42f..04a98be 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -296,6 +296,17 @@ impl Default for DefaultPermissions { default.superuser.push(permission); default.tenant.push(permission); } + // inbuxa: DLP and mail flow rules, and held mail, are the + // server's: never a tenant's (dlp-and-mail-flow-rules spec, + // settled answer 3) + Permission::SysMailRuleGet + | Permission::SysMailRuleUpdate + | Permission::SysDlpPolicyGet + | Permission::SysDlpPolicyUpdate + | Permission::SysDlpReviewGet + | Permission::SysDlpReviewUpdate => { + default.superuser.push(permission); + } // inbuxa: AL-12: tenant administrators lock and delegate // within their tenant Permission::SysAccountLockGet diff --git a/crates/common/src/manager/compliance_roles.rs b/crates/common/src/manager/compliance_roles.rs index e9e2733..3b53f5f 100644 --- a/crates/common/src/manager/compliance_roles.rs +++ b/crates/common/src/manager/compliance_roles.rs @@ -65,6 +65,10 @@ const OFFICER: &[Permission] = &[ Permission::SysLegalHoldUpdate, Permission::SysLegalHoldExport, Permission::SysAccountLockGet, + // dlp-and-mail-flow-rules spec, §2.8: see DLP rules, review held mail + Permission::SysDlpPolicyGet, + Permission::SysDlpReviewGet, + Permission::SysDlpReviewUpdate, ]; /// What a tenant's officer holds besides [`READS`]. @@ -113,6 +117,11 @@ fn created_key(tenant: Option) -> ValueClass { }) } +/// The server-level Compliance Officer role the server made, if it has. +pub async fn server_role(data: &Store) -> trc::Result> { + recorded(data, None).await +} + async fn recorded(data: &Store, tenant: Option) -> trc::Result> { Ok(data .get_value::(ValueKey::from(created_key(tenant))) @@ -172,13 +181,19 @@ pub async fn ensure_compliance_roles(registry: &RegistryStore, data: &Store) -> /// A new tenant gets its Compliance Officer role. pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> { - create_once(registry, data, Some(tenant), tenant_role(tenant)).await.map(|_| ()) + create_once(registry, data, Some(tenant), tenant_role(tenant)) + .await + .map(|_| ()) } /// Before a tenant is deleted: removes its Compliance Officer role if nobody /// holds it, so the role doesn't block the delete. Returns whether it did, /// so a delete refused for another reason can put it back. -pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result { +pub async fn tenant_deleting( + registry: &RegistryStore, + data: &Store, + tenant: Id, +) -> trc::Result { let Some(role) = recorded(data, Some(tenant)).await? else { return Ok(false); }; @@ -220,7 +235,9 @@ mod tests { // Beyond what any user holds for their own account for permission in all.into_iter().filter(|p| !user.contains(p)) { let name = permission.as_str(); - let holds = name.starts_with("sysLegalHold"); + // Placing holds and reviewing held mail are the officer's + // job, not settings (settled answers 2 and 4) + let holds = name.starts_with("sysLegalHold") || name.starts_with("sysDlpReview"); assert!( !(name.ends_with("Update") && !holds) && !(name.ends_with("Create") && !holds) @@ -249,7 +266,11 @@ mod tests { assert!(officer.contains(&hold)); assert!(!tenant.contains(&hold)); } - for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] { + for both in [ + Permission::SysComplianceGet, + Permission::SysAuditGet, + Permission::SysAccountGet, + ] { assert!(officer.contains(&both) && tenant.contains(&both)); } assert!(!officer.contains(&Permission::SysAuditSettingsUpdate)); @@ -257,9 +278,15 @@ mod tests { #[test] fn records_are_per_place() { - let ValueClass::Any(server) = created_key(None) else { panic!() }; - let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() }; - let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { panic!() }; + let ValueClass::Any(server) = created_key(None) else { + panic!() + }; + let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { + panic!() + }; + let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { + panic!() + }; assert_eq!(server.key, b"Pc"); assert_ne!(a.key, b.key); assert!(a.key.starts_with(b"Pc")); diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 3c7d996..fac45e4 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -46,6 +46,21 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysLegalHoldUpdate, Permission::SysLegalHoldExport, Permission::SysComplianceGet, + Permission::SysMailRuleGet, + Permission::SysMailRuleUpdate, + Permission::SysDlpPolicyGet, + Permission::SysDlpPolicyUpdate, + Permission::SysDlpReviewGet, + Permission::SysDlpReviewUpdate, +]; + +/// Granted to the server-level Compliance Officer role once it exists: +/// seeing DLP rules and reviewing held mail (dlp-and-mail-flow-rules spec, +/// §2.8, settled answer 4). A new install's role has them from the start. +const OFFICER_GRANTS: &[Permission] = &[ + Permission::SysDlpPolicyGet, + Permission::SysDlpReviewGet, + Permission::SysDlpReviewUpdate, ]; /// Granted to the default tenant administrator roles: reading and exporting @@ -65,13 +80,16 @@ const TENANT_GRANTS: &[Permission] = &[ enum Audience { Admin, Tenant, + Officer, } fn granted_key(permission: Permission, audience: Audience) -> ValueClass { let mut key = b"Pg".to_vec(); // Admin grants keep the key they were first recorded under - if audience == Audience::Tenant { - key.extend_from_slice(b"tenant:"); + match audience { + Audience::Admin => {} + Audience::Tenant => key.extend_from_slice(b"tenant:"), + Audience::Officer => key.extend_from_slice(b"officer:"), } key.extend_from_slice(permission.as_str().as_bytes()); ValueClass::Any(AnyClass { @@ -82,7 +100,8 @@ fn granted_key(permission: Permission, audience: Audience) -> ValueClass { pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> { grant(bp, Audience::Admin, ADMIN_GRANTS).await?; - grant(bp, Audience::Tenant, TENANT_GRANTS).await + grant(bp, Audience::Tenant, TENANT_GRANTS).await?; + grant(bp, Audience::Officer, OFFICER_GRANTS).await } async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> { @@ -101,39 +120,47 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> if pending.is_empty() { return Ok(()); } - // An administrator's default roles include the plain User role, which - // every user also holds; only roles that are the audience's alone get it - let admin_roles: Vec = bp - .registry - .object::(Id::singleton()) - .await? - .map(|auth| { - let (own, shared) = match audience { - Audience::Admin => ( - auth.default_admin_role_ids.as_slice(), - [ - auth.default_user_role_ids.as_slice(), - auth.default_group_role_ids.as_slice(), - auth.default_tenant_role_ids.as_slice(), - ] - .concat(), - ), - Audience::Tenant => ( - auth.default_tenant_role_ids.as_slice(), - [ - auth.default_user_role_ids.as_slice(), - auth.default_group_role_ids.as_slice(), + // The officer role is the one the server made, if it has made it yet: a + // new install makes it after this, with the permissions already in it + let admin_roles: Vec = if audience == Audience::Officer { + super::compliance_roles::server_role(&bp.data_store) + .await? + .into_iter() + .collect() + } else { + // An administrator's default roles include the plain User role, which + // every user also holds; only roles that are the audience's alone get it + bp.registry + .object::(Id::singleton()) + .await? + .map(|auth| { + let (own, shared) = match audience { + Audience::Admin => ( auth.default_admin_role_ids.as_slice(), - ] - .concat(), - ), - }; - own.iter() - .filter(|id| !shared.contains(id)) - .copied() - .collect() - }) - .unwrap_or_default(); + [ + auth.default_user_role_ids.as_slice(), + auth.default_group_role_ids.as_slice(), + auth.default_tenant_role_ids.as_slice(), + ] + .concat(), + ), + Audience::Tenant | Audience::Officer => ( + auth.default_tenant_role_ids.as_slice(), + [ + auth.default_user_role_ids.as_slice(), + auth.default_group_role_ids.as_slice(), + auth.default_admin_role_ids.as_slice(), + ] + .concat(), + ), + }; + own.iter() + .filter(|id| !shared.contains(id)) + .copied() + .collect() + }) + .unwrap_or_default() + }; // Fetched by id: the registry's listing doesn't reach stored roles for role_id in admin_roles { let Some(stored) = bp diff --git a/crates/jmap-proto/src/object/inbuxa_mail_rule.rs b/crates/jmap-proto/src/object/inbuxa_mail_rule.rs new file mode 100644 index 0000000..7c90dab --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_mail_rule.rs @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:MailRule/get` and `/set` under `urn:inbuxa:jmap`: mail flow rules +//! and DLP rules (dlp-and-mail-flow-rules spec, §2.2). `kind` says which, +//! and which permissions reach it. The set call's `reason` argument, if +//! given, goes into the audit log with the change. + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::deserialize::DeserializeArguments, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct MailRule; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailRuleProperty { + Id, + Name, + Description, + /// `dlp` or `transport`. + Kind, + Enabled, + /// Lower runs first. + Priority, + /// `outgoing`, `incoming` or `any`. + Direction, + Conditions, + Exceptions, + Actions, + StopProcessing, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailRuleValue { + Id(Id), +} + +impl Property for MailRuleProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside conditions and actions stay plain keys + match parent { + None => MailRuleProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + MailRuleProperty::Id => "id", + MailRuleProperty::Name => "name", + MailRuleProperty::Description => "description", + MailRuleProperty::Kind => "kind", + MailRuleProperty::Enabled => "enabled", + MailRuleProperty::Priority => "priority", + MailRuleProperty::Direction => "direction", + MailRuleProperty::Conditions => "conditions", + MailRuleProperty::Exceptions => "exceptions", + MailRuleProperty::Actions => "actions", + MailRuleProperty::StopProcessing => "stopProcessing", + MailRuleProperty::CreatedBy => "createdBy", + MailRuleProperty::CreatedAt => "createdAt", + MailRuleProperty::UpdatedAt => "updatedAt", + } + .into() + } +} + +impl MailRuleProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => MailRuleProperty::Id, + b"name" => MailRuleProperty::Name, + b"description" => MailRuleProperty::Description, + b"kind" => MailRuleProperty::Kind, + b"enabled" => MailRuleProperty::Enabled, + b"priority" => MailRuleProperty::Priority, + b"direction" => MailRuleProperty::Direction, + b"conditions" => MailRuleProperty::Conditions, + b"exceptions" => MailRuleProperty::Exceptions, + b"actions" => MailRuleProperty::Actions, + b"stopProcessing" => MailRuleProperty::StopProcessing, + b"createdBy" => MailRuleProperty::CreatedBy, + b"createdAt" => MailRuleProperty::CreatedAt, + b"updatedAt" => MailRuleProperty::UpdatedAt, + ) + } +} + +impl FromStr for MailRuleProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + MailRuleProperty::parse(s).ok_or(()) + } +} + +impl Element for MailRuleValue { + type Property = MailRuleProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(MailRuleProperty::Id) => Id::from_str(value).ok().map(MailRuleValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + MailRuleValue::Id(id) => id.to_string().into(), + } + } +} + +/// The set call's own argument: why, for the audit log. +#[derive(Debug, Clone, Default)] +pub struct MailRuleSetArguments { + pub reason: Option, +} + +impl<'de> DeserializeArguments<'de> for MailRuleSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "reason" { + self.reason = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl JmapObject for MailRule { + type Property = MailRuleProperty; + + type Element = MailRuleValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = MailRuleSetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = MailRuleProperty::Id; +} + +impl From for MailRuleValue { + fn from(id: Id) -> Self { + MailRuleValue::Id(id) + } +} + +impl JmapObjectId for MailRuleValue { + fn as_id(&self) -> Option { + match self { + MailRuleValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + MailRuleValue::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 = MailRuleValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for MailRuleProperty { + 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 b38b377..a85bb6f 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -28,6 +28,7 @@ pub mod inbuxa_data_inventory; // inbuxa: personal-data catalog pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_legal_hold; // inbuxa: legal hold +pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules pub mod inbuxa_hold_export; // inbuxa: legal hold exports pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index ffb7298..b2678aa 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -82,6 +82,9 @@ impl Response<'_> { GetResponseMethod::LegalHold(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::MailRule(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::HoldExport(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 f8a4f31..a447be1 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -53,6 +53,7 @@ impl Response<'_> { GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, + GetRequestMethod::MailRule(request) => request.resolve_references(self)?, GetRequestMethod::HoldExport(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, GetRequestMethod::TenantProtocolPolicy(request) => { @@ -122,6 +123,9 @@ impl Response<'_> { SetRequestMethod::LegalHold(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::MailRule(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::HoldExport(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 11549ce..8a91a58 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -65,6 +65,8 @@ pub enum MethodObject { LegalHold, HoldExport, ProtocolPolicy, + // inbuxa: DLP and mail flow rules + MailRule, TenantProtocolPolicy, } @@ -102,7 +104,8 @@ impl MethodObject { | MethodObject::AuditVerification | MethodObject::AccountLock | MethodObject::LegalHold - | MethodObject::HoldExport => Capability::Inbuxa, + | MethodObject::HoldExport + | MethodObject::MailRule => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } @@ -296,6 +299,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set", (MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get", (MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", + (MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get", + (MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set", (MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get", (MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set", (MethodFunction::Set, MethodObject::AuditVerification) => { @@ -448,6 +453,8 @@ impl MethodName { "inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set), "inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get), "inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), + "inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get), + "inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set), "inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get), "inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set), "inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set), @@ -518,6 +525,7 @@ impl Display for MethodObject { MethodObject::AuditVerification => "inbuxa:AuditVerification", MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::LegalHold => "inbuxa:LegalHold", + MethodObject::MailRule => "inbuxa:MailRule", MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 263441e..1d5f0e1 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -123,6 +123,7 @@ pub enum GetRequestMethod { AuditSettings(Box>), AccountLock(Box>), LegalHold(Box>), + MailRule(Box>), HoldExport(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( @@ -158,6 +159,7 @@ pub enum SetRequestMethod<'x> { AuditVerification(Box>), AccountLock(Box>), LegalHold(Box>), + MailRule(Box>), HoldExport(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 8c86a70..28512b9 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -609,6 +609,21 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: DLP and mail flow rules + (MethodFunction::Get, MethodObject::MailRule) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::MailRule(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::MailRule) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::MailRule(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: legal hold (MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 9b74daa..67eaaf8 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -110,6 +110,7 @@ pub enum GetResponseMethod { AuditSettings(GetResponse), AccountLock(GetResponse), LegalHold(GetResponse), + MailRule(GetResponse), HoldExport(GetResponse), ProtocolPolicy(GetResponse), TenantProtocolPolicy( @@ -145,6 +146,7 @@ pub enum SetResponseMethod { AuditVerification(Box>), AccountLock(Box>), LegalHold(Box>), + MailRule(Box>), HoldExport(Box>), Explanation(Box>), ProtocolPolicy(Box>), @@ -799,6 +801,18 @@ impl<'x> From> for } // inbuxa: legal hold +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::MailRule(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::MailRule(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::LegalHold(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 5955c74..27d808c 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -103,6 +103,16 @@ impl JmapAuthorization for AccessToken { // inbuxa: account lock (AL-12) GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet, GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet, + // inbuxa: DLP and mail flow rules share an object; either + // permission reaches it, and the handler shows each kind + // only to those who may see it + GetRequestMethod::MailRule(_) => { + if self.has_permission(Permission::SysMailRuleGet) { + Permission::SysMailRuleGet + } else { + Permission::SysDlpPolicyGet + } + } GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions @@ -247,6 +257,19 @@ impl JmapAuthorization for AccessToken { Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate, ), + // inbuxa: DLP and mail flow rules: either change + // permission gets in; the handler checks each rule's kind + SetRequestMethod::MailRule(_) => { + if self.has_permission(Permission::SysMailRuleUpdate) + || self.has_permission(Permission::SysDlpPolicyUpdate) + { + Ok(()) + } else { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to change mail rules")) + } + } // inbuxa: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -407,6 +430,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::AccountLock | MethodObject::LegalHold | MethodObject::HoldExport + | MethodObject::MailRule | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 180e597..903bb61 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -279,6 +279,9 @@ impl RequestHandler for Server { SetResponseMethod::LegalHold(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::MailRule(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::HoldExport(set_response) => { set_response.update_created_ids(&mut response); } @@ -486,6 +489,11 @@ impl RequestHandler for Server { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::legal_hold::get(self, *req).await?.into() } + // inbuxa: DLP and mail flow rules + GetRequestMethod::MailRule(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into() + } // inbuxa: the audit log (AU-9) GetRequestMethod::AuditEvent(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -910,6 +918,22 @@ impl RequestHandler for Server { .await? .into() } + SetRequestMethod::MailRule(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + let reason = req.arguments.reason.clone(); + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + reason, + *req, + |req| Box::pin(crate::inbuxa::mail_rule::set(self, access_token, req)), + ) + .await? + .into() + } SetRequestMethod::AuditExport(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::audit_log::export_set(self, access_token, session, *req) diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 5ce0da1..2888c77 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -429,6 +429,7 @@ impl IntermediateChangesResponse { | MethodObject::AccountLock | MethodObject::LegalHold | MethodObject::HoldExport + | MethodObject::MailRule | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), diff --git a/crates/jmap/src/inbuxa/mail_rule.rs b/crates/jmap/src/inbuxa/mail_rule.rs new file mode 100644 index 0000000..5b43abf --- /dev/null +++ b/crates/jmap/src/inbuxa/mail_rule.rs @@ -0,0 +1,327 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:MailRule` (dlp-and-mail-flow-rules spec, §2.2, §2.8): mail flow +//! rules and DLP rules. One object, two kinds, each with its own +//! permissions: `sysMailRuleGet`/`Update` for transport rules, +//! `sysDlpPolicyGet`/`Update` for DLP rules. Rules are the server's: nobody +//! in a tenant reaches them (settled answer 3). The request layer records +//! every change in the audit log. + +use common::{Server, auth::AccessToken}; +use inbuxa_features::mailflow::rules::{self, Kind, Rule}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_mail_rule::{MailRule, MailRuleProperty as P, MailRuleValue}, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Property, Value}; +use registry::schema::enums::Permission; +use std::borrow::Cow; +use store::write::now; +use types::id::Id; + +type RValue = Value<'static, P, MailRuleValue>; + +const ALL: &[P] = &[ + P::Id, + P::Name, + P::Description, + P::Kind, + P::Enabled, + P::Priority, + P::Direction, + P::Conditions, + P::Exceptions, + P::Actions, + P::StopProcessing, + P::CreatedBy, + P::CreatedAt, + P::UpdatedAt, +]; + +/// Properties the server sets; a client that sends them is refused. +const SERVER_SET: &[P] = &[P::Id, P::CreatedBy, P::CreatedAt, P::UpdatedAt]; + +fn can_see(access_token: &AccessToken, kind: Kind) -> bool { + access_token.has_permission(match kind { + Kind::Dlp => Permission::SysDlpPolicyGet, + Kind::Transport => Permission::SysMailRuleGet, + }) +} + +fn can_change(access_token: &AccessToken, kind: Kind) -> bool { + access_token.has_permission(match kind { + Kind::Dlp => Permission::SysDlpPolicyUpdate, + Kind::Transport => Permission::SysMailRuleUpdate, + }) +} + +fn server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("Mail rules are the server's.")) + } else { + Ok(()) + } +} + +fn json_to_value(json: serde_json::Value) -> RValue { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(n) = n.as_u64() { + Value::Number(n.into()) + } else if let Some(n) = n.as_i64() { + Value::Number(n.into()) + } else { + Value::Number(n.as_f64().unwrap_or_default().into()) + } + } + serde_json::Value::String(s) => Value::Str(Cow::Owned(s)), + serde_json::Value::Array(items) => { + Value::Array(items.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, value) in map { + out.insert_unchecked(Key::Owned(key), json_to_value(value)); + } + Value::Object(out) + } + } +} + +fn date(seconds: u64) -> RValue { + Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into()) +} + +fn to_value(rule: &Rule, properties: &[P]) -> RValue { + let json = serde_json::to_value(rule).unwrap_or_default(); + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(MailRuleValue::Id(Id::from(rule.id))), + P::CreatedAt => date(rule.created_at), + P::UpdatedAt => date(rule.updated_at), + other => json + .get(other.to_cow().as_ref()) + .cloned() + .map_or(Value::Null, json_to_value), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// A rule as sent: its JSON object, top-level keys only those a client may +/// set. +fn client_json(value: Value<'_, P, MailRuleValue>) -> Result, SetError

> { + let mut map = serde_json::Map::new(); + for (key, value) in value.into_expanded_object() { + match &key { + Key::Property(p) if SERVER_SET.contains(p) => { + return Err(SetError::invalid_properties() + .with_property(p.clone()) + .with_description("The server sets this.")); + } + Key::Property(p) => { + map.insert(p.to_cow().into_owned(), value.into()); + } + _ => { + return Err(SetError::invalid_properties().with_property(key.clone().into_owned())); + } + } + } + Ok(map) +} + +fn parse(json: serde_json::Map) -> Result> { + let rule: Rule = serde_json::from_value(serde_json::Value::Object(json)).map_err(|err| { + SetError::invalid_properties().with_description(format!("Not a valid rule: {err}")) + })?; + rule.validate().map_err(|invalid| { + let property = invalid.property.parse::

().unwrap_or(P::Name); + SetError::invalid_properties() + .with_property(property) + .with_description(invalid.reason) + })?; + Ok(rule) +} + +fn forbidden(kind: Kind) -> SetError

{ + SetError::forbidden().with_description(match kind { + Kind::Dlp => "Changing DLP rules needs the permission to change DLP rules.", + Kind::Transport => "Changing mail flow rules needs the permission to change them.", + }) +} + +fn rule_id(id: Id) -> Option { + u32::try_from(id.id()).ok() +} + +/// `inbuxa:MailRule/get`: the rules the caller may see, in the order they +/// run. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let visible: Vec = rules::all(server.store()) + .await? + .into_iter() + .filter(|rule| can_see(access_token, rule.kind)) + .collect(); + match ids { + None => { + response.list = visible + .iter() + .map(|rule| to_value(rule, &properties)) + .collect() + } + Some(ids) => { + for id in ids { + match rule_id(id).and_then(|id| visible.iter().find(|r| r.id == id)) { + Some(rule) => response.list.push(to_value(rule, &properties)), + None => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +/// `inbuxa:MailRule/set`: create, change or delete rules, each checked +/// against the permissions for its kind (and, on a change of kind, both). +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, MailRule>, +) -> trc::Result> { + server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let data = server.store(); + let actor = server.audit_actor(access_token).await; + + for (client_id, value) in request.unwrap_create() { + let rule = match client_json(value).and_then(parse) { + Ok(rule) => rule, + Err(error) => { + response.not_created.append(client_id, error); + continue; + } + }; + if !can_change(access_token, rule.kind) { + response.not_created.append(client_id, forbidden(rule.kind)); + continue; + } + let at = now(); + let rule = Rule { + created_by: actor.name.clone(), + created_at: at, + updated_at: at, + ..rule + }; + let id = rules::create(data, &rule).await?; + let mut out = Map::with_capacity(1); + out.insert_unchecked( + Key::Property(P::Id), + Value::Element(MailRuleValue::Id(Id::from(id))), + ); + response.created.insert(client_id, Value::Object(out)); + } + + for (id, value) in request.unwrap_update().into_valid() { + let Some(current) = (match rule_id(id) { + Some(rule_id) => rules::get(data, rule_id).await?, + None => None, + }) else { + response.not_updated.append(id, SetError::not_found()); + continue; + }; + if !can_see(access_token, current.kind) { + response.not_updated.append(id, SetError::not_found()); + continue; + } + if !can_change(access_token, current.kind) { + response.not_updated.append(id, forbidden(current.kind)); + continue; + } + // The stored rule, with each property sent replacing its own + let mut json = match serde_json::to_value(¤t) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + }; + let changes = match client_json(value) { + Ok(changes) => changes, + Err(error) => { + response.not_updated.append(id, error); + continue; + } + }; + json.extend(changes); + let next = match parse(json) { + Ok(next) => next, + Err(error) => { + response.not_updated.append(id, error); + continue; + } + }; + if next.kind != current.kind && !can_change(access_token, next.kind) { + response.not_updated.append(id, forbidden(next.kind)); + continue; + } + let next = Rule { + id: current.id, + created_by: current.created_by.clone(), + created_at: current.created_at, + updated_at: now(), + ..next + }; + if next != current { + rules::update(data, &next).await?; + } + response.updated.append(id, None); + } + + for id in request.unwrap_destroy().into_valid() { + let Some(current) = (match rule_id(id) { + Some(rule_id) => rules::get(data, rule_id).await?, + None => None, + }) else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + if !can_see(access_token, current.kind) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + } + if !can_change(access_token, current.kind) { + response.not_destroyed.append(id, forbidden(current.kind)); + continue; + } + rules::delete(data, current.id).await?; + response.destroyed.push(id); + } + + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 44fafb5..aa72ecd 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -10,6 +10,7 @@ pub mod access; pub mod account_lock; pub mod legal_hold; +pub mod mail_rule; pub mod hold_export; pub mod hold_export_api; pub mod audit; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 440db94..f578905 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1748,6 +1748,13 @@ pub enum Permission { SysLegalHoldExport = 672, // inbuxa: personal-data catalog, the data inventory and compliance overview SysComplianceGet = 673, + // inbuxa: DLP and mail flow rules + SysMailRuleGet = 674, + SysMailRuleUpdate = 675, + SysDlpPolicyGet = 676, + SysDlpPolicyUpdate = 677, + SysDlpReviewGet = 678, + SysDlpReviewUpdate = 679, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index 912925e..ed72ffe 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7091,6 +7091,12 @@ impl EnumImpl for Permission { b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate, b"sysLegalHoldExport" => Permission::SysLegalHoldExport, b"sysComplianceGet" => Permission::SysComplianceGet, + b"sysMailRuleGet" => Permission::SysMailRuleGet, + b"sysMailRuleUpdate" => Permission::SysMailRuleUpdate, + b"sysDlpPolicyGet" => Permission::SysDlpPolicyGet, + b"sysDlpPolicyUpdate" => Permission::SysDlpPolicyUpdate, + b"sysDlpReviewGet" => Permission::SysDlpReviewGet, + b"sysDlpReviewUpdate" => Permission::SysDlpReviewUpdate, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7781,6 +7787,12 @@ impl EnumImpl for Permission { Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate", Permission::SysLegalHoldExport => "sysLegalHoldExport", Permission::SysComplianceGet => "sysComplianceGet", + Permission::SysMailRuleGet => "sysMailRuleGet", + Permission::SysMailRuleUpdate => "sysMailRuleUpdate", + Permission::SysDlpPolicyGet => "sysDlpPolicyGet", + Permission::SysDlpPolicyUpdate => "sysDlpPolicyUpdate", + Permission::SysDlpReviewGet => "sysDlpReviewGet", + Permission::SysDlpReviewUpdate => "sysDlpReviewUpdate", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8464,6 +8476,12 @@ impl EnumImpl for Permission { 671 => Some(Permission::SysLegalHoldUpdate), 672 => Some(Permission::SysLegalHoldExport), 673 => Some(Permission::SysComplianceGet), + 674 => Some(Permission::SysMailRuleGet), + 675 => Some(Permission::SysMailRuleUpdate), + 676 => Some(Permission::SysDlpPolicyGet), + 677 => Some(Permission::SysDlpPolicyUpdate), + 678 => Some(Permission::SysDlpReviewGet), + 679 => Some(Permission::SysDlpReviewUpdate), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8908,7 +8926,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 674; + const COUNT: usize = 680; } impl serde::Serialize for Permission { diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 80705aa..524954f 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -79,6 +79,21 @@ lockedAt = ["metadata"] lockedBy = ["identifier"] delegates = ["identifier"] +[object."inbuxa:MailRule"] +file = "inbuxa_mail_rule.rs" +default = "none" +whose = ["administrator", "holder", "correspondent"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:MailRule".properties] +name = ["content"] +description = ["content"] +conditions = ["contact", "content"] +exceptions = ["contact", "content"] +actions = ["contact", "content"] +createdBy = ["identifier"] + [object."inbuxa:LegalHold"] file = "inbuxa_legal_hold.rs" default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 5822e0bfd25a5dc8449ec70f3b06a3c66bfd767e..e14adf65072e60c58d3628b0a0181cb1b60fa130 100644 GIT binary patch delta 18933 zcmV*dKvKWftqG&934nwFv;s8>f8l{*1d^Ms5FqLH@f{fG;MvxKHJXtNwSQdtq}W|o zFdgstXHU+r<07FiMg(s%EII!c)wGK3RZ`%8FqO9rn-o<4|)5oV^`aNAnR9#)fQ!iS>Oa%Kj)2iN+(dxx;WWe16DT`9; z+DNxWbBr5h+V^BJKzy8Ge`T`<5Y_coI#`dvDK_=2KoIM-(k#w)F(l0d;ienfcX2S) z^x(rK!OVx zy*CuX0EUO=Mc)bj%DckTk1#?Zj#JoUNo6Mj1Xb!e@th7egTx*If4oHP4)zNN0GC=M zh<=^I7Q#f<79cNf=jewHOz4W-W#$Dpu`VGtNVgqDQ(RFv8pYgKeXb8Z_=3(KYBvbN za?U~e)jW(NtmPc1pY6jq(n8LGhIKrQ#8+{SV-|4{C3FwnTN7<7WN6unT;%3!;S|7} zo(J)o-$Zz29YBoze>%YMJ|H6LUN8d2JAw$N3p)ZP^ac^l^oJ2R)+IzR)hU8d_$ZWD z@t7nGJ4%+sd4+QXGvPt`^QzTEI_5Pi z#Xz=!cm<1CrjEzpcvXvV?f?$M36(D5_4Pgm$f{q2(=8AJf7|8oXoS}S*Joe_+6xnk zCWsik@+BBEo5YZKy-P@DDv2QpRW3pG^GFPf)wYDBCy@}&PTm>!nY0#{(g21}P;;@ByNzft)lT|=hcLAkFlQGStH3?Q~ zG8uMcXW_z%e+?$%_LJBoV4=BWSUs6VK;f7QH2d`#xRiOVr78GW=T>Mhha5rWO%?_= zjW!jVw_6z7wA@r|q4C1tee+F4XDt}U)@>L^zB`4BEck-sO@`KK|L_FUcpKI#eGH7X zpoFKJZX%>$rR9*i#U?`X_L4A7Lrnzrn@NKFU0<#he*v2^x+`kR;NGPvCv1Wu@`Kko zn~1$kIGoe)CS+|ADh5F#b5ksX%i-<#F_`guO#)MftMApbd!XmhvwOJb(X)HF=hCx# zz~|7j!3fae>HRL+F$`0%DGkGfeldncX0cdq$T$|29?!)Hh7>Hu&xCdc8Nry6m1#)y z0L!E-f2r43robJLyGfwK11r<&u4kVPd`N|k!wP(=FYZ`ZH_5zL zH9aW?$2wL3QTMB9puG4psIH4?puBShP)*;O2J3gP0H_W_6NE$$iRUvi32@(UW)eWZ z-_Rt0K8LAEfc;)$6G75*rq9y zgT7EU;C53Lk6BcgTbB*pH!VU=Y%bJwYpyQyws-H09*s?&D4vsngMgM@QY9>W9@L(&PDwW8l;d z0VA5|?kelK#&ITOXdDf1hUhzK=WyrDtD{pD8Q32=JnXOp#m10;riaipKvrM@(3E@< zARko%q8U`vApPMb0IJa?!eQJ6f806)Z>*P4kcCF^g<95v8t*7m0N=K4^n7Fk^qA~; zF2)evRB_us$>WX3F_4K_8G}-u>28kB={~PNU|29y2Z7(SV<38*#Bo@$IF2q}9ET4J z4rE3}9LXOI97hcX3FT}XKaayWccIon+3`k>B9s?!jlSNG+QZ;(>6SR-e|9VfZ$j3l zq5d{fN1r1l{B2Sl#SqRb?}Q5y`g`GQ2n;k1BiuBVk<2)Vf$?Dw05gLj1}2P!09rpH zVsLCg1ixA@-RZn=&SpS6gtc^g3-_sHpH^iH)R4frbe@*ciBRvA2n*M9P zau$Vz*N9cvV>rZKV6>g=-j5ccQY8hn=}A*y`9YqHY!31c0_XK1gPQ}rgTRG@JsG_} z*gFWG9q`HE`hYJ2e{Dh!X|FcF$LUm|9Svn1k~gA=YT9r*FmFH+*tFktV4?9u+XmxdDpRUvVqdwPdwY$|P93J8p0WWzIDk2Y=c zoQ|otcGEJcpBtK&{1XCf0=3Y&(3g4NA98qH^!tONabsEEAJJf)SX$WKT{5ECoIdD>&0(qNyJ0D08 z{EO0%Fn1^N<&7s%5P~}n`4GluCBSjcTf<7IRp6++7BoIP(ySHsN_Wo?@{`; z07?_|3za^K##Iaf(5M&_F@Jd<5-?3~nusYV8UkpqXiP+9x`u$Mb&UYx-E_NPv%8yW z#ACQqhUIj0wCspi@+nQakxc1f7~xhjN9u1RkAMlck~vs^BY6Z&x|Ph)h8xKvaQs#> z2QxR4MJTo5p3+;CMP#%s9l#0QWCARB!#$jVaqH_zwd$};&dk+3;C~}@K!vb3n(WEG zhXEFo*7Tq}p<}3!Eb!qL-j2n4PzEBM!$0S^$Plct)YYM8W(TSjHHwCt|Nyw-%-Vysme0=5g2Am+iq(u;Q zetdCi$Nz?2MFJR%2Hx;gF}YZufC!w!Tph$dd3Sb!XR>fQ)T)OvqM(0eGX)buv2A;{ zARtW8DCcX=&4Z*mH|zouatexb^AOOSWduQQZXN=Pvy33A&CNqdZLYoUfeXB>1 zSj!7Iy6t6@qItfUxD0>n`@^j=$nOuf${?R3)G7o0-axAW_`mIDnY!0xxD%$(1JF_Z_a;(I66R4#mc;oi$`Q;Y8U*1}CSH{&ooJOLzb#0UjnG(iSo zy-~!`-7yZu$|XV#MT|i4J}F|EP8kRDyQK)F`sD!h&%gW&>`%5ig$HPE_dCG+zkBer za+87Il?AF(U~hlC8^rAVkm14hejV6gw~m+Cw#&Q(`7b&6P32>7uyz!#9moc(6_vK_ z0VphB1s=Bh(-L-EE44+likPWn`yf!K*qc0P0O+8OLaI_Mrgh$=%ffxvgi_Wz7fpL# zXNx~z!CviG82AnX|7@zYotwL~v1|Jc{DeYkb`1T4{MLUr!F+2~pbMsqTxS|Bf*x`u4Z? zH|MG=A=B(mO>b+5*={2YJd9?CQMjIS6cesxwkh1?taH0URU-6C-1{pN!uy*uTMj~2 zR`2V=`#67C$5M=DSz*joFyWz@0AB3uE5}Fpn6KN78z!@Y60B)(CA<7<=EmqlnYUSf zziEA;4W-&r*!tw24i~$i3a)lojI{7)iOZ_A8}qVC9x;-d{Z$IBW_MDb;k10r>#D>G z{s4}Q-asPu1g)6KU0s2>cHo9@X?6<@5BekG7{h=d)$nV9-Y5w!b)U!E}nwSJuOzD#;Up55mlq@9>h# znu z*W7=DA8$qDV_nG?K?dL+0o7Rf^y>5l?m@;qnnUW31n&q25fJxKd$gJu+5|l zVMdy61)6np*A>OyR7B>?z_(Dt5JbG*zX20Hyz3)<`wmD@lvcCm#r`iav|+8Q@$AD! z!$mPIv0+Bhc80Y_gN8APR9Uc)!7ScZtEzvns|jBE*d5tB%+tm7k(~)-c;zv?Qkdvt z?^pXtc9(XA^ZbNLA(woow&BIQ%02{837QY^7q3YFy@BhcRq21{eRB%S=u1nyYi1cF z&0g9cQEW#7+mXxmF=J?7GLD99M*^FJcAOq#)Z(<~XKzxWv!7I#`_(H)Ils?aI4!zbq3T;A(P5u& zn=pHdM2CHGzk3;EZ;!>TR*Bgyd@Z8RLQIw<~Okk13z^KBll6 z$rEw%GyF$k{v)4XXv{%GY*Uo-``sYIyWhuDoFBDFXqx<(isMF0g@%tMbrgS*rBr92^j+u#jYUDIs4;0#`&&41?YF>ouszjUY4E6uWw=R;-#T*6e?V%WZfnD{FS# zO4_C-+*u0wXe)akSxGFbm7Rr^#IkgFUav%^rla-DwhQSL8>w2`M$XspGdWgUKyRF- ztBvQ6G4B&45!icX=u0$r4!U@1HNH3SkcSOWy|MP$`kL#%Kzpz8-5p#EvtF@w^hemX zw_s%=G`mPnx}zMz!y%2&PN|&v4Mt_8iQ!~v7iLI)?QErcJFRWi)QP{JJK5N~T-!Y{ z^cADeW?1dhgJx7=Y}vv@>kAaC=WOkV^+|AiTN87(hhQ^~7vO(8lcaL9g8e-tO}F4R zBoZ^>DbPcWVp{6S1~Dez8^iiS`i1*nBjaf4`<~|U43__O3prwG{jZmCTE``W7(V!A zVf=P6zqUV-yW}&EysI2z+4a@6v!iUbtu4K{xV;9e;!+=MIrDT{z#*!=1gFKx{DaF< zuYI0+?J>cjpv!-Q$G9$w@@u0@)y{iCm`ay_Rh8ktj?*2e`~?JUa8KLRsfu&lrjimo zQgc){O_+gkS>iugTqb|&t((fUo7i63f0ogd#BwiUD&p+5V_n700A8z=zZ8N8PL*JA z$7S`^{nn~lXY1JtVlsd_=I#p=Ejc!{WYO)&Z`xUUw>y6z!#X+30KCjoXH}$>Z@~ z*YkS`XerQOLU1HhRPc5%JFVO89gTUn$J4!!aOMYFQ_#%TS#o-J2U=(2Du+d?CTB-E zG>McgN5Fr7%ov>k7*p=qb(Hp|E;%@0-L4=XXB*L^CH)vUffI?9zXLL}U*jBznKZlC zuJcx@m3C)?M6FahBLXxy{N&Evpu&rx$th*!;tZU#uT!^MZ*xcM?0I;_)a=-QVAd`H zzD5j#uBaMWu#b~|h z>_#W6Q)igdWEo-7@+8HX%UTBA>8@WK&xQkP_siZ^?1mDO3w{?RW4v^g8Yej|^e0@s zW(A(8|JiE+_4J=BicXEip3Itnan2qG?3R2dz33BhO=zJd{tPoz3%5hugS*F+*>pbW zqy&G${!}yUU3*Dl20B+&t{X{`T415**i}HX^#+Wy0ahESw1$a1!5PS1W7T@%KBEXJ z$>~>$3<>@&$D3^RAP70e03)`9_=K;2J3YR2Hw{~$K{ivm0>U&yN==<)}gsOkkk9P&$j&&8PgWabJO(*JkUem8&%z`s|K=`gsO%u;70- z_VLMLKD$QR;_V!1_6(#1U;XkU3c~-mAksh(Y0hGK^wUo-|IKDUQ7M#BC(z6VD12;B z_ol6jH@5ZS&F#(k47M3xp)VH6e@}njfGZ-bSP9sG{7!+g$E&-T^l`U(sHzx9@0=F! zSF_5n@29lJF2XWz|7J=5Z3YSEvZ8UzaMZcH{pA8wfMk~ZLcFxH`T_%>D_erE#D%Ls zyrx7M#@kB0Z|{DwZBe&xX1{P5uh?+Q5KUNCk;$dRcAHa-gSUxo(+hEyxR`(7c>ELa z{CUc%E8GADCk@=yxvhsO1G85ejAf8FyQ-n-&?(iba$H8{z<*zo+ZjY8_*Ws9(nL7;j$tU`fnOqmwZ_qsvjOyU7*m*~uS% z_D2S8Ff4X8db}Gh+X$GtyBLsKp>Cfb!7a$0J9Y+E?bip331$skKRRX4*vPA==w^4fIo_q#yF6L+F$(x(23lXO)X)ELT z-cy#zpK_p5nf@rSn?32E40g}IpF@U<{hy!rn96dY`UC8=&PIRc_j633tSjdtdOXYV z8c4sMst4fhy(6ff9fZH=&~+fG?u0kuL2lUM#YDH#M$mk9(ObeNeBZL0i(qG}ee-Mz zRz5JhP-r2_!Os|GvWbAZK_U#R!nEr6m@%}4ilfUTX=2{GW&>5$d%29{3%Ie)EOr;p z_~OK2Ry~(2t+s#GlOU4p(SKRN2XgIh(?K1#bpH$5N#Ql!m5N~++tbp&o`u;|P8oRO zGLkXvy|E%r7X&Rvap}9fV4cS-!BIKBOw$-`9>5hs6>D^)BCj4FX)oec|!2pKcudr zp?4TA$jd$0RNo~xX)hM_m(vm}{@i#~qyxiR11V{bP_Dm0}CPCR71Ys7c?&1JsAKSb;sQ&CbA* z_ujFC-VA@lglu}?9o6*rss?=3f2G$SEO@^M*x-#?D%E?E;L&Pd-|EY)YxM^2TbOsW zScmw!t58RRJ;Uo1>^OIKuC2@vlclCQ2isq>H5>+{d&l1V6#74c)MYVewR$8?bNJ#I)Lp%wD++&8fPMaqE{U3eK0#p}%`6~}1&s((ktNYB zm??43bz41TjzXvhv-+~x%u*~d9Czv^uT}{$KydtiAO}1ixX*&UxSgv<_Id*|s00rI zI2-_h+}V+&A_ymr(~boI&O0GF+AN%{WjVEzFz23BExiK*cpfH2&Q9wQ^Vd4tBT0V- zwP!KdqN-=^61-DEdmcR%uo!rJF8J=HSoOEzlPu3_ot zU#3e*2#JXz3ZezPeC^blM-W6BSnZ?_sdP~w3kYPQ!0U9IK2%&lr%M9meV0oDlr9Q< z?g~)=(**&r)2E#vpwb0_0_K0Zy?c)9Q3Rnn{q`+=gy0vFoJABwyU)0IEn_%u)RPMAARi$s4>xX_zA%UZD6 zaG{>Ug?>eYm3y}AQ@GGu81GUK*Xdl~w`!A?W!6Avyys#LRS@o^>5Wo3^E)ufiFIuV zf(RT`w^b)}hMpn_<@aeJ^a7nO36$WN&`3Em6fSh0-dDmLt&#=yd_-t zI!S=i1p!=urSWA5f=G3r!rHx&KGTyVu@W_$RFcvZE_8_#{!(ZdLl8tjc>zUV_)bp| zgmU;P%? z)2%p$fRfzG;|Ns8W%tkux$G%|5FCM5(!^s3f=E~9!X%=SIr~R25pAmMflKrhTZryU zp|+Vi5_Yo0UPANLu8oG%L_tvO3U`9{NupQ_=Mu7%qDVrJi4Hb^qF7H81@G}dtV9(I z?&W}r4ttnJL~(x^6U7*XJO-l+nw`=p-oO5!xII9Z+-d|wv?H-z)Ort&lrUS zowbeUOmU2Wl%Ia<++C~0ln~P>)OnXTqfo>Yg}Uv)UOj)3DZ>*5dJjuyI?~Y%j|kKS zdUY4*$xT6HY8E(LCjtIAZG4$x)G~FrH2GQy~bc)b=rg^lb%6@ zsNrHc_V)oj?yK8_&PMU`W&a*Wu33@GiZEiI_~KS(q(`hOn5c+3|W2$)0`rU;^nF< zML{BnB5+hCc2YwTg~DrVbVWT*_gPuWMN<@6NS@R*K@!=(-}_3E(G*z-X0(l{{tPrp zFz?ps8rOq5`z8q!J<)Ut0Q9)x(_6e)+SL*O(i49}mjMEbL_~c!2CrjW^5tekVnRew zbiYaSKRU4`dzvIz<3XF)n3^I9b$KcmP7y>A?7U(J&=f)FBrT!mK7f_sQOco17RA3b zl`KevpvWgEd-f0%OcYrX$LJVRhtUL41WqfRrvZG5Bvfs|7fb9XJxLHdU2nlPR;-eq zB#D2)Rn{`wNqws)Nn!(!Cx^1Ltu3xT4-hZwSv&}=~5EY6j2BwJA}T5wBXlp ziTiZh)zYXSVnRewG}MrWA_;YHC#`_vPpN+?ETSw5t^%pJ)gEZF;6DAMb38#fO%&Xx zrTjS}2qJI6*m{?K6=V!W6nd1uqY07-ToZzeszo8qycSUq1s9qeP|g3*NF!+_B3lIR zU0HpNzz{hiegR8%DJl{^M+D@Wu3-B{{V-Dm3Opc-PsezvDU5_X$+36w$!GeIJ*Iy# z5JisxKpX|s*kX+|@I-*XTm}l`Dgt9T!XP8PCDKc}$NWJQB!VRJ2Lw707f?eHgnonk z%j>71NJ0UlPSBJC7fCbs5Y{CKBJW@`4 zGC!SNN&rAf&MrlvnjF~j&r+LOL_vQPtY6ouq`ia13ki}41Z|K6Fj->9on?8S72=pV z$Bm_?io%ihi8sr6^(Cu6UDr4m#QK z0n=P#U|8+2nH4R)4jwjwVw51ePHZ?#7TB*ogBEr#Rj8o|Lf0v3Zi0*^$RhV;UbkI& zFU$2*QMg9qW4^3J*`6qg!jWy6OM3-Fk%XYOXK6Q6i>D;(G6Wz#Rd;`%z*{;m0|<0a zeCN3v+YHsG`Kk_mAA-NH>MSh!AuBdjP*zbTVR(`fq`^ce3KKOUswfP1ra#SbN8X6i zCB~ssWI_A}>S7O{maShzO%zoWF5yXpPiJM3wW2hV6GfK9F+zk?*P0-T+{?$~njnfa zRnfKbcQi>7gY!~wge-r>Ho-GeO_W5d1_p;HrzwIEoJ&GGo;4)FC5fD9a!9-uC$gRt z5X)uzeoqRBRiDUW8I-LOLl*%m*d%m?ELi$*I;8loinCBJJw!P!F{kVbl9=mNkk#GPEB)8tMnu1%GMH=Vd| zROzw+o=pc#x0)I<-rnY)JK1n(D3TCrWvw)@3`G`dD{<#xD1y)joB%s{~btmiaD|l%Sp@i2Wmd0GDy8iy4X}ghrSz52d`W*;HE? z=Vu68tz6sP&5=)%#oz|4Yqj%90kICxj!xd!%p4Nrk%7Q`@dupA<^ha>91{1>`xq@C zxVq@zR5l=~iL&T*T5R2(%V@ITO$F1od;5VCWl?$>?9+dB6{>5ZC)ZW5o)`P-OW@0j zB#Yg`)viJoQzTjJ1Dsq}53-!5*@8p?8(4Y2hfo1a@Ir8->M^P<+~nxH>5BAejIn@t z-FwEWQW^8s^LZE&87rJ>?Q6!XQW^6ft-Hw|5(U<3nF+j@%9xwYJ)Fh2jNPOQ0uOL( z=)4W=5tx4nTniF1uTp*H*XjLc&CyLVXWw{1PMl=U{?KvW2Pqrn{e#;b?PSglm-*bA zS9UUE&)`6u)wxb(%-|6JknybOWX6t1?K$ICsXp^DUg2+D_bex!3BcmVxhaveR{1h5 z8a^H@iXc?p|KSEr@d#W5tO^gJh#@eMs;IWEvB7^%_SsM1axN>hO607dgsxm`xRcHV zHr^eOA(64>1#4GIV~pUg$vG*Jvw~kt$)}4x-4}QR1-b7U-U$YRFY;^pjd4?&&xoE< zd@So!#+)tdiZiQJ#tioAP5$X3FE>mAjMbYwArcoW??1h(c-KNgLIf`IxaQoCeC9f< zL9c&n*swOqj2&tgVzaX;rcd_;e#0AryqQcgXWzr6pjzP9=}Z7F1U0@j(DqMZiOBlXC%;%-L~?t2w($=Ijsz9~@XZE?@|PNJt_e07GuQzvpE( z=}e$19#Y=MCYiB=*K%3yAx0$Yb~cHL;Wd9w*s^bT9j`8=C7Q+s)dLGmhP}I>(=i|x z=cZJj`NH=T91=d>~PcTq5D>5!W87tB*7+M=Cv$mNWR$Bmh)2@V{`*M4{1K*^#)EvKS6&$ zH8!IYB_RqELf-{2Ww5*q1SYb_T|~;e@j&oJeuu%w`h-ektmv;>rfhrBr!#^5w|xQ5 z!K|eRf-eG=@%xG|l_r_7=lgWcDpjZY%!|}N60}k|GwcwGTwvGfTmU_H({jxv41tMc z6<=diB4dSV^?t)Or9Pbrz^nyMNi~1dmIeY7fuM9{_D~gjVF0*9U-UKwV-d@oPUXzF z8z@`OuG5(Sz82VUc%97HGuTkEhSRB>87}xg=8v2muJ>a)6WHLDP{yuOCFb2m41xh1d(_GL_~Cn6;T}QBYbxy#;4hSr*@PA*v{Rfe-fLqbj2CKox~A7av72OqT`F zOG_3ZiYNqe1Oq~fA_{*&*josRTX?FkL>9$qMiC_yMHbqFHoBLk6iE~V6%*Pvm%C<9 z6h%Me6#7mU?TMo3NB12?k06Tt15b%zc$M<^EVBK`6@(|tiyu5cEjyJnpJd#Pk&(FA zY3dZoPUXyA`R!CYZ0xB$iQ_MuxVZiK_{3q*DNf*7ynOY8Su=l}ptCN83!z`6yPfw4 zTm(xCff2cgA#jlkjGO760r?az1TL2JtduvxBq|raPVuoJp)L%8i!31YF}^nnK~05> zI#e$FkM5xoq*Suaj&FlvEIIImhZ=Tkp3#_~s^T+$))9!#(-_Vb-(xRQ#HrH(Pu&i9 zx^RdKpZL@1iN}9HDK^vne&;-eOuuc0+!?p@(@uk4{ zQe>1MU&@#wsEm3fu^vgR5~s-cN??2?G5#np{wOj2BryIYG5#zt{wy*6O08&Zvx}L35+wL9cNNA&V*K+y%ZTG$d@vv2r8oV?Li=P%c+)V0lGnb1y0vUfo=iprf!Vi>R^3}%Y?bAJh_s9N3`2Ltpgzs;U zeI`ipnM!oxZkz?kP4@);y(>D8my&F@u7=3s;7fz+(Ao)Z%1DA3+`4i%c^*L!LB$XZ z)1@HPlLRsF$#P%YcG9^3gzULS$g8zRgRGUSU z1Uq<=tY{=LO%a6PR4}bGu_AhsB(?^Vlq{twk`TVRl}VLhC|n5iAfP!Xi9SUTf6MiYc|Iv4mABA{Xbw3^EdBtZ-=e3Y&ms88WSU~Yl}Kjd7fr*I+A9W%T! zb0Li=B;s|M3vo;d&JR9?3xR(hf>zYpI0yp`+@Ciy4nBB{$ zb}DD4GiCu>r%M8OC;?|hl_Z2`5=4^(>-2N>D2i!{EYwQ#h#?3f`SuP@VIZuAAT`hg z!G~)1S*SUkE(lb2QdfUe$pU)|SD`jlAt-eNiHqGr;83vFaUnwxMBsd<6iTAgWr0Sl zv7W+(-WT8(QawrS|nzHMM&RKjG}Q@GHFblrX95-M3@N00ha5>Ny|1Wt3M^+Bgg z0!5j3jU=H6f=E@|SMtg`khs`Ka9B%)SyHESfqzfoHMAuc(CL4Y0QhxO-!}V966$G! zAUxIX?!BCJ=ha3ubUP1->J*FV*Vh&uemJC=XXY{~Hj`uJ@+@4wb&FZHd>aR|Z+LrB ze?KbiMa6ySTgN2$)-?&D*X@JrjoT;LPrX5K8rOtrQr!38z(E1yV}pVNhvB(1{Yq3W ze7x} z5_19G9a$p@Xo@5RgQaz+5=N55_7FT@%$p)`k(&(S#aBWF7=kV0dP1FfE>LGpojSLw zb%Mph<4Otfq<%|x&Ue&ri{agu^fUNbJG;ao&0>VdG4_A<$JB58c|gZXf8ko`FPtHH zvG9q1yEyR}D8+ew5M#o?ZJ5s~o|9(I3xpm?7F#%;#~y);{0gtfxd%@k!4kRrcZ&^v zIHcL}+xe|SS1C3V*g!tpqfIAREY~--%?y9p#Kr9|*G>(Z6q`vMEd2m4vODkA+Q||- z1nlyrLPmcQ#9qS-5^!wh-_sPyf;dcn5Lt37NE(VLf+TX6wo-vq zx+H-2rNQ%SFA4QDLGTSYfLC{wAZ93%Q2sfWBrsWG@5=PfeXEI)1Tl!V4-<*IZgH9@ zxLxLAjT?$21VK3AE*Y$$g(PTEW#O$XC3Ydu1t5RGhGR%(BteYA8@R!x7)cO&mzJ`S zCP*SVE?IDIRBAJeD2Z0Mh)80JAc+Xe1E&ds7wPA&min$Hh$1xv?`qOUlrl6)FkgR3 zrQxLsg2*MT{8AgMbV(rZq?)5-iM@h%KMQFtG89n=UK~zE86pTG*D%mNUAy5>Y+7>d z#Y2A?h(+3oLPaW8z;)iP;JqWMBlILm40^xof$md8A&A?Vi84eGMIe;Yg$ku23PL0$ zLl(quz)KVc+`Ux$15FTwTMDj*#n5Jdiw?gV~JwV5klsi~MM1Dyu66lc*f zN%rf~SGHz`zZ~K_hxpEUC0V!&B3=W*fY;73gif$uGQGB>&r@XS>M7C$a9xdr@4QO z`b#r#a;}|T%W1CQ1(+njeC*|>PxY9&^O>FMGIPH%E74{3Cz?LVW^<0>y<>Rk2wpsX z|2SK`w|MBMO?I}1AKk5?cWkeY?D5&)qB+iY5SwHHft8)dgWIBc`D&JC&Ftu>pJ(^0 z?F{ajzxu^?;(&+YU%q&i%ug4>>=X7#L(>FmSjG z{~i5x2FBd|%U6H>DF_C8KltzHugUe*`9F|T#++rV^(Mo=O*;FMx0`r@PmfRDo+j{j zF&{0p3>~Zl4?0>C9C|{<*dI8ocIpG_=4N`$)Cbhd&GcHS53ZA&XpN{r0_%TPzS`gJ zcm5c$hyKa@@-%@CI8S!OZfDSYqy2V1Tih-LzOquEdQ-WP>@F)r)&j|wv`NVCl^(eV zf0h23`vHkU4p|6S_&Wjiu7rooD~R&>4c-vffGw6xmleVvc17!#6^`%g7O0n1yXba1 zGy&Q9yUDgyo9VaKAvf@Laan)0NuyYk#qIp&77Acza0O2FIHAIp{w-W4yU4)-y>$%F z#{);Q0TvV-u!&oLF@SsebqChoUbM9v;z1=~QQhtJ`?AUJ%WQq-Tv9fvP_J!v59MWY zXNurh<^!y+D+p%hC~i81v%V=RC$s|n7BYQ%d;-G=UaZYrRV!KJ=)wnWz-E(i!IQ40 zcD6gWPEyQJV3=f9;4b5yVC?4WwNu#ZB!!f$Y4}ILsmCq@wNHOiD6ByH{ZOxa}V(7 zXD{&br(gd1%AHWNWoNg-iT*u^tbfebj?Zz6jcJd2{ks?WTXuA`%2;(=!3_(mM&P|} znwYI`s}v`HV!b7^dLC2YkIOXr~wg*R?n$7-wQ(FbBl!}Jz^a*U6 zwHJ=)B)fySc+NrquZkg;5_G0&)JSROJh2@&tmN0O<)>-Emt*mVYx_}&zn?p&g?pE4 zyC()LGpv6~^8&rpAh?rVU297v$GfJncar8rE` ze2|w9@J8!eP}&~tdVNHuSiVuaK(%GZOs9MKfO^0I|GSq1EeO zpnQMnJ&d(`JX8Jqzod^i`@wz^_EoqM?Y2v*?{(I!>U>8!5>)7lgqs9{qC*5>NCXun zUBRO~4edi$1@46E7gQH+W>08~YGro`@9(W)(xJOXkvyxt?f2wbf0{Ot6Y{%IyVwiZ z)6$8u&ad8TM)Rt8UTl@9m)WM<+L>MMTXKK1ar3GwO93R%T(Gy=Q&#of8l%yJk_nq& zT?{bM!$b}sZXw{cgbcE`hGIa3v-`}dcnFe0kM{NDfNK%jvCS$9oCS6nl!T;b^lLp* zqCoYV96*j&*$!LauEt?zYzBBh-z4jf_9Ix-9RzZz)-N!NJ23o$izs{yHrW{>qj!He z{cxKMlw@8~#GY8tw8MKpE$znFGq2cx(M;>6^polgEgaufKXd!jU^7{6^9F|&r~)U8 zOQp-G&oqhp&i7T;HNMgY(!9P_g?a@c(!*7$#ld)BZ&;~l{GeCD7K@Bn8Ga7Bz4``C z!FZvlb>}`S+YmGsXHQU#&=3bBB+q{oubD(^Jqn)IwXxC+QloZf|9SleOAl7J)dP@O z6JD740X{aR9=2vKn>ttF#ELnfJ>?sB%EDDluUim%@QeFE)n%W;SFOdxE$osZ`G@jO|$zPf((v@GnZ-K&3R$pY@V zpO%G9`%De7fIC;&6FUQnK2IjFL$;NQz@W3Rg8L~o*I{qspV zqW<|GkR$4@Ps$N>*Z+VVQSW_Hj;QxOSB~V>iMrIW)hQ>;&Zv;7^PZ>lFng5(>zx2) z7S6F7?Olt=mxqpD5XnO8%eh@diN&JiuDXU7b&LM$W*F7@gm>N4AOs-G)t@6)DGk9 zx=AXfTM{su+z(NdQ63`Nm3GmVbZ#-RM3!=ht)OLl>Js*Mej)^oW{gDz-WfHeK6Qgp zp)~Ay;n7|=l2N-#Jfb>+636y?nnng)0MXa#y5iqlP2crt@tQcw+hNr9|J4 z=Px5|)>nzqr(Q?kL`Xs(s|E?@Y z+~|X=;tbqJGUem(oVFilg`oXfvdENeYxq8G~rkLPuYP_oJZ~Q=-K^=%MG{I_Y<>S zHc|s%h50tAV6nyz9>_Xs3T!25oRyCA%e9lrm6bE<;e3fk&y!`^WQXqBo?{uKwF?$3 z1qsV^nSFnO!z**dl;}^Qhh#-S=uCH$rE7nxtG<-r>^!a@k|W*=oEc#wnXaFu`xhWqf+Ia_ zM-UTy5HOMi^d7Pf)?jmoiXPkm4;tqv^%3^_nOER$cW80HAj)%>mq4Ghz^`Hck{TFB z=z4z!u@N7$iDd@c$nrm_iSbiUEUDDAPb=zw0Z0kA_Ez&AP*{0pr7lf=x5>Hs(v-;+ zYyD{>*`8aeJWm`U6X#WG)@Li;crTt*ya_!dR=j^Av4AjBoo-9xYD~v>TW9OJt;$?I z=LTQf>QX}R+<*D|;3sW-IxVvrfzQ?!1>HF>ZQoOWi1f9q@R^4n27v#G#Rmwymkhdr z^$oabWdhK79-l+L=(^G0RrdbUyTxj+O*ac)|E|-2AlF)IaY_1DIKbHjWr-RV#`9j( zlkf@*!_dTYa}!2HxP=F^guJWduo(Fe`$9Ly16UNfqC>3X0w2X=qC`|Q>^;Fo^1WP* z_BzskM1!33)sx{~r!4mF4DQ3i&8)Q52XC(UL%!{)v235hXA15(Ig;t$jZ2&JQI)Fg z40hIs5BCUf`z2uHQ~lY~VB&VW1KyGNL38y?;ifjG)i|DE8vy?qZi?P$kg=}rkM?um zv@t`?a?@Kl1S=S;F`<6XvG{nY zHow$&zVtuieTf?Dy9$dJ7N1^NU;3YshP_(iMkB>XT-&{P6f#l>>_LgX@iX4n3}diF za2b$I9(Y|aQUv@0JjbOhIIiCZJdi|g#3N;}Za75pKYXX>5061q#Nc)>$W{KB<|}T0 zn+~kungNZXfQRjh_1Kt3M#UHI=iNGQ6Lk+W0!3_8{o(dP%Vi=j>7&~Q1_t*H!Rnj>MLIYHP>Z@Z*2%D z^WM)&qkN;tMxL&tltwiJnG$Dh>pC)jG;nDg^s(8dqToE*Ke4Yjsmcd$Y2$&f?`V!x z_YgSK$*8)CYjXC3M(=YP?oQCuh{$SBBN%!Z0S#RFidTOqN1A&?I{;_6C)WjCJC5YH z_iJNPKj5F9AqatQw84a(;O;_#q4cpu>o?`kI6B*JbFPy92A+>U;2%v6=JdLMYc@e) zA_hevAi@IYnl>m89F5|LC`a0D(?K}frn~5ePv#$d8B9$dBNOc6GWl=#Lhl*-+`K7A zx*M2xCuk2~1HQ(~Z);(=oRrOC2uKo?zafh+*$cH#&&t^e}=b~c>48^KXB}Rta-0{V;cVtko)6~xT zOFCm)p}wE^U(*nVA+l29mFR>`Io^_qOe1Zm!di(H*Gx00Oe-=*Sjg`8!iNk<&l0iN zcEZd65JZUT;o1{4(I3f0n1OFRFX*Wr1p|A}I7)94G`nL7$uJpzo-o{p90KElF@g-N z5|m~j`e^Ik;5-b<^6wouC^ge9uqcfF)Ql`PU>$6q=#>J%)p zyTtoas%C>%`krW?0_5?YaSS1|8_)*1mWuKgHD(@#d2)U>VzEW8c^-i|zewVlIoI#q z$!3@Rw@Q>!gmBwQD+S8kM3;xfs;XtV+w^|i^zr(9ifYq;+dSDvz<9ANCQLbGABiE< z+WZmCusKg2%P@9j*wpj$Yh^1?4u|iu{SQ=6b<85AL%}A2qgok|M8-~W-`|{r8DOoq zaFdYV*To=4SU=ET z!x7QYD85~P6T#?NBGz+Bd9fhI?6arks^0G;CEo(Em}2Vfk>uX${eJg>Yz19nNs2Z{BqH?_0 zq&jS_G55d$UpAovX?O%WDAGoI=I~{ATT96$4iv*^{{#Mo%Y*9VAnAXxN~bQZJt7De?%mewSc4%eSrcpBlZ!$<{Elnl|O z3=iIa6l|m$DObk+<8B)uaSUNNiGWpeY*n2IGo!H3V_+Y2jA!X6d(qdPU7Kd?3+F9i zUqjpQZWa&pd!1>2&yn^~@uwTnsj41PMfLf~llK@#K5Th_TOGlJ1uYF;C-7(@>Q)$y zI-=skRn`owbX_wFWU)pLC*Xi{BW*SzU2Qgh!(%b41rJZ!u7wp33NB93qPhNpG$*{a z-0RR!BT;U%HIF37{5lM$=!Esyo z4eF@F_*UuMgH*92w_J&69Dvulc17maVJjPZZgEON?*I}JA+Nh7Oem(0T4SvFqf5E3 UfMTbvOWWiB7ren|6|VIT0K(uN9RL6T delta 18855 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 (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account.jmap_request(USING, json!([[method, arguments, "0"]])).await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + (call[0].as_str().unwrap_or_default().to_string(), call[1].clone()) +} + +fn dlp_rule() -> Value { + json!({ + "name": "Cards leaving", + "kind": "dlp", + "direction": "outgoing", + "priority": 10, + "conditions": [ + {"type": "recipientOutside"}, + {"type": "detected", "detectors": [{"id": "payment-card", "atLeast": 5}]} + ], + "actions": [{"type": "hold", "notice": "Held for review", "notifySender": true}] + }) +} + +fn transport_rule() -> Value { + json!({ + "name": "Disclaimer", + "kind": "transport", + "direction": "outgoing", + "priority": 1, + "conditions": [{"type": "recipientOutside"}], + "actions": [{"type": "addDisclaimer", "text": "Sent by Example Co.", "position": "bottom"}] + }) +} + +async fn names(account: &Account) -> Vec { + let (name, response) = call(account, "inbuxa:MailRule/get", json!({"ids": null})).await; + assert_eq!(name, "inbuxa:MailRule/get", "{response}"); + response["list"] + .as_array() + .unwrap() + .iter() + .map(|r| r["name"].as_str().unwrap().to_string()) + .collect() +} + +pub async fn test(test: &mut TestServer) { + println!("Running mail rule tests..."); + let admin = test.account("admin@example.com"); + + // Created, then listed in the order they run + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"create": {"d": dlp_rule(), "t": transport_rule()}}), + ) + .await; + let dlp_id = response["created"]["d"]["id"] + .as_str() + .unwrap_or_else(|| panic!("DLP rule created: {response}")) + .to_string(); + let transport_id = response["created"]["t"]["id"].as_str().unwrap().to_string(); + assert_eq!(names(&admin).await, vec!["Disclaimer", "Cards leaving"]); + + let (_, response) = call(&admin, "inbuxa:MailRule/get", json!({"ids": [dlp_id]})).await; + let rule = &response["list"][0]; + assert_eq!(rule["conditions"][1]["detectors"][0]["atLeast"], 5, "{rule}"); + assert_eq!(rule["actions"][0]["notifySender"], true); + assert_eq!(rule["createdBy"], "admin@example.com"); + assert!(rule["createdAt"].as_str().is_some_and(|d| d.ends_with('Z')), "{rule}"); + + // Checked when written + let mut inbound = dlp_rule(); + inbound["direction"] = "incoming".into(); + let mut unknown = dlp_rule(); + unknown["conditions"][1]["detectors"][0]["id"] = "no-such-detector".into(); + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"create": {"a": inbound, "b": unknown, "c": {"name": "x", "kind": "dlp"}}}), + ) + .await; + assert_eq!(response["notCreated"]["a"]["properties"][0], "direction", "{response}"); + assert!( + response["notCreated"]["b"]["description"].as_str().unwrap().contains("no-such-detector"), + "{response}" + ); + assert!(response["notCreated"].get("c").is_some(), "{response}"); + + // Changed in place; what the server sets can't be sent + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"update": { + transport_id.as_str(): {"name": "Footer", "priority": 50}, + dlp_id.as_str(): {"createdBy": "someone else"} + }}), + ) + .await; + assert!(response["updated"].get(transport_id.as_str()).is_some(), "{response}"); + assert_eq!(response["notUpdated"][dlp_id.as_str()]["properties"][0], "createdBy", "{response}"); + assert_eq!(names(&admin).await, vec!["Cards leaving", "Footer"]); + + // A compliance officer sees DLP rules, not mail flow rules, and changes + // neither (settled answer 4) + let mut officer_role = None; + for id in admin + .registry_query_ids(ObjectType::Role, Vec::<(&str, &str)>::new(), Vec::<&str>::new()) + .await + { + let role = admin.registry_get::(id).await; + if role.description == "Compliance Officer" && role.member_tenant_id.is_none() { + officer_role = Some(id); + } + } + let officer = admin + .create_user_account("rules-officer@example.com", "officer-secret-7731", "Officer", &[], vec![]) + .await; + admin + .registry_update_object( + ObjectType::Account, + officer.id(), + json!({Property::Roles: UserRoles::Custom(CustomRoles { + role_ids: Map::new(vec![officer_role.expect("the officer role")]), + })}), + ) + .await; + assert_eq!(names(&officer).await, vec!["Cards leaving"]); + let (name, response) = + call(&officer, "inbuxa:MailRule/set", json!({"create": {"d": dlp_rule()}})).await; + assert_eq!(name, "error", "the officer created a DLP rule: {response}"); + + // Deleted + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"destroy": [transport_id]}), + ) + .await; + assert_eq!(response["destroyed"][0], transport_id.as_str(), "{response}"); + assert_eq!(names(&admin).await, vec!["Cards leaving"]); + + // Every change is in the audit log + let (_, response) = call( + &admin, + "inbuxa:AuditEvent/query", + json!({"filter": {"targetKind": "inbuxa:MailRule"}, "calculateTotal": true}), + ) + .await; + assert!( + response["total"].as_u64().unwrap_or(0) >= 4, + "creates, update and destroy audited: {response}" + ); +} + +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn mail_rules_tests() { + let mut test = TestServerBuilder::new("mail_rules_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index e0d212e..4c51b14 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -14,6 +14,7 @@ pub mod ai_explain; pub mod account_lock; // inbuxa: account lock with delegation pub mod legal_hold; // inbuxa: legal hold pub mod compliance; // inbuxa: the compliance roles +pub mod mail_rules; // inbuxa: DLP and mail flow rules pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once -- 2.54.0