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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user