From de514115dd375f15739ce94b469bb598e598079a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 19:27:27 -0700 Subject: [PATCH] DLP: how long held mail waits is a setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inbuxa:DlpSettings (singleton, urn:inbuxa:jmap): keepHeldDays, 1 to 90, 7 by default (settled answer 5 made it a setting). sysDlpPolicyGet reads it, sysDlpPolicyUpdate changes it, server-level, audited by the request layer. Each held message keeps the days it was given, and the sender's notices say that number. Privacy catalog entry; spec §2.6 updated. mail_rules_tests: 7 by default, 0 refused, 3 set and a message held afterwards expires 3 days after it was held, the expiry notice says 3. --- crates/features/src/mailflow/held.rs | 72 +++++++- .../src/object/inbuxa_dlp_settings.rs | 153 +++++++++++++++++ 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 | 7 + crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 14 ++ crates/jmap-proto/src/response/mod.rs | 14 ++ crates/jmap/src/api/auth.rs | 9 + crates/jmap/src/api/request.rs | 27 +++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/dlp_settings.rs | 154 ++++++++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + crates/smtp/src/inbound/mailflow.rs | 6 +- crates/smtp/src/queue/held.rs | 11 +- docs/spec/features/dlp-and-mail-flow-rules.md | 5 +- resources/privacy/catalog.toml | 4 + tests/src/system/mail_rules.rs | 33 +++- 19 files changed, 512 insertions(+), 9 deletions(-) create mode 100644 crates/jmap-proto/src/object/inbuxa_dlp_settings.rs create mode 100644 crates/jmap/src/inbuxa/dlp_settings.rs diff --git a/crates/features/src/mailflow/held.rs b/crates/features/src/mailflow/held.rs index 7e85ae1..a0d2707 100644 --- a/crates/features/src/mailflow/held.rs +++ b/crates/features/src/mailflow/held.rs @@ -25,14 +25,42 @@ use trc::AddContext; const FEATURE: u8 = b'R'; const KIND_HELD: u8 = b'h'; +const KIND_SETTINGS: u8 = b's'; /// How far off a held message's release is set: a century, so it never /// comes due on its own. pub const HOLD_SECONDS: u64 = 100 * 365 * 24 * 60 * 60; -/// How long unreviewed mail waits before it's rejected (settled answer 5). +/// How long unreviewed mail waits before it's rejected, unless the setting +/// says otherwise (settled answer 5). pub const KEEP_DAYS: u64 = 7; +/// `inbuxa:DlpSettings`: how many days held mail waits for a reviewer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Settings { + pub keep_held_days: u64, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + keep_held_days: KEEP_DAYS, + } + } +} + +impl Settings { + /// The property at fault and why, or fine. + pub fn check(&self) -> Result<(), (&'static str, &'static str)> { + if (1..=90).contains(&self.keep_held_days) { + Ok(()) + } else { + Err(("keepHeldDays", "must be from 1 to 90 days")) + } + } +} + /// A rule that held the message, with its notice. #[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] pub struct HeldRule { @@ -59,6 +87,13 @@ pub struct Held { /// Seconds since the epoch. pub held_at: u64, pub expires_at: u64, + /// The days it was given, for what the sender is told. + #[serde(default = "default_keep_days")] + pub keep_days: u64, +} + +fn default_keep_days() -> u64 { + KEEP_DAYS } impl Held { @@ -106,6 +141,31 @@ fn key(queue_id: u64) -> ValueKey { ValueKey::from(class(queue_id)) } +fn settings_class() -> ValueClass { + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key: vec![FEATURE, KIND_SETTINGS], + }) +} + +pub async fn settings(data: &Store) -> trc::Result { + Ok(data + .get_value::>(ValueKey::from(settings_class())) + .await + .caused_by(trc::location!())? + .map(|Json(settings)| settings) + .unwrap_or_default()) +} + +pub async fn set_settings(data: &Store, settings: &Settings) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set(settings_class(), Json(settings).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(()) +} + pub async fn get(data: &Store, queue_id: u64) -> trc::Result> { Ok(data .get_value::>(key(queue_id)) @@ -172,6 +232,7 @@ mod tests { counts: vec![("payment-card".into(), 5)], held_at: 1_000, expires_at: 1_000 + KEEP_DAYS * 86_400, + keep_days: KEEP_DAYS, }; let json = serde_json::to_value(&held).unwrap(); assert_eq!(json["heldAt"], 1_000); @@ -180,4 +241,13 @@ mod tests { assert!(held.is_expired(1_000 + KEEP_DAYS * 86_400)); assert!(HOLD_SECONDS > 90 * 365 * 86_400); } + + #[test] + fn settings_range() { + assert_eq!(Settings::default().keep_held_days, 7); + assert!(Settings { keep_held_days: 1 }.check().is_ok()); + assert!(Settings { keep_held_days: 90 }.check().is_ok()); + assert!(Settings { keep_held_days: 0 }.check().is_err()); + assert!(Settings { keep_held_days: 91 }.check().is_err()); + } } diff --git a/crates/jmap-proto/src/object/inbuxa_dlp_settings.rs b/crates/jmap-proto/src/object/inbuxa_dlp_settings.rs new file mode 100644 index 0000000..d943024 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_dlp_settings.rs @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:DlpSettings/get` and `/set` under `urn:inbuxa:jmap`: the DLP +//! settings singleton (dlp-and-mail-flow-rules spec, §2.6): how many days +//! held mail waits for a reviewer before it goes back to the sender. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct DlpSettings; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DlpSettingsProperty { + Id, + KeepHeldDays, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DlpSettingsValue { + Id(Id), +} + +impl Property for DlpSettingsProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + DlpSettingsProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DlpSettingsProperty::Id => "id", + DlpSettingsProperty::KeepHeldDays => "keepHeldDays", + } + .into() + } +} + +impl DlpSettingsProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => DlpSettingsProperty::Id, + b"keepHeldDays" => DlpSettingsProperty::KeepHeldDays, + ) + } +} + +impl FromStr for DlpSettingsProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + DlpSettingsProperty::parse(s).ok_or(()) + } +} + +impl Element for DlpSettingsValue { + type Property = DlpSettingsProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(DlpSettingsProperty::Id) => { + Id::from_str(value).ok().map(DlpSettingsValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DlpSettingsValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for DlpSettings { + type Property = DlpSettingsProperty; + + type Element = DlpSettingsValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = DlpSettingsProperty::Id; +} + +impl From for DlpSettingsValue { + fn from(id: Id) -> Self { + DlpSettingsValue::Id(id) + } +} + +impl JmapObjectId for DlpSettingsValue { + fn as_id(&self) -> Option { + match self { + DlpSettingsValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + DlpSettingsValue::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 = DlpSettingsValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for DlpSettingsProperty { + 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 a0053ca..003cf17 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -24,6 +24,7 @@ pub mod fastmail_masked_email; // inbuxa: masked email pub mod inbuxa_account_lock; // inbuxa: account lock with delegation pub mod inbuxa_ai_limits; // inbuxa: AI spam classification pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1 +pub mod inbuxa_dlp_settings; // inbuxa: DLP settings 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 diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index fb45299..216d826 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -64,6 +64,9 @@ impl Response<'_> { GetResponseMethod::LogSettings(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::DlpSettings(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::DataInventory(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 ac1a860..b4a3ed3 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -47,6 +47,7 @@ impl Response<'_> { GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, GetRequestMethod::LogSettings(request) => request.resolve_references(self)?, + GetRequestMethod::DlpSettings(request) => request.resolve_references(self)?, GetRequestMethod::DataInventory(request) => request.resolve_references(self)?, GetRequestMethod::InventorySnapshot(request) => request.resolve_references(self)?, GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?, @@ -106,6 +107,9 @@ impl Response<'_> { SetRequestMethod::LogSettings(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::DlpSettings(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::Explanation(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 3431827..99d5258 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -50,6 +50,7 @@ pub enum MethodObject { // inbuxa: AI call limits AiLimits, LogSettings, + DlpSettings, DataInventory, InventorySnapshot, // inbuxa: "Explain this" with the local model @@ -96,6 +97,7 @@ impl MethodObject { MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa, MethodObject::LogSettings => Capability::Inbuxa, + MethodObject::DlpSettings => Capability::Inbuxa, MethodObject::DataInventory => Capability::Inbuxa, MethodObject::InventorySnapshot => Capability::Inbuxa, MethodObject::Explanation => Capability::Inbuxa, @@ -288,9 +290,11 @@ impl MethodName { (MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get", (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", (MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get", + (MethodFunction::Get, MethodObject::DlpSettings) => "inbuxa:DlpSettings/get", (MethodFunction::Get, MethodObject::DataInventory) => "inbuxa:DataInventory/get", (MethodFunction::Get, MethodObject::InventorySnapshot) => "inbuxa:InventorySnapshot/get", (MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set", + (MethodFunction::Set, MethodObject::DlpSettings) => "inbuxa:DlpSettings/set", (MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set", (MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get", (MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query", @@ -444,9 +448,11 @@ impl MethodName { "inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get), "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), "inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get), + "inbuxa:DlpSettings/get" => (MethodObject::DlpSettings, MethodFunction::Get), "inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get), "inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get), "inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set), + "inbuxa:DlpSettings/set" => (MethodObject::DlpSettings, MethodFunction::Set), "inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set), "inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get), "inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query), @@ -522,6 +528,7 @@ impl Display for MethodObject { MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::AiLimits => "inbuxa:AiLimits", MethodObject::LogSettings => "inbuxa:LogSettings", + MethodObject::DlpSettings => "inbuxa:DlpSettings", MethodObject::DataInventory => "inbuxa:DataInventory", MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot", MethodObject::Explanation => "inbuxa:Explanation", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index c4ff028..0923c14 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -117,6 +117,7 @@ pub enum GetRequestMethod { DeletedAccount(Box>), AiLimits(Box>), LogSettings(Box>), + DlpSettings(Box>), DataInventory(Box>), InventorySnapshot(Box>), AuditEvent(Box>), @@ -154,6 +155,7 @@ pub enum SetRequestMethod<'x> { DeletedAccount(Box>), AiLimits(Box>), LogSettings(Box>), + DlpSettings(Box>), Explanation(Box>), AuditSettings(Box>), AuditExport(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index a811598..4d067db 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -176,6 +176,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::DlpSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DlpSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::DataInventory) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DataInventory(value)), Err(err) => RequestMethod::invalid(err), @@ -378,6 +385,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::DlpSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DlpSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index cfc4c44..6320886 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -104,6 +104,7 @@ pub enum GetResponseMethod { DeletedAccount(GetResponse), AiLimits(GetResponse), LogSettings(GetResponse), + DlpSettings(GetResponse), DataInventory(GetResponse), InventorySnapshot(GetResponse), AuditEvent(GetResponse), @@ -142,6 +143,7 @@ pub enum SetResponseMethod { DeletedAccount(Box>), AiLimits(Box>), LogSettings(Box>), + DlpSettings(Box>), AuditSettings(Box>), AuditExport(Box>), AuditVerification(Box>), @@ -363,6 +365,12 @@ impl<'x> From> for } } +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::DlpSettings(value)) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::DataInventory(value)) @@ -387,6 +395,12 @@ impl<'x> From> for } } +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::DlpSettings(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: SetResponse) -> Self { ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value))) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 724a450..83af147 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -92,6 +92,7 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet, // inbuxa: log file retention, with the tracers' permissions GetRequestMethod::LogSettings(_) => Permission::SysTracerGet, + GetRequestMethod::DlpSettings(_) => Permission::SysDlpPolicyGet, // inbuxa: personal-data catalog, the inventory and its history GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => { Permission::SysComplianceGet @@ -227,6 +228,13 @@ impl JmapAuthorization for AccessToken { Permission::SysTracerUpdate, Permission::SysTracerUpdate, ), + SetRequestMethod::DlpSettings(s) => validate_set( + s, + self, + Permission::SysDlpPolicyUpdate, + Permission::SysDlpPolicyUpdate, + Permission::SysDlpPolicyUpdate, + ), // inbuxa: the audit log (AU-7, AU-9, AU-11) SetRequestMethod::AuditSettings(s) => validate_set( s, @@ -430,6 +438,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::DeletedAccount | MethodObject::AiLimits | MethodObject::LogSettings + | MethodObject::DlpSettings | MethodObject::DataInventory | MethodObject::InventorySnapshot | MethodObject::Explanation diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 513bc9b..3dbc901 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -264,6 +264,9 @@ impl RequestHandler for Server { SetResponseMethod::LogSettings(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::DlpSettings(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AuditSettings(set_response) => { set_response.update_created_ids(&mut response); } @@ -461,6 +464,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:DlpSettings/get + GetRequestMethod::DlpSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::dlp_settings::get(self, access_token, *req) + .await? + .into() + } // inbuxa: inbuxa:DataInventory/get GetRequestMethod::DataInventory(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -826,6 +836,23 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:DlpSettings/set + SetRequestMethod::DlpSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| Box::pin(crate::inbuxa::dlp_settings::set(self, access_token, req)), + ) + .await? + .into() + } // inbuxa: the audit log (AU-7, AU-11, AU-6) SetRequestMethod::AuditSettings(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index f3c2af9..343ef12 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -419,6 +419,7 @@ impl IntermediateChangesResponse { | MethodObject::DeletedAccount | MethodObject::AiLimits | MethodObject::LogSettings + | MethodObject::DlpSettings | MethodObject::DataInventory | MethodObject::InventorySnapshot | MethodObject::Explanation diff --git a/crates/jmap/src/inbuxa/dlp_settings.rs b/crates/jmap/src/inbuxa/dlp_settings.rs new file mode 100644 index 0000000..1fb9ad7 --- /dev/null +++ b/crates/jmap/src/inbuxa/dlp_settings.rs @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:DlpSettings/get` and `/set`: how many days held mail waits for a +//! reviewer (dlp-and-mail-flow-rules spec, §2.6), 1 to 90, 7 by default. +//! Server-level, like the rules; applies to mail held from then on. + +use common::{Server, auth::AccessToken}; +use inbuxa_features::mailflow::held::{self, Settings}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_dlp_settings::{DlpSettings, DlpSettingsProperty as P, DlpSettingsValue}, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use types::id::Id; + +type LValue = Value<'static, P, DlpSettingsValue>; + +const ALL: &[P] = &[P::Id, P::KeepHeldDays]; + +fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("DLP settings are server-level.")) + } else { + Ok(()) + } +} + +fn to_value(settings: &Settings, properties: &[P]) -> LValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(DlpSettingsValue::Id(Id::singleton())), + P::KeepHeldDays => Value::Number(settings.keep_held_days.into()), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:DlpSettings/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + assert_server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(1)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let settings = held::settings(server.store()).await?; + match ids { + None => response.list.push(to_value(&settings, &properties)), + Some(ids) => { + for id in ids { + if id.is_singleton() { + response.list.push(to_value(&settings, &properties)); + } else { + response.push_not_found(id); + } + } + } + } + Ok(response) +} + +fn apply( + settings: &mut Settings, + property: &P, + value: &Value<'_, P, DlpSettingsValue>, +) -> Result<(), String> { + match property { + P::KeepHeldDays => { + settings.keep_held_days = value + .as_u64() + .ok_or_else(|| "must be a whole number of days".to_string())? + } + P::Id => return Err("is immutable".to_string()), + } + Ok(()) +} + +/// `inbuxa:DlpSettings/set`: updates the singleton. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, DlpSettings>, +) -> trc::Result> { + assert_server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response + .not_created + .append(client_id, SetError::singleton()); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::singleton()); + } + let data = server.store(); + for (id, value) in request.unwrap_update().into_valid() { + if !id.is_singleton() { + response.not_updated.append(id, SetError::not_found()); + continue; + } + let mut settings = held::settings(data).await?; + let mut error = None; + for (key, value) in value.into_expanded_object() { + let Key::Property(property) = &key else { + error = Some(SetError::invalid_properties().with_property(key.into_owned())); + break; + }; + if let Err(why) = apply(&mut settings, property, &value) { + error = Some( + SetError::invalid_properties() + .with_property(property.clone()) + .with_description(why), + ); + break; + } + } + if error.is_none() + && let Err((property, why)) = settings.check() + { + error = Some( + SetError::invalid_properties() + .with_property(property.parse::

().unwrap_or(P::Id)) + .with_description(format!("{property} {why}.")), + ); + } + match error { + Some(error) => response.not_updated.append(id, error), + None => { + held::set_settings(data, &settings).await?; + response.updated.append(id, None); + } + } + } + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 5756737..9e978f1 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -12,6 +12,7 @@ pub mod account_lock; pub mod legal_hold; pub mod mail_rule; pub mod held_message; +pub mod dlp_settings; pub mod hold_export; pub mod hold_export_api; pub mod audit; diff --git a/crates/smtp/src/inbound/mailflow.rs b/crates/smtp/src/inbound/mailflow.rs index 4088f92..36789e9 100644 --- a/crates/smtp/src/inbound/mailflow.rs +++ b/crates/smtp/src/inbound/mailflow.rs @@ -478,6 +478,9 @@ impl Session { size: u64, ) { let at = store::write::now(); + let keep_days = held::settings(self.server.store()) + .await + .map_or(KEEP_DAYS, |s| s.keep_held_days); let account = self.data.authenticated_as.as_ref(); let record = Held { queue_id, @@ -490,7 +493,8 @@ impl Session { rules: draft.rules, counts: draft.counts, held_at: at, - expires_at: at + KEEP_DAYS * 86_400, + expires_at: at + keep_days * 86_400, + keep_days, }; if let Err(err) = held::create(self.server.store(), &record).await { trc::error!( diff --git a/crates/smtp/src/queue/held.rs b/crates/smtp/src/queue/held.rs index 291c1bd..3e4f55e 100644 --- a/crates/smtp/src/queue/held.rs +++ b/crates/smtp/src/queue/held.rs @@ -24,7 +24,7 @@ use common::{ }; use inbuxa_features::{ audit::{Action, Actor, Outcome, Record, Target}, - mailflow::held::{self, HOLD_SECONDS, Held, KEEP_DAYS}, + mailflow::held::{self, HOLD_SECONDS, Held}, }; use mail_builder::{ MessageBuilder, @@ -83,7 +83,8 @@ pub async fn reject(server: &Server, record: &Held, note: Option<&str>) -> trc:: match note { Some(note) => text.push_str(&format!("\r\nThe reviewer's note: {note}\r\n")), None => text.push_str(&format!( - "\r\nNobody reviewed it within {KEEP_DAYS} days, so it was returned.\r\n" + "\r\nNobody reviewed it within {} days, so it was returned.\r\n", + record.keep_days )), } notify( @@ -107,9 +108,10 @@ pub async fn notify_held(server: &Server, record: &Held) { .join(" "); let text = format!( "Your message \"{}\" to {} is held for review under this server's rules: {notices}\r\n\r\n\ - It will be sent if a reviewer releases it, and returned otherwise within {KEEP_DAYS} days.\r\n", + It will be sent if a reviewer releases it, and returned otherwise within {} days.\r\n", record.subject, record.recipients.join(", "), + record.keep_days, ); notify( server, @@ -166,7 +168,8 @@ pub async fn expire(server: &Server) -> trc::Result { }, changes: vec![], details: Some(format!( - "Rejected: nobody reviewed it within {KEEP_DAYS} days; the sender was told" + "Rejected: nobody reviewed it within {} days; the sender was told", + record.keep_days )), reason: None, outcome: Outcome::success(), diff --git a/docs/spec/features/dlp-and-mail-flow-rules.md b/docs/spec/features/dlp-and-mail-flow-rules.md index 79cbb4a..6db0d68 100644 --- a/docs/spec/features/dlp-and-mail-flow-rules.md +++ b/docs/spec/features/dlp-and-mail-flow-rules.md @@ -304,8 +304,9 @@ held mail, so what's released is what would have gone out. The daily clean-up rejects what's past its 7 days (recorded as the server's doing). `preview` returns the text (64 KB) only when asked for, and each read is recorded as `blobAccess`. Emails › Queue refuses to change or delete held -mail, and the sender can't unsend it. The 7 days is a constant for now; a -setting comes with the console page. +mail, and the sender can't unsend it. How many days held mail waits is +`inbuxa:DlpSettings.keepHeldDays`, 1 to 90, 7 by default; each held message +keeps the days it was given. ### 2.7 What's recorded diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index e26adf1..b6a6b9a 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -79,6 +79,10 @@ lockedAt = ["metadata"] lockedBy = ["identifier"] delegates = ["identifier"] +[object."inbuxa:DlpSettings"] +file = "inbuxa_dlp_settings.rs" +default = "none" + [object."inbuxa:HeldMessage"] file = "inbuxa_held_message.rs" default = "none" diff --git a/tests/src/system/mail_rules.rs b/tests/src/system/mail_rules.rs index faff97e..856762f 100644 --- a/tests/src/system/mail_rules.rs +++ b/tests/src/system/mail_rules.rs @@ -19,6 +19,7 @@ use registry::schema::{ }; use registry::types::map::Map; use serde_json::{Value, json}; +use std::str::FromStr; const USING: &[&str] = &[ "urn:ietf:params:jmap:core", @@ -817,6 +818,26 @@ pub async fn hold(test: &mut TestServer) { .as_str() .unwrap_or_else(|| panic!("{response}")) .to_string(); + // How long held mail waits: 7 days unless set, from 1 to 90 + let (_, response) = call(&admin, "inbuxa:DlpSettings/get", json!({"ids": null})).await; + assert_eq!(response["list"][0]["keepHeldDays"], 7, "{response}"); + let (_, response) = call( + &admin, + "inbuxa:DlpSettings/set", + json!({"update": {"singleton": {"keepHeldDays": 0}}}), + ) + .await; + assert!( + response["notUpdated"].get("singleton").is_some(), + "{response}" + ); + let (_, response) = call( + &admin, + "inbuxa:DlpSettings/set", + json!({"update": {"singleton": {"keepHeldDays": 3}}}), + ) + .await; + assert!(response["updated"].get("singleton").is_some(), "{response}"); let body = "hold-me: card 4242 4242 4242 4242"; // Accepted, held, listed @@ -844,6 +865,9 @@ pub async fn hold(test: &mut TestServer) { assert_eq!(list[0]["sender"], "hold-sender@example.com"); assert_eq!(list[0]["subject"], "Held one"); assert_eq!(list[0]["rules"][0]["name"], "Hold cards"); + let span = chrono_seconds(list[0]["expiresAt"].as_str().unwrap()) + - chrono_seconds(list[0]["heldAt"].as_str().unwrap()); + assert_eq!(span, 3 * 86_400, "held for the days set"); assert_eq!( list[0]["counts"], json!([{"detector": "words", "count": 1}, {"detector": "payment-card", "count": 1}]) @@ -1057,7 +1081,7 @@ pub async fn hold(test: &mut TestServer) { ); let notice = received(&sender, "Not sent: Held three", "X-Flow", Some(&mailbox)).await; assert!( - notice[0].1.contains("Nobody reviewed it within 7 days"), + notice[0].1.contains("Nobody reviewed it within 3 days"), "{notice:?}" ); let (_, response) = call( @@ -1075,6 +1099,13 @@ pub async fn hold(test: &mut TestServer) { call(&admin, "inbuxa:MailRule/set", json!({"destroy": [rule]})).await; } +/// Seconds since the epoch of a UTC date the server wrote. +fn chrono_seconds(date: &str) -> i64 { + jmap_proto::types::date::UTCDate::from_str(date) + .map(|d| d.timestamp()) + .unwrap_or_default() +} + /// Subjects in `account` matching `text` right now, not in `drafts`. async fn received_now(account: &Account, text: &str, drafts: &str) -> Vec { let (_, response) = call(