From 441ad0b18e86674c0de414e8fadd19999b18f66b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 20:46:04 -0700 Subject: [PATCH] Journaling: capture at the queue, the built-in journal, retention Phase 2 of the journaling spec. - A copy of each message is taken in MessageWrapper::queue, after DLP and transport rules, for every enabled journal that takes it (direction and scope: everyone, or accounts, groups, domains, tenants). If the copy can't be taken the message isn't queued (temporary failure). - The journal report: the envelope one field a line (sender, To, Cc, Bcc from the envelope, list members from their ORCPT, direction, held for review), then the queued message byte for byte as message/rfc822. - The built-in journal under J in the inbuxa subspace: one chain per node whose links name each entry by SHA-256, so entries can expire out of chain order; purge leaves a marker, and verify catches an entry changed or removed early and a report that doesn't match. - Retention per journal (30 to 3650 days); an entry keeps what it was written with. The daily maintenance purges what's due, keeping entries whose people a legal hold covers (deleted accounts a hold keeps too), and records the counts in the audit log. - inbuxa:Journal get/set, audited by the request layer. Permissions 680-683: administrators see and change journals; the Compliance Officer sees, searches and exports. Whoever changes journals may grant search and export without holding them, so officers can still be appointed. - Catalog entries (inbuxa:Journal, source "journal"); spec as-built notes. tests/src/system/journal.rs: validation, internal mail with a Bcc, outgoing into two journals, incoming over LMTP, the report and its original, tamper and early removal caught, hold-aware purge, retention changes leave entries alone, disabled and removed journals take nothing. --- crates/common/src/auth/permissions.rs | 15 + crates/common/src/manager/compliance_roles.rs | 4 + .../common/src/manager/granted_permissions.rs | 6 + crates/features/src/journal/entries.rs | 689 ++++++++++++++++++ crates/features/src/journal/mod.rs | 431 +++++++++++ crates/features/src/journal/report.rs | 359 +++++++++ crates/features/src/lib.rs | 1 + crates/features/src/mailflow/rules.rs | 2 +- .../jmap-proto/src/object/inbuxa_journal.rs | 205 ++++++ 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 | 10 +- crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 15 + crates/jmap-proto/src/response/mod.rs | 15 + crates/jmap/src/api/auth.rs | 11 + crates/jmap/src/api/request.rs | 24 + crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/journal.rs | 273 +++++++ crates/jmap/src/inbuxa/mod.rs | 1 + crates/registry/src/schema/enums.rs | 5 + crates/registry/src/schema/enums_impl.rs | 14 +- .../services/src/task_manager/maintenance.rs | 54 ++ crates/smtp/src/queue/journal.rs | 159 ++++ crates/smtp/src/queue/mod.rs | 1 + crates/smtp/src/queue/spool.rs | 21 + docs/spec/features/journaling.md | 32 + resources/privacy/catalog.toml | 25 + resources/schema/schema.json.gz | Bin 153429 -> 153468 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/journal.rs | 471 ++++++++++++ tests/src/system/mod.rs | 1 + 33 files changed, 2853 insertions(+), 4 deletions(-) create mode 100644 crates/features/src/journal/entries.rs create mode 100644 crates/features/src/journal/mod.rs create mode 100644 crates/features/src/journal/report.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_journal.rs create mode 100644 crates/jmap/src/inbuxa/journal.rs create mode 100644 crates/smtp/src/queue/journal.rs create mode 100644 tests/src/system/journal.rs diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 04a98be..a111b77 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -165,6 +165,14 @@ impl AccessToken { mut requested_permissions: Permissions, ) -> Result<(), Vec> { requested_permissions.difference(self.permissions_bits()); + // inbuxa: journaling, JR-18: whoever sets up journals may give + // others (or, through a role, themselves) the reading of them, + // which administrators don't hold by default; the role change is + // in the audit log + if self.has_permission(Permission::SysJournalUpdate) { + requested_permissions.clear(Permission::SysJournalSearch as usize); + requested_permissions.clear(Permission::SysJournalExport as usize); + } if requested_permissions.is_empty() { Ok(()) } else { @@ -307,6 +315,13 @@ impl Default for DefaultPermissions { | Permission::SysDlpReviewUpdate => { default.superuser.push(permission); } + // inbuxa: journals are the server's; administrators set them + // up but read what's journaled only if granted it + // (journaling spec, JR-18, settled answer 5) + Permission::SysJournalGet | Permission::SysJournalUpdate => { + default.superuser.push(permission); + } + Permission::SysJournalSearch | Permission::SysJournalExport => {} // inbuxa: AL-12: tenant administrators lock and delegate // within their tenant Permission::SysAccountLockGet diff --git a/crates/common/src/manager/compliance_roles.rs b/crates/common/src/manager/compliance_roles.rs index 3b53f5f..21f65a1 100644 --- a/crates/common/src/manager/compliance_roles.rs +++ b/crates/common/src/manager/compliance_roles.rs @@ -69,6 +69,10 @@ const OFFICER: &[Permission] = &[ Permission::SysDlpPolicyGet, Permission::SysDlpReviewGet, Permission::SysDlpReviewUpdate, + // journaling spec, JR-18: see journals, search and export them + Permission::SysJournalGet, + Permission::SysJournalSearch, + Permission::SysJournalExport, ]; /// What a tenant's officer holds besides [`READS`]. diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index fac45e4..7d653a9 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -52,6 +52,8 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysDlpPolicyUpdate, Permission::SysDlpReviewGet, Permission::SysDlpReviewUpdate, + Permission::SysJournalGet, + Permission::SysJournalUpdate, ]; /// Granted to the server-level Compliance Officer role once it exists: @@ -61,6 +63,10 @@ const OFFICER_GRANTS: &[Permission] = &[ Permission::SysDlpPolicyGet, Permission::SysDlpReviewGet, Permission::SysDlpReviewUpdate, + // journaling spec, JR-18: see journals, search and export them + Permission::SysJournalGet, + Permission::SysJournalSearch, + Permission::SysJournalExport, ]; /// Granted to the default tenant administrator roles: reading and exporting diff --git a/crates/features/src/journal/entries.rs b/crates/features/src/journal/entries.rs new file mode 100644 index 0000000..ebdf3c9 --- /dev/null +++ b/crates/features/src/journal/entries.rs @@ -0,0 +1,689 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The built-in journal (JR-5, JR-6, JR-13). Keys, after `J`: +//! +//! - `e` + node + seq: a chain link: its seq, the hash of the link before +//! it, and the SHA-256 of its entry. One chain per node, as the audit log +//! keeps (AU-6), but a link names its entry by hash instead of holding it, +//! so an entry can go at the end of its own retention without breaking +//! the chain: entries don't expire in chain order. +//! - `c` + node + seq: the entry, as JSON; its bytes are what the link's +//! hash names. +//! - `p` + node + seq: when an entry past its retention was purged. A link +//! whose entry is gone without this marker is a broken chain. +//! - `t` + time + node + seq: the time index, for search. +//! - `x` + expiry + node + seq: the expiry index, for purge. +//! - `h` + node: the chain's head: its hash, then its seq as the last eight +//! bytes, which each append asserts. +//! - `f` + node: where the chain starts after purged links at its start +//! were cleared, and the hash the first kept link names. +//! +//! The report itself is a blob, kept by a temporary link that lasts until +//! its entry is purged. Nothing here changes or removes an entry before +//! its time; nothing in JMAP can. + +use super::{Direction, FEATURE, Json}; +use crate::hold::HELD_UNTIL; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use sha2::{Digest, Sha256}; +use std::fmt; +use store::{ + BlobStore, Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, BlobLink, BlobOp, ValueClass, assert::AssertValue}, +}; +use tokio::sync::Mutex; +use trc::AddContext; +use types::blob_hash::BlobHash; + +const KIND_LINK: u8 = b'e'; +const KIND_CONTENT: u8 = b'c'; +const KIND_PURGED: u8 = b'p'; +const KIND_TIME: u8 = b't'; +const KIND_EXPIRY: u8 = b'x'; +const KIND_HEAD: u8 = b'h'; +const KIND_FLOOR: u8 = b'f'; + +const APPEND_ATTEMPTS: usize = 5; +/// Entries purged per batch. +const PURGE_BATCH: usize = 100; + +/// Where one entry sits: its node's chain and its place in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EntryId { + pub node: u64, + pub seq: u64, +} + +impl EntryId { + /// As one number, for JMAP ids: the node in the top 16 bits. + pub fn to_u64(&self) -> u64 { + (self.node << 48) | (self.seq & ((1 << 48) - 1)) + } + + pub fn from_u64(id: u64) -> Self { + EntryId { + node: id >> 48, + seq: id & ((1 << 48) - 1), + } + } +} + +impl fmt::Display for EntryId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}-{}", self.node, self.seq) + } +} + +/// One journaled message (JR-5). +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Entry { + pub queue_id: u64, + /// Seconds. + pub at: u64, + pub direction: Direction, + pub sender: String, + pub authenticated: bool, + pub recipients: Vec, + pub subject: String, + pub message_id: String, + /// The people here on either side, whose holds keep the entry. + pub accounts: Vec, + pub tenants: Vec, + /// The journals that took it. + pub journals: Vec, + pub held: bool, + /// The report's blob, hex. + pub blob: String, + pub size: u64, + /// SHA-256 of the report, hex. + pub sha256: String, + /// Seconds. + pub expires_at: u64, +} + +impl Entry { + pub fn blob_hash(&self) -> Option { + let bytes = unhex(&self.blob)?; + BlobHash::try_from_hash_slice(&bytes).ok() + } +} + +#[derive(Debug, Clone, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +struct Link { + seq: u64, + prev: String, + content: String, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +struct Floor { + seq: u64, + prev: String, +} + +#[derive(Debug, Clone, Default, PartialEq)] +struct Head { + seq: u64, + hash: String, +} + +impl Head { + fn to_bytes(&self) -> Vec { + let mut bytes = self.hash.as_bytes().to_vec(); + bytes.extend_from_slice(&self.seq.to_be_bytes()); + bytes + } +} + +impl Deserialize for Head { + fn deserialize(bytes: &[u8]) -> trc::Result { + let split = bytes.len().checked_sub(8).ok_or_else(|| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid journal chain head") + })?; + Ok(Head { + seq: u64::from_be_bytes(bytes[split..].try_into().unwrap()), + hash: String::from_utf8_lossy(&bytes[..split]).into_owned(), + }) + } +} + +struct Raw(Vec); + +impl Deserialize for Raw { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(Raw(bytes.to_vec())) + } +} + +fn class(kind: u8, parts: &[u64]) -> ValueClass { + let mut key = Vec::with_capacity(2 + parts.len() * 8); + key.push(FEATURE); + key.push(kind); + for part in parts { + key.extend_from_slice(&part.to_be_bytes()); + } + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(kind: u8, parts: &[u64]) -> ValueKey { + ValueKey::from(class(kind, parts)) +} + +/// Where an entry's content is kept, for tests that check tampering shows. +pub fn content_key(id: EntryId) -> ValueKey { + key(KIND_CONTENT, &[id.node, id.seq]) +} + +/// The numbers after the kind byte, from the key's tail. +fn parse_key(key: &[u8], kind: u8, parts: usize) -> Option> { + let len = 2 + parts * 8; + let tail = key.get(key.len().checked_sub(len)?..)?; + (tail[0] == FEATURE && tail[1] == kind).then_some(())?; + Some( + tail[2..] + .chunks_exact(8) + .map(|chunk| u64::from_be_bytes(chunk.try_into().unwrap())) + .collect(), + ) +} + +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(value: &str) -> Option> { + (value.len() % 2 == 0).then_some(())?; + (0..value.len()) + .step_by(2) + .map(|i| u8::from_str_radix(value.get(i..i + 2)?, 16).ok()) + .collect() +} + +pub fn sha256(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes)) +} + +async fn head(data: &Store, node: u64) -> trc::Result> { + data.get_value::(key(KIND_HEAD, &[node])) + .await + .caused_by(trc::location!()) +} + +async fn floor(data: &Store, node: u64) -> trc::Result { + Ok(data + .get_value::>(key(KIND_FLOOR, &[node])) + .await + .caused_by(trc::location!())? + .map(|Json(floor)| floor) + .unwrap_or(Floor { + seq: 1, + prev: String::new(), + })) +} + +async fn nodes(data: &Store) -> trc::Result> { + let mut nodes = Vec::new(); + data.iterate( + IterateParams::new(key(KIND_HEAD, &[0]), key(KIND_HEAD, &[u64::MAX])).no_values(), + |key, _| { + if let Some(parts) = parse_key(key, KIND_HEAD, 1) { + nodes.push(parts[0]); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(nodes) +} + +/// Lines up this process's appends; the store's assert settles the rest. +static APPENDING: Mutex<()> = Mutex::const_new(()); + +/// Adds an entry to this node's chain, and links its report's blob (already +/// written) until the entry is purged. An error means nothing was written. +pub async fn append(data: &Store, node: u64, entry: &Entry) -> trc::Result { + let blob = entry.blob_hash().ok_or_else(|| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Journal entry without a blob") + })?; + let content = Json(entry).serialize()?; + let content_hash = sha256(&content); + let _appending = APPENDING.lock().await; + let mut attempt = 0; + loop { + attempt += 1; + let current = head(data, node).await?; + let (seq, prev) = current + .as_ref() + .map_or((1, String::new()), |head| (head.seq + 1, head.hash.clone())); + let link = Json(&Link { + seq, + prev, + content: content_hash.clone(), + }) + .serialize()?; + let new_head = Head { + seq, + hash: sha256(&link), + }; + + let mut batch = BatchBuilder::new(); + batch.assert_value( + class(KIND_HEAD, &[node]), + current.map_or(AssertValue::None, |head| AssertValue::U64(head.seq)), + ); + batch + .set(class(KIND_LINK, &[node, seq]), link) + .set(class(KIND_CONTENT, &[node, seq]), content.clone()) + .set(class(KIND_TIME, &[entry.at, node, seq]), vec![]) + .set(class(KIND_EXPIRY, &[entry.expires_at, node, seq]), vec![]) + .set(class(KIND_HEAD, &[node]), new_head.to_bytes()) + .set( + BlobOp::Link { + hash: blob.clone(), + to: BlobLink::Temporary { until: HELD_UNTIL }, + }, + vec![], + ) + .set(BlobOp::Commit { hash: blob.clone() }, vec![]); + match data.write(batch.build_all()).await { + Ok(_) => return Ok(EntryId { node, seq }), + Err(err) + if attempt < APPEND_ATTEMPTS + && matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => {} + Err(err) => return Err(err.caused_by(trc::location!())), + } + } +} + +/// One entry, unless it was purged. +pub async fn get(data: &Store, id: EntryId) -> trc::Result> { + Ok(data + .get_value::>(key(KIND_CONTENT, &[id.node, id.seq])) + .await + .caused_by(trc::location!())? + .map(|Json(entry)| entry)) +} + +/// Entries written in `[after, before)` (seconds), newest first, up to +/// `limit`. +pub async fn list( + data: &Store, + after: u64, + before: u64, + limit: usize, +) -> trc::Result> { + let mut ids = Vec::new(); + data.iterate( + IterateParams::new( + key(KIND_TIME, &[after, 0, 0]), + key(KIND_TIME, &[before.saturating_sub(1), u64::MAX, u64::MAX]), + ) + .descending() + .no_values(), + |key, _| { + if let Some(parts) = parse_key(key, KIND_TIME, 3) { + ids.push(EntryId { + node: parts[1], + seq: parts[2], + }); + } + Ok(ids.len() < limit) + }, + ) + .await + .caused_by(trc::location!())?; + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(entry) = get(data, id).await? { + out.push((id, entry)); + } + } + Ok(out) +} + +/// What a purge did. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Purged { + pub removed: usize, + /// Past their time, kept for a legal hold. + pub kept_for_hold: usize, +} + +/// Removes entries past their retention (JR-13), except those `held` keeps: +/// the entry, its indexes and its blob's link go; the chain link stays, +/// with a purge marker. Then each chain's start moves past purged links. +pub async fn purge( + data: &Store, + now: u64, + held: impl Fn(&Entry) -> bool + Sync + Send, +) -> trc::Result { + let mut due = Vec::new(); + data.iterate( + IterateParams::new( + key(KIND_EXPIRY, &[0, 0, 0]), + key(KIND_EXPIRY, &[now, u64::MAX, u64::MAX]), + ) + .ascending() + .no_values(), + |key, _| { + if let Some(parts) = parse_key(key, KIND_EXPIRY, 3) { + due.push(( + parts[0], + EntryId { + node: parts[1], + seq: parts[2], + }, + )); + } + Ok(due.len() < 100_000) + }, + ) + .await + .caused_by(trc::location!())?; + + let mut purged = Purged::default(); + for chunk in due.chunks(PURGE_BATCH) { + let mut batch = BatchBuilder::new(); + for (expires_at, id) in chunk { + let parts = [id.node, id.seq]; + let Some(entry) = get(data, *id).await? else { + // Its entry is already gone: only the index is left + batch.clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq])); + continue; + }; + if held(&entry) { + purged.kept_for_hold += 1; + continue; + } + batch + .clear(class(KIND_CONTENT, &parts)) + .clear(class(KIND_TIME, &[entry.at, id.node, id.seq])) + .clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq])) + .set(class(KIND_PURGED, &parts), now.to_be_bytes().to_vec()); + if let Some(blob) = entry.blob_hash() { + batch.clear(BlobOp::Link { + hash: blob, + to: BlobLink::Temporary { until: HELD_UNTIL }, + }); + } + purged.removed += 1; + } + if !batch.is_empty() { + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + } + + for node in nodes(data).await? { + advance_floor(data, node).await?; + } + Ok(purged) +} + +/// Clears the purged links at the start of a node's chain, recording where +/// it now starts and the hash that start names. +async fn advance_floor(data: &Store, node: u64) -> trc::Result<()> { + let start = floor(data, node).await?; + let mut cleared: Vec = Vec::new(); + let mut next = start.clone(); + let mut purged_seqs = Vec::new(); + data.iterate( + IterateParams::new( + key(KIND_PURGED, &[node, start.seq]), + key(KIND_PURGED, &[node, u64::MAX]), + ) + .ascending() + .no_values(), + |key, _| { + if let Some(parts) = parse_key(key, KIND_PURGED, 2) { + purged_seqs.push(parts[1]); + } + Ok(purged_seqs.len() < 100_000) + }, + ) + .await + .caused_by(trc::location!())?; + for seq in purged_seqs { + if seq != next.seq { + break; + } + let Some(Raw(link)) = data + .get_value::(key(KIND_LINK, &[node, seq])) + .await + .caused_by(trc::location!())? + else { + break; + }; + next = Floor { + seq: seq + 1, + prev: sha256(&link), + }; + cleared.push(seq); + } + if cleared.is_empty() { + return Ok(()); + } + // The floor moves first: a run cut short leaves links before it, which + // the next run clears, never a chain that looks broken + let mut batch = BatchBuilder::new(); + batch.set(class(KIND_FLOOR, &[node]), Json(&next).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + for chunk in cleared.chunks(PURGE_BATCH) { + let mut batch = BatchBuilder::new(); + for seq in chunk { + batch + .clear(class(KIND_LINK, &[node, *seq])) + .clear(class(KIND_PURGED, &[node, *seq])); + } + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + Ok(()) +} + +/// One node's chain, as [`verify`] found it. +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize)] +#[serde(rename_all = "camelCase")] +pub struct ChainReport { + pub node: u64, + pub entries: u64, + pub purged: u64, + pub first_seq: u64, + pub last_seq: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub broken_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Rechecks every node's chain (JR-6): each link names the hash of the one +/// before it, seqs run without gaps, the head matches the last link, each +/// entry hashes to what its link names or was purged, and, with `blobs`, +/// each report is there and hashes to what its entry names. +pub async fn verify(data: &Store, blobs: Option<&BlobStore>) -> trc::Result> { + let mut reports = Vec::new(); + for node in nodes(data).await? { + let start = floor(data, node).await?; + let head = head(data, node).await?.unwrap_or_default(); + let mut report = ChainReport { + node, + entries: 0, + purged: 0, + first_seq: start.seq, + last_seq: start.seq.saturating_sub(1), + broken_at: None, + reason: None, + }; + let mut links = Vec::new(); + data.iterate( + IterateParams::new( + key(KIND_LINK, &[node, start.seq]), + key(KIND_LINK, &[node, u64::MAX]), + ) + .ascending(), + |key, value| { + if let Some(parts) = parse_key(key, KIND_LINK, 2) { + links.push((parts[1], value.to_vec())); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + let mut expected_seq = start.seq; + let mut expected_prev = start.prev.clone(); + for (seq, bytes) in links { + let broken = |report: &mut ChainReport, reason: &str| { + report.broken_at = Some(EntryId { node, seq }.to_string()); + report.reason = Some(reason.to_string()); + }; + let Ok(Json(link)) = Json::::deserialize(&bytes) else { + broken(&mut report, "The link can't be read."); + break; + }; + if seq != expected_seq || link.seq != seq { + report.broken_at = Some(EntryId { node, seq }.to_string()); + report.reason = Some(format!( + "Entry {expected_seq} is missing; the next one found is {seq}." + )); + break; + } + if link.prev != expected_prev { + broken( + &mut report, + "The link doesn't follow from the one before it: one of them was changed.", + ); + break; + } + match data + .get_value::(key(KIND_CONTENT, &[node, seq])) + .await + .caused_by(trc::location!())? + { + Some(Raw(content)) => { + if sha256(&content) != link.content { + broken(&mut report, "The entry was changed after it was written."); + break; + } + if let Some(blobs) = blobs { + let Ok(Json(entry)) = Json::::deserialize(&content) else { + broken(&mut report, "The entry can't be read."); + break; + }; + let report_bytes = match entry.blob_hash() { + Some(hash) => blobs + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + .caused_by(trc::location!())?, + None => None, + }; + match report_bytes { + Some(bytes) if sha256(&bytes) == entry.sha256 => {} + Some(_) => { + broken(&mut report, "The report doesn't match its entry."); + break; + } + None => { + broken(&mut report, "The report is missing."); + break; + } + } + } + report.entries += 1; + } + None => { + if data + .get_value::(key(KIND_PURGED, &[node, seq])) + .await + .caused_by(trc::location!())? + .is_none() + { + broken(&mut report, "The entry was removed before its time."); + break; + } + report.purged += 1; + } + } + expected_prev = sha256(&bytes); + expected_seq = seq + 1; + report.last_seq = seq; + } + + if report.broken_at.is_none() + && (head.seq != report.last_seq + || (report.last_seq >= report.first_seq && head.hash != expected_prev)) + { + report.broken_at = Some( + EntryId { + node, + seq: report.last_seq, + } + .to_string(), + ); + report.reason = Some( + "The chain's recorded end doesn't match its last link: entries were removed \ + or changed at the end." + .into(), + ); + } + reports.push(report); + } + Ok(reports) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_read_back() { + let ValueClass::Any(any) = class(KIND_EXPIRY, &[5, 3, 9]) else { + panic!() + }; + assert_eq!(parse_key(&any.key, KIND_EXPIRY, 3), Some(vec![5, 3, 9])); + let mut with_subspace = vec![SUBSPACE_INBUXA]; + with_subspace.extend_from_slice(&any.key); + assert_eq!( + parse_key(&with_subspace, KIND_EXPIRY, 3), + Some(vec![5, 3, 9]) + ); + assert_eq!(parse_key(&any.key, KIND_TIME, 3), None); + } + + #[test] + fn hex_round_trips() { + let bytes = [0u8, 1, 0xab, 0xff]; + assert_eq!(unhex(&hex(&bytes)), Some(bytes.to_vec())); + assert_eq!(unhex("abc"), None); + assert_eq!(unhex("zz"), None); + } + + #[test] + fn ids_read_back() { + let id = EntryId { node: 3, seq: 77 }; + assert_eq!(EntryId::from_u64(id.to_u64()), id); + assert_eq!(id.to_string(), "3-77"); + } +} diff --git a/crates/features/src/journal/mod.rs b/crates/features/src/journal/mod.rs new file mode 100644 index 0000000..f0c2e35 --- /dev/null +++ b/crates/features/src/journal/mod.rs @@ -0,0 +1,431 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Journaling (journaling spec, JR-1 to JR-18): a copy of each message the +//! server queues, with its envelope, kept where nothing in the product +//! changes or removes it before its retention ends. +//! +//! - this module: journals, what makes one valid, and where they're kept; +//! - [`report`]: the journal report around the untouched message (JR-3); +//! - [`entries`]: the built-in journal and its chain (JR-5, JR-6, JR-13). +//! +//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts +//! with `J`; journals are `j` + id (u32), as JSON. There are few, so they're +//! read whole. + +pub mod entries; +pub mod report; + +use crate::{hold::Member, mailflow::rules::jmap_ids}; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned}; +use std::{ + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, +}; +use trc::AddContext; + +pub(crate) const FEATURE: u8 = b'J'; +const KIND_JOURNAL: u8 = b'j'; +const CREATE_ATTEMPTS: usize = 5; + +/// Retention a journal may be given, in days (settled answer 3). +pub const MIN_RETENTION_DAYS: u32 = 30; +pub const MAX_RETENTION_DAYS: u32 = 3650; +/// Most entries in one scope list. +const MAX_LIST: usize = 5_000; + +/// Which way a message goes, from this server's side (JR-9). +#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum Direction { + /// From someone here to at least one recipient elsewhere. + Outgoing, + /// From elsewhere to someone here. + Incoming, + /// From someone here, to people here only. + Internal, + Any, +} + +impl Direction { + pub fn as_str(&self) -> &'static str { + match self { + Direction::Outgoing => "outgoing", + Direction::Incoming => "incoming", + Direction::Internal => "internal", + Direction::Any => "any", + } + } + + /// A message's direction: `Any` is never one. + pub fn of(sender_local: bool, any_remote: bool, any_local: bool) -> Direction { + match (sender_local, any_remote) { + (true, true) => Direction::Outgoing, + (true, false) => Direction::Internal, + (false, _) if any_local => Direction::Incoming, + // Nobody here on either side: relayed mail counts as outgoing + (false, _) => Direction::Outgoing, + } + } + + fn includes(&self, direction: Direction) -> bool { + *self == Direction::Any || *self == direction + } +} + +/// Whose mail a journal takes (JR-9): everyone, or people reached through +/// their account, domain, group or tenant. Ids are in the JMAP form. +#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Scope { + #[serde(default)] + pub everyone: bool, + #[serde(default, with = "jmap_ids")] + pub accounts: Vec, + #[serde(default, with = "jmap_ids")] + pub groups: Vec, + #[serde(default, with = "jmap_ids")] + pub domains: Vec, + #[serde(default, with = "jmap_ids")] + pub tenants: Vec, +} + +impl Scope { + fn lists(&self) -> [&Vec; 4] { + [&self.accounts, &self.groups, &self.domains, &self.tenants] + } + + /// Whether this scope reaches one person here. + pub fn covers(&self, member: &Member) -> bool { + self.everyone + || self.accounts.contains(&member.account) + || member.domains.iter().any(|d| self.domains.contains(d)) + || member.groups.iter().any(|g| self.groups.contains(g)) + || member.tenant.is_some_and(|t| self.tenants.contains(&t)) + } +} + +/// A journal (JR-9): what it takes, and how long its entries are kept. +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Journal { + #[serde(default)] + pub id: u32, + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub enabled: bool, + pub direction: Direction, + pub scope: Scope, + /// How long an entry this journal writes is kept. An entry keeps the + /// retention it was written with (JR-12). + pub retention_days: u32, + #[serde(default)] + pub created_by: String, + #[serde(default)] + pub created_at: u64, + #[serde(default)] + pub updated_at: u64, +} + +/// Why a journal was refused: the property, and what to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invalid { + pub property: &'static str, + pub reason: String, +} + +fn invalid(property: &'static str, reason: impl Into) -> Result<(), Invalid> { + Err(Invalid { + property, + reason: reason.into(), + }) +} + +impl Journal { + pub fn validate(&self) -> Result<(), Invalid> { + if self.name.trim().is_empty() { + return invalid("name", "Give the journal a name."); + } + if self.name.len() > 200 || self.description.len() > 2_000 { + return invalid("name", "The name or description is too long."); + } + if !(MIN_RETENTION_DAYS..=MAX_RETENTION_DAYS).contains(&self.retention_days) { + return invalid( + "retentionDays", + format!("Keep entries between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS} days."), + ); + } + let chosen = self.scope.lists().iter().any(|list| !list.is_empty()); + if self.scope.everyone == chosen { + return invalid( + "scope", + "Journal everyone, or choose accounts, groups, domains or tenants; not both.", + ); + } + if self.scope.lists().iter().any(|list| list.len() > MAX_LIST) { + return invalid("scope", format!("Choose at most {MAX_LIST} of each.")); + } + Ok(()) + } + + /// Whether this journal takes a message going `direction` with these + /// people here on either side. + pub fn takes(&self, direction: Direction, members: &[Member]) -> bool { + self.enabled + && self.direction.includes(direction) + && (self.scope.everyone || members.iter().any(|m| self.scope.covers(m))) + } +} + +/// A value stored as JSON. +pub(crate) struct Json(pub T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize a journal record") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid journal record") + .reason(err) + }) + } +} + +fn class(id: u32) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.push(FEATURE); + key.push(KIND_JOURNAL); + key.extend_from_slice(&id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(id: u32) -> ValueKey { + ValueKey::from(class(id)) +} + +pub async fn get(data: &Store, id: u32) -> trc::Result> { + Ok(data + .get_value::>(key(id)) + .await + .caused_by(trc::location!())? + .map(|Json(journal)| journal)) +} + +/// Every journal, oldest first. +pub async fn all(data: &Store) -> trc::Result> { + let mut journals = Vec::new(); + data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| { + if let Ok(Json(journal)) = Json::::deserialize(value) { + journals.push(journal); + } + Ok(true) + }) + .await + .caused_by(trc::location!())?; + journals.sort_by_key(|journal| journal.id); + Ok(journals) +} + +/// Writes a new journal under the next free id, which it returns. +pub async fn create(data: &Store, journal: &Journal) -> trc::Result { + let mut attempt = 0; + loop { + attempt += 1; + let id = all(data).await?.iter().map(|j| j.id).max().unwrap_or(0) + 1; + let stored = Journal { + id, + ..journal.clone() + }; + let mut batch = BatchBuilder::new(); + batch.assert_value(class(id), AssertValue::None); + batch.set(class(id), Json(&stored).serialize()?); + match data.write(batch.build_all()).await { + Ok(_) => { + invalidate(); + return Ok(id); + } + Err(err) + if attempt < CREATE_ATTEMPTS + && matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => {} + Err(err) => return Err(err.caused_by(trc::location!())), + } + } +} + +/// Replaces a stored journal (same id). +pub async fn update(data: &Store, journal: &Journal) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set(class(journal.id), Json(journal).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + invalidate(); + Ok(()) +} + +/// Removes a journal. Its entries stay, each until its own time. +pub async fn delete(data: &Store, id: u32) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.clear(class(id)); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + invalidate(); + Ok(()) +} + +/// How long a node keeps its copy of the journals before reading them again. +pub const TTL: Duration = Duration::from_secs(30); + +type Cached = Option<(Instant, Arc>)>; +static CACHE: RwLock = RwLock::new(None); + +/// Forgets this node's copy, so the next message reads the journals again. +pub fn invalidate() { + if let Ok(mut cache) = CACHE.write() { + *cache = None; + } +} + +/// The enabled journals, from this node's copy (refreshed every [`TTL`]). +pub async fn enabled(data: &Store) -> trc::Result>> { + if let Ok(cache) = CACHE.read() + && let Some((at, journals)) = cache.as_ref() + && at.elapsed() < TTL + { + return Ok(journals.clone()); + } + let journals = Arc::new( + all(data) + .await? + .into_iter() + .filter(|journal| journal.enabled) + .collect::>(), + ); + if let Ok(mut cache) = CACHE.write() { + *cache = Some((Instant::now(), journals.clone())); + } + Ok(journals) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn journal(scope: Scope) -> Journal { + Journal { + id: 1, + name: "Finance".into(), + description: String::new(), + enabled: true, + direction: Direction::Any, + scope, + retention_days: 365, + created_by: String::new(), + created_at: 0, + updated_at: 0, + } + } + + fn member(account: u32, groups: Vec) -> Member { + Member { + account, + domains: vec![1], + groups, + tenant: None, + } + } + + #[test] + fn scope_is_everyone_or_chosen() { + assert!( + journal(Scope { + everyone: true, + ..Default::default() + }) + .validate() + .is_ok() + ); + assert!(journal(Scope::default()).validate().is_err()); + let both = Scope { + everyone: true, + groups: vec![4], + ..Default::default() + }; + assert_eq!(journal(both).validate().unwrap_err().property, "scope"); + } + + #[test] + fn retention_has_bounds() { + let mut j = journal(Scope { + everyone: true, + ..Default::default() + }); + j.retention_days = 29; + assert_eq!(j.validate().unwrap_err().property, "retentionDays"); + j.retention_days = 3651; + assert!(j.validate().is_err()); + j.retention_days = 3650; + assert!(j.validate().is_ok()); + } + + #[test] + fn takes_by_direction_and_member() { + let mut j = journal(Scope { + groups: vec![7], + ..Default::default() + }); + assert!(j.takes(Direction::Outgoing, &[member(3, vec![7])])); + assert!(!j.takes(Direction::Outgoing, &[member(3, vec![8])])); + assert!(!j.takes(Direction::Outgoing, &[])); + j.direction = Direction::Incoming; + assert!(!j.takes(Direction::Outgoing, &[member(3, vec![7])])); + j.enabled = false; + assert!(!j.takes(Direction::Incoming, &[member(3, vec![7])])); + } + + #[test] + fn directions() { + assert_eq!(Direction::of(true, true, true), Direction::Outgoing); + assert_eq!(Direction::of(true, false, true), Direction::Internal); + assert_eq!(Direction::of(false, false, true), Direction::Incoming); + assert_eq!(Direction::of(false, true, true), Direction::Incoming); + } + + #[test] + fn scope_ids_are_jmap_ids() { + let scope: Scope = serde_json::from_str(r#"{"groups":["b"],"tenants":[7]}"#).unwrap(); + assert_eq!(scope.groups, vec![1]); + assert_eq!(scope.tenants, vec![7]); + assert_eq!( + serde_json::to_value(&scope).unwrap()["tenants"], + serde_json::json!(["h"]) + ); + } +} diff --git a/crates/features/src/journal/report.rs b/crates/features/src/journal/report.rs new file mode 100644 index 0000000..c8c3da8 --- /dev/null +++ b/crates/features/src/journal/report.rs @@ -0,0 +1,359 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The journal report (JR-3, JR-4): a message whose first part lists the +//! envelope, one field a line, and whose second part is the message as it +//! was queued, byte for byte, as `message/rfc822`. Field names are fixed +//! English: a report is a record, and scripts read it. + +use super::Direction; +use mail_builder::headers::{Header, date::Date, text::Text}; +use mail_parser::MessageParser; +use sha2::{Digest, Sha256}; + +/// One envelope recipient, with the address it was given as (a list's, for +/// the list's members). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recipient { + pub address: String, + pub orcpt: Option, +} + +/// What the queue knows about a message. +#[derive(Debug, Clone)] +pub struct Envelope<'x> { + pub sender: &'x str, + pub authenticated: bool, + pub recipients: &'x [Recipient], + pub queue_id: u64, + /// Seconds. + pub received: u64, + pub direction: Direction, + pub held: bool, +} + +/// What a report says, besides the envelope's own fields. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Fields { + pub subject: String, + pub message_id: String, + pub to: Vec, + pub cc: Vec, + /// Envelope recipients in neither To nor Cc, nor reached through a list. + pub bcc: Vec, + /// A list's address, and its members among the recipients. + pub expanded: Vec<(String, Vec)>, +} + +/// One line's worth of a value: no line breaks, no control characters. +fn line(value: &str) -> String { + value + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect::() + .trim() + .to_string() +} + +/// The address an ORCPT names, without its `rfc822;` type. +fn orcpt_address(orcpt: &str) -> String { + let orcpt = orcpt.trim(); + let bare = match orcpt.split_once(';') { + Some((kind, address)) if kind.eq_ignore_ascii_case("rfc822") => address, + _ => orcpt, + }; + bare.trim().to_lowercase() +} + +/// Sorts the envelope's recipients by how they were addressed. +pub fn fields(envelope: &Envelope<'_>, original: &[u8]) -> Fields { + let parsed = MessageParser::default().parse_headers(original); + let headed = |which: Option<&mail_parser::Address<'_>>| -> Vec { + which + .map(|list| { + list.iter() + .filter_map(|addr| addr.address()) + .map(|address| address.to_lowercase()) + .collect() + }) + .unwrap_or_default() + }; + let (subject, message_id, header_to, header_cc) = match &parsed { + Some(message) => ( + message.subject().map(line).unwrap_or_default(), + message + .message_id() + .map(|id| format!("<{}>", line(id))) + .unwrap_or_default(), + headed(message.to()), + headed(message.cc()), + ), + None => Default::default(), + }; + + let mut fields = Fields { + subject, + message_id, + ..Default::default() + }; + for rcpt in envelope.recipients { + let address = rcpt.address.to_lowercase(); + let via = rcpt + .orcpt + .as_deref() + .map(orcpt_address) + .filter(|via| !via.is_empty() && *via != address); + if header_to.contains(&address) { + fields.to.push(line(&rcpt.address)); + } else if header_cc.contains(&address) { + fields.cc.push(line(&rcpt.address)); + } else if let Some(via) = via { + match fields.expanded.iter_mut().find(|(list, _)| *list == via) { + Some((_, members)) => members.push(line(&rcpt.address)), + None => fields + .expanded + .push((line(&via), vec![line(&rcpt.address)])), + } + } else { + fields.bcc.push(line(&rcpt.address)); + } + } + fields +} + +/// The report's first part. +pub fn text(envelope: &Envelope<'_>, fields: &Fields) -> String { + let mut out = String::new(); + let mut field = |name: &str, value: &str| { + if !value.is_empty() { + out.push_str(name); + out.push_str(": "); + out.push_str(value); + out.push_str("\r\n"); + } + }; + let sender = if envelope.sender.is_empty() { + "<>".to_string() + } else { + line(envelope.sender) + }; + field("Sender", &sender); + field( + "Authenticated", + if envelope.authenticated { "yes" } else { "no" }, + ); + field("Subject", &fields.subject); + field("Message-ID", &fields.message_id); + field("Queue ID", &format!("{:x}", envelope.queue_id)); + field( + "Received", + &mail_parser::DateTime::from_timestamp(envelope.received as i64).to_rfc3339(), + ); + field("Direction", envelope.direction.as_str()); + field("To", &fields.to.join(", ")); + field("Cc", &fields.cc.join(", ")); + field("Bcc", &fields.bcc.join(", ")); + for (list, members) in &fields.expanded { + field("Expanded", &format!("{list} -> {}", members.join(", "))); + } + if envelope.held { + field("Held for review", "yes"); + } + out +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Whether a message can travel as 8bit: no NULs, no line past 998 bytes. +fn fits_8bit(message: &[u8]) -> bool { + !message.contains(&0) && message.split(|b| *b == b'\n').all(|l| l.len() <= 998) +} + +/// The whole report: headers, the fields, then the original untouched. +/// `from` is the address the report is from; `host` names the server in its +/// Message-ID. +pub fn build( + envelope: &Envelope<'_>, + original: &[u8], + from: &str, + host: &str, +) -> (Vec, Fields) { + let fields = fields(envelope, original); + let body = text(envelope, &fields); + // A boundary that can't occur in the original + let mut boundary = format!("journal-{}", &hex(&Sha256::digest(original))[..32]); + while original + .windows(boundary.len()) + .any(|window| window == boundary.as_bytes()) + { + boundary.push('x'); + } + + let mut out: Vec = Vec::with_capacity(original.len() + body.len() + 1024); + out.extend_from_slice(format!("From: Journal <{}>\r\n", line(from)).as_bytes()); + out.extend_from_slice(b"Date: "); + out.extend_from_slice(Date::new(envelope.received as i64).to_rfc822().as_bytes()); + out.extend_from_slice(b"\r\n"); + out.extend_from_slice(b"Subject: "); + let subject = if fields.subject.is_empty() { + "Journal report".to_string() + } else { + format!("Journal report: {}", fields.subject) + }; + Text::new(subject).write_header(&mut out, "Subject: ".len()); + out.extend_from_slice( + format!( + "Message-ID: \r\n", + envelope.queue_id, + envelope.received, + line(host) + ) + .as_bytes(), + ); + out.extend_from_slice(format!("X-Inbuxa-Journal: {:x}\r\n", envelope.queue_id).as_bytes()); + out.extend_from_slice(b"MIME-Version: 1.0\r\n"); + out.extend_from_slice( + format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n").as_bytes(), + ); + out.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + out.extend_from_slice( + b"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n", + ); + out.extend_from_slice(body.as_bytes()); + out.extend_from_slice(format!("\r\n--{boundary}\r\n").as_bytes()); + out.extend_from_slice(b"Content-Type: message/rfc822\r\n"); + out.extend_from_slice(b"Content-Disposition: attachment; filename=\"original.eml\"\r\n"); + out.extend_from_slice(if fits_8bit(original) { + b"Content-Transfer-Encoding: 8bit\r\n\r\n".as_slice() + } else { + b"Content-Transfer-Encoding: binary\r\n\r\n".as_slice() + }); + out.extend_from_slice(original); + // The line break before a boundary belongs to the boundary: the + // original keeps its own last one + out.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + (out, fields) +} + +/// Where the original starts and ends inside a report [`build`] made. +pub fn original(report: &[u8]) -> Option<&[u8]> { + let parsed = MessageParser::default().parse(report)?; + let part = parsed.attachment(0)?; + let start = part.raw_body_offset() as usize; + let end = part.raw_end_offset() as usize; + report.get(start..end) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ORIGINAL: &[u8] = b"From: alice@example.com\r\n\ +To: Bank \r\n\ +Cc: bob@example.com\r\n\ +Subject: Q3 figures\r\n\ +Message-ID: \r\n\ +\r\n\ +The figures.\r\n"; + + fn rcpt(address: &str, orcpt: Option<&str>) -> Recipient { + Recipient { + address: address.into(), + orcpt: orcpt.map(Into::into), + } + } + + fn envelope(recipients: &[Recipient]) -> Envelope<'_> { + Envelope { + sender: "alice@example.com", + authenticated: true, + recipients, + queue_id: 0x1a2b, + received: 1_790_000_000, + direction: Direction::Outgoing, + held: false, + } + } + + #[test] + fn recipients_sorted_by_how_they_were_addressed() { + let recipients = [ + rcpt("pay@bank.example", None), + rcpt("Bob@example.com", Some("rfc822;bob@example.com")), + rcpt("carol@example.com", None), + rcpt("dan@example.com", Some("finance@example.com")), + rcpt("erin@example.com", Some("rfc822;Finance@example.com")), + ]; + let fields = fields(&envelope(&recipients), ORIGINAL); + assert_eq!(fields.subject, "Q3 figures"); + assert_eq!(fields.message_id, ""); + assert_eq!(fields.to, vec!["pay@bank.example"]); + assert_eq!(fields.cc, vec!["Bob@example.com"]); + assert_eq!(fields.bcc, vec!["carol@example.com"]); + assert_eq!( + fields.expanded, + vec![( + "finance@example.com".to_string(), + vec![ + "dan@example.com".to_string(), + "erin@example.com".to_string() + ] + )] + ); + } + + #[test] + fn report_carries_the_original_untouched() { + let recipients = [ + rcpt("pay@bank.example", None), + rcpt("carol@example.com", None), + ]; + let (report, _) = build( + &envelope(&recipients), + ORIGINAL, + "postmaster@example.com", + "mx.example.com", + ); + let text = String::from_utf8_lossy(&report); + assert!(text.contains("Sender: alice@example.com\r\n")); + assert!(text.contains("Bcc: carol@example.com\r\n")); + assert!(text.contains("Queue ID: 1a2b\r\n")); + assert!(text.contains("Direction: outgoing\r\n")); + assert!(text.contains("Subject: Journal report: Q3 figures\r\n")); + assert!(!text.contains("Held for review")); + assert_eq!(original(&report), Some(ORIGINAL)); + let unterminated = &ORIGINAL[..ORIGINAL.len() - 2]; + let (report, _) = build( + &envelope(&recipients), + unterminated, + "postmaster@example.com", + "mx.example.com", + ); + assert_eq!(original(&report), Some(unterminated)); + } + + #[test] + fn values_stay_on_one_line() { + let recipients = [rcpt("x@example.com", None)]; + let mut env = envelope(&recipients); + env.sender = "evil@example.com\r\nBcc: nobody@example.com"; + env.held = true; + let body = text(&env, &Fields::default()); + assert_eq!(body.matches("\r\n").count(), body.lines().count()); + assert!(body.contains("Sender: evil@example.com Bcc: nobody@example.com\r\n")); + assert!(body.contains("Held for review: yes\r\n")); + } + + #[test] + fn an_empty_sender_is_shown_as_such() { + let recipients = [rcpt("x@example.com", None)]; + let mut env = envelope(&recipients); + env.sender = ""; + assert!(text(&env, &Fields::default()).starts_with("Sender: <>\r\n")); + } +} diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 9df288c..1351d41 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -22,6 +22,7 @@ pub mod ai; pub mod audit; pub mod branding; pub mod hold; +pub mod journal; pub mod lock; pub mod mailflow; pub mod masked_email; diff --git a/crates/features/src/mailflow/rules.rs b/crates/features/src/mailflow/rules.rs index 1816523..a94174b 100644 --- a/crates/features/src/mailflow/rules.rs +++ b/crates/features/src/mailflow/rules.rs @@ -62,7 +62,7 @@ fn one() -> u32 { /// Group and tenant ids in the JMAP form clients use (`"b"`, `"c"`…), held /// as numbers for matching. Plain numbers are read too. -mod jmap_ids { +pub(crate) mod jmap_ids { use serde::{Deserialize, Deserializer, Serializer, de::Error, ser::SerializeSeq}; use std::str::FromStr; use types::id::Id; diff --git a/crates/jmap-proto/src/object/inbuxa_journal.rs b/crates/jmap-proto/src/object/inbuxa_journal.rs new file mode 100644 index 0000000..5b79069 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_journal.rs @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:Journal/get` and `/set` under `urn:inbuxa:jmap`: journals +//! (journaling spec, JR-9, JR-12). What a journal has taken stays when the +//! journal changes or goes; each entry keeps its own retention. + +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 Journal; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JournalProperty { + Id, + Name, + Description, + Enabled, + /// `outgoing`, `incoming`, `internal` or `any`. + Direction, + /// Everyone, or chosen accounts, groups, domains and tenants. + Scope, + /// How long an entry is kept; each keeps what it was written with. + RetentionDays, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JournalValue { + Id(Id), +} + +impl Property for JournalProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside the scope stay plain keys + match parent { + None => JournalProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + JournalProperty::Id => "id", + JournalProperty::Name => "name", + JournalProperty::Description => "description", + JournalProperty::Enabled => "enabled", + JournalProperty::Direction => "direction", + JournalProperty::Scope => "scope", + JournalProperty::RetentionDays => "retentionDays", + JournalProperty::CreatedBy => "createdBy", + JournalProperty::CreatedAt => "createdAt", + JournalProperty::UpdatedAt => "updatedAt", + } + .into() + } +} + +impl JournalProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => JournalProperty::Id, + b"name" => JournalProperty::Name, + b"description" => JournalProperty::Description, + b"enabled" => JournalProperty::Enabled, + b"direction" => JournalProperty::Direction, + b"scope" => JournalProperty::Scope, + b"retentionDays" => JournalProperty::RetentionDays, + b"createdBy" => JournalProperty::CreatedBy, + b"createdAt" => JournalProperty::CreatedAt, + b"updatedAt" => JournalProperty::UpdatedAt, + ) + } +} + +impl FromStr for JournalProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + JournalProperty::parse(s).ok_or(()) + } +} + +impl Element for JournalValue { + type Property = JournalProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(JournalProperty::Id) => Id::from_str(value).ok().map(JournalValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + JournalValue::Id(id) => id.to_string().into(), + } + } +} + +/// The set call's own argument: why, for the audit log. +#[derive(Debug, Clone, Default)] +pub struct JournalSetArguments { + pub reason: Option, +} + +impl<'de> DeserializeArguments<'de> for JournalSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "reason" { + self.reason = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl JmapObject for Journal { + type Property = JournalProperty; + + type Element = JournalValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = JournalSetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = JournalProperty::Id; +} + +impl From for JournalValue { + fn from(id: Id) -> Self { + JournalValue::Id(id) + } +} + +impl JmapObjectId for JournalValue { + fn as_id(&self) -> Option { + match self { + JournalValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + JournalValue::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 = JournalValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for JournalProperty { + 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 003cf17..7d91f9a 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -30,6 +30,7 @@ pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules +pub mod inbuxa_journal; // inbuxa: journaling 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 diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 216d826..08a22b4 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -88,6 +88,9 @@ impl Response<'_> { GetResponseMethod::MailRule(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::Journal(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::HeldMessage(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 b4a3ed3..dd69481 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -55,6 +55,7 @@ impl Response<'_> { GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, GetRequestMethod::MailRule(request) => request.resolve_references(self)?, + GetRequestMethod::Journal(request) => request.resolve_references(self)?, GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?, GetRequestMethod::HoldExport(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, @@ -131,6 +132,9 @@ impl Response<'_> { SetRequestMethod::MailRule(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::Journal(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::HeldMessage(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 99d5258..b42d028 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -69,6 +69,8 @@ pub enum MethodObject { // inbuxa: DLP and mail flow rules MailRule, HeldMessage, + // inbuxa: journaling + Journal, TenantProtocolPolicy, } @@ -109,7 +111,8 @@ impl MethodObject { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule - | MethodObject::HeldMessage => Capability::Inbuxa, + | MethodObject::HeldMessage + | MethodObject::Journal => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } @@ -307,6 +310,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", (MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get", (MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set", + (MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get", + (MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set", (MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get", (MethodFunction::Set, MethodObject::HeldMessage) => "inbuxa:HeldMessage/set", (MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get", @@ -465,6 +470,8 @@ impl MethodName { "inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), "inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get), "inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set), + "inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get), + "inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set), "inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get), "inbuxa:HeldMessage/set" => (MethodObject::HeldMessage, MethodFunction::Set), "inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get), @@ -539,6 +546,7 @@ impl Display for MethodObject { MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::MailRule => "inbuxa:MailRule", + MethodObject::Journal => "inbuxa:Journal", MethodObject::HeldMessage => "inbuxa:HeldMessage", MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 0923c14..b2ad5ef 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -125,6 +125,7 @@ pub enum GetRequestMethod { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + Journal(Box>), HeldMessage(Box>), HoldExport(Box>), ProtocolPolicy(Box>), @@ -163,6 +164,7 @@ pub enum SetRequestMethod<'x> { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + Journal(Box>), HeldMessage(Box>), HoldExport(Box>), ProtocolPolicy(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 4d067db..7c0f1b0 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -653,6 +653,21 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: journaling + (MethodFunction::Get, MethodObject::Journal) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Journal(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::Journal) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Journal(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: legal hold (MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 6320886..d9e9e74 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -112,6 +112,7 @@ pub enum GetResponseMethod { AccountLock(GetResponse), LegalHold(GetResponse), MailRule(GetResponse), + Journal(GetResponse), HeldMessage(GetResponse), HoldExport(GetResponse), ProtocolPolicy(GetResponse), @@ -150,6 +151,7 @@ pub enum SetResponseMethod { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + Journal(Box>), HeldMessage(Box>), HoldExport(Box>), Explanation(Box>), @@ -841,6 +843,19 @@ impl<'x> From> for Respon } } +// inbuxa: journaling +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Journal(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Journal(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::LegalHold(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 83af147..73b7e36 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -116,6 +116,8 @@ impl JmapAuthorization for AccessToken { Permission::SysDlpPolicyGet } } + // inbuxa: journaling (JR-18) + GetRequestMethod::Journal(_) => Permission::SysJournalGet, GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions @@ -288,6 +290,14 @@ impl JmapAuthorization for AccessToken { .details("You are not authorized to change mail rules")) } } + // inbuxa: journaling (JR-18) + SetRequestMethod::Journal(s) => validate_set( + s, + self, + Permission::SysJournalUpdate, + Permission::SysJournalUpdate, + Permission::SysJournalUpdate, + ), // inbuxa: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -451,6 +461,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::HoldExport | MethodObject::MailRule | MethodObject::HeldMessage + | MethodObject::Journal | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 3dbc901..d627d51 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -285,6 +285,9 @@ impl RequestHandler for Server { SetResponseMethod::MailRule(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::Journal(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::HeldMessage(set_response) => { set_response.update_created_ids(&mut response); } @@ -512,6 +515,11 @@ impl RequestHandler for Server { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into() } + // inbuxa: journaling + GetRequestMethod::Journal(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::journal::get(self, access_token, *req).await?.into() + } // inbuxa: the audit log (AU-9) GetRequestMethod::AuditEvent(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -985,6 +993,22 @@ impl RequestHandler for Server { .await? .into() } + SetRequestMethod::Journal(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + let reason = req.arguments.reason.clone(); + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + reason, + *req, + |req| Box::pin(crate::inbuxa::journal::set(self, access_token, req)), + ) + .await? + .into() + } SetRequestMethod::AuditExport(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::audit_log::export_set(self, access_token, session, *req) diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 343ef12..5370409 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -431,6 +431,7 @@ impl IntermediateChangesResponse { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::Journal | MethodObject::HeldMessage | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy diff --git a/crates/jmap/src/inbuxa/journal.rs b/crates/jmap/src/inbuxa/journal.rs new file mode 100644 index 0000000..2497235 --- /dev/null +++ b/crates/jmap/src/inbuxa/journal.rs @@ -0,0 +1,273 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:Journal` (journaling spec, JR-9, JR-12, JR-18): journals, seen +//! with `sysJournalGet` and changed with `sysJournalUpdate`, which the +//! request layer checks. Journals are the server's: nobody in a tenant +//! reaches them. The request layer records every change in the audit log. +//! Changing or removing a journal never touches what it has taken. + +use common::{Server, auth::AccessToken}; +use inbuxa_features::journal::{self, Journal as Stored}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_journal::{Journal, JournalProperty as P, JournalValue}, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Property, Value}; +use std::borrow::Cow; +use store::write::now; +use types::id::Id; + +type JValue = Value<'static, P, JournalValue>; + +const ALL: &[P] = &[ + P::Id, + P::Name, + P::Description, + P::Enabled, + P::Direction, + P::Scope, + P::RetentionDays, + P::CreatedBy, + P::CreatedAt, + P::UpdatedAt, +]; + +/// Properties the server sets; a client that sends them is refused. +const SERVER_SET: &[P] = &[P::Id, P::CreatedBy, P::CreatedAt, P::UpdatedAt]; + +fn server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("Journals are the server's.")) + } else { + Ok(()) + } +} + +fn json_to_value(json: serde_json::Value) -> JValue { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(n) = n.as_u64() { + Value::Number(n.into()) + } else if let Some(n) = n.as_i64() { + Value::Number(n.into()) + } else { + Value::Number(n.as_f64().unwrap_or_default().into()) + } + } + serde_json::Value::String(s) => Value::Str(Cow::Owned(s)), + serde_json::Value::Array(items) => { + Value::Array(items.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, value) in map { + out.insert_unchecked(Key::Owned(key), json_to_value(value)); + } + Value::Object(out) + } + } +} + +fn date(seconds: u64) -> JValue { + Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into()) +} + +fn to_value(journal: &Stored, properties: &[P]) -> JValue { + let json = serde_json::to_value(journal).unwrap_or_default(); + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(JournalValue::Id(Id::from(journal.id))), + P::CreatedAt => date(journal.created_at), + P::UpdatedAt => date(journal.updated_at), + other => json + .get(other.to_cow().as_ref()) + .cloned() + .map_or(Value::Null, json_to_value), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// A journal as sent: its JSON object, top-level keys only those a client +/// may set. +fn client_json( + value: Value<'_, P, JournalValue>, +) -> Result, SetError

> { + let mut map = serde_json::Map::new(); + for (key, value) in value.into_expanded_object() { + match &key { + Key::Property(p) if SERVER_SET.contains(p) => { + return Err(SetError::invalid_properties() + .with_property(p.clone()) + .with_description("The server sets this.")); + } + Key::Property(p) => { + map.insert(p.to_cow().into_owned(), value.into()); + } + _ => { + return Err(SetError::invalid_properties().with_property(key.clone().into_owned())); + } + } + } + Ok(map) +} + +fn parse(json: serde_json::Map) -> Result> { + let journal: Stored = + serde_json::from_value(serde_json::Value::Object(json)).map_err(|err| { + SetError::invalid_properties().with_description(format!("Not a valid journal: {err}")) + })?; + journal.validate().map_err(|invalid| { + let property = invalid.property.parse::

().unwrap_or(P::Name); + SetError::invalid_properties() + .with_property(property) + .with_description(invalid.reason) + })?; + Ok(journal) +} + +fn journal_id(id: Id) -> Option { + u32::try_from(id.id()).ok() +} + +/// `inbuxa:Journal/get`: every journal, oldest first. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let journals = journal::all(server.store()).await?; + match ids { + None => { + response.list = journals + .iter() + .map(|journal| to_value(journal, &properties)) + .collect() + } + Some(ids) => { + for id in ids { + match journal_id(id).and_then(|id| journals.iter().find(|j| j.id == id)) { + Some(journal) => response.list.push(to_value(journal, &properties)), + None => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +/// `inbuxa:Journal/set`: create, change or remove journals. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, Journal>, +) -> trc::Result> { + server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let data = server.store(); + let actor = server.audit_actor(access_token).await; + + for (client_id, value) in request.unwrap_create() { + let stored = match client_json(value).and_then(parse) { + Ok(stored) => stored, + Err(error) => { + response.not_created.append(client_id, error); + continue; + } + }; + let at = now(); + let stored = Stored { + created_by: actor.name.clone(), + created_at: at, + updated_at: at, + ..stored + }; + let id = journal::create(data, &stored).await?; + let mut out = Map::with_capacity(1); + out.insert_unchecked( + Key::Property(P::Id), + Value::Element(JournalValue::Id(Id::from(id))), + ); + response.created.insert(client_id, Value::Object(out)); + } + + for (id, value) in request.unwrap_update().into_valid() { + let Some(current) = (match journal_id(id) { + Some(journal_id) => journal::get(data, journal_id).await?, + None => None, + }) else { + response.not_updated.append(id, SetError::not_found()); + continue; + }; + // The stored journal, with each property sent replacing its own + let mut json = match serde_json::to_value(¤t) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + }; + let changes = match client_json(value) { + Ok(changes) => changes, + Err(error) => { + response.not_updated.append(id, error); + continue; + } + }; + json.extend(changes); + let next = match parse(json) { + Ok(next) => next, + Err(error) => { + response.not_updated.append(id, error); + continue; + } + }; + let next = Stored { + id: current.id, + created_by: current.created_by.clone(), + created_at: current.created_at, + updated_at: now(), + ..next + }; + if next != current { + journal::update(data, &next).await?; + } + response.updated.append(id, None); + } + + for id in request.unwrap_destroy().into_valid() { + let Some(current) = (match journal_id(id) { + Some(journal_id) => journal::get(data, journal_id).await?, + None => None, + }) else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + journal::delete(data, current.id).await?; + response.destroyed.push(id); + } + + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 9e978f1..5d2ab1b 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -11,6 +11,7 @@ pub mod access; pub mod account_lock; pub mod legal_hold; pub mod mail_rule; +pub mod journal; pub mod held_message; pub mod dlp_settings; pub mod hold_export; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index f578905..e0d5c98 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1755,6 +1755,11 @@ pub enum Permission { SysDlpPolicyUpdate = 677, SysDlpReviewGet = 678, SysDlpReviewUpdate = 679, + // inbuxa: journaling + SysJournalGet = 680, + SysJournalUpdate = 681, + SysJournalSearch = 682, + SysJournalExport = 683, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index ed72ffe..f8cba76 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7097,6 +7097,10 @@ impl EnumImpl for Permission { b"sysDlpPolicyUpdate" => Permission::SysDlpPolicyUpdate, b"sysDlpReviewGet" => Permission::SysDlpReviewGet, b"sysDlpReviewUpdate" => Permission::SysDlpReviewUpdate, + b"sysJournalGet" => Permission::SysJournalGet, + b"sysJournalUpdate" => Permission::SysJournalUpdate, + b"sysJournalSearch" => Permission::SysJournalSearch, + b"sysJournalExport" => Permission::SysJournalExport, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7793,6 +7797,10 @@ impl EnumImpl for Permission { Permission::SysDlpPolicyUpdate => "sysDlpPolicyUpdate", Permission::SysDlpReviewGet => "sysDlpReviewGet", Permission::SysDlpReviewUpdate => "sysDlpReviewUpdate", + Permission::SysJournalGet => "sysJournalGet", + Permission::SysJournalUpdate => "sysJournalUpdate", + Permission::SysJournalSearch => "sysJournalSearch", + Permission::SysJournalExport => "sysJournalExport", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8482,6 +8490,10 @@ impl EnumImpl for Permission { 677 => Some(Permission::SysDlpPolicyUpdate), 678 => Some(Permission::SysDlpReviewGet), 679 => Some(Permission::SysDlpReviewUpdate), + 680 => Some(Permission::SysJournalGet), + 681 => Some(Permission::SysJournalUpdate), + 682 => Some(Permission::SysJournalSearch), + 683 => Some(Permission::SysJournalExport), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8926,7 +8938,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 680; + const COUNT: usize = 684; } impl serde::Serialize for Permission { diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs index b8e2414..3f404e9 100644 --- a/crates/services/src/task_manager/maintenance.rs +++ b/crates/services/src/task_manager/maintenance.rs @@ -291,6 +291,12 @@ async fn store_maintenance( trc::error!(err.details("Failed to return unreviewed held mail")); } + // inbuxa: journaling, JR-13: entries past their retention go, + // except those a legal hold keeps + if let Err(err) = purge_journal(server).await { + trc::error!(err.details("Failed to purge journal entries")); + } + // inbuxa: AU-7: audit records past their retention go; a // failure leaves them for the next run if let Err(err) = server.audit_purge().await { @@ -409,6 +415,54 @@ async fn store_maintenance( Ok(TaskResult::Success(vec![])) } +/// inbuxa: journaling, JR-13: removes journal entries past their +/// retention, keeping any whose sender or recipients a legal hold covers +/// (deleted accounts a hold keeps included), and records how many went. +async fn purge_journal(server: &Server) -> trc::Result<()> { + use inbuxa_features::audit::{Action, Actor, Outcome, Record, Target}; + let mut held = server.held_accounts().await?; + if !held.is_empty() { + for (account_id, kept) in + inbuxa_features::undelete::data::kept_accounts(server.store()).await? + { + if server.is_kept_held(account_id, &kept).await? { + held.insert(account_id); + } + } + } + let at = store::write::now(); + let purged = inbuxa_features::journal::entries::purge(server.store(), at, |entry| { + entry.accounts.iter().any(|account| held.contains(account)) + }) + .await?; + if purged.removed > 0 || purged.kept_for_hold > 0 { + server + .audit_note(Record { + at: at * 1000, + actor: Actor::system("Journal"), + via: None, + remote_ip: None, + action: Action::Destroy, + target: Target { + kind: "inbuxa:JournalEntry".into(), + id: None, + name: None, + account_id: None, + tenant_id: None, + }, + changes: vec![], + details: Some(format!( + "{} past their retention removed; {} kept for a legal hold", + purged.removed, purged.kept_for_hold + )), + reason: None, + outcome: Outcome::success(), + }) + .await; + } + Ok(()) +} + async fn account_maintenance( server: &Server, task: &TaskAccountMaintenance, diff --git a/crates/smtp/src/queue/journal.rs b/crates/smtp/src/queue/journal.rs new file mode 100644 index 0000000..10280c3 --- /dev/null +++ b/crates/smtp/src/queue/journal.rs @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: journaling (journaling spec, JR-1 to JR-5, JR-11): the copy +//! taken as a message is queued, after DLP and transport rules, so it has +//! the envelope the message actually leaves or arrives with. + +use crate::queue::{FROM_AUTHENTICATED, FROM_AUTOGENERATED, FROM_DSN, FROM_REPORT, Message}; +use common::Server; +use inbuxa_features::{ + hold::Member, + journal::{ + self, Direction, + entries::{self, Entry}, + report::{self, Envelope, Recipient}, + }, + mailflow::held::HOLD_SECONDS, +}; +use store::write::{BatchBuilder, BlobLink, BlobOp, now}; +use types::blob_hash::BlobHash; + +/// Marks a journal report the server queued itself, so it's never +/// journaled (JR-2). Free in the message flags (the MAIL parameters use +/// the low bits, the sources bits 32 to 37). +pub const FROM_JOURNAL: u64 = 1 << 48; + +/// Journals `message`, whose queued bytes are `raw`, into every enabled +/// journal that takes it. An error means it may not have been journaled, +/// and the caller must not queue it. +pub async fn capture( + server: &Server, + queue_id: u64, + message: &Message, + raw: &[u8], +) -> trc::Result<()> { + if message.flags & (FROM_JOURNAL | FROM_REPORT) != 0 { + return Ok(()); + } + let journals = journal::enabled(server.store()).await?; + if journals.is_empty() { + return Ok(()); + } + + // Who's here on either side, and which way it goes + let mut members: Vec = Vec::new(); + let mut sender_local = message.flags & FROM_AUTHENTICATED != 0 + || (message.return_path.is_empty() && message.flags & (FROM_DSN | FROM_AUTOGENERATED) != 0); + if !message.return_path.is_empty() + && let Some(id) = server + .account_id_from_email(&message.return_path, false) + .await? + { + sender_local = true; + if let Some(member) = server.member_of(id).await { + members.push(member); + } + } + let (mut any_local, mut any_remote) = (false, false); + for rcpt in &message.recipients { + let address = rcpt.address.to_lowercase(); + let domain = address.rsplit_once('@').map_or("", |(_, d)| d); + let local_domain = server.domain(domain).await.ok().flatten().is_some(); + match server.account_id_from_email(&address, false).await? { + Some(id) => { + any_local = true; + if !members.iter().any(|m| m.account == id) + && let Some(member) = server.member_of(id).await + { + members.push(member); + } + } + None if local_domain => any_local = true, + None => any_remote = true, + } + } + let direction = Direction::of(sender_local, any_remote, any_local); + let taken: Vec<&journal::Journal> = journals + .iter() + .filter(|j| j.takes(direction, &members)) + .collect(); + if taken.is_empty() { + return Ok(()); + } + + // DLP holds a message by putting its release a century off + let at = now(); + let held = !message.recipients.is_empty() + && message + .recipients + .iter() + .all(|rcpt| rcpt.retry.due >= at + HOLD_SECONDS / 2); + let recipients: Vec = message + .recipients + .iter() + .map(|rcpt| Recipient { + address: rcpt.address.to_string(), + orcpt: rcpt.orcpt.as_deref().map(Into::into), + }) + .collect(); + let envelope = Envelope { + sender: &message.return_path, + authenticated: message.flags & FROM_AUTHENTICATED != 0, + recipients: &recipients, + queue_id, + received: message.created, + direction, + held, + }; + let host = server.core.network.server_name.as_str(); + let (bytes, fields) = report::build(&envelope, raw, &format!("postmaster@{host}"), host); + + // The report's blob, reserved until the entry links it + let hash = BlobHash::generate(&bytes); + let mut batch = BatchBuilder::new(); + batch.set( + BlobOp::Link { + hash: hash.clone(), + to: BlobLink::Temporary { until: at + 120 }, + }, + vec![], + ); + server.store().write(batch.build_all()).await?; + server + .blob_store() + .put_blob(hash.as_slice(), &bytes, server.core.email.compression) + .await?; + + let retention_days = taken + .iter() + .map(|j| j.retention_days) + .max() + .unwrap_or_default(); + let mut tenants: Vec = members.iter().filter_map(|m| m.tenant).collect(); + tenants.sort_unstable(); + tenants.dedup(); + let entry = Entry { + queue_id, + at, + direction, + sender: message.return_path.to_string(), + authenticated: envelope.authenticated, + recipients: recipients.iter().map(|r| r.address.clone()).collect(), + subject: fields.subject, + message_id: fields.message_id, + accounts: members.iter().map(|m| m.account).collect(), + tenants, + journals: taken.iter().map(|j| j.id).collect(), + held, + blob: entries::hex(hash.as_slice()), + size: bytes.len() as u64, + sha256: entries::sha256(&bytes), + expires_at: at + u64::from(retention_days) * 86_400, + }; + entries::append(server.store(), server.core.network.node_id, &entry).await?; + Ok(()) +} diff --git a/crates/smtp/src/queue/mod.rs b/crates/smtp/src/queue/mod.rs index 3e717bd..d31323c 100644 --- a/crates/smtp/src/queue/mod.rs +++ b/crates/smtp/src/queue/mod.rs @@ -24,6 +24,7 @@ use utils::DomainPart; pub mod dsn; pub mod held; // inbuxa: mail held for review +pub mod journal; // inbuxa: journaling pub mod manager; pub mod quota; pub mod spool; diff --git a/crates/smtp/src/queue/spool.rs b/crates/smtp/src/queue/spool.rs index c3e80ce..131aecb 100644 --- a/crates/smtp/src/queue/spool.rs +++ b/crates/smtp/src/queue/spool.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ @@ -453,6 +455,25 @@ impl MessageWrapper { return false; } + // inbuxa: journaling, JR-1: the copy is taken before the message is + // queued; if it can't be, the message isn't queued either + if let Err(err) = crate::queue::journal::capture( + server, + self.queue_id, + &self.message, + message.as_ref(), + ) + .await + { + trc::error!( + err.details("Failed to journal a message.") + .span_id(session_id) + .caused_by(trc::location!()) + ); + + return false; + } + trc::event!( Queue(event), SpanId = session_id, diff --git a/docs/spec/features/journaling.md b/docs/spec/features/journaling.md index d667f20..2754199 100644 --- a/docs/spec/features/journaling.md +++ b/docs/spec/features/journaling.md @@ -261,6 +261,38 @@ read, export. Each phase is its own PR with tests; releases as John decides. Like DLP, it stays out of production until John says. +## As built + +Phase 2 (`feature/journal-capture`), where it differs from the design or +fills in what it left open: + +- **The chain** is the journal's own (`crates/features/src/journal/ + entries.rs`), not the audit log's code shared. Entries expire out of chain + order (each keeps its journal's retention, and holds keep some longer), so + a link names its entry by SHA-256 instead of holding it: purging removes + the entry, its indexes and its report's blob link, and writes a purge + marker; the link stays. An entry missing without a marker is a broken + chain. Purged links at a chain's start are cleared and a floor recorded, + as the audit log does. +- **If the copy can't be taken**, the message isn't queued: the sender gets + a temporary failure and tries again. Nothing leaves unjournaled. +- **The report** says `Authenticated: yes|no` instead of the signed-in + account (the queue doesn't keep which account it was). `Added by rule` + comes with **Journal it** in phase 3. A recipient given with an ORCPT + that names another address counts as expanded from that address. +- **Journal reports** the server queues carry message flag bit 48 + (`FROM_JOURNAL`); an older version ignores the bit. +- **Permissions 680–683**: administrators get `sysJournalGet`/`Update`; the + Compliance Officer gets `Get`, `Search` and `Export`. So that an + administrator can still appoint an officer (and grant reading as settled + answer 5 describes), whoever holds `sysJournalUpdate` may grant `Search` + and `Export` without holding them; the role change is in the audit log. +- **`inbuxa:JournalEntry`** (get, query) and **Check the journal** over + JMAP come in phase 4 with search, so every read is audited from the first + version that allows one. Phase 2 has `inbuxa:Journal` only. +- **Outside archives** (a journal's destination) come in phase 3; every + journal writes to the built-in journal until then. + ## Known gaps - A message a person saves to Sent over IMAP, or sends through another diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index b6a6b9a..7461873 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -113,6 +113,18 @@ exceptions = ["contact", "content"] actions = ["contact", "content"] createdBy = ["identifier"] +[object."inbuxa:Journal"] +file = "inbuxa_journal.rs" +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:Journal".properties] +name = ["content"] +description = ["content"] +createdBy = ["identifier"] + [object."inbuxa:LegalHold"] file = "inbuxa_legal_hold.rs" default = "none" @@ -408,6 +420,19 @@ captures = ["x:Email.maxMaskedAddresses"] leaves_host = false written_by = ["crates/features/src/masked_email/data.rs"] +# Journaling (journaling spec, JR-5, JR-14): a copy of each message a +# journal takes, with its envelope, kept for the journal's retention even +# after the account is deleted, and longer while a legal hold covers +# someone on it. +[source."journal"] +categories = ["content", "identifier", "contact", "metadata"] +whose = ["holder", "correspondent"] +where = ["data-store", "blob-store"] +scope = "server" +retention = { setting = "inbuxa:Journal.retentionDays" } +leaves_host = false +written_by = ["crates/features/src/journal/entries.rs", "crates/smtp/src/queue/journal.rs"] + [source."outbound-reports"] categories = ["network", "identifier", "content"] whose = ["correspondent"] diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 85a0a525d9bd3dba6b5f5750b6bde5be1ac229b7..91d98fc71905820cc21e96cc168856e1e4a97811 100644 GIT binary patch delta 19841 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 delta 19810 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 diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index dfb1703..8c25d1c 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -bLr7o49yK6CtWpENWq5zjgKrbCSzAXW3DZfNAEpnXEE \ No newline at end of file +JGXu3u5TIsHK6jNasyQy1wVxK-2Ku-PSa5NgiJP61q0 \ No newline at end of file diff --git a/tests/src/system/journal.rs b/tests/src/system/journal.rs new file mode 100644 index 0000000..3377d52 --- /dev/null +++ b/tests/src/system/journal.rs @@ -0,0 +1,471 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Journaling (journaling spec, phase 2): journals over JMAP, the copy +//! taken as mail is queued with its whole envelope, the report around the +//! untouched message, retention, purge, and a chain that shows tampering. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, + smtp::SmtpConnection, +}; +use inbuxa_features::journal::{ + Direction, + entries::{self, Entry, EntryId}, + report, +}; +use registry::schema::structs::{Expression, MtaStageAuth}; +use serde_json::{Value, json}; +use store::{Deserialize, write::BatchBuilder}; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:submission", + "urn:inbuxa:jmap", +]; + +async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + ( + call[0].as_str().unwrap_or_default().to_string(), + call[1].clone(), + ) +} + +/// Sends a message whose headers name `to`, to the envelope `rcpt_to`. +async fn send( + sender: &Account, + identity: &str, + mailbox: &str, + to: &[&str], + rcpt_to: &[&str], + subject: &str, +) -> Value { + let (_, response) = call( + sender, + "Email/set", + json!({"create": {"e": { + "mailboxIds": {mailbox: true}, + "from": [{"email": sender.name()}], + "to": to.iter().map(|a| json!({"email": a})).collect::>(), + "subject": subject, + "bodyValues": {"b": {"value": "The body."}}, + "textBody": [{"partId": "b", "type": "text/plain"}] + }}}), + ) + .await; + let email = response["created"]["e"]["id"] + .as_str() + .unwrap_or_else(|| panic!("draft: {response}")) + .to_string(); + call( + sender, + "EmailSubmission/set", + json!({"create": {"s": { + "emailId": email, + "identityId": identity, + "envelope": { + "mailFrom": {"email": sender.name()}, + "rcptTo": rcpt_to.iter().map(|a| json!({"email": a})).collect::>() + } + }}}), + ) + .await + .1 +} + +async fn all_entries(test: &TestServer) -> Vec<(EntryId, Entry)> { + entries::list(test.server.store(), 0, u64::MAX, 10_000) + .await + .unwrap() +} + +async fn entry_for(test: &TestServer, subject: &str) -> Option<(EntryId, Entry)> { + all_entries(test) + .await + .into_iter() + .find(|(_, e)| e.subject == subject) +} + +async fn report_of(test: &TestServer, entry: &Entry) -> Vec { + let hash = entry.blob_hash().expect("blob hash"); + test.server + .blob_store() + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + .unwrap() + .expect("report blob") +} + +pub async fn test(test: &mut TestServer) { + println!("Running journaling tests..."); + let admin = test.account("admin@example.com"); + let sender = admin + .create_user_account( + "journal-sender@example.com", + "journal-sender-secret-7101", + "Journal sender", + &[], + vec![], + ) + .await; + let other = admin + .create_user_account( + "journal-other@example.com", + "journal-other-secret-7102", + "Journal other", + &[], + vec![], + ) + .await; + let (_, response) = call( + &sender, + "Identity/set", + json!({"create": {"i": {"name": "Sender", "email": "journal-sender@example.com"}}}), + ) + .await; + let identity = response["created"]["i"]["id"].as_str().unwrap().to_string(); + let (_, response) = call( + &sender, + "Mailbox/set", + json!({"create": {"m": {"name": "Journal drafts"}}}), + ) + .await; + let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string(); + + // Nothing is journaled while there are no journals + let response = send( + &sender, + &identity, + &mailbox, + &["journal-other@example.com"], + &["journal-other@example.com"], + "Before any journal", + ) + .await; + assert!(response["created"].get("s").is_some(), "{response}"); + assert!(all_entries(test).await.is_empty()); + + // Journals: checked when written, the server's own properties refused + let (_, response) = call( + &admin, + "inbuxa:Journal/set", + json!({"create": { + "short": {"name": "Short", "enabled": true, "direction": "any", + "scope": {"everyone": true}, "retentionDays": 29}, + "both": {"name": "Both", "enabled": true, "direction": "any", + "scope": {"everyone": true, "accounts": [sender.id_string()]}, + "retentionDays": 365}, + "none": {"name": "None", "enabled": true, "direction": "any", + "scope": {}, "retentionDays": 365}, + "server": {"name": "Mine", "enabled": true, "direction": "any", + "scope": {"everyone": true}, "retentionDays": 365, + "createdBy": "me"}, + "all": {"name": "Everything", "enabled": true, "direction": "any", + "scope": {"everyone": true}, "retentionDays": 365}, + "out": {"name": "Sender's outgoing", "enabled": true, "direction": "outgoing", + "scope": {"accounts": [sender.id_string()]}, "retentionDays": 3650} + }}), + ) + .await; + for refused in ["short", "both", "none", "server"] { + assert_eq!( + response["notCreated"][refused]["type"], "invalidProperties", + "{refused}: {response}" + ); + } + assert_eq!( + response["notCreated"]["short"]["properties"], + json!(["retentionDays"]) + ); + assert_eq!( + response["notCreated"]["both"]["properties"], + json!(["scope"]) + ); + let everything = response["created"]["all"]["id"] + .as_str() + .unwrap_or_else(|| panic!("{response}")) + .to_string(); + let outgoing = response["created"]["out"]["id"] + .as_str() + .unwrap() + .to_string(); + let (_, response) = call(&admin, "inbuxa:Journal/get", json!({"ids": null})).await; + let list = response["list"].as_array().unwrap(); + assert_eq!(list.len(), 2, "{response}"); + assert_eq!(list[0]["name"], "Everything"); + assert_eq!(list[0]["createdBy"], "admin@example.com"); + assert_eq!(list[1]["scope"]["accounts"], json!([sender.id_string()])); + // Each node reads journals again within 30 seconds; this one at once + inbuxa_features::journal::invalidate(); + + // Internal mail with a Bcc recipient: one entry, the whole envelope + let response = send( + &sender, + &identity, + &mailbox, + &["journal-sender@example.com"], + &["journal-sender@example.com", "journal-other@example.com"], + "Internal with Bcc", + ) + .await; + assert!(response["created"].get("s").is_some(), "{response}"); + let (_, entry) = entry_for(test, "Internal with Bcc") + .await + .expect("journaled"); + assert_eq!(entry.direction, Direction::Internal); + assert_eq!(entry.sender, "journal-sender@example.com"); + assert!(entry.authenticated); + assert_eq!(entry.recipients.len(), 2, "{entry:?}"); + assert_eq!( + entry.journals.len(), + 1, + "internal isn't outgoing: {entry:?}" + ); + assert!(!entry.held); + assert_eq!(entry.expires_at, entry.at + 365 * 86_400); + let bytes = report_of(test, &entry).await; + assert_eq!(entries::sha256(&bytes), entry.sha256); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("Direction: internal\r\n"), "{text}"); + assert!( + text.contains("To: journal-sender@example.com\r\n"), + "{text}" + ); + assert!( + text.contains("Bcc: journal-other@example.com\r\n"), + "{text}" + ); + let original = report::original(&bytes).expect("original part"); + let original = String::from_utf8_lossy(original); + assert!( + original.contains("Subject: Internal with Bcc"), + "{original}" + ); + assert!(original.contains("The body."), "{original}"); + assert!(!original.contains("Bcc:"), "the original is as sent"); + + // Outgoing: both journals take it, and it's kept for the longer + let response = send( + &sender, + &identity, + &mailbox, + &["someone@elsewhere.org"], + &["someone@elsewhere.org"], + "Leaving", + ) + .await; + assert!(response["created"].get("s").is_some(), "{response}"); + let (leaving_id, entry) = entry_for(test, "Leaving").await.expect("journaled"); + assert_eq!(entry.direction, Direction::Outgoing); + assert_eq!(entry.journals.len(), 2, "{entry:?}"); + assert_eq!(entry.expires_at, entry.at + 3650 * 86_400); + + // Incoming from outside + admin + .registry_create_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "someone@elsewhere.org", + &["journal-other@example.com"], + "From: someone@elsewhere.org\r\nTo: journal-other@example.com\r\nSubject: Arriving\r\n\r\nHi.\r\n", + ) + .await; + let (_, entry) = entry_for(test, "Arriving").await.expect("journaled"); + assert_eq!(entry.direction, Direction::Incoming); + assert!(!entry.authenticated); + assert_eq!(entry.accounts, vec![other.id().document_id()]); + + // The chain checks out, reports included + let store = test.server.store(); + let blobs = test.server.blob_store(); + let reports = entries::verify(store, Some(blobs)).await.unwrap(); + assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}"); + let journaled = all_entries(test).await.len() as u64; + assert!(reports.iter().map(|r| r.entries).sum::() >= journaled); + + // An entry changed in the store shows; put back, it checks out again + let key = entries::content_key(leaving_id); + let stored = store + .get_value::(key.clone()) + .await + .unwrap() + .expect("stored entry") + .0; + let mut forged: Entry = serde_json::from_slice(&stored).unwrap(); + forged.recipients = vec!["nobody@elsewhere.org".into()]; + let mut batch = BatchBuilder::new(); + batch.set(key.class.clone(), serde_json::to_vec(&forged).unwrap()); + store.write(batch.build_all()).await.unwrap(); + let reports = entries::verify(store, None).await.unwrap(); + let broken = reports + .iter() + .find(|r| r.broken_at.is_some()) + .expect("broken"); + assert_eq!( + broken.broken_at.as_deref(), + Some(leaving_id.to_string().as_str()) + ); + assert!( + broken + .reason + .as_deref() + .unwrap_or_default() + .contains("changed") + ); + let mut batch = BatchBuilder::new(); + batch.set(key.class.clone(), stored.clone()); + store.write(batch.build_all()).await.unwrap(); + assert!( + entries::verify(store, None) + .await + .unwrap() + .iter() + .all(|r| r.broken_at.is_none()) + ); + + // An entry removed without a purge shows too + let mut batch = BatchBuilder::new(); + batch.clear(key.class.clone()); + store.write(batch.build_all()).await.unwrap(); + let reports = entries::verify(store, None).await.unwrap(); + assert!( + reports.iter().any(|r| r + .reason + .as_deref() + .unwrap_or_default() + .contains("before its time")), + "{reports:?}" + ); + let mut batch = BatchBuilder::new(); + batch.set(key.class.clone(), stored); + store.write(batch.build_all()).await.unwrap(); + + // Retention: nothing is due yet; a year on, what's kept for a hold + // stays, the rest goes, and the chain still checks out + let now = store::write::now(); + let purged = entries::purge(store, now, |_| false).await.unwrap(); + assert_eq!(purged.removed, 0); + let sender_id = sender.id().document_id(); + let later = now + 400 * 86_400; + let purged = entries::purge(store, later, |e| e.accounts.contains(&sender_id)) + .await + .unwrap(); + assert!(purged.removed >= 1, "{purged:?}"); + assert!(purged.kept_for_hold >= 1, "{purged:?}"); + assert!(entry_for(test, "Arriving").await.is_none(), "purged"); + assert!(entry_for(test, "Internal with Bcc").await.is_some(), "held"); + assert!(entry_for(test, "Leaving").await.is_some(), "ten years"); + let reports = entries::verify(store, Some(blobs)).await.unwrap(); + assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}"); + assert!(reports.iter().map(|r| r.purged).sum::() >= 1); + + // Once the hold is gone the held entry goes too + let purged = entries::purge(store, later, |_| false).await.unwrap(); + assert!(purged.removed >= 1, "{purged:?}"); + assert!(entry_for(test, "Internal with Bcc").await.is_none()); + assert!( + entries::verify(store, Some(blobs)) + .await + .unwrap() + .iter() + .all(|r| r.broken_at.is_none()) + ); + + // Changing a journal's retention doesn't touch what it has taken + let before = entry_for(test, "Leaving").await.unwrap().1.expires_at; + let (_, response) = call( + &admin, + "inbuxa:Journal/set", + json!({"update": {outgoing.clone(): {"retentionDays": 30}}}), + ) + .await; + assert!(response["updated"].get(&outgoing).is_some(), "{response}"); + assert_eq!( + entry_for(test, "Leaving").await.unwrap().1.expires_at, + before + ); + + // Journals turned off or removed take nothing more; entries stay + let (_, response) = call( + &admin, + "inbuxa:Journal/set", + json!({"update": {everything.clone(): {"enabled": false}}, "destroy": [outgoing]}), + ) + .await; + assert!(response["updated"].get(&everything).is_some(), "{response}"); + assert_eq!(response["destroyed"].as_array().map(|d| d.len()), Some(1)); + inbuxa_features::journal::invalidate(); + let count = all_entries(test).await.len(); + let response = send( + &sender, + &identity, + &mailbox, + &["journal-other@example.com"], + &["journal-other@example.com"], + "After the journals", + ) + .await; + assert!(response["created"].get("s").is_some(), "{response}"); + assert_eq!(all_entries(test).await.len(), count); + assert!(entry_for(test, "Leaving").await.is_some()); + + // Every change to a journal is in the audit log + let (_, response) = call( + &admin, + "inbuxa:AuditEvent/query", + json!({"filter": {"targetKind": "inbuxa:Journal"}}), + ) + .await; + assert!( + response["ids"].as_array().map_or(0, |ids| ids.len()) >= 4, + "{response}" + ); +} + +struct Raw(Vec); + +impl Deserialize for Raw { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(Raw(bytes.to_vec())) + } +} + +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn journal_tests() { + let mut test = TestServerBuilder::new("journal_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 4c51b14..b181c64 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -15,6 +15,7 @@ pub mod account_lock; // inbuxa: account lock with delegation pub mod legal_hold; // inbuxa: legal hold pub mod compliance; // inbuxa: the compliance roles pub mod mail_rules; // inbuxa: DLP and mail flow rules +pub mod journal; // inbuxa: journaling pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once