Files
inbuxa-server/crates/jmap-proto/src/error/set.rs
T
jcoffey-dev e0060c9e6e
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 23m36s
DLP at DATA: block, warn and override over SMTP and JMAP
Phase 2f of the DLP and mail flow rules spec: the rules now run on mail
an authenticated sender submits, after the DATA system script and
before headers and DKIM signing (§2.1).

- smtp/inbound/mailflow.rs: builds what the rules look at from the
  message (subject, the text version of each body, one level of attached
  messages, attachment text via the extractor, 10 MB of text at most)
  and the envelope (sender's groups and tenant; each recipient local or
  not, and its groups). Skipped entirely when no enabled rule applies to
  outgoing mail. Rules that can't be loaded refuse with a 451: nothing
  unchecked leaves.
- Block: 550 5.7.1 with the rule's notice. Warn: 550 5.7.1 with the
  notice and how to override: "[override: reason]" at the start of the
  subject, taken out before the message goes on (settled answer 1).
  Until phase 3, a hold rule blocks rather than let mail through.
- JMAP: EmailSubmission takes inbuxa:dlpOverride {reason}; a refusal
  comes back as inbuxa:dlpWarning or inbuxa:dlpBlocked with each rule's
  name and notice (description too, for older clients).
- Audit: one record per DLP match, the sender as actor, action create,
  target a message: the recipient domains, each rule with its detectors'
  counts, the outcome, an override's reason. Never the matched text. No
  new audit action: an older node that meets one fails its daily
  clean-up, which would make rolling back unsafe (spec §2.7 updated).

Tests: mail_rules_tests gains the DLP flow over JMAP (no rules, warning
with rule and notice, local recipient not warned, override with a
reason, block that no reason passes, the subject tag stripped from the
delivered message, audit records with no card or key text). smtp
inbound tests pass; system_tests passed twice after one timeout in the
email delivery tests that didn't recur.
2026-09-28 17:52:37 -07:00

387 lines
12 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use jmap_tools::{Key, Property};
use registry::types::{
error::{PatchError, ValidationError},
id::ObjectId,
};
use std::borrow::Cow;
use types::id::Id;
#[derive(Debug, Clone, serde::Serialize)]
#[serde(bound(serialize = "InvalidProperty<P>: serde::Serialize"))]
#[serde(transparent)]
#[repr(transparent)]
pub struct SetError<P: Property>(Box<SetErrorInner<P>>);
#[derive(Debug, Clone, serde::Serialize)]
#[serde(bound(serialize = "InvalidProperty<P>: serde::Serialize"))]
struct SetErrorInner<P: Property> {
#[serde(rename = "type")]
type_: SetErrorType,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<Vec<InvalidProperty<P>>>,
#[serde(rename = "existingId")]
#[serde(skip_serializing_if = "Option::is_none")]
existing_id: Option<Id>,
#[serde(rename = "objectId")]
#[serde(skip_serializing_if = "Option::is_none")]
object_id: Option<ObjectId>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(rename = "linkedObjects")]
linked_objects: Vec<ObjectId>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(rename = "validationErrors")]
validation_errors: Vec<ValidationError>,
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): each rule that
// warned or blocked, with its notice
#[serde(skip_serializing_if = "Vec::is_empty")]
rules: Vec<DlpRule>,
}
/// inbuxa: a DLP rule named in an `inbuxa:dlpWarning` or `inbuxa:dlpBlocked`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct DlpRule {
pub name: String,
pub notice: String,
}
#[derive(Debug, Clone)]
pub enum InvalidProperty<T: Property> {
Property(Key<'static, T>),
Path(Vec<Key<'static, T>>),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SetErrorType {
#[serde(rename = "forbidden")]
Forbidden,
#[serde(rename = "overQuota")]
OverQuota,
#[serde(rename = "tooLarge")]
TooLarge,
#[serde(rename = "rateLimit")]
RateLimit,
#[serde(rename = "notFound")]
NotFound,
#[serde(rename = "invalidPatch")]
InvalidPatch,
#[serde(rename = "willDestroy")]
WillDestroy,
#[serde(rename = "invalidProperties")]
InvalidProperties,
#[serde(rename = "singleton")]
Singleton,
#[serde(rename = "mailboxHasChild")]
MailboxHasChild,
#[serde(rename = "mailboxHasEmail")]
MailboxHasEmail,
#[serde(rename = "blobNotFound")]
BlobNotFound,
#[serde(rename = "tooManyKeywords")]
TooManyKeywords,
#[serde(rename = "tooManyMailboxes")]
TooManyMailboxes,
#[serde(rename = "forbiddenFrom")]
ForbiddenFrom,
#[serde(rename = "invalidEmail")]
InvalidEmail,
#[serde(rename = "tooManyRecipients")]
TooManyRecipients,
#[serde(rename = "noRecipients")]
NoRecipients,
#[serde(rename = "invalidRecipients")]
InvalidRecipients,
#[serde(rename = "forbiddenMailFrom")]
ForbiddenMailFrom,
#[serde(rename = "forbiddenToSend")]
ForbiddenToSend,
#[serde(rename = "cannotUnsend")]
CannotUnsend,
#[serde(rename = "alreadyExists")]
AlreadyExists,
#[serde(rename = "invalidScript")]
InvalidScript,
#[serde(rename = "scriptIsActive")]
ScriptIsActive,
#[serde(rename = "addressBookHasContents")]
AddressBookHasContents,
#[serde(rename = "nodeHasChildren")]
NodeHasChildren,
#[serde(rename = "calendarHasEvent")]
CalendarHasEvent,
#[serde(rename = "noSupportedScheduleMethods")]
NoSupportedScheduleMethods,
// Stalwart registry errors
#[serde(rename = "objectIsLinked")]
ObjectIsLinked,
#[serde(rename = "invalidForeignKey")]
InvalidForeignKey,
#[serde(rename = "primaryKeyViolation")]
PrimaryKeyViolation,
#[serde(rename = "validationFailed")]
ValidationFailed,
// inbuxa: a create that couldn't run (ai-explain spec: busy, timeout, …)
#[serde(rename = "serverFail")]
ServerFail,
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): a warning the
// sender may answer with inbuxa:dlpOverride, and a block
#[serde(rename = "inbuxa:dlpWarning")]
DlpWarning,
#[serde(rename = "inbuxa:dlpBlocked")]
DlpBlocked,
}
impl SetErrorType {
pub fn as_str(&self) -> &'static str {
match self {
SetErrorType::Forbidden => "forbidden",
SetErrorType::OverQuota => "overQuota",
SetErrorType::TooLarge => "tooLarge",
SetErrorType::RateLimit => "rateLimit",
SetErrorType::NotFound => "notFound",
SetErrorType::InvalidPatch => "invalidPatch",
SetErrorType::WillDestroy => "willDestroy",
SetErrorType::InvalidProperties => "invalidProperties",
SetErrorType::Singleton => "singleton",
SetErrorType::BlobNotFound => "blobNotFound",
SetErrorType::MailboxHasChild => "mailboxHasChild",
SetErrorType::MailboxHasEmail => "mailboxHasEmail",
SetErrorType::TooManyKeywords => "tooManyKeywords",
SetErrorType::TooManyMailboxes => "tooManyMailboxes",
SetErrorType::ForbiddenFrom => "forbiddenFrom",
SetErrorType::InvalidEmail => "invalidEmail",
SetErrorType::TooManyRecipients => "tooManyRecipients",
SetErrorType::NoRecipients => "noRecipients",
SetErrorType::InvalidRecipients => "invalidRecipients",
SetErrorType::ForbiddenMailFrom => "forbiddenMailFrom",
SetErrorType::ForbiddenToSend => "forbiddenToSend",
SetErrorType::CannotUnsend => "cannotUnsend",
SetErrorType::AlreadyExists => "alreadyExists",
SetErrorType::InvalidScript => "invalidScript",
SetErrorType::ScriptIsActive => "scriptIsActive",
SetErrorType::AddressBookHasContents => "addressBookHasContents",
SetErrorType::NodeHasChildren => "nodeHasChildren",
SetErrorType::CalendarHasEvent => "calendarHasEvent",
SetErrorType::NoSupportedScheduleMethods => "noSupportedScheduleMethods",
SetErrorType::ObjectIsLinked => "objectIsLinked",
SetErrorType::InvalidForeignKey => "invalidForeignKey",
SetErrorType::PrimaryKeyViolation => "primaryKeyViolation",
SetErrorType::ValidationFailed => "validationFailed",
SetErrorType::ServerFail => "serverFail",
SetErrorType::DlpWarning => "inbuxa:dlpWarning",
SetErrorType::DlpBlocked => "inbuxa:dlpBlocked",
}
}
}
impl<T: Property> SetError<T> {
pub fn new(type_: SetErrorType) -> Self {
SetError(Box::new(SetErrorInner {
type_,
description: None,
properties: None,
existing_id: None,
object_id: None,
linked_objects: Vec::new(),
validation_errors: Vec::new(),
rules: Vec::new(),
}))
}
/// inbuxa: the DLP rules behind a warning or block.
pub fn with_dlp_rules(mut self, rules: Vec<DlpRule>) -> Self {
self.0.rules = rules;
self
}
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.0.description = description.into().into();
self
}
pub fn error_type(&self) -> &SetErrorType {
&self.0.type_
}
pub fn description(&self) -> Option<&str> {
self.0.description.as_deref()
}
pub fn validation_errors(&self) -> &[ValidationError] {
&self.0.validation_errors
}
pub fn with_property(mut self, property: impl Into<InvalidProperty<T>>) -> Self {
self.0.properties = vec![property.into()].into();
self
}
pub fn with_properties(
mut self,
properties: impl IntoIterator<Item = impl Into<InvalidProperty<T>>>,
) -> Self {
self.0.properties = properties
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into();
self
}
pub fn with_object_id(mut self, object_id: ObjectId) -> Self {
self.0.object_id = object_id.into();
self
}
pub fn with_object_id_opt(mut self, object_id: Option<ObjectId>) -> Self {
self.0.object_id = object_id;
self
}
pub fn with_linked_objects(mut self, linked_objects: Vec<ObjectId>) -> Self {
self.0.linked_objects = linked_objects;
self
}
pub fn with_validation_errors(mut self, validation_errors: Vec<ValidationError>) -> Self {
self.0.validation_errors = validation_errors;
self
}
pub fn with_existing_id(mut self, id: Id) -> Self {
self.0.existing_id = id.into();
self
}
pub fn invalid_properties() -> Self {
Self::new(SetErrorType::InvalidProperties)
}
pub fn invalid_patch() -> Self {
Self::new(SetErrorType::InvalidPatch)
}
pub fn forbidden() -> Self {
Self::new(SetErrorType::Forbidden)
}
pub fn not_found() -> Self {
Self::new(SetErrorType::NotFound)
}
pub fn blob_not_found() -> Self {
Self::new(SetErrorType::BlobNotFound)
}
pub fn over_quota() -> Self {
Self::new(SetErrorType::OverQuota).with_description("Account quota exceeded.")
}
pub fn already_exists() -> Self {
Self::new(SetErrorType::AlreadyExists)
}
pub fn no_supported_schedule_methods(calendar_address: &str) -> Self {
Self::new(SetErrorType::NoSupportedScheduleMethods).with_description(format!(
"No supported scheduling method for calendar address {calendar_address}."
))
}
pub fn too_large() -> Self {
Self::new(SetErrorType::TooLarge)
}
pub fn will_destroy() -> Self {
Self::new(SetErrorType::WillDestroy).with_description("ID will be destroyed.")
}
pub fn singleton() -> Self {
Self::new(SetErrorType::Singleton)
.with_description("Singletons cannot be created or destroyed.")
}
pub fn address_book_has_contents() -> Self {
Self::new(SetErrorType::AddressBookHasContents)
.with_description("Address book is not empty.")
}
pub fn node_has_children() -> Self {
Self::new(SetErrorType::NodeHasChildren).with_description("Cannot delete non-empty folder.")
}
pub fn calendar_has_event() -> Self {
Self::new(SetErrorType::CalendarHasEvent).with_description("Calendar is not empty.")
}
}
impl<T: Property> From<T> for InvalidProperty<T> {
fn from(property: T) -> Self {
InvalidProperty::Property(Key::Property(property))
}
}
impl<T: Property> From<(T, T)> for InvalidProperty<T> {
fn from((a, b): (T, T)) -> Self {
InvalidProperty::Path(vec![Key::Property(a), Key::Property(b)])
}
}
impl<T: Property> From<Key<'static, T>> for InvalidProperty<T> {
fn from(property: Key<'static, T>) -> Self {
InvalidProperty::Property(property)
}
}
impl<T: Property> serde::Serialize for InvalidProperty<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
InvalidProperty::Property(p) => p.serialize(serializer),
InvalidProperty::Path(p) => {
use std::fmt::Write;
let mut path = String::with_capacity(64);
for (i, p) in p.iter().enumerate() {
if i > 0 {
path.push('/');
}
let _ = write!(path, "{}", p.to_string());
}
path.serialize(serializer)
}
}
}
}
impl From<PatchError> for SetError<registry::schema::properties::Property> {
fn from(err: PatchError) -> Self {
SetError(Box::new(SetErrorInner {
type_: SetErrorType::InvalidPatch,
description: err.message.into(),
properties: Some(vec![InvalidProperty::Property(Key::Owned(err.path))]),
existing_id: None,
object_id: None,
linked_objects: Vec::new(),
validation_errors: Vec::new(),
rules: Vec::new(),
}))
}
}