From 09ccad4b4be9a05fa267ade0da6d52587faed67f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 6 Oct 2026 15:24:34 -0700 Subject: [PATCH] Scheduled reports: Download, a report for a period as a ZIP inbuxa:ReportExport/set creates {reportId, from?, to?}: the report built for that period (its last full one by default, at most 90 days back, as far as metrics are kept) as a ZIP of summary.txt and the section CSVs, stored as an upload, mailing nobody. Reading needs sysScheduledReportGet, as seeing the report does; a tenant administrator downloads only their own; every download is in the audit log. A download doesn't move the deliverability baseline the next mail compares with. --- .../src/object/inbuxa_report_export.rs | 175 ++++++++++++++++++ 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 | 7 + crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 14 ++ crates/jmap-proto/src/response/mod.rs | 13 ++ crates/jmap/src/api/auth.rs | 12 +- crates/jmap/src/api/request.rs | 31 ++++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/scheduled_reports.rs | 157 +++++++++++++++- .../services/src/inbuxa_scheduled_reports.rs | 30 +++ docs/spec/SPEC.md | 2 +- resources/privacy/catalog.toml | 10 + tests/src/system/scheduled_reports.rs | 35 ++++ 16 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 crates/jmap-proto/src/object/inbuxa_report_export.rs diff --git a/crates/jmap-proto/src/object/inbuxa_report_export.rs b/crates/jmap-proto/src/object/inbuxa_report_export.rs new file mode 100644 index 0000000..6174ce4 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_report_export.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:ReportExport/set` under `urn:inbuxa:jmap`: a scheduled report +//! for a period as a ZIP of a summary and CSVs, without mailing anyone +//! (scheduled-reports spec, RP-19). Exports aren't kept; `/get` finds none. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct ReportExport; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReportExportProperty { + Id, + ReportId, + From, + To, + BlobId, + Size, + Sha256, + Files, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReportExportValue { + Id(Id), +} + +impl Property for ReportExportProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside the lists stay plain keys + match parent { + None => ReportExportProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ReportExportProperty::Id => "id", + ReportExportProperty::ReportId => "reportId", + ReportExportProperty::From => "from", + ReportExportProperty::To => "to", + ReportExportProperty::BlobId => "blobId", + ReportExportProperty::Size => "size", + ReportExportProperty::Sha256 => "sha256", + ReportExportProperty::Files => "files", + } + .into() + } +} + +impl ReportExportProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => ReportExportProperty::Id, + b"reportId" => ReportExportProperty::ReportId, + b"from" => ReportExportProperty::From, + b"to" => ReportExportProperty::To, + b"blobId" => ReportExportProperty::BlobId, + b"size" => ReportExportProperty::Size, + b"sha256" => ReportExportProperty::Sha256, + b"files" => ReportExportProperty::Files, + ) + } +} + +impl FromStr for ReportExportProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + ReportExportProperty::parse(s).ok_or(()) + } +} + +impl Element for ReportExportValue { + type Property = ReportExportProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(ReportExportProperty::Id) => { + Id::from_str(value).ok().map(ReportExportValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ReportExportValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for ReportExport { + type Property = ReportExportProperty; + + type Element = ReportExportValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = ReportExportProperty::Id; +} + +impl From for ReportExportValue { + fn from(id: Id) -> Self { + ReportExportValue::Id(id) + } +} + +impl JmapObjectId for ReportExportValue { + fn as_id(&self) -> Option { + match self { + ReportExportValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + ReportExportValue::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 = ReportExportValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for ReportExportProperty { + 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 c244db6..79ed5e7 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -33,6 +33,7 @@ pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check +pub mod inbuxa_report_export; // inbuxa: scheduled reports, RP-19 pub mod inbuxa_scheduled_report; // inbuxa: scheduled reports pub mod inbuxa_scheduled_report_settings; // inbuxa: scheduled reports pub mod inbuxa_journal; // inbuxa: journaling diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 117d05d..f265811 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -97,6 +97,9 @@ impl Response<'_> { GetResponseMethod::DeliverabilitySettings(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::ReportExport(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::ScheduledReport(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 6910ab0..1c3f56d 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -58,6 +58,7 @@ impl Response<'_> { GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?, GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?, GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?, + GetRequestMethod::ReportExport(request) => request.resolve_references(self)?, GetRequestMethod::ScheduledReport(request) => request.resolve_references(self)?, GetRequestMethod::ScheduledReportSettings(request) => request.resolve_references(self)?, GetRequestMethod::Journal(request) => request.resolve_references(self)?, @@ -150,6 +151,9 @@ impl Response<'_> { SetRequestMethod::DeliverabilitySettings(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::ReportExport(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::ScheduledReport(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 3fe41c5..9020fcc 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -73,6 +73,7 @@ pub enum MethodObject { // inbuxa: the deliverability check DeliverabilityReport, DeliverabilitySettings, + ReportExport, ScheduledReport, ScheduledReportSettings, HeldMessage, @@ -126,6 +127,7 @@ impl MethodObject { | MethodObject::HeldMessage | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | MethodObject::ReportExport | MethodObject::ScheduledReport | MethodObject::ScheduledReportSettings | MethodObject::Journal @@ -336,6 +338,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set", (MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get", (MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set", + (MethodFunction::Get, MethodObject::ReportExport) => "inbuxa:ReportExport/get", + (MethodFunction::Set, MethodObject::ReportExport) => "inbuxa:ReportExport/set", (MethodFunction::Get, MethodObject::ScheduledReport) => "inbuxa:ScheduledReport/get", (MethodFunction::Set, MethodObject::ScheduledReport) => "inbuxa:ScheduledReport/set", (MethodFunction::Get, MethodObject::ScheduledReportSettings) => "inbuxa:ScheduledReportSettings/get", @@ -518,6 +522,8 @@ impl MethodName { "inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set), "inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get), "inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set), + "inbuxa:ReportExport/get" => (MethodObject::ReportExport, MethodFunction::Get), + "inbuxa:ReportExport/set" => (MethodObject::ReportExport, MethodFunction::Set), "inbuxa:ScheduledReport/get" => (MethodObject::ScheduledReport, MethodFunction::Get), "inbuxa:ScheduledReport/set" => (MethodObject::ScheduledReport, MethodFunction::Set), "inbuxa:ScheduledReportSettings/get" => (MethodObject::ScheduledReportSettings, MethodFunction::Get), @@ -607,6 +613,7 @@ impl Display for MethodObject { MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport", MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings", + MethodObject::ReportExport => "inbuxa:ReportExport", MethodObject::ScheduledReport => "inbuxa:ScheduledReport", MethodObject::ScheduledReportSettings => "inbuxa:ScheduledReportSettings", MethodObject::Journal => "inbuxa:Journal", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 74fa294..5de1895 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -128,6 +128,7 @@ pub enum GetRequestMethod { SecurityAcceptance(Box>), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ReportExport(Box>), ScheduledReport(Box>), ScheduledReportSettings(Box>), Journal(Box>), @@ -178,6 +179,7 @@ pub enum SetRequestMethod<'x> { ), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ReportExport(Box>), ScheduledReport(Box>), ScheduledReportSettings(Box>), Journal(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index e15094d..1ce359b 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -743,6 +743,20 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::ReportExport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ReportExport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::ReportExport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ReportExport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: journaling (MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 9f0b58f..d0272b3 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -115,6 +115,7 @@ pub enum GetResponseMethod { SecurityAcceptance(GetResponse), DeliverabilityReport(GetResponse), DeliverabilitySettings(GetResponse), + ReportExport(GetResponse), ScheduledReport(GetResponse), ScheduledReportSettings(GetResponse), Journal(GetResponse), @@ -165,6 +166,7 @@ pub enum SetResponseMethod { ), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ReportExport(Box>), ScheduledReport(Box>), ScheduledReportSettings(Box>), Journal(Box>), @@ -896,6 +898,17 @@ impl<'x> From From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ReportExport(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ReportExport(Box::new(value))) + } +} impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::ScheduledReport(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index d199856..2ba40cb 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -130,7 +130,8 @@ impl JmapAuthorization for AccessToken { // inbuxa: scheduled-reports spec; a tenant administrator sees // their own tenant's reports (RP-22) GetRequestMethod::ScheduledReport(_) - | GetRequestMethod::ScheduledReportSettings(_) => Permission::SysScheduledReportGet, + | GetRequestMethod::ScheduledReportSettings(_) + | GetRequestMethod::ReportExport(_) => Permission::SysScheduledReportGet, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, @@ -375,6 +376,14 @@ impl JmapAuthorization for AccessToken { Permission::SysScheduledReportUpdate, Permission::SysScheduledReportUpdate, ), + // inbuxa: RP-19, a download reads what the report would send + SetRequestMethod::ReportExport(s) => validate_set( + s, + self, + Permission::SysScheduledReportGet, + Permission::SysScheduledReportGet, + Permission::SysScheduledReportGet, + ), // inbuxa: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -548,6 +557,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | MethodObject::ReportExport | MethodObject::ScheduledReport | MethodObject::ScheduledReportSettings | MethodObject::HeldMessage diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 8d4fc5e..2cd4895 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -299,6 +299,9 @@ impl RequestHandler for Server { SetResponseMethod::DeliverabilitySettings(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::ReportExport(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::ScheduledReport(set_response) => { set_response.update_created_ids(&mut response); } @@ -577,6 +580,12 @@ impl RequestHandler for Server { .await? .into() } + GetRequestMethod::ReportExport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::scheduled_reports::get_exports(self, access_token, *req) + .await? + .into() + } // inbuxa: journaling GetRequestMethod::Journal(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -1127,6 +1136,28 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: RP-19; a download is in the audit log too + SetRequestMethod::ReportExport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| { + Box::pin(crate::inbuxa::scheduled_reports::set_exports( + self, + access_token, + req, + )) + }, + ) + .await? + .into() + } SetRequestMethod::ScheduledReportSettings(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::audit::recorded( diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index e37c942..398d786 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -434,6 +434,7 @@ impl IntermediateChangesResponse { | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | MethodObject::ReportExport | MethodObject::ScheduledReport | MethodObject::ScheduledReportSettings | MethodObject::Journal diff --git a/crates/jmap/src/inbuxa/scheduled_reports.rs b/crates/jmap/src/inbuxa/scheduled_reports.rs index 0696da7..5edc603 100644 --- a/crates/jmap/src/inbuxa/scheduled_reports.rs +++ b/crates/jmap/src/inbuxa/scheduled_reports.rs @@ -23,6 +23,7 @@ use jmap_proto::{ set::{SetRequest, SetResponse}, }, object::{ + inbuxa_report_export::{ReportExport, ReportExportProperty as X, ReportExportValue}, inbuxa_scheduled_report::{ ScheduledReport, ScheduledReportProperty as R, ScheduledReportValue, }, @@ -35,7 +36,7 @@ use jmap_proto::{ types::date::UTCDate, }; use jmap_tools::{Element, Key, Map, Property, Value}; -use std::borrow::Cow; +use std::{borrow::Cow, str::FromStr}; use store::write::now; use types::id::Id; @@ -533,3 +534,157 @@ pub async fn set_settings( } Ok(response) } + +/// RP-19: the furthest back a download goes, as far as metrics are kept. +const EXPORT_MAX_AGE: u64 = 90 * 86_400; + +fn parse_date(value: &serde_json::Value) -> Option { + value + .as_str() + .and_then(|s| UTCDate::from_str(s).ok()) + .map(|d| d.timestamp().max(0) as u64) +} + +/// `inbuxa:ReportExport/get`: exports aren't kept, so there's never one. +pub async fn get_exports( + _server: &Server, + _access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let (ids, not_found) = request.unwrap_ids(1)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + for id in ids.unwrap_or_default() { + response.push_not_found(id); + } + Ok(response) +} + +/// `inbuxa:ReportExport/set`: create `{reportId, from?, to?}` builds that +/// report for the period (by default its last full one) as a ZIP of a +/// summary and CSVs, stored as an upload, and mails nobody (RP-19). +pub async fn set_exports( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, ReportExport>, +) -> trc::Result> { + use sha2::{Digest, Sha256}; + use std::io::{Cursor, Write}; + use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; + + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append( + id, + SetError::forbidden().with_description("Exports can't be changed."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden().with_description("Exports aren't kept to destroy."), + ); + } + for (client_id, value) in request.unwrap_create() { + let json = serde_json::to_value(&value).unwrap_or_default(); + let invalid = |property: X, why: &str| { + SetError::invalid_properties() + .with_property(property) + .with_description(why.to_string()) + }; + let report = match json + .get("reportId") + .and_then(|v| v.as_str()) + .and_then(|s| Id::from_str(s).ok()) + { + Some(id) => model::report(server.store(), id.id()) + .await? + .filter(|r| visible(access_token, r)), + None => None, + }; + let Some(report) = report else { + response + .not_created + .append(client_id, invalid(X::ReportId, "A report you can see.")); + continue; + }; + let now = now(); + let (default_from, default_to) = report.schedule.period( + report + .schedule + .next_due(report.last_due.min(now)) + .filter(|d| *d <= now) + .unwrap_or(report.last_due.min(now)), + ); + let from = json + .get("from") + .and_then(parse_date) + .unwrap_or(default_from); + let to = json.get("to").and_then(parse_date).unwrap_or(default_to); + if from >= to || to > now + 60 || from + EXPORT_MAX_AGE < now { + response.not_created.append( + client_id, + invalid( + X::From, + "A period that has started, ends no later than now, and goes back at most 90 days.", + ), + ); + continue; + } + let files = + services::inbuxa_scheduled_reports::export_files(server, &report, from, to).await?; + let fail = |err: zip::result::ZipError| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to write a report export") + .reason(err) + }; + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + let names: Vec = files.iter().map(|(name, _)| name.clone()).collect(); + for (name, body) in &files { + zip.start_file(name.as_str(), options).map_err(fail)?; + zip.write_all(body).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to write a report export") + .reason(err) + })?; + } + let bytes = zip.finish().map_err(fail)?.into_inner(); + let sha256 = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let blob = server + .put_jmap_blob(access_token.account_id(), &bytes) + .await?; + + let mut created = Map::with_capacity(7); + created.insert_unchecked( + Key::Property(X::Id), + Value::Element(ReportExportValue::Id(Id::from(now))), + ); + created.insert_unchecked( + Key::Property(X::BlobId), + Value::Str(blob.to_string().into()), + ); + created.insert_unchecked( + Key::Property(X::Size), + Value::Number((bytes.len() as u64).into()), + ); + created.insert_unchecked(Key::Property(X::Sha256), Value::Str(sha256.into())); + created.insert_unchecked(Key::Property(X::From), date(from)); + created.insert_unchecked(Key::Property(X::To), date(to)); + created.insert_unchecked( + Key::Property(X::Files), + Value::Array(names.into_iter().map(|n| Value::Str(n.into())).collect()), + ); + response.created.insert(client_id, Value::Object(created)); + } + Ok(response) +} diff --git a/crates/services/src/inbuxa_scheduled_reports.rs b/crates/services/src/inbuxa_scheduled_reports.rs index fb8ac8a..6a8cb19 100644 --- a/crates/services/src/inbuxa_scheduled_reports.rs +++ b/crates/services/src/inbuxa_scheduled_reports.rs @@ -1087,6 +1087,36 @@ async fn certificates( })) } +/// RP-19: the report for a period as files, without mailing anyone: the +/// summary as text, and a CSV for each section that has rows. A download +/// doesn't move the deliverability baseline the next mail compares with. +pub async fn export_files( + server: &Server, + report: &Report, + from: u64, + to: u64, +) -> trc::Result)>> { + let built = build(server, report, from, to).await?; + let mut summary = format!("{}\n{}\n\n", report.name, model::period_label(from, to)); + if built.parts.is_empty() { + summary.push_str("Nothing to report for this period.\n"); + } + for part in &built.parts { + summary.push_str(&format!("{}\n", part.title)); + for line in &part.lines { + summary.push_str(&format!(" {line}\n")); + } + summary.push('\n'); + } + let mut files = vec![("summary.txt".to_string(), summary.into_bytes())]; + for part in built.parts { + if let Some((name, body)) = part.csv { + files.push((name, body.into_bytes())); + } + } + Ok(files) +} + // --- The mail ------------------------------------------------------------- fn escape(s: &str) -> String { diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index 5398f14..09436b3 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -377,7 +377,7 @@ Not a rebuild: the **security to-do list** is INBUXA's own design (inbuxa-drafts Not a rebuild: the **deliverability check** is INBUXA's own design (inbuxa-drafts `specs/deliverability.md`). Each sending node checks what other servers see of it (blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS, certificates) and keeps a report: `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings` (`crates/jmap/src/inbuxa/deliverability.rs`, `crates/services/src/inbuxa_deliverability.rs`), and the `sysDeliverabilityGet`, `sysDeliverabilityUpdate` and `sysDeliverabilityCheck` permissions. -Not a rebuild: **scheduled reports and the weekly digest** are INBUXA's own design (inbuxa-drafts `specs/scheduled-reports.md`). An administrator picks sections, a schedule in a time zone and recipients on this server; every node looks for due reports once a minute, one claims each run with the task lock, and the report is built from data the server already keeps and mailed DKIM-signed: `inbuxa:ScheduledReport` and `inbuxa:ScheduledReportSettings` (`crates/jmap/src/inbuxa/scheduled_reports.rs`, `crates/features/src/scheduled_reports/`, `crates/services/src/inbuxa_scheduled_reports.rs`, `crates/smtp/src/reporting/inbuxa_send.rs`), and the `sysScheduledReportGet` and `sysScheduledReportUpdate` permissions. The weekly digest is a built-in report, on by default. +Not a rebuild: **scheduled reports and the weekly digest** are INBUXA's own design (inbuxa-drafts `specs/scheduled-reports.md`). An administrator picks sections, a schedule in a time zone and recipients on this server; every node looks for due reports once a minute, one claims each run with the task lock, and the report is built from data the server already keeps and mailed DKIM-signed: `inbuxa:ScheduledReport`, `inbuxa:ScheduledReportSettings` and `inbuxa:ReportExport` (a download: a ZIP of a summary and CSVs) (`crates/jmap/src/inbuxa/scheduled_reports.rs`, `crates/features/src/scheduled_reports/`, `crates/services/src/inbuxa_scheduled_reports.rs`, `crates/smtp/src/reporting/inbuxa_send.rs`), and the `sysScheduledReportGet` and `sysScheduledReportUpdate` permissions. The weekly digest is a built-in report, on by default. ## 5. The web front ends diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 9ac68ac..9b358d6 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -188,6 +188,16 @@ retention = "object-life" [object."inbuxa:ScheduledReport".properties] recipients = ["contact"] +[object."inbuxa:ReportExport"] +file = "inbuxa_report_export.rs" +default = "none" +whose = ["administrator", "holder", "correspondent"] +where = ["blob-store"] +scope = "tenant" +retention = { setting = "x:Jmap.uploadTtl" } +[object."inbuxa:ReportExport".properties] +blobId = ["contact", "network", "metadata"] + [object."inbuxa:ScheduledReportSettings"] file = "inbuxa_scheduled_report_settings.rs" default = "none" diff --git a/tests/src/system/scheduled_reports.rs b/tests/src/system/scheduled_reports.rs index 9c87b12..6c6806c 100644 --- a/tests/src/system/scheduled_reports.rs +++ b/tests/src/system/scheduled_reports.rs @@ -325,6 +325,41 @@ pub async fn test(test: &mut TestServer) { .await; assert_eq!(name, "error", "a tenant changed the sender: {response}"); + // --- Download (RP-19) ---------------------------------------------------- + let (_, response) = call( + admin, + "inbuxa:ReportExport/set", + json!({"create": { + "last": {"reportId": &id}, + "old": {"reportId": &id, "from": "2020-01-01T00:00:00Z", "to": "2020-01-02T00:00:00Z"} + }}), + ) + .await; + let export = &response["created"]["last"]; + assert!(export["blobId"].is_string(), "{response}"); + assert!( + export["size"].as_u64().unwrap_or_default() > 0, + "{response}" + ); + assert_eq!( + export["sha256"].as_str().map(|s| s.len()), + Some(64), + "{response}" + ); + assert_eq!(export["files"][0], "summary.txt", "{response}"); + assert!( + response["notCreated"]["old"].is_object(), + "a 2020 period was exported: {response}" + ); + // A tenant administrator can't download a report that isn't theirs + let (_, response) = call( + &t_admin, + "inbuxa:ReportExport/set", + json!({"create": {"theirs": {"reportId": &id}}}), + ) + .await; + assert!(response["notCreated"]["theirs"].is_object(), "{response}"); + // --- Deleting ------------------------------------------------------------ let (_, response) = call( admin,