DLP: how long held mail waits is a setting
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -419,6 +419,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::LogSettings
|
||||
| MethodObject::DlpSettings
|
||||
| MethodObject::DataInventory
|
||||
| MethodObject::InventorySnapshot
|
||||
| MethodObject::Explanation
|
||||
|
||||
@@ -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<DlpSettings>,
|
||||
) -> trc::Result<GetResponse<DlpSettings>> {
|
||||
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<SetResponse<DlpSettings>> {
|
||||
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::<P>().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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user