Files
inbuxa-server/crates/features/src/mailflow/held.rs
T
jcoffey-dev de514115dd
ci / fork-checks (pull_request) Successful in 51s
ci / build (pull_request) Successful in 4m32s
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.
2026-09-28 19:27:27 -07:00

254 lines
7.4 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Mail held for review (dlp-and-mail-flow-rules spec, §2.6).
//!
//! A held message is queued as any other, but released [`HOLD_SECONDS`]
//! from now, the queue's own future-release mechanism: nothing about the
//! queue's stored format changes, so a node on an older version reads it
//! and simply never sends it. Beside it, a review record under `R` `h` +
//! queue id (u64) says why it's held, for the review queue.
//!
//! A reviewer releases it (it's rescheduled from the queue's settings and
//! delivered) or rejects it (it's removed, and the sender told). Unreviewed
//! mail is rejected after [`KEEP_DAYS`].
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
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, 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 {
pub name: String,
pub notice: String,
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Held {
pub queue_id: u64,
pub sender: String,
#[serde(default)]
pub account_id: Option<u32>,
#[serde(default)]
pub tenant_id: Option<u32>,
pub recipients: Vec<String>,
pub subject: String,
pub size: u64,
pub rules: Vec<HeldRule>,
/// Each detector that counted, and its count.
#[serde(default)]
pub counts: Vec<(String, usize)>,
/// 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 {
pub fn is_expired(&self, now: u64) -> bool {
now >= self.expires_at
}
}
struct Json<T>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize held message")
.reason(err)
})
}
}
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid held message")
.reason(err)
})
}
}
fn class(queue_id: u64) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(KIND_HELD);
key.extend_from_slice(&queue_id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
fn key(queue_id: u64) -> ValueKey<ValueClass> {
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<Settings> {
Ok(data
.get_value::<Json<Settings>>(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<Option<Held>> {
Ok(data
.get_value::<Json<Held>>(key(queue_id))
.await
.caused_by(trc::location!())?
.map(|Json(held)| held))
}
pub async fn is_held(data: &Store, queue_id: u64) -> trc::Result<bool> {
get(data, queue_id).await.map(|held| held.is_some())
}
/// Every held message, oldest first.
pub async fn all(data: &Store) -> trc::Result<Vec<Held>> {
let mut held = Vec::new();
data.iterate(IterateParams::new(key(0), key(u64::MAX)), |_, value| {
if let Ok(Json(record)) = Json::<Held>::deserialize(value) {
held.push(record);
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
held.sort_by_key(|h| (h.held_at, h.queue_id));
Ok(held)
}
pub async fn create(data: &Store, held: &Held) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(held.queue_id), Json(held).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn delete(data: &Store, queue_id: u64) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(queue_id));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_format_and_expiry() {
let held = Held {
queue_id: 42,
sender: "[email protected]".into(),
account_id: Some(7),
tenant_id: None,
recipients: vec!["[email protected]".into()],
subject: "Numbers".into(),
size: 900,
rules: vec![HeldRule {
name: "Cards".into(),
notice: "Held for review".into(),
}],
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);
assert_eq!(serde_json::from_value::<Held>(json).unwrap(), held);
assert!(!held.is_expired(1_000 + KEEP_DAYS * 86_400 - 1));
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());
}
}