Journaling: search, read and export over JMAP, and the chain check
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 8m0s

Phase 4 of the journaling spec.

- inbuxa:JournalEntry/query and /get (sysJournalSearch): filter by time,
  sender, recipient, either, direction, subject words, Message-ID and
  journal, newest first; the whole report only when asked for.
- inbuxa:JournalExport/set (sysJournalExport): a reason is required; a
  ZIP of the matching reports with manifest.csv, exceptions.csv and
  manifest.sha256, up to 10,000 reports and 1 GB.
- inbuxa:JournalVerification/set (sysJournalGet): chains and reports
  rechecked.
- Every search, listing, read, export and check is written to the audit
  log before anything is returned, with existing actions only.
- Catalog entries for the three objects; spec as-built notes.

journal_tests: administrators can't search; a Compliance Officer searches,
lists, reads a report, exports (reason required) and checks the chain;
the officer can't change journals; each of those is in the audit log.
This commit is contained in:
2026-09-28 21:45:09 -07:00
parent abd5811420
commit daa486f7e7
17 changed files with 1725 additions and 2 deletions
@@ -0,0 +1,340 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The journal's JMAP objects under `urn:inbuxa:jmap` (journaling spec,
//! JR-6, JR-15 to JR-17):
//!
//! - `inbuxa:JournalEntry/get` and `/query`: what was journaled, read-only.
//! `report` (the whole journal report) comes only when asked for.
//! - `inbuxa:JournalExport/set`: create one to get a ZIP of the reports a
//! filter matches.
//! - `inbuxa:JournalVerification/set`: create one to recheck every chain.
//!
//! They share one set of properties. Nested values (an export's filter, a
//! verification's chains) are plain JSON objects.
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 JournalEntry;
#[derive(Debug, Clone, Default)]
pub struct JournalExport;
#[derive(Debug, Clone, Default)]
pub struct JournalVerification;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JournalEntryProperty {
Id,
ReceivedAt,
Direction,
Sender,
Authenticated,
Recipients,
Subject,
MessageId,
JournalIds,
Held,
Size,
Sha256,
ExpiresAt,
Report,
Filter,
Reason,
BlobId,
Count,
Verified,
Chains,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JournalEntryValue {
Id(Id),
}
impl Property for JournalEntryProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside a filter or a chain report stay plain keys
match parent {
None => JournalEntryProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
JournalEntryProperty::Id => "id",
JournalEntryProperty::ReceivedAt => "receivedAt",
JournalEntryProperty::Direction => "direction",
JournalEntryProperty::Sender => "sender",
JournalEntryProperty::Authenticated => "authenticated",
JournalEntryProperty::Recipients => "recipients",
JournalEntryProperty::Subject => "subject",
JournalEntryProperty::MessageId => "messageId",
JournalEntryProperty::JournalIds => "journalIds",
JournalEntryProperty::Held => "held",
JournalEntryProperty::Size => "size",
JournalEntryProperty::Sha256 => "sha256",
JournalEntryProperty::ExpiresAt => "expiresAt",
JournalEntryProperty::Report => "report",
JournalEntryProperty::Filter => "filter",
JournalEntryProperty::Reason => "reason",
JournalEntryProperty::BlobId => "blobId",
JournalEntryProperty::Count => "count",
JournalEntryProperty::Verified => "verified",
JournalEntryProperty::Chains => "chains",
}
.into()
}
}
impl JournalEntryProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => JournalEntryProperty::Id,
b"receivedAt" => JournalEntryProperty::ReceivedAt,
b"direction" => JournalEntryProperty::Direction,
b"sender" => JournalEntryProperty::Sender,
b"authenticated" => JournalEntryProperty::Authenticated,
b"recipients" => JournalEntryProperty::Recipients,
b"subject" => JournalEntryProperty::Subject,
b"messageId" => JournalEntryProperty::MessageId,
b"journalIds" => JournalEntryProperty::JournalIds,
b"held" => JournalEntryProperty::Held,
b"size" => JournalEntryProperty::Size,
b"sha256" => JournalEntryProperty::Sha256,
b"expiresAt" => JournalEntryProperty::ExpiresAt,
b"report" => JournalEntryProperty::Report,
b"filter" => JournalEntryProperty::Filter,
b"reason" => JournalEntryProperty::Reason,
b"blobId" => JournalEntryProperty::BlobId,
b"count" => JournalEntryProperty::Count,
b"verified" => JournalEntryProperty::Verified,
b"chains" => JournalEntryProperty::Chains,
)
}
}
impl FromStr for JournalEntryProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
JournalEntryProperty::parse(s).ok_or(())
}
}
impl Element for JournalEntryValue {
type Property = JournalEntryProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(JournalEntryProperty::Id) => {
Id::from_str(value).ok().map(JournalEntryValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
JournalEntryValue::Id(id) => id.to_string().into(),
}
}
}
/// One condition of an `inbuxa:JournalEntry/query` filter. Several in one
/// filter object must all hold.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JournalFilter {
/// From this time on (UTC date).
After(String),
/// Before this time (UTC date).
Before(String),
/// Part of the sender's address.
Sender(String),
/// Part of a recipient's address.
Recipient(String),
/// Part of the sender's or a recipient's address.
Address(String),
/// `outgoing`, `incoming` or `internal`.
Direction(String),
/// Words that must all be in the subject.
Text(String),
MessageId(String),
JournalId(Id),
_T(String),
}
impl Default for JournalFilter {
fn default() -> Self {
JournalFilter::_T(String::new())
}
}
impl<'de> DeserializeArguments<'de> for JournalFilter {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
hashify::fnc_map!(key.as_bytes(),
b"after" => {
*self = JournalFilter::After(map.next_value()?);
},
b"before" => {
*self = JournalFilter::Before(map.next_value()?);
},
b"sender" => {
*self = JournalFilter::Sender(map.next_value()?);
},
b"recipient" => {
*self = JournalFilter::Recipient(map.next_value()?);
},
b"address" => {
*self = JournalFilter::Address(map.next_value()?);
},
b"direction" => {
*self = JournalFilter::Direction(map.next_value()?);
},
b"text" => {
*self = JournalFilter::Text(map.next_value()?);
},
b"messageId" => {
*self = JournalFilter::MessageId(map.next_value()?);
},
b"journalId" => {
*self = JournalFilter::JournalId(map.next_value()?);
},
_ => {
*self = JournalFilter::_T(key.to_string());
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
);
Ok(())
}
}
/// Entries sort newest first, by `receivedAt`; nothing else.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JournalComparator {
ReceivedAt,
_T(String),
}
impl Default for JournalComparator {
fn default() -> Self {
JournalComparator::_T(String::new())
}
}
impl<'de> DeserializeArguments<'de> for JournalComparator {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "property" {
let value = map.next_value::<Cow<str>>()?;
*self = if value == "receivedAt" {
JournalComparator::ReceivedAt
} else {
JournalComparator::_T(value.into_owned())
};
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
macro_rules! journal_object {
($object:ty, $filter:ty, $comparator:ty) => {
impl JmapObject for $object {
type Property = JournalEntryProperty;
type Element = JournalEntryValue;
type Id = Id;
type Filter = $filter;
type Comparator = $comparator;
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = JournalEntryProperty::Id;
}
};
}
journal_object!(JournalEntry, JournalFilter, JournalComparator);
journal_object!(JournalExport, (), ());
journal_object!(JournalVerification, (), ());
impl From<Id> for JournalEntryValue {
fn from(id: Id) -> Self {
JournalEntryValue::Id(id)
}
}
impl JmapObjectId for JournalEntryValue {
fn as_id(&self) -> Option<Id> {
match self {
JournalEntryValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
JournalEntryValue::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 = JournalEntryValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for JournalEntryProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -32,6 +32,7 @@ pub mod inbuxa_legal_hold; // inbuxa: legal hold
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items
pub mod inbuxa_journal; // inbuxa: journaling
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
pub mod inbuxa_held_message; // inbuxa: mail held for review
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model