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.
This commit is contained in:
1 parent
ff5480cb55
commit
09ccad4b4b
16 files changed
+494
-3
No files matched your search
@@ -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<Self> {
|
||||
// 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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
ReportExportProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ReportExportValue {
|
||||
type Property = ReportExportProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
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<Id> for ReportExportValue {
|
||||
fn from(id: Id) -> Self {
|
||||
ReportExportValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for ReportExportValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
ReportExportValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
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<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -128,6 +128,7 @@ pub enum GetRequestMethod {
|
||||
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||
ReportExport(Box<GetRequest<crate::object::inbuxa_report_export::ReportExport>>),
|
||||
ScheduledReport(Box<GetRequest<crate::object::inbuxa_scheduled_report::ScheduledReport>>),
|
||||
ScheduledReportSettings(Box<GetRequest<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>>),
|
||||
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||
@@ -178,6 +179,7 @@ pub enum SetRequestMethod<'x> {
|
||||
),
|
||||
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||
ReportExport(Box<SetRequest<'x, crate::object::inbuxa_report_export::ReportExport>>),
|
||||
ScheduledReport(Box<SetRequest<'x, crate::object::inbuxa_scheduled_report::ScheduledReport>>),
|
||||
ScheduledReportSettings(Box<SetRequest<'x, crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>>),
|
||||
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -115,6 +115,7 @@ pub enum GetResponseMethod {
|
||||
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
|
||||
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
|
||||
ReportExport(GetResponse<crate::object::inbuxa_report_export::ReportExport>),
|
||||
ScheduledReport(GetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>),
|
||||
ScheduledReportSettings(GetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>),
|
||||
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||
@@ -165,6 +166,7 @@ pub enum SetResponseMethod {
|
||||
),
|
||||
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||
ReportExport(Box<SetResponse<crate::object::inbuxa_report_export::ReportExport>>),
|
||||
ScheduledReport(Box<SetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>>),
|
||||
ScheduledReportSettings(Box<SetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>>),
|
||||
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||
@@ -896,6 +898,17 @@ impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_settings::Deliver
|
||||
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value)))
|
||||
}
|
||||
}
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_report_export::ReportExport>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_report_export::ReportExport>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::ReportExport(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_report_export::ReportExport>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_report_export::ReportExport>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::ReportExport(Box::new(value)))
|
||||
}
|
||||
}
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::ScheduledReport(value))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -434,6 +434,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::DeliverabilityReport
|
||||
| MethodObject::DeliverabilitySettings
|
||||
| MethodObject::ReportExport
|
||||
| MethodObject::ScheduledReport
|
||||
| MethodObject::ScheduledReportSettings
|
||||
| MethodObject::Journal
|
||||
|
||||
@@ -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<u64> {
|
||||
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<ReportExport>,
|
||||
) -> trc::Result<GetResponse<ReportExport>> {
|
||||
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<SetResponse<ReportExport>> {
|
||||
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<String> = 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::<String>();
|
||||
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)
|
||||
}
|
||||
@@ -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<Vec<(String, Vec<u8>)>> {
|
||||
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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user