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/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"); + } +} 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 5822e0b..e14adf6 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index cfffa0e..7ae9d8d 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU \ No newline at end of file +yF7PlBR3UBqxlabhW5zZ5WacQWsynG1wNYEiJVAl6w4 \ No newline at end of file diff --git a/tests/src/system/mail_rules.rs b/tests/src/system/mail_rules.rs new file mode 100644 index 0000000..38b9c26 --- /dev/null +++ b/tests/src/system/mail_rules.rs @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:MailRule` (dlp-and-mail-flow-rules spec, §2.2, §2.8): rules are +//! stored, listed in the order they run, checked when written, kept apart +//! by kind for permissions, and every change is audited. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{CustomRoles, Role, UserRoles}, +}; +use registry::types::map::Map; +use serde_json::{Value, json}; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:inbuxa:jmap", + "urn:inbuxa:jmap:registry", +]; + +async fn call(account: &Account, method: &str, mut arguments: Value) -> (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